技术创想:来一份前端日历

文摘   科技   2022-10-14 15:19   中国香港  

本文作者:领创集团前端工程师 唐冬


前言

在网页、APP中,日历可以说是非常常见的一个东西了,例如macbook自带的日历:

或者是某旅游APP的日历:
甚至现在的日历已经不光是看日期或者选择日期等功能,它能承载更多信息,发挥更多作用,例如lark上日历:

那么作为一个常年与图形界面打交道的前端er是否思考过它是如何实现的呢?这就要用到js的内置日期对象Date了,不断的操作Date对象来输出任意年月日到页面中。本文将使用原生js来实现一个常见的日历,理解并可以利用到复杂的场景中。


需求
以element-plus UI的日历组件为例,我们动手实现一下它这个功能。

功能分析:在当前面板需要展示年月日信息,且今日的格子需要高亮,右上角有上个月、下个月还有今日按钮,点击分别切换到上个月、下个月数据,点击今日按钮快速跳回到今日面板。点击日期格子,则该格子会被选中,如果点击的日期格子不属于当前月份,则直接跳入它所属于的月份。日期格子的排列顺序为,开头为上个月的(灰色格子),中间为当前月的(高亮格子),尾部格子为下个月的格子(灰色格子)。
下面进入coding环节。

实现
样式颜色字体间距等直接仿照element-plus UI,这里不多做阐述。结构按上中下划分,头部为年月与按钮区域,中间为“星期”区域,该区域不会变化,下面就渲染具体日期格子,需要动态渲染。
<div class="calendar-wrap"> <!-- 顶部按钮区域 --> <section class="head"> <span id="year"></span><span id="month"></span> <div class="btn-wrap"> <span id="btn-previous">Previous Month</span> <span id="btn-today">Today</span> <span id="btn-next">Next Month</span> </div> </section> <section class="content"> <!-- 星期 --> <section class="week"> <div>Sun</div> <div>Mon</div> <div>Tue</div> <div>Wed</div> <div>Thu</div> <div>Fri</div> <div>Sat</div> </section> <!-- 日期 --> <section class="date-number"> <ul id="number-wrap"></ul> </section> </section></div>
<ul id="number-wrap"></ul>里面的内容即为日期小格子。现在先获取我们需要用到的页面元素,并且先将需要的月份名称装起来。
const yearElement = document.getElementById('year');const monthElement = document.getElementById('month');const ulElement = document.getElementById('number-wrap');
const previousBtn = document.getElementById('btn-previous');const todayBtn = document.getElementById('btn-today');const nextBtn = document.getElementById('btn-next');
const monthArray = [ 'January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December',];
记录下当前的年月日,以及面板的年月,还有被选中的年月日。
// 记录当前年月日const nowDate = new Date();const nowYear = nowDate.getFullYear();const nowMonth = nowDate.getMonth();const nowDay = nowDate.getDate();
// 记录面板年月let panelYear = nowYear;let panelMonth = nowMonth;// 记录被选中年月日let selectedYear = null;let selectedMonth = null;let selectedDay = null;
事实上当我们有了年月之后就可以渲染当前年月的日历了,即面板日历,这里render函数传递了initDate参数,如果没有值,当前面板年月就为当前年月,被渲染出日期小格子。我们可以先通过反复操作Date对象拿到关键的数据:1、需要渲染多少个上个月的格子;2、上个月多少天;3、这个月多少天;
const renderBoxFn = (initDate = null) => { const currentDate = initDate || nowDate; const currentYear = currentDate.getFullYear(); const currentMonth = currentDate.getMonth(); currentDate.setDate(1); // 需要渲染多少个上个月的格子 const currentFirstWeek = currentDate.getDay(); currentDate.setDate(0); // 上个月的年份与月份 const previousYear = currentDate.getFullYear(); const previousMonth = currentDate.getMonth(); // 上个月多少天 const previousDateNumber = currentDate.getDate();
// 设置成下个月 currentDate.setDate(63); // 下个月的年份与月份 const nextYear = currentDate.getFullYear(); const nextMonth = currentDate.getMonth();
// 设置成当前月 currentDate.setDate(0);
// 当前月份多少天 const currentDateNumber = currentDate.getDate(); // 当前月份最后一天星期几 const currentDateWeek = currentDate.getDay(); // 需要渲染下个月的格子是多少个 const nextDeltaNumber = 6 - currentDateWeek;}
有了上面的数据可以循环出上个月的日期格子。
const renderBoxFn = (initDate = null) => { // ....省略前面代码 // 生成一个文档碎片,挂载li元素 const fragment = document.createDocumentFragment();
// 所有li元素都是append追加到ul元素下,所以每次挂载前都清空以前的元素 ulElement.innerHTML = null;
// 生成并挂载上个月的li for (let i = currentFirstWeek; i > 0; i --) { let previousLi = document.createElement('li'); previousLi.innerText = previousDateNumber - i + 1; previousLi.classList.add('previous-box'); // 将年月日信息保存在li的store当中,点击的时候方便读取,store是自己随意定义的名称 previousLi.store = { year: previousYear, month: previousMonth, date: previousDateNumber - i + 1, }; fragment.appendChild(previousLi); } // 挂载元素 ulElement.appendChild(fragment);}
上个月的日期格子被渲染出来了:
接着渲染当前月的日期格子。
const renderBoxFn = (initDate = null) => { // ....省略前面代码 // 生成并挂载当前月的li for (let i = 1; i <= currentDateNumber; i ++) { let currentLi = document.createElement('li'); currentLi.innerText = i; currentLi.store = { year: currentYear, month: currentMonth, date: i, }; // 渲染到了今年今月今日就加个is-today的class类,做个标记 if (nowYear === currentYear && nowMonth === currentMonth && nowDay === i) { currentLi.classList.add('is-today'); } // 渲染到了被选中的年月日则加个is-selected的class类,标记一下 if (selectedYear === currentYear && selectedMonth === currentMonth && selectedDay === i) { currentLi.classList.add('is-selected'); } fragment.appendChild(currentLi); }}
当月的日期格子被渲染出来: 
最后需要渲染出下个月的日期格子,但是如果当前这个月的日期格子已经铺满了最后一行的话,下一个月的日期可以渲染到下一行或者直接不渲染下个月,我们这边先处理成不渲染下一行,即下个月的日期格子,只用来补齐最后一行空白格子。
const renderBoxFn = (initDate = null) => { // ....省略前面代码 // 生成并挂载下个月的li for (let i = 0; i < nextDeltaNumber; i ++) { let nextLi = document.createElement('li'); nextLi.innerText = i + 1; nextLi.classList.add('next-box'); nextLi.store = { year: nextYear, month: nextMonth, date: i + 1, }; fragment.appendChild(nextLi); }}
补齐了最后一行日期格子,完整日历出来了: 
面板的年月日都渲染出来了,拿前面获取到的要操作的元素来添加点击事件,首先点击每个日期格子,需要把它代理到它的父元素上。当判断点击的li格子是当前面板的日期时,是不需要再刷日历,去重新渲染每一个li的,即触发了下面的coverCurrentLiFn函数,只刷新当月的格子,给它加上被选中的class类
// 点击li,事件代理到ul上ulElement.onclick = (e) => { const { year, month, date } = e.target.store; // 如果点击的li是已经被选中的,且是当前面板的年月,则不必再render面板日期 if (year === selectedYear && month === selectedMonth && date === selectedDay && panelYear === year && panelMonth === month ){ return null; } selectedYear = year; selectedMonth = month; selectedDay = date; if (panelYear === year && panelMonth === month) { coverCurrentLiFn(year, month, date); return null; } renderBoxFn(new Date(year, month, date)); panelYear = year; panelMonth = month; yearElement.innerHTML = year; monthElement.innerHTML = monthArray[month];}// 覆盖当前的liconst coverCurrentLiFn = (yearClick, monthClick, dateClick) => { const queryAllLi = document.querySelectorAll('#number-wrap li'); Array.from(queryAllLi).forEach((li) => { const { year, month, date } = li.store; if (yearClick === year && monthClick === month && dateClick === date) { li.classList.add('is-selected'); } else { li.classList.remove('is-selected'); } })}
接下来点击上个月按钮,触发面板格子刷新成上个月的数据,生成新的上个月的日期对象只需要将面板日改成0即可;点击下一个月也是同理,将面板日改成32即可;
// 上个月previousBtn.onclick = () => {  const preDate = new Date(panelYear, panelMonth, 0);  yearElement.innerHTML = panelYear = preDate.getFullYear();  panelMonth = preDate.getMonth();  monthElement.innerHTML = monthArray[panelMonth];  renderBoxFn(preDate);}// 下个月nextBtn.onclick = () => {  const nextDate = new Date(panelYear, panelMonth, 32);  yearElement.innerHTML = panelYear = nextDate.getFullYear();  panelMonth = nextDate.getMonth();  monthElement.innerHTML = monthArray[panelMonth];  renderBoxFn(nextDate);}
当页面打开的时候就需要展示一下面板年月日,默认就是当前日期的年月数据,所以我们当页面加载后就初始化渲染一下;当点击今日按钮的时候,也相当于重新渲染了一下面板,所以给今日按钮绑定初次渲染函数;
// 重置或首次渲染const initRenderFn = () => { panelYear = nowYear; panelMonth = nowMonth; yearElement.innerHTML = nowYear; monthElement.innerHTML = monthArray[nowMonth]; renderBoxFn();}
// 页面加载完成后调用initRenderFn();
// 点击今天todayBtn.onclick = initRenderFn;
到此,基本功能都已实现。

总结

  • 依靠操作js内置的Date对象来规避平年、闰年,某月有多少天,上个月与下个月有多少天,某一日期所处星期几等问题;

  • 对于Date对象来说,new出来的实例,已经可以获得默认或者指定年月日的信息,设置天(setDate)如果为负数,则得到了上个月的日期对象,同理设置个比较大(大于当前月的天数),则得到了下个月的日期对象;再次操作这些对象,又可以得到上上个月或者下下个月的日期对象,如此往复变成万年历;

  • 一个小格子代表一天,也代表一个li元素,在生成这个li元素的时候,给这个元素添加key-value,可以很好的区分他们,后面点击的时候可以取到值,这是挂载信息的一种方式,有比较多的应用场景。

代码编写思路:
  • 获取当前年、月、日;

  • 把当前日期设置成1号,获取1号所在的星期几,即从第几个格子开始写入当月日期;

  • 然后减去1天,得到上个月最后一天日期,即上个月总天数;

  • 把日期对象设置成下个月1号,然后减去1天,得到这个月最后一天日期,即为这个月总天数;

  • 先渲染上个月末尾的几个格子,然后再渲染这个月的所有日期格子,最后渲染下个月的开头几个格子,它们组成了完整的面板展示日期;

拓展方向

前端对日历的应用比较广泛,随着业务的复杂度提高,日历组件也将承载更多的功能。由于篇幅限制,本文只实现了普通的展示,点击,选中等功能,复刻的ElementUI的日历组件,但是我们可以思考一下其它的形式,比如日期的单选,多选,点选,连选,日期格子内容的自定义,挂载更多内容、标识等,了解基本原理过后这些都不再困难,配合React、Vue将会使开发变得更加简单。
欢迎关注领创集团知乎官方账号
每周一个技术干货分享!



关于领创集团

(Advance Intelligence Group)
领创集团成立于 2016年,致力于通过科技创新的本地化应用,改造和重塑金融和零售行业,以多元化的业务布局打造一个服务于消费者、企业和商户的生态圈。集团旗下包含企业业务和消费者业务两大板块,企业业务包含 ADVANCE.AI 和 Ginee,分别为银行、金融、金融科技、零售和电商行业客户提供基于 AI 技术的数字身份验证、风险管理产品和全渠道电商服务解决方案;消费者业务 Atome Financial 包括亚洲领先的先享后付平台 Atome 和数字金融服务。2021年 9月,领创集团宣布完成超4亿美元 D 轮融资,融资完成后领创集团估值已超 20亿美元,成为新加坡最大的独立科技创业公司之一。

领创集团Advance Group
领创集团是亚太地区AI技术驱动的科技集团。
 最新文章