• h5日历组件制作


    一、背景

    当市面上诸如antd、element之类框架的日历组件无法满足需求时,只能考虑自己手写一个日历了。

    在自己实现之前会觉得日期的展示这块不好处理,而实现过后会发现这块的处理逻辑很有通用性,无论是pc端还是移动端、vue栈还是react,都能套用同样的处理方式,其他的交互和自定义展示就没啥难度了,根据业务需要自己定制就行。

    本文以react移动端日历组件为例,文末提供完整代码。

    二、功能

    可实现的功能有:

    • 默认展示当月日历
    • 可切换年或月
    • 可选中任意日期,支持多选
    • 可对任意日期进行特殊标记展示

    了解代码思路后,你可以随意修改代码,功能随意拓展。

    三、实现

    1、dom结构和样式

    日历是纵向按星期来对齐的,

    星期栏单独一个容器,flex布局,里面7个元素平分:

    const weekList = ['日', '一', '二', '三', '四', '五', '六'];
    
    <div className={styles.weekBar}>
      {weekList.map((v) => (
        <div key={v} className={styles.weekCol}>
          {v}
        </div>
      ))}
    </div>
    
    .weekBar {
      display: flex;
      align-items: center;
      color: #666;
    
      .weekCol {
        height: 100px;
        width: 14.28571%;
        display: flex;
        align-items: center;
        justify-content: center;
      }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23

    日期列表参照 antd-mobile 的展示,固定6行,共42项,共用一个容器,和星期栏类似,flex布局,子元素占总宽度的1/7,溢出换行(flex-wrap: wrap)。

    2、日期展示

    前面日期列表的展示结构确定了,剩下的就是拿到42项日期的数组数据,然后遍历渲染dom即可。

    我的思路是把这42项数据分成三部分,上月的部分数据 + 当前月的所有数据 + 下月的部分数据,拼接起来就是要展示日期表格数据。

    • 变量:
    cYear // 所选的年份
    cMonth // 所选的月份
    
    • 1
    • 2
    • 关键API:
    new Date(cYeear, cMonth, 1) // 获取指定年月的第一天的日期对象
    new Date(cYeear, cMonth, 0) // 获取指定年月的最后一天的日期对象
    // 备注:cMonth比我们实际的月份小1,和getMonth()是对齐的,例如二月就是1;cMonth这里也可以传负值,API会智能计算,例如-1表示上一年的12月
    
    • 1
    • 2
    • 3
    • 用到的函数:
    // 获取指定年月的当月所有天的数组,例如:2022年8月得到的是[1,2,3,4,......,31]
    const getDateListByMonth = (year, month) => {
      const days = new Date(year, month, 0).getDate();
      return new Array(days).fill(null).map((v, i) => i + 1);
    }
    
    // 获取指定年月的1号是周几,备注:
    const getWeekOfFirstDay = (year, month) => {
      return new Date(year, month - 1, 1).getDay();
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 上月的部分数据
      根据当前月的1号是周几,来判断需要取几条上月的数据,然后拿到上月的完整数据进行数组截取。特殊情况:如果1号是周日就取7条。
    // 上月的部分数据
    const week = getWeekOfFirstDay(cYear, cMonth);
    const lastMonthNums = getDateListByMonth(cYear, cMonth - 1);
    const preffixList = lastMonthNums.slice(week === 0 ? -7 : -week).map((v) => ({
      id: tools.formatDate(new Date(cYear, cMonth - 2, v), '{y}-{m}-{d}'),
      monthType: 'last',
      disabled: true,
      num: v,
    }));
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 当前月的全部数据
      当前月会完全展示,取完整数据。
    // 当月的数据
    const curMonthNums = getDateListByMonth(cYear, cMonth);
    const currentList = curMonthNums.map((v) => {
      const vDate = new Date(cYear, cMonth - 1, v);
      const isRedDot = +vDate >= +todayZeroDate;
      const id = tools.formatDate(vDate, '{y}-{m}-{d}');
      return {
        id,
        monthType: 'current',
        disabled: false,
        chose: choseDateList.includes(id),
        num: v,
        isRedDot,
      };
    });
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 下月的部分数据
      总共展示6行7列,共42条数据,减去上述中上月和当前月的数据条数,就是下月数据里要截取的数量。
    // 下月的部分数据
    const nextMonthNums = getDateListByMonth(cYear, cMonth + 1);
    const nextList = nextMonthNums
      .slice(0, 6 * 7 - preffixList.length - currentList.length)
      .map((v) => ({
        id: tools.formatDate(new Date(cYear, cMonth, v), '{y}-{m}-{d}'),
        monthType: 'next',
        disabled: true,
        num: v,
      }));
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 数据字段解释:
    {
      id, // 唯一标识,我这里格式为“2022-08-25”,方便取用
      monthType, // 月类型,分为:上月、当月、下月,备用
      disabled, // 是否置灰
      num, // 展示的日期数字
      isRedDot, // 是否展示红点标识
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    以上数据字段可以随意拓展,根据你的需要来对日历展示不同的状态、不同标记等,或者对日历点击事件做自定义的操作。

    四、其他

    1、农历

    网上有一些公历转农历的插件,可以拿来用,例如:lunar-calendar,还包含传统节假日。

    2、二十四节气

    //计算节气
    function getJQ(yyyy,mm,dd){
     var sTermInfo = new Array(0,21208,42467,63836,85337,107014,128867,150921,173149,195551,218072,240693,263343,285989,308563,331033,353350,375494,397447,419210,440795,462224,483532,504758);
     var solarTerm = new Array("小寒","大寒","立春","雨水","惊蛰","春分","清明","谷雨","立夏","小满","芒种","夏至","小暑","大暑","立秋","处暑","白露","秋分","寒露","霜降","立冬","小雪","大雪","冬至");
     var tmp1 = new Date((31556925974.7*(yyyy-1900)+sTermInfo[mm*2+1]*60000)+Date.UTC(1900,0,6,2,5));
     var tmp2 = tmp1.getUTCDate();
     var solarTerms = "";
     if (tmp2==dd)
       solarTerms = solarTerm[mm*2+1];
     tmp1 = new Date((31556925974.7*(yyyy-1900)+sTermInfo[mm*2]*60000)+Date.UTC(1900,0,6,2,5));
     tmp2= tmp1.getUTCDate();
     if (tmp2==dd)
       solarTerms = solarTerm[mm*2];
     return solarTerms;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15

    3、法定节假日

    法定节假日的安排是国家每年不同时间制定颁布的,只能手动录入,没法预测计算,这个可以找一些第三方API,例如阿里云、腾讯云的一些服务,可能需要付费。

    五、示例代码

    import React, { useState, useEffect } from 'react';
    import styles from './styles.less';
    import tools from 'ut-utils';
    
    const todayDate = new Date(); // 今日日期,实际项目中推荐从接口拿服务器日期用
    const todayZeroDate = new Date(todayDate.getFullYear(), todayDate.getMonth(), todayDate.getDate()); // 今日零点的日期,用于后续转换时间戳后日期大小的比较
    
    const weekList = ['日', '一', '二', '三', '四', '五', '六'];
    
    export default Page = () => {
      const [cYear, setCYear] = useState(todayZeroDate.getFullYear());
      const [cMonth, setCMonth] = useState(todayZeroDate.getMonth() + 1);
    
      const [dateList, setDateList] = useState([]);
      const [choseDateList, setChoseDateList] = useState([]);
    
      const onChangeYear = (num: number) => {
        setCYear(cYear + num);
      };
    
      const onChangeMonth = (num: number) => {
        if (cMonth + num <= 0) {
          setCYear(cYear - 1);
          setCMonth(12 - (cMonth + num));
        } else if (cMonth + num >= 13) {
          setCYear(cYear + 1);
          setCMonth(cMonth + num - 12);
        } else {
          setCMonth(cMonth + num);
        }
      };
    
      // 获取指定年月的当月所有天的数组
      const getDateListByMonth = (year, month) => {
        const days = new Date(year, month, 0).getDate();
        return new Array(days).fill(null).map((v, i) => i + 1);
      };
    
      // 获取指定年月的1号是周几
      const getWeekOfFirstDay = (year, month) => {
        return new Date(year, month - 1, 1).getDay();
      };
    
      // 获取日期渲染数据
      useEffect(() => {
        // 上月的部分数据
        const week = getWeekOfFirstDay(cYear, cMonth);
        const lastMonthNums = getDateListByMonth(cYear, cMonth - 1);
        const preffixList = lastMonthNums.slice(week === 0 ? -7 : -week).map((v) => ({
          id: tools.formatDate(new Date(cYear, cMonth - 2, v), '{y}-{m}-{d}'),
          monthType: 'last',
          disabled: true,
          num: v,
        }));
        // 当月的数据
        const curMonthNums = getDateListByMonth(cYear, cMonth);
        const currentList = curMonthNums.map((v) => {
          const vDate = new Date(cYear, cMonth - 1, v);
          const isRedDot = +vDate >= +todayZeroDate;
          const id = tools.formatDate(vDate, '{y}-{m}-{d}');
          return {
            id,
            monthType: 'current',
            disabled: false,
            chose: choseDateList.includes(id),
            num: v,
            isRedDot,
          };
        });
        // 下月的部分数据
        const nextMonthNums = getDateListByMonth(cYear, cMonth + 1);
        const nextList = nextMonthNums
          .slice(0, 6 * 7 - preffixList.length - currentList.length)
          .map((v) => ({
            id: tools.formatDate(new Date(cYear, cMonth, v), '{y}-{m}-{d}'),
            monthType: 'next',
            disabled: true,
            num: v,
          }));
        // 拼接
        setDateList([...preffixList, ...currentList, ...nextList]);
      }, [cYear, cMonth, choseDateList]);
      
      // 点击选中日期的事件
      const onClickDate = (item) => {
        if (item.disabled) {
          return;
        }
        const index = choseDateList.indexOf(item.id);
        if (index === -1) {
          choseDateList.push(item.id);
        } else {
          choseDateList.splice(index, 1);
        }
        setChoseDateList([...choseDateList]);
      };
    
      return (
        <div className={styles.popupBody}>
          <div className={styles.info}>
            <p className="chosed">已选择 {choseDateList.length}</p>
          </div>
          <div className={styles.main}>
            {/* 切换年月栏 */}
            <div className={styles.topBar}>
              <div className={styles.topBarL}>
                <span onClick={() => onChangeYear(-1)}>--</span>
                <span onClick={() => onChangeMonth(-1)}>-</span>
              </div>
              <p className={styles.dateTxt}>
                {cYear}{cMonth}</p>
              <div className={styles.topBarR}>
                <span onClick={() => onChangeMonth(1)}>+</span>
                <span onClick={() => onChangeYear(1)}>++</span>
              </div>
            </div>
    
            {/* 星期栏 */}
            <div className={styles.weekBar}>
              {weekList.map((v) => (
                <div key={v} className={styles.weekCol}>
                  {v}
                </div>
              ))}
            </div>
    
            {/* 日期栏 */}
            <div className={styles.dateWrap}>
              {dateList.map((v) => (
                <div
                  key={v.id}
                  className={`${styles.dateItem}
                    ${v.disabled ? styles.grey : ''}
                    ${v.chose ? styles.chose : ''}`}
                  onClick={() => onClickDate(v)}
                >
                  <div className={styles.numWrap}>
                    <span>{v.num}</span>
                    {v.isRedDot ? <i className={styles.dot} /> : null}
                  </div>
                </div>
              ))}
            </div>
          </div>
        </div>
      );
    };
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68
    • 69
    • 70
    • 71
    • 72
    • 73
    • 74
    • 75
    • 76
    • 77
    • 78
    • 79
    • 80
    • 81
    • 82
    • 83
    • 84
    • 85
    • 86
    • 87
    • 88
    • 89
    • 90
    • 91
    • 92
    • 93
    • 94
    • 95
    • 96
    • 97
    • 98
    • 99
    • 100
    • 101
    • 102
    • 103
    • 104
    • 105
    • 106
    • 107
    • 108
    • 109
    • 110
    • 111
    • 112
    • 113
    • 114
    • 115
    • 116
    • 117
    • 118
    • 119
    • 120
    • 121
    • 122
    • 123
    • 124
    • 125
    • 126
    • 127
    • 128
    • 129
    • 130
    • 131
    • 132
    • 133
    • 134
    • 135
    • 136
    • 137
    • 138
    • 139
    • 140
    • 141
    • 142
    • 143
    • 144
    • 145
    • 146
    • 147
    • 148
    .popupBody {
      background-color: #eaeaea;
      font-size: 14px;
    
      .info {
        padding: 12px;
        margin-bottom: 10px;
        background-color: #fff;
        line-height: 1.5;
      }
      .main {
        padding: 0 12px 12px;
        background-color: #fff;
    
        .topBar {
          display: flex;
          align-items: center;
          justify-content: space-between;
          height: 50px;
          border-bottom: 1px solid #f3f3f3;
        }
        .topBarL, .topBarR {
          display: flex;
          align-items: center;
          font-size: 16px;
    
          span {
            margin: 0 10px;
          }
        }
        .weekBar {
          display: flex;
          align-items: center;
          color: #666;
    
          .weekCol {
            height: 50px;
            width: 14.28571%;
            display: flex;
            align-items: center;
            justify-content: center;
          }
        }
        .dateWrap {
          display: flex;
          align-items: center;
          flex-wrap: wrap;
    
          .dateItem {
            height: 50px;
            width: 14.28571%;
            display: flex;
            align-items: center;
            justify-content: center;
    
            &.grey {
              color: #ccc;
            }
            .numWrap {
              width: 70%;
              height: 70%;
              border-radius: 50%;
              display: flex;
              align-items: center;
              justify-content: center;
              position: relative;
    
              .dot {
                display: block;
                position: absolute;
                bottom: 0;
                left: 50%;
                transform: translateX(-50%);
                width: 5px;
                height: 5px;
                background-color: red;
                border-radius: 50%;
              }
            }
            &.chose {
              .numWrap {
                background-color: #409eff;
                color: #fff;
              }
            }
          }
        }
      }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68
    • 69
    • 70
    • 71
    • 72
    • 73
    • 74
    • 75
    • 76
    • 77
    • 78
    • 79
    • 80
    • 81
    • 82
    • 83
    • 84
    • 85
    • 86
    • 87
    • 88
    • 89
  • 相关阅读:
    Scala
    消费者需求疲软,京东第二季度业绩好于预期,618“立大功”
    Seata安装并注册集成到Nacos服务上
    个人所得税赡养老人书面分摊协议
    软件设计模式系列之五——建造者模式
    linux/bsd/mac上面模块基址/dlopen句柄互相转换的方法
    长连接心跳原理与机制&&工程上踩坑与优化
    influxdb2如何同时应用多个聚合函数
    员工上网行为监控能监控到哪些内容?员工上网行为监控有哪些?
    【开源】基于Vue.js的森林火灾预警系统的设计和实现
  • 原文地址:https://blog.csdn.net/u010059669/article/details/126524159