封装一个工作日历组件,顺便复习一下Date常用方法

背景

上周接到一个需求,开发一个工作日历组件。找了一圈没找到合适的,索性自己写一个。

下面给大家分享一下组件中使用到的一些日期API和后面实现的框选元素功能。

效果展示

demo体验地址:dbfu.github.io/work-calend...

开始之前

lunar-typescript

介绍组件之前先给大家介绍一个库lunar-typescript

lunar是一个支持阳历、阴历、佛历和道历的日历工具库,它开源免费,有多种开发语言的版本,不依赖第三方,支持阳历、阴历、佛历、道历、儒略日的相互转换,还支持星座、干支、生肖、节气、节日、彭祖百忌、每日宜忌、吉神宜趋、凶煞宜忌、吉神方位、冲煞、纳音、星宿、八字、五行、十神、建除十二值星、青龙名堂等十二神、黄道日及吉凶等。仅供参考,切勿迷信。

这个库封装了很多常用的api,并且使用起来也比较简单。

本文用到了上面库的获取农历和节气方法。

复习Date Api

new Date

可以使用new Date()传年月日三个参数来构造日期,这里注意一下月是从零开始的。

获取星期几

可以使用getDay方法获取,注意一下,获取的值是从0开始的,0表示星期日。

获取上个月最后一天

基于上面api,如果第三个参数传0,就表示上个月最后一天,-1,是上个月倒数第二天,以此类推。(PS:这个方法还是我有次面试,面试官告诉我的。)

获取某个月有多少天

想获取某个月有多少天,只需要获取当月最后天的日期,而当月最后一天,可以用上面new Date第三个参数传零的方式获取。

假设我想获取2023年12月有多少天,按照下面方式就可以获取到。

日期加减

假设我现在想实现在某个日期上加一天,可以像下面这样实现。

这样实现有个不好的地方,改变了原来的date,如果不想改变date,可以这样做。

比较两个日期

在写这个例子的时候,我发现一个很神奇的事情,先看例子。

大于等于结果是true,小于等于结果也是true,正常来说肯定是等于的,但是等于返回的是false,是不是很神奇。

其实原理很简单,用等于号去比较的时候,会直接比较两个对象的引用,因为是分别new的,所以两个引用肯定不相等,返回false。

用大于等于去比较的时候,会默认使用date的valueOf方法返回值去比较,而valueOf返回值也就是时间戳,他们时间戳是一样的,所以返回true。

说到这里,给大家分享一个经典面试题。

console.log(a == 1 && a == 2 && a == 3),希望打印出true

原理和上面类似,感兴趣的可以挑战一下。

这里推荐大家比较两个日期使用getTime方法获取时间戳,然后再去比较。

实战

数据结构

开发之前先把数据结构定一下,一个正确的数据结构会让程序开发变得简单。

根据上面效果图,可以把数据结构定义成这样:

ts 复制代码
/**
 * 日期信息
 */
export interface DateInfo {
  /**
   * 年
   */
  year: number;
  /**
   * 月
   */
  month: number;
  /**
   * 日
   */
  day: number;
  /**
   * 日期
   */
  date: Date;
  /**
   * 农历日
   */
  cnDay: string;
  /**
   * 农历月
   */
  cnMonth: string;
  /**
   * 农历年
   */
  cnYear: string;
  /**
   * 节气
   */
  jieQi: string;
  /**
   * 是否当前月
   */
  isCurMonth?: boolean;
  /**
   * 星期几
   */
  week: number;
  /**
   * 节日名称
   */
  festivalName: string;
}

/**
 * 月份的所有周 
 */
export interface MonthWeek {
  /**
   * 月
   */
  month: number;
  /**
   * 按周分组的日期,7天一组
   */
  weeks: DateInfo[][];
}

通过算法生成数据结构

现在数据结构定义好了,下面该通过算法生成上面数据结构了。

封装获取日期信息方法

ts 复制代码
/**
 * 获取给定日期的信息。
 * @param date - 要获取信息的日期。
 * @param isCurMonth - 可选参数,指示日期是否在当前月份。
 * @returns 包含有关日期的各种信息的对象。
 */
export const getDateInfo = (date: Date, isCurMonth?: boolean): DateInfo => {
  // 从给定日期创建 农历 对象
  const lunar = Lunar.fromDate(date);

  // 获取 Lunar 对象中的农历日、月和年
  const cnDay = lunar.getDayInChinese();
  const cnMonth = lunar.getMonthInChinese();
  const cnYear = lunar.getYearInChinese();

  // 获取农历节日
  const festivals = lunar.getFestivals();

  // 获取 Lunar 对象中的节气
  const jieQi = lunar.getJieQi();

  // 从日期对象中获取年、月和日
  const year = date.getFullYear();
  const month = date.getMonth();
  const day = date.getDate();

  // 创建包含日期信息的对象
  return {
    year,
    month,
    day,
    date,
    cnDay,
    cnMonth,
    cnYear,
    jieQi,
    isCurMonth,
    week: date.getDay(),
    festivalName: festivals?.[0] || festivalMap[`${month + 1}-${day}`],
  };
};

