日历组件大家都不陌生,在做一些中后台项目时,多数都会遇到。实际项目时,往往都会选择一套完整的UI框架,如Vue常用的ElementUI, React常用的antd Design等,这些组件库都会集成功能完善的日历组件,"拿来主义",我们会使用就好了。但是仔细想一下, 基本的日历组件以展示为主,交互并不复杂,那么我们能不能自己撸一个日历组件呢?接下来我们就一步一步, 分析该如何写出一个日历组件
一、核心功能
一个完整的日历展示大致如上图。包含三个区块:
- 月份日期;
- 上月末尾日期(红色框);
- 下月开头日期(蓝色框);
如果仅仅显示当月日期也是可以的, 但是日历上会有空白区域,不是那么美观, 通常的做法是用上月和下月的天数补齐, 因此我们这里采用42宫格的日历表,这样可以完整的展示任何月份的日历信息。
难点问题也围绕这三个, 如何正确渲染区块日期
计算原理:
1)获取当前月份的天数,计算出1号是周几,用于渲染当前月份的起始位置, 结束位置就是当月天数;2)补充上月末尾天数;上月末尾天数需要根据上月的天数和1号星期索引来计算: 上月天数 - 当月1号星期索引 + i(循环参数) + 1;3)下月开始天数,只需要知道当前日期表剩余的天数,从1号开始递增填补即可。
接下来我们就 step by step, 写出日历组件。
这里我们分两步走,首先先完成逻辑部分,要暴露出 generateCalendar(date: Date)
函数, 这个函数最终会吐出完整的月份表的数据, 即42宫格数据,包括上月和下月的数据,然后根据数据,再写样式。
二、月份日期计算
月份日期计算要知道两点:这个月有几天,这个月1号是周几?
我们通过javascript的内置日期类:Date可以操作几乎所有日期相关内容。
2.1 月份天数
我们知道一年月份中,1,3,5,7,8,10,12月有31天,4,6,9,11月有30天,闰年2月29天,平年2月29天。所以我们需要两个函数:isLeap(year: number)
和 getDays(year: number, month: year)
来确定给日期的准确天数。
// 是否为闰年
const isLeap = (year: number) => {
return (year % 4 === 0 && year % 100 !== 0) || year % 100 === 0;
};
// 获取xx年xx月有几天
const getDays = (year: number, month: number): number => {
const feb = isLeap(year) ? 29 : 28;
const daysPerMonth = [31, feb, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
return daysPerMonth[month];
};
然后我们需要知道这个月1号是周几,这个我们可以通过Date.prototype.getDay()
来获取。
注意:该API获取的星期是从周日开始,即0表示周日,其他以此递增。
我们这里使用的月份表示6x7的42宫格, 可以完整的展示月份信息。接下来我们来来构造generateCalendar(date: Date)
函数, 先计算出当前月份的位置:
export interface CalendarItem {
year: number;
month: number;
day: number;
isCurrentMonth: boolean;
}
// ...
export const generateCalendar = (date: Date) => {
const currentYear = date.getFullYear();
const currentMonth = date.getMonth();
// 当月天数
const days = getDays(currentYear, currentMonth);
// 1号是星期几
const weekIndex = new Date(`${currentYear}, ${currentMonth + 1}, 1`).getDay();
const calendarTable: CalendarItem[] = [];
for (let i = 0; i < calendarGrid; i++) {
if (i < weekIndex) {
// 补充上月天数
} else if (i >= days + weekIndex) {
// 补充下月天数
}
}
// 填充当月日期
for (let d = 1; d <= days; d++) {
calendarTable[weekIndex + d - 1] = {
year: currentYear,
month: currentMonth,
day: d,
isCurrentMonth: true,
};
}
return calendarTable;
};
接口CalendarItem
中,day表示这天是几号,isCurrentMonth表示这天是否是当月的日期, 因为我们的表里会有上月和下月的日期,有可能或出现相同日期的时间(同日不同月),我们要对不是当月的日期做次级处理(置灰处理),需要isCurrentMonth标识。
我们这里采用的是构造一维数组:calendarTable来渲染保存日期,很自然,这个一维数组的中间一部分才是当月日期;
注意看填充当月日期部分代码:例如本月有30天,1号是周五,那么本月日日期的起始位置是calendarTable数组中的第 5 + 1 - 1(weekIndx + d - 1)的位置开始, 一直填充到30(days)为止;
2.2 上月天数和下月天数补充
完成第一步,日历就已算完成,但是表的开头和结尾会有空白, 不是那么美观, 我们可以用上月末尾天数和下月起始天数来填充。
上月和下月是根据当前月份来确定的, 注意:
- 如果当前月是1月, 那么上月就是上一年的12月;
- 如果当前月是12月,那么下月就是下一年的1月;
- 其他中间月份,月份正常±1即可,年份不变;
// 获取下个月/上个月有多少天
const getNextOrLastMonthDays = (date: Date, type: 'next' | 'last') => {
const month = date.getMonth();
const year = date.getFullYear();
if (type === 'last') {
const lastMonth = month === 0 ? 11 : month - 1;
const lastYear = lastMonth === 11 ? year - 1 : year;
return {
year: lastYear,
month: lastMonth,
days: getDays(lastYear, lastMonth),
};
}
const nextMonth = month === 11 ? 0 : month + 1;
const nextYear = nextMonth === 0 ? year + 1 : year;
return {
year: nextYear,
month: nextMonth,
days: getDays(nextYear, nextMonth),
};
};
然后我们继续完成generateCalendar
函数:
// ...
const calendarGrid = 42; // 7 * 6宫格;
const generateCalendar = (date: Date) => {
const currentYear = date.getFullYear();
const currentMonth = date.getMonth();
// 当月天数
const days = getDays(currentYear, currentMonth);
// 获取上月末尾天数和下月开头的天数,用于填补当月日历空白
const { days: lastMonthDays, year: lastMonthYear, month: lastMonth } = getNextOrLastMonthDays(date, 'last');
const { year: nextMonthYear, month: nextMonthMonth } = getNextOrLastMonthDays(date, 'next');
// 1号是星期几
const weekIndex = new Date(`${currentYear}, ${currentMonth + 1}, 1`).getDay();
// 显示在当月末尾的下月天数
const trailDays = calendarGrid - weekIndex - days;
let trailVal = 0;
const calendarTable: CalendarItem[] = [];
for (let i = 0; i < calendarGrid; i++) {
if (i < weekIndex) {
// 补充上月天数
calendarTable[i] = {
year: lastMonthYear,
month: lastMonth,
day: lastMonthDays - weekIndex + i + 1,
isCurrentMonth: false,
};
} else if (i >= days + weekIndex) {
// 补充下月天数
if (trailVal < trailDays) {
trailVal += 1;
}
calendarTable[i] = {
year: nextMonthYear,
month: nextMonthMonth,
day: trailVal,
isCurrentMonth: false,
};
}
}
// 填充当月日期
for (let d = 1; d <= days; d++) {
calendarTable[weekIndex + d - 1] = {
year: currentYear,
month: currentMonth,
day: d,
isCurrentMonth: true,
};
}
return calendarTable;
};
再次引用原理:
1)获取当前月份的天数,计算出1号是周几,用于渲染当前月份的起始位置, 结束位置就是当月天数;2)补充上月末尾天数;上月末尾天数需要根据上月的天数和1号星期索引来计算: 上月天数 - 当月1号星期索引 + i(循环参数) + 1;3)下月开始天数,只需要知道当前日期表剩余的天数,从1号开始递增填补即可。
结合代码的补充上月和下月天数部分,就会比较清晰了。
自此,核心函数 generateCalendar(date: Date)
就完成了, 它接收任意日期参数, 就会返回日期所在月份的完整信息。接下来就是展示部分。
三、日历展示
一个基本的日历有日历头(星期栏)和日期内容组成。
我们要写的是vue组件, 使用vue3.2, 结合setup语法糖,写起来不要太爽~
模板大致如下:
<template>
<div class="calendar">
<!-- 表头操作 -->
<div class="calendar-operate">
<!-- 左侧日切切换按钮组 -->
<div class="button-group">
<button class="button" @click="changeMonth('prev')">
<i class="icon ri-arrow-left-s-line"></i>
</button>
<button class="button" @click="changeMonth('next')">
<i class="icon ri-arrow-right-s-line"></i>
</button>
</div>
<!-- 中间显示当前年月 -->
<div class="calendar-operate__title">{{ dateText }}</div>
<!-- 切换今天按钮 -->
<button class="button" :disabled="isToday" @click="currentDate">今天</button>
</div>
<!-- for循环渲染星期数, 周末弱化显示 -->
<div class="calendar-header">
<span
v-for="(item, index) in weekMapZh"
:key="index"
class="calendar-header__item"
:class="{ gray: index === 0 || index === 6 }"
>{{ item }}</span
>
</div>
<!-- 日期表显示 -->
<div class="calendar-content" :data-month="date.getMonth() + 1">
<div
v-for="(item, index) in calendarTable"
:key="index"
class="calendar-content__item"
:class="[{ light: !item.isCurrentMonth }, { active: isActive(item) }]"
>
{{ item.day }}
</div>
</div>
</div>
</template>
这里说下一些细节功能的实现:
首先,我们要高亮当天日期,例如今天是1号,我们要在日历表里着重显示这一天。
<script lang="ts" setup>
// 当切换时间时, date是变化的
const date = ref<Date>(new Date());
const calendarTable = computed(() => generateCalendar(date.value));
// ...
/**
* 当天日期高亮显示, 兼容切换日期:
* 年月日都要对上才能高亮
* ps: 日历可能会显示下月/上月的同样日期, 仅当月日期高亮
*/
const isActive = (item: CalendarItem) => {
return isAllTrue([
item.day === date.value.getDate(),
item.isCurrentMonth,
item.month === new Date().getMonth(),
item.year === new Date().getFullYear(),
]);
};
</script>
其次, 想要有个切换月份的功能, 即切换到上月或下月,这里有个逻辑,因为generateCalendar(date: Date)
函数接收的是Date类型的参数,因此我们所有对日期的操作都将返回Date类型,即改变date这个参数,然后使用计算属性,会联动生成新的日历表。
至此, 一个基本的,带切换日期的基础日历就完成了🎉🎉
四、总结
这里一步一步的完成了一个基本日历组件,功能并不复杂。 这里做一下简单的复盘:
- 对Date类要有一定了解,起初觉得日历有些复杂的原因就是对Date类的用法不熟悉;
- 理清逻辑,对要实现的产品功能有一个清晰的认识。只有明白要做什么,才能去思考怎么做;
- 扩展性。 我们还要考虑组件的扩展性,通常我们写业务组件会更多一些, 可能这个业务组件只在某几个页面中使用,但是表现形式可能会有所不同,这时我们就要考虑怎样用少的代码,清晰的逻辑让组件易于使用和后期再扩展, 同时要保证组件代码是可维护的(该写注释的写注释,该解耦的解耦~),这样才是一个合格的组件。
最后贴上源码地址, 欢迎小伙伴们多多指教,多多star⭐️