写一个比ant Design 的 日历组件(Calender)更好的组件(上)

lxf2023-03-11 18:54:01

前言

之前写的react组件

  • Affix组件: [react组件库源码+ 单测解析(Affix 固钉组件)]
  • Form组件:实现一个比ant-design更好form组件,可用于生产环境!
  • GridLayout组件:秒杀ant design布局组件
  • Button和ButtonGroup 按钮组件: react组件库源码+ 单测解析(Button和ButtonGroup 按钮组件)

普通的日历组件如下:

写一个比ant Design 的 日历组件(Calender)更好的组件(上)

这个组件就是ant design的日历组件,我们一点一点实现它的主要功能,为啥标题写了一个上呢,因为我们下半部分才会写超越ant功能的部分,就是可以对日期进行拖拽,类似

写一个比ant Design 的 日历组件(Calender)更好的组件(上)

没有这样交互的日历组件,说实话没啥用,因为这个交互太常见了,比如你在某段时间内去写一些任务,然后定时提醒自己,会议啊,开发任务啊什么的。所以我个人感觉ant 的日历组件实用性非常低。

如何渲染每个月的数据

如下,我们如何渲染每个月,比如下面是2012年,11月15日的样式

写一个比ant Design 的 日历组件(Calender)更好的组件(上)

代码如下:

<Calendar firstDayOfWeek={3} />

firstDayOfWeek是3,代表日历第一列是从星期三开始的,所以你写firstDayOfWeek = 2,那么第一列就是星期二开始,如下

写一个比ant Design 的 日历组件(Calender)更好的组件(上)

对于日期,我们使用了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;
};

接着,我们丰富一下日历组件的功能,我们省去什么月视图,年视图的代码,确实没啥好讲的,月和年视图难度太低了。

大家休息一下,接着干!

写一个比ant Design 的 日历组件(Calender)更好的组件(上)

接着,我们看一下渲染日历的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>;
      })()}

写一个比ant Design 的 日历组件(Calender)更好的组件(上) cellAppend是针对这个区域

写一个比ant Design 的 日历组件(Calender)更好的组件(上)

代码如下:

{(() => {
        const cellCtx =  cellAppend(createCalendarCell(cellData)
        return cellAppend && <div className={prefixCls([blockName, 'table-body-cell-content'])}>{cellCtx}</div>;
      })()}

好了,到这里以上代码的逻辑,足以你倒腾一个ant功能类似的日历组件了,我们下半部分会写超越ant的部分,就是日历可以设置一段日期显示在日历上,如下(参考了蚂蚁金服同学的实现原理

写一个比ant Design 的 日历组件(Calender)更好的组件(上)