上面使用了lunar-typescript库,获取了一些农历信息,节气和农历节日。方法第二个参数isCurMonth是用来标记是否是当月的,因为很多月的第一周或最后一周都会补一些其他月日期。

把月日期按照每周7天格式化

思路是先获取给定月的第一天是星期几,如果前面有空白,用上个月日期填充,然后遍历当月日期,把当月日期填充到数组中,如果后面有空白,用下个月日期填充。

ts 复制代码
/**
 * 返回给定年份和月份的周数组。
 * 每个周是一个天数数组。
 *
 * @param year - 年份。
 * @param month - 月份 (0-11)。
 * @param weekStartDay - 一周的起始日 (0-6) (0: 星期天, 6: 星期六)。
 * @returns 给定月份的周数组。
 */
const getMonthWeeks = (year: number, month: number, weekStartDay: number) => {
  // 获取给定月份的第一天
  const start = new Date(year, month, 1);

  // 这里为了支持周一或周日在第一天的情况,封装了获取星期几的方法
  const day = getDay(start, weekStartDay);

  const days = [];

  // 获取给定月份的前面的空白天数,假如某个月第一天是星期3,并且周日开始,那么这个月前面的空白天数就是3
  // 如果是周一开始,那么这个月前面的空白天数就是2
  // 用上个月日期替换空白天数
  for (let i = 0; i < day; i += 1) {
    days.push(getDateInfo(new Date(year, month, -day + i + 1)));
  }

  // 获取给定月份的天数
  const monthDay = new Date(year, month + 1, 0).getDate();

  // 把当月日期放入数组
  for (let i = 1; i <= monthDay; i += 1) {
    days.push(getDateInfo(new Date(year, month, i), true));
  }

  // 获取给定月份的最后一天
  const endDate = new Date(year, month + 1, 0);
  // 获取最后一天是星期几
  const endDay = getDay(endDate, weekStartDay);

  // 和前面一样,如果有空白位置就用下个月日期补充上
  for (let i = endDay; i <= 5; i += 1) {
    days.push(getDateInfo(new Date(year, month + 1, i - endDay + 1)));
  }

  // 按周排列
  const weeks: DateInfo[][] = [];
  for (let i = 0; i < days.length; i += 1) {
    if (i % 7 === 0) {
      weeks.push(days.slice(i, i + 7));
    }
  }

  // 默认每个月都有6个周,如果没有的话就用下个月日期补充。
  while (weeks.length < 6) {
    const endDate = weeks[weeks.length - 1][6];
    weeks.push(
      Array.from({length: 7}).map((_, i) => {
        const newDate = new Date(endDate.date);
        newDate.setDate(newDate.getDate() + i + 1)
        return getDateInfo(newDate);
      })
    );
  }
  return weeks;
};

getDay方法实现

ts 复制代码
function getDay(date: Date, weekStartDay: number) {
  // 获取给定日期是星期几
  const day = date.getDay();
  // 根据给定的周开始日,计算出星期几在第一天的偏移量
  if (weekStartDay === 1) {
    if (day === 0) {
      return 6;
    } else {
      return day - 1;
    }
  }
  return day;
}

获取一年的月周数据

ts 复制代码
/**
 * 获取年份的所有周,按月排列
 * @param year 年
 * @param weekStartDay 周开始日 0为周日 1为周一
 * @returns
 */
export const getYearWeeks = (year: number, weekStartDay = 0): MonthWeek[] => {
  const weeks = [];
  for (let i = 0; i <= 11; i += 1) {
    weeks.push({month: i, weeks: getMonthWeeks(year, i, weekStartDay)});
  }
  return weeks;
};

页面

页面布局使用了grid和table,使用grid布局让一行显示4个,并且会自动换行。日期显示使用了table布局。

如果想学习grid布局,推荐这篇文章

工作日历日期分为三种类型,工作日、休息日、节假日。在渲染单元格根据不同的日期类型,渲染不同背景颜色用于区分。

维护日期类型

背景

虽然节假日信息可以从网上公共api获取到,但是我们的业务希望可以自己调整日期类型,这个简单给单元格加一个点击事件,点击后弹出一个框去维护当前日期类型,但是业务希望能支持框选多个日期,然后一起调整,这个就稍微麻烦一点,下面给大家分享一下我的做法。

实现思路

实现框选框

