前言
之前写的react组件:
- Affix组件: [react组件库源码+ 单测解析(Affix 固钉组件)]
- Form组件:实现一个比ant-design更好form组件,可用于生产环境!
- GridLayout组件:秒杀ant design布局组件
- Button和ButtonGroup 按钮组件: react组件库源码+ 单测解析(Button和ButtonGroup 按钮组件)
普通的日历组件如下:
这个组件就是ant design的日历组件,我们一点一点实现它的主要功能,为啥标题写了一个上呢,因为我们下半部分才会写超越ant功能的部分,就是可以对日期进行拖拽,类似
没有这样交互的日历组件,说实话没啥用,因为这个交互太常见了,比如你在某段时间内去写一些任务,然后定时提醒自己,会议啊,开发任务啊什么的。所以我个人感觉ant 的日历组件实用性非常低。
如何渲染每个月的数据
如下,我们如何渲染每个月,比如下面是2012年,11月15日的样式
代码如下:
<Calendar firstDayOfWeek={3} />
firstDayOfWeek是3,代表日历第一列是从星期三开始的,所以你写firstDayOfWeek = 2,那么第一列就是星期二开始,如下
对于日期,我们使用了dayjs组件,每个月有多少天,直接使用daysInMonth() API即可,不用去背什么1,3,5,7...
先上代码,我们把dom结构先看一下
<tbody>
// dateList就是每个月的数据
{dateList.map((dateRow, dateRowIndex) => (
// dateRow是日历的每一行数据,后面会解释
</tr>
))}
</tbody>
所以这里最重要的就是dateList是什么
// mode为 'month' 时,构造日历列表
const dateList = useMemo(
() => createDateList(year, month, firstDayOfWeek, value, format),
[year, month, firstDayOfWeek, format, value],
);
然后我们看一下createDateList方法
我们简单说下思路,然后下方附上代码实现。
首先数据结构是二维数组,如下:
[[01,02,03,04,05,06,07],[ 08, 09, 10,11,12,13,14]....]
代表日期的1号,2号,3号。。。。每行渲染7个日期。
假如单纯是展示这个月,比如这个月有30天,那就很简单了,直接push从01到30即可,问题就来自,一般我们默认第一列是周一,那么我们这个月的1号不一定是周一,对吧。
那么我们就需要把上一个月的周一到这个月1号的日期填进来,同理月末,也需要填进去一些下个月的日期,因为30号不一定就是日历当月的末尾星期天,对吧。
所以核心思路就是这个,我们可以通过以下的公式求得月初1号的星期数跟第一列之间的差多少天
// 思路:你想计算两个数之间的距离,z为一个周期,x为已知的日期,y为目标的日期,计算方式就是 (x-y + z) %z
const lastMonthDaysCount = (这个月1号的星期数 - firstDayOfWeek(第一列的星期数) + 7) % 7;
为什么这个公式成立,大家可以自己比划思考一下。
然后这个月1号的星期数怎么求呢,我们先写一个获取传入日期是星期几的函数。
/**
* 获取一个日期是周几(1~7)
*/
export const getDay = (dt: Date): number => {
// 这是dayjs提供的现成的方法,但是dayjs会把星期天返回0,根据我们中国人习惯还是星期7比较合适
let day = dayjs(dt).day();
if (day === 0) {
day = 7;
}
return day;
};
其中可通过dayjs(${year}-${month}
)求得当前月的1号是什么(有点废话啊,1号就是1号呗,这里代码写的有点多此一举)
然后getDay(dayjs(${year}-${month}
).toDate()),获取到这个月的第一天是星期几了。
最后,我们的思路就是,二维数组先push上一个月进到本月日历的日期有哪些。然后在push这个月的日期,最后再push下个月的进到本月日历的日期。
// 声明一个装载二维数组的变量
const rowList = [];
// 声明二维数组里的一维数组,用来装载日历每一行的日期
let list = [];
// 记录这是第几周
let weekCount = 1;
// 这个月的第一个日子是什么(dayjs的格式)
const monthFirstDay = dayjs(`${year}-${month}`);
// lastMonthDaysCount是我们上面计算的上一个月有多少日期要进到日历来
for (let i = 0; i < lastMonthDaysCount; i++) {
// 获取月份中第一个日子的日期减一天,subtract是dayjs的方法
const dayObj = monthFirstDay.subtract(i + 1, 'day');
list.unshift(dayObj);
}
上面的dayObj其实需要包装一下,为了不增加复杂度,我们暂且理解为list放入的是dayjs的日期
接着,我们添加本月的数据
// 添加本月日期
// monthDaysCount 获取当前月份包含的天数
// endOf('month')获取某月的最后一天,daysInMonth 获取当前月份包含的天数
const monthDaysCount = dayjs(`${year}-${month}`).daysInMonth();
for (let i = 0; i < monthDaysCount; i++) {
// 获取月份中第一个日子的日期加一天
const dayObj = monthFirstDay.add(i, 'day');
list.push(dayObj);
// 因为一周有7天,list数据每次装载7个元素,所以,如果list的长度是7的话,就要新建一个list重新装数据
if (list.length === 7) {
rowList.push(list);
list = [];
weekCount += 1;
}
}
最后,我们添下个月的数据
// 添加下月日期
if (list.length) {
// 获取到本月最后一天的日期
const monthLastDay = dayjs(`${year}-${month}`).endOf('month');
// 获取到到日历结尾,下一个月还需要添加多少日期进来
const nextMonthDaysCount = 7 - list.length;
for (let i = 0; i < nextMonthDaysCount; i++) {
const dayObj = monthLastDay.add(i + 1, 'day');
list.push(dayObj));
}
rowList.push(list);
}
大家可以想一下为啥上个月和下个月没有判断 list.length === 7呢,因为不可能上个月和下个月装载的数组超过7。
有了上面的逻辑,你切换年月,然后再刷新视图就行了。
下面是完整计算当月日历显示多少天的代码。
/**
* 创建日历单元格数据
* @param year 日历年份
* @param month 日历月份
* @param firstDayOfWeek 周起始日(1~7)
* @param currentValue 当前日期
* @param format 日期格式
*/
export const createDateList = (
year: number,
month: number,
firstDayOfWeek: number,
currentValue: dayjs.Dayjs,
format: string,
): CalendarCell[][] => {
const createCellData = (belongTo: number, isCurrent: boolean, date: Date, weekOrder: number): CalendarCell => {
// 获取一个日期是周几(1~7)
const day = getDay(date);
return {
mode: 'month',
belongTo,
isCurrent,
day,
weekOrder,
date,
formattedDate: dayjs(date).format(format),
filterDate: null,
formattedFilterDate: null,
isShowWeekend: true,
};
};
// 获取月份中第一个日子的日期,例如:'2022-11-01'
const monthFirstDay = dayjs(`${year}-${month}`);
const rowList = [] as CalendarCell[][];
let list = [] as CalendarCell[];
let weekCount = 1;
// 添加上个月中会在本月显示的最后几天日期
// getDay(monthFirstDay.toDate()) 获取到获取月份中第一个日子是星期几
// firstDayOfWeek 第一天从星期几开始,仅在日历展示维度为月份时(mode = month)有效。默认为 1。可选项:1/2/3/4/5/6/7
// lastMonthDaysCount获取当前跟你想要展示的firstDayOfWeek的距离,思路是你想计算两个数之间的距离,z为一个周期,x为已知的日期,y为目标的日期,计算方式就是 (x-y + z) %z
const lastMonthDaysCount = (getDay(monthFirstDay.toDate()) - firstDayOfWeek + 7) % 7;
for (let i = 0; i < lastMonthDaysCount; i++) {
// 获取月份中第一个日子的日期减一天
const dayObj = monthFirstDay.subtract(i + 1, 'day');
list.unshift(createCellData(-1, false, dayObj.toDate(), weekCount));
}
// 添加本月日期
// monthDaysCount 获取当前月份包含的天数
// endOf('month')获取某月的最后一天,daysInMonth 获取当前月份包含的天数
const monthDaysCount = monthFirstDay.endOf('month').daysInMonth();
for (let i = 0; i < monthDaysCount; i++) {
const dayObj = monthFirstDay.add(i, 'day');
list.push(createCellData(0, currentValue.isSame(dayObj), dayObj.toDate(), weekCount));
if (list.length === 7) {
rowList.push(list);
list = [];
weekCount += 1;
}
}
// 添加下月日期
if (list.length) {
const monthLastDay = dayjs(`${year}-${month}`).endOf('month');
const nextMonthDaysCount = 7 - list.length;
for (let i = 0; i < nextMonthDaysCount; i++) {
const dayObj = monthLastDay.add(i + 1, 'day');
list.push(createCellData(1, false, dayObj.toDate(), weekCount));
}
rowList.push(list);
}
return rowList;
};
接着,我们丰富一下日历组件的功能,我们省去什么月视图,年视图的代码,确实没啥好讲的,月和年视图难度太低了。
大家休息一下,接着干!
接着,我们看一下渲染日历的dom,有哪些需要丰富的功能点。下面的dateList,我们在上面已经求出来了。
<tbody>
{dateList.map((dateRow, dateRowIndex) => (
<tr key={String(dateRowIndex)}>
{dateRow.map((dateCell, dateCellIndex) => {
// dateCell包含哪些信息呢,我们下面有说明
// 若不显示周末,隐藏 day 为 6 或 7 的元素
if (!isShowWeekend && [6, 7].indexOf(dateCell.day) >= 0) return null;
// 其余日期正常显示
const isNow = dateCell.formattedDate === currentDate;
return (
<CalendarCellComp
key={dateCellIndex}
mode={mode}
theme={theme}
cell={cell}
cellData={dateCell}
cellAppend={cellAppend}
fillWithZero={fillWithZero}
isCurrent={dateCell.isCurrent}
isNow={isNow}
isDisabled={dateCell.belongTo !== 0}
createCalendarCell={createCalendarCell}
onCellClick={(event) => clickCell(event, dateCell)}
onCellDoubleClick={(event) => doubleClickCell(event, dateCell)}
onCellRightClick={(event) => rightClickCell(event, dateCell)}
/>
);
})}
</tr>
))}
</tbody>
dataCell的interface如下:
export interface CalendarCell extends ControllerOptions {
/**
* 用于表示日期单元格属于哪一个月份。值为 0 表示是当前日历显示的月份中的日期,值为 -1 表示是上个月的,值为 1 表示是下个月的(日历展示维度是“月”时有值)
*/
belongTo?: number;
/**
* 日历单元格日期
*/
date?: Date;
/**
* 日期单元格对应的星期,值为 1~7,表示周一到周日。(日历展示维度是“月”时有值)
*/
day?: number;
/**
* 日历单元格日期字符串(输出日期的格式和 format 有关)
* @default ''
*/
formattedDate?: string;
/**
* 日期单元格是否为当前高亮日期或高亮月份
*/
isCurrent?: boolean;
/**
* 日期在本月的第几周(日历展示维度是“月”时有值)
*/
weekOrder?: number;
}
我们有一个隐藏周末的功能,只要判断 day属性是否是6,7即可,如下
if (!isShowWeekend && [6, 7].indexOf(dateCell.day) >= 0) return null;
我们如何判断当前日期,如下:
const isNow = dateCell.formattedDate === dayjs().format('YYYY-MM-DD');
接着我们要看渲染具体日期的组件CalendarCellComp的实现了。
我把代码粘贴一下
import React, { MouseEvent } from 'react';
import { CalendarCell, TdCalendarProps } from './type';
import useConfig from '../hooks/useConfig';
import usePrefixClass from './hooks/usePrefixClass';
import { useLocaleReceiver } from '../locale/LocalReceiver';
import { blockName } from './_util';
const CalendarCellComp: React.FC<CalendarCellProps> = (props) => {
const {
mode, // 这个属性忽略,我们这里认为是'month'即可
cell, // 单元格插槽
cellAppend, // 单元格插槽,在原来的内容之后追加
theme, // 这个属性忽略,认为是'full'即可
isDisabled = false, // isDisabled 等于 dateCell.belongTo !== 0,belongTo是0代表本月日期,是能点击的,上个月这个值是-1,下个月是1,所以不能点击
cellData, // cellData上面已经介绍过了
isCurrent, // 日期单元格是否为当前高亮日期
isNow, // 是否是今天
fillWithZero, // 是否日期填0,比如1号,填0就是,01号
createCalendarCell,
onCellClick, // 单击日历中的一个日期事件
onCellDoubleClick, // 双击事件
onCellRightClick, // 右击事件
} = props;
// 这里会判断是否要自动补0
const fix0 = (num: number) => {
const fillZero = num < 10 && (fillWithZero ?? true);
return fillZero ? `0${num}` : num;
};
return (
<td
onClick={onCellClick}
onDoubleClick={onCellDoubleClick}
onContextMenu={onCellRightClick}
>
{(() => {
// 如果要自定义cell的话,可以传入function
if (cell && typeof cell === 'function') return cell( createCalendarCell(cellData));
let cellCtx = fix0(cellData.date.getDate());
return <div>{cellCtx}</div>;
})()}
{(() => {
const cellCtx = cellAppend(createCalendarCell(cellData)
return cellAppend && <div className={prefixCls([blockName, 'table-body-cell-content'])}>{cellCtx}</div>;
})()}
</td>
);
};
export default CalendarCellComp;
上面的代码我这里解释一下,这里是自定义数字的
if (cell && typeof cell === 'function') return cell( createCalendarCell(cellData));
let cellCtx = fix0(cellData.date.getDate());
return <div>{cellCtx}</div>;
})()}
cellAppend是针对这个区域
代码如下:
{(() => {
const cellCtx = cellAppend(createCalendarCell(cellData)
return cellAppend && <div className={prefixCls([blockName, 'table-body-cell-content'])}>{cellCtx}</div>;
})()}
好了,到这里以上代码的逻辑,足以你倒腾一个ant功能类似的日历组件了,我们下半部分会写超越ant的部分,就是日历可以设置一段日期显示在日历上,如下(参考了蚂蚁金服同学的实现原理)