定义一个fixed布局的div,设置背景色和边框颜色,背景色稍微有点透明。监听全局点击事件,记录初始位置,然后监听鼠标移动事件,拿当前位置减去初始位置就是宽度和高度了,初始位置就是div的left和top。

获取框选框内符合条件的dom元素

当框选框位置改变的时候,获取所有符合条件的dom元素,然后通过坐标位置判断dom元素是否和框选框相交,如果相交,说明被框选了,把当前dom返回出去。

判断两个矩形是否相交

ts 复制代码
interface Rect {
  x: number;
  y: number;
  width: number;
  height: number;
}

export function isRectangleIntersect(rect1: Rect, rect2: Rect) {
  // 获取矩形1的左上角和右下角坐标
  const x1 = rect1.x;
  const y1 = rect1.y;
  const x2 = rect1.x + rect1.width;
  const y2 = rect1.y + rect1.height;

  // 获取矩形2的左上角和右下角坐标
  const x3 = rect2.x;
  const y3 = rect2.y;
  const x4 = rect2.x + rect2.width;
  const y4 = rect2.y + rect2.height;

  // 如果 `rect1` 的左上角在 `rect2` 的右下方(即 `x1 < x4` 和 `y1 < y4`),并且 `rect1` 的右下角在 `rect2` 的左上方(即 `x2 > x3` 和 `y2 > y3`),那么这意味着两个矩形相交,函数返回 `true`。
  // 否则,函数返回 `false`,表示两个矩形不相交。
  if (x1 < x4 && x2 > x3 && y1 < y4 && y2 > y3) {
    return true;
  } else {
    return false;
  }
}

具体实现

框选框组件实现

tsx 复制代码
import { useEffect, useRef, useState } from 'react';

import { createPortal } from 'react-dom';
import { isRectangleIntersect } from './utils';

interface Props {
  selectors: string;
  sourceClassName: string;
  onSelectChange?: (selectDoms: Element[]) => void;
  onSelectEnd?: () => void;
  style?: React.CSSProperties,
}

function BoxSelect({
  selectors,
  sourceClassName,
  onSelectChange,
  style,
  onSelectEnd,
}: Props) {

  const [position, setPosition] = useState({ top: 0, left: 0, width: 0, height: 0 });

  const isPress = useRef(false);

  const startPos = useRef<any>();

  useEffect(() => {
    // 滚动的时候,框选框位置不变,但是元素位置会变,所以需要重新计算
    function scroll() {
      if (!isPress.current) return;
      setPosition(prev => ({ ...prev }));
    }

    // 鼠标按下,开始框选
    function sourceMouseDown(e: any) {
      isPress.current = true;
      startPos.current = { top: e.clientY, left: e.clientX };
      setPosition({ top: e.clientY, left: e.clientX, width: 1, height: 1 })
      // 解决误选择文本情况
      window.getSelection()?.removeAllRanges();
    }
    // 鼠标移动,移动框选
    function mousemove(e: MouseEvent) {
      if (!isPress.current) return;

      let left = startPos.current.left;
      let top = startPos.current.top;
      const width = Math.abs(e.clientX - startPos.current.left);
      const height = Math.abs(e.clientY - startPos.current.top);

      // 当后面位置小于前面位置的时候,需要把框的坐标设置为后面的位置
      if (e.clientX < startPos.current.left) {
        left = e.clientX;
      }

      if (e.clientY < startPos.current.top) {
        top = e.clientY;
      }

      setPosition({ top, left, width, height })
    }

    // 鼠标抬起
    function mouseup() {

      if(!isPress.current) return;

      startPos.current = null;
      isPress.current = false;
      // 为了重新渲染一下
      setPosition(prev => ({ ...prev }));

      onSelectEnd && onSelectEnd();
    }

    const sourceDom = document.querySelector(`.${sourceClassName}`);

    if (sourceDom) {
      sourceDom.addEventListener('mousedown', sourceMouseDown);
    }

    document.addEventListener('scroll', scroll);
    document.addEventListener('mousemove', mousemove);
    document.addEventListener('mouseup', mouseup);

    return () => {
      document.removeEventListener('scroll', scroll);
      document.removeEventListener('mousemove', mousemove);
      document.removeEventListener('mouseup', mouseup);

      if (sourceDom) {
        sourceDom.removeEventListener('mousedown', sourceMouseDown);
      }
    }
  }, [])

  useEffect(() => {
    const selectDoms: Element[] = [];
    const boxes = document.querySelectorAll(selectors);
    (boxes || []).forEach((box) => {
      // 判断是否在框选区域
      if (isRectangleIntersect({
        x: position.left,
        y: position.top,
        width: position.width,
        height: position.height,
      },
        box.getBoundingClientRect()
      )) {
        selectDoms.push(box);
      }
    });
    onSelectChange && onSelectChange(selectDoms);
  }, [position]);


  return createPortal((
    isPress.current && (
      <div
        className='fixed bg-[rgba(0,0,0,0.2)]'
        style={{
          border: '1px solid #666',
          ...style,
          ...position,
        }}
      />)
  ), document.body)
}


export default BoxSelect;

使用框选框组件,并在框选结束后,给框选日期设置类型

tsx 复制代码
import { Modal, Radio } from 'antd';
import { useEffect, useMemo, useRef, useState } from 'react';
import BoxSelect from './box-select';
import WorkCalendar from './work-calendar';

import './App.css';

function App() {

  const [selectDates, setSelectDates] = useState<string[]>([]);
  const [open, setOpen] = useState(false);
  const [dateType, setDateType] = useState<number | null>();
  const [dates, setDates] = useState<any>({});

  const selectDatesRef = useRef<string[]>([]);

  const workDays = useMemo(() => {
    return Object.keys(dates).filter(date => dates[date] === 1)
  }, [dates])

  const restDays = useMemo(() => {
    return Object.keys(dates).filter(date => dates[date] === 2)
  }, [dates]);

  const holidayDays = useMemo(() => {
    return Object.keys(dates).filter(date => dates[date] === 3)
  }, [dates]);

  useEffect(() => {
    selectDatesRef.current = selectDates;
  }, [selectDates]);

  return (
    <div>
      <WorkCalendar
        defaultWeekStartDay={0}
        workDays={workDays}
        holidayDays={holidayDays}
        restDays={restDays}
        selectDates={selectDates}
        year={new Date().getFullYear()}
      />
      <BoxSelect
        // 可框选区域
        sourceClassName='work-calendar'
        // 可框选元素的dom选择器,
        selectors='td.date[data-date]'
        // 框选元素改变时的回调,可以拿到框选中元素
        onSelectChange={(selectDoms) => {
          // 内部给td元素上设置了data-date属性,这样就可以从dom元素上拿到日期
          setSelectDates(selectDoms.map(dom => dom.getAttribute('data-date') as string))
        }}
        // 框选结束事件
        onSelectEnd={() => {
          // 如果有框选就弹出设置弹框
          if (selectDatesRef.current.length) {
            setOpen(true)
          }
        }}
      />
      <Modal
        title="设置日期类型"
        open={open}
        onCancel={() => {
          setOpen(false);
          setSelectDates([]);
          setDateType(null);
        }}
        onOk={() => {
          setOpen(false);
          selectDatesRef.current.forEach(date => {
            setDates((prev: any) => ({
              ...prev,
              [date]: dateType,
            }))
          })
          setSelectDates([]);
          setDateType(null);
        }}
      >
        <Radio.Group
          options={[
            { label: '工作日', value: 1 },
            { label: '休息日', value: 2 },
            { label: '节假日', value: 3 },
          ]}
          value={dateType}
          onChange={e => setDateType(e.target.value)}
        />
      </Modal>
    </div>
  )
}

export default App

工作日历改造

给td的class里加了个date,并且给元素上加了个data-date属性

如果被框选,改变一下背景色

效果展示

小结

本来想给mousemove加节流函数,防止触发太频繁影响性能,后面发现不加节流很流畅,加了节流后因为延迟,反而不流畅了,后面如果有性能问题,再优化吧。

最后

借助这次封装又复习了一下Date的一些常用方法,也学到了一些关于Date不常见但是很有用的方法。

demo体验地址:dbfu.github.io/work-calend...

demo仓库地址:github.com/dbfu/work-c...

相关推荐
热爱编程的小曾10 分钟前
sqli-labs靶场 less 8
前端·数据库·less
gongzemin22 分钟前
React 和 Vue3 在事件传递的区别
前端·vue.js·react.js
Apifox34 分钟前
如何在 Apifox 中通过 Runner 运行包含云端数据库连接配置的测试场景
前端·后端·ci/cd
-代号952739 分钟前
【JavaScript】十四、轮播图
javascript·css·css3
树上有只程序猿1 小时前
后端思维之高并发处理方案
前端
庸俗今天不摸鱼2 小时前
【万字总结】前端全方位性能优化指南(十)——自适应优化系统、遗传算法调参、Service Worker智能降级方案
前端·性能优化·webassembly
QTX187302 小时前
JavaScript 中的原型链与继承
开发语言·javascript·原型模式
黄毛火烧雪下2 小时前
React Context API 用于在组件树中共享全局状态
前端·javascript·react.js
Apifox2 小时前
如何在 Apifox 中通过 CLI 运行包含云端数据库连接配置的测试场景
前端·后端·程序员
一张假钞2 小时前
Firefox默认在新标签页打开收藏栏链接
前端·firefox