手把手带你用React撸一个日程组件

2022-04-15 0 361
目录
  • 业务背景
  • 使用技术
  • 技术难点
  • 设计思路
    • 一脸懵逼苦
    • 开始构思
    • 目录结构
    • 拆分组件
  • 代码实现
    • Container容器组件
    • CalendatrHeader头部容器组件
    • ScheduleCantainer详细日程容器
      • 左侧刻度
      • ScheduleItem日程容器条目
  • 总结

    业务背景

    先简单描述一下业务场景吧, 就是会调用用户在企业微信或者钉钉这类办公软件里面的日程信息, 在web端给安排日程的展示, 如果日程冲突, 就会展示冲突那天的日程, 让安排人员合理安排时间日程, 避免冲突, 如图;

    手把手带你用React撸一个日程组件

    使用技术

    1. UI框架: React(Hook);
    2. 插件: moment(对于一个位居18线的懒B码农必备插件, 要不然自己转来转去的太麻烦了);

    技术难点

    1. API设计;
    2. 组件拆分;
    3. UI和业务解耦;
    4. 开箱即用;

    设计思路

    一脸懵逼苦

    开发项目的时候, 组件库用的是千篇一律的antd, 评审过后下意识的去antd找了一下有没有可以开箱即用的组件.

    不巧!!! 还真没有这种周或者日筛选的组件, 百般恼火, 阿里写了那么多组件, 为啥偏偏漏了这个呢?

    于是转战万能的百度, 查有没有相关的组件, 后来查到了fullcalendar这个组件, 但是后来连人家的文档和demo都没看, 毅然决然的决定自己写一个!

    综合看来有几个原因哈:

    1. 虽然人家的组件写得好, 但是很多业务是千变万化的, 不一定能满足所有的业务需求;
    2. 其次, 新来的开发人员在不熟悉这个组件的时候, 还需要去阅读文档, 增加了维护成本;
    3. 第三点嘛, 在有限的时间内挑战一下自己;

    开始构思

    其实开始构思的时候, 也想过去参考一下优秀组件的API设计, 但是, 现在有些组件写的真的很难用, 而且不理解他为蛤蟆这么写, 所以用自己站在一个使用者的角度思考了一下, 还是觉得按照自己作为一个十八流的底层最懒B的程序员的调用方式去设计——-开箱即用.

    而且还有很重要的一点就是和业务解耦, 方便其他的项目直接用起来呀, 何乐而不为呢?

    于是自己开始花了一上午青春, 结合自己的想法, 绘制了一个设计图:

    手把手带你用React撸一个日程组件

    这个就是用ProcessOn绘制的, 本人用的不多, 画得不好哈, 见谅!

    目录结构

    └─Calendar
        │  data.d.ts  类型定义文件
        │  index.tsx  入口文件
        │
        ├─components
        │  ├─CalendatrHeader 头部容器组件
        │  │  │  index.less
        │  │  │  index.tsx
        │  │  │
        │  │  └─components
        │  │      ├─DailyOptions 顶部切换日期和切换模式状态组件
        │  │      │      index.less
        │  │      │      index.tsx
        │  │      │
        │  │      └─WeeklyOptions 周模式日期和星期组件
        │  │              index.less
        │  │              index.tsx
        │  │
        │  ├─Container 容器组件
        │  │      Container.tsx
        │  │      index.less
        │  │
        │  ├─ScheduleCantainer 下半部日程容器
        │  │      index.less
        │  │      index.tsx
        │  │
        │  └─ScheduleItem 灰色部分每一条日程组件
        │          index.less
        │          index.tsx
        │
        └─utils
                index.ts 工具文件

    拆分组件

    仔细看图, 不难看出, 组件大块上我拆分成了三个部分:
    Container容器: 该组件是整个组件的容器, 负责UI核心状态数据, 维护两个状态:

    1. targetDay: 当前选中日期时间戳(为什么选用时间戳后续解释);
    2. switchWeekAndDay: 保存日和周的状态;

    CalendatrHeader头部容器组件: Container容器的子组件, 该组件负责切换日期, 改变组件周和日的状态; 该组件内, 包含日历组件, 星期组件, 日期筛选组件, 日和周切换组件, 今天按钮组件, 最后还有一个业务组件的容器(businessRender);

    ScheduleCantainer日程容器组件: 该组件被25 (因为是从今天0点到次日凌晨0点的区间) 个scheduleRender组件撑开, 子组件还包括时间刻度组件;

    scheduleRender: 特意说一下这个组件, 这个组件接受一个回调, 回调会返回一个JSX, 这个JSX就是调用者传入的自定义样式的日程组件(具体内容在后文讲吧);

    这就是大致的组件拆分, 文字表达确实欠佳, 可以结合图片YY;

    接下来就开干吧!!!

    代码实现

    先看一下接受的参数类型定义:

    type dataType = {
      startTime: DOMTimeStamp; // 开始时间戳
      endTime: DOMTimeStamp; // 结束时间戳
      [propsName: string]: any; // 业务数据
    };
    
    type ContainerType = {
      data: dataType[]; // 业务数据
      initDay?: DOMTimeStamp; // 初始化时间戳
      onChange?: (params: DOMTimeStamp) => void; // 改变日期时的onChange方法
      height?: number; // ScheduleCantainer容器的高度
      scheduleRender?: ({
        data: dataType,
        timestampRange: [DOMTimeStamp, DOMTimeStamp],
      }) => JSX.Element; // 传入的回调, 会接收到当前这条数据的业务数据, 当前业务数据所在的时间戳范围;
      businessRender?: ({ timestamp: DOMTimeStamp }) => React.ReactNode; // 传入的业务组件, 查询前端蔡徐坤那个, 看图, 想起来了吗?
      mode?: 'day' | 'week'; // 初始化展示日和天的模式
    };
    

    Container容器组件

    代码:

    const Container: React.FC<ContainerType> = ({
      initDay,
      onChange,
      scheduleRender,
      businessRender,
      data,
      height = 560,
      mode = 'day',
    }) => {
      // 当前选择日期时间戳
      const [targetDay, setTargetDay] = useState<DOMTimeStamp>(initDay);
      // 切换日和周
      const [switchWeekandDay, setSwitchWeekandDay] = useState<'day' | 'week'>(mode);
    
      return (
        <div className={style.Calendar_Container}>
          <CalendatrHeader
            targetDay={targetDay}
            setTargetDay={(timestamp) => {
              onChange(timestamp);
              setTargetDay(timestamp);
            }}
            businessRender={businessRender}
            switchWeekandDay={switchWeekandDay}
            setSwitchWeekandDay={setSwitchWeekandDay}
          />
          <ScheduleCantainer
            height={height}
            data={data}
            targetDay={targetDay}
            scheduleRender={scheduleRender}
          />
        </div>
      );
    };
    

    看代码可以思考一下, 肯定要将全局的状态数据提升到最高层级去控制, 也符合React的组件设计哲学;

    维护了当前时间戳和日/周的状态, 所有子组件的状态都是根据targetDay去展示的;

    CalendatrHeader头部容器组件

    头部容器我觉得其他的还好, 由于星期是写死的(主要是参考了一下苹果的那个日程组件, 苹果的星期就没换, 所以参考大厂优秀的设计), 所以比较敲脑壳的就是如何能准确的展示一周的日期;

    其实展示一周的日期我写了两种方式:

    第一种是以当前的日期的星期为基准, 分别向前和向后去计算, 最后输出一个[29, 30, 31, 1, 2, 3, 4]这样的List, 如果恰巧今天是1号 或者 2号, 就去拉去上个月最后一天的日期往前递减;

    第二种方式就是下面代码的方式, 也是拿到当前日期的星期去定位, 通过时间戳去动态计算出来, 只要知道往前减几天, 往后追加几天就好了;

    其实两种方式都可以, 最后我用了第二种, 显然第二种更加简洁;

    如下图:

    手把手带你用React撸一个日程组件

     当前一周就要输出[12, 13, 14, 15, 16, 17, 18]

    下面是上述难点具体实现的代码:

    const calcWeekDayList: (params: number) => WeekType = (params) => {
        const result = [];
        for (let i = 1; i < weekDay(params); i++) {
          result.unshift(params - 3600 * 1000 * 24 * i);
        }
        for (let i = 0; i < 7 - weekDay(params) + 1; i++) {
          result.push(params + 3600 * 1000 * 24 * i);
        }
        return [...result] as WeekType;
      };
    

    代码:

    const CalendatrHeader: React.FC<CalendatrHeaderType> = ({
      targetDay,
      setTargetDay,
      switchWeekandDay,
      businessRender,
      setSwitchWeekandDay,
    }) => {
      // 当前一周的日期
      const [dateTextList, setDateTextList] = useState<WeekType | []>([]);
      // 这个状态是在切换周的时候, 直接增加或者减少一周的时间戳, 下一周或者上一周的日期就会被自动算出来;
      const [currTime, setCurrTime] = useState<number>(targetDay); 
    
      useEffect(() => {
        setDateTextList(calcWeekDayList(targetDay));
      }, [targetDay]);
    
      // 根据当前时间戳, 计算之前和之后天数的日期, 由于星期是固定不变的, 所以只计算当前一周的日期就好了
      const calcWeekDayList: (params: number) => WeekType = (params) => {
        const result = [];
        for (let i = 1; i < weekDay(params); i++) {
          result.unshift(params - 3600 * 1000 * 24 * i);
        }
        for (let i = 0; i < 7 - weekDay(params) + 1; i++) {
          result.push(params + 3600 * 1000 * 24 * i);
        }
        return [...result] as WeekType;
      };
    
      const onChangeWeek: (type: 'prevWeek' | 'nextWeek', switchWay: 'week' | 'day') => void = (
        type,
        switchWay,
      ) => {
        if (switchWay === 'week') {
          const calcWeekTime =
            type === 'prevWeek' ? currTime - 3600 * 1000 * 24 * 7 : currTime + 3600 * 1000 * 24 * 7;
          setCurrTime(calcWeekTime);
          setDateTextList([...calcWeekDayList(calcWeekTime)]);
        }
    
        if (switchWay === 'day') {
          const calcWeekTime =
            type === 'prevWeek' ? targetDay - 3600 * 1000 * 24 : targetDay + 3600 * 1000 * 24;
          setCurrTime(calcWeekTime);
          setTargetDay(calcWeekTime);
        }
      };
    
      return (
        <div className={style.Calendar_Header}>
          <DailyOptions
            targetDay={targetDay}
            setCurrTime={setCurrTime}
            setTargetDay={setTargetDay}
            dateTextList={dateTextList}
            switchWeekandDay={switchWeekandDay}
            setSwitchWeekandDay={(value) => {
              setSwitchWeekandDay(value);
              if (value === 'week') {
                setDateTextList(calcWeekDayList(targetDay));
              }
            }}
            onChangeWeek={(type) => onChangeWeek(type, switchWeekandDay)}
          />
          {switchWeekandDay === 'week' && (
            <WeeklyOptions
              targetDay={targetDay}
              setTargetDay={setTargetDay}
              dateTextList={dateTextList}
            />
          )}
          <div className={style.Calendar_Header_businessRender}>
            <div className={style.Calendar_Header_Zone}>GMT+8</div>
            {businessRender({ timestamp: targetDay })}
          </div>
        </div>
      );
    };
    

    DailyOptions : 其实就是头部切换”一周日期” & “日和周模式” & “今天”的组件的容器;

    WeeklyOptions : 这个是下面展示当前一周星期几和日期的组件, 如果切换为day的话不展示: 如图:

    手把手带你用React撸一个日程组件

    businessRender: 这个就是肖战哥哥那一栏用户传入的业务组件;

    ScheduleCantainer详细日程容器

    也就是图片这部分:

    手把手带你用React撸一个日程组件

    其实这部分代码比较多, 就不方便全都贴出来了; 我根据功能点贴出来部分片段吧;

    左侧刻度

    左侧刻度其实就是写死的 从00:00 – 01:00 —> 23:00 – 00:00, 但是在写的时候有一个小的问题, 就是这个组件是浮动到左侧的, 而且他要随着右侧条目的滚动而滚动, 其实一开始我写到一个盒子里了, 滚动容器整体就一起滚动了, 但是遇到了一个小问题, 由于右侧条目会变得超宽, 就会出现横向滚动条, 如果横滚整个容器的话, 左侧的时间刻度就会被滚动出可视区域.

    所以还是绝对定位之后, 监听右侧日程条目的滚动事件, 动态的改变左侧的style的top值, 反向赋值就好了, 由于是向下滚动的, 所以左侧的时间刻度需要向上滚动, 所以top值取反就会达到同步的效果; 真是个小机灵鬼吧, 嘿嘿; 这个代码就不占用篇幅了, 大家自由发挥吧, 如果有更好的方式, 欢迎评论区留言.

    ScheduleItem日程容器条目

    先看下这个组件的代码:

    const ScheduleItem: React.FC<ScheduleItemType> = ({
      timestampRange,
      dataItem,
      scheduleRender,
      width,
      dataItemLength,
    }) => {
      // 计算容器高度
      const calcHeight: (timestampList: [number, number]) => number = (timestampList) =>
        timestampList.length > 1 ? (timestampList[1] - timestampList[0]) / 1000 / 60 / 2 : 30;
      const calcTop: (startTime: number) => number = (startTime) => moment(startTime).minute() / 2;
      // 计算 ScheduleItem 宽度
      const calcWidth: (w: number, d: number) => string = (w, d) =>
        width === 0 || dataItemLength * width < 347 ? '100%' : `${d * w}px`;
    
      return (
        <div style={{ position: 'relative' }} className={style.Calendar_ScheduleItem_Fath}>
          <div
            className={style.Calendar_ScheduleItem}
            style={{ width: calcWidth(width, dataItemLength) }}
          >
            {dataItem.map((data, index) => {
              return (
                <Fragment key={index}>
                  {data.startTime >= timestampRange[0] && data.startTime < timestampRange[1] && (
                    <div
                      className={`${style.Calendar_ScheduleItem_container} Calendar_ScheduleItem_container`}
                      style={{
                        height: `${calcHeight([data.startTime, data.endTime]) || 30}px`,
                        top: calcTop(data.startTime),
                      }}
                    >
                      {scheduleRender({ data, timestampRange })}
                    </div>
                  )}
                </Fragment>
              );
            })}
          </div>
        </div>
      );
    };
    

    这一部分呢(就是下面灰色一条一条的部分), 为什么要单独出一个组件呢? 可以先思考一下……

    好了, 不卖关子了, 其实就是为了好定位用户的日程数据, 例如今天的10:00 — 11:00, 定位到哪里的问题.

    还记得这个API吗?

    scheduleRender?: ({
        data: dataType,
        timestampRange: [DOMTimeStamp, DOMTimeStamp],
      }) => JSX.Element; 
    

    这个组件内会有[DOMTimeStamp, DOMTimeStamp] 这样的一个参数(DOMTimeStamp时间戳的意思), 这两个时间戳其实就是当前时段的 10:00 — 11:00 的其实和截至时间戳, 由于我们接受的startTime和endTime也是时间戳, 通过比较大小是否在这个范围, 就可以控制展示和隐藏, 这回明白为什么采用时间戳了吧, 直接比较数字大小就好了;

    我们再说一下这个东东的样式问题:

    其实这个东东我我写死了30px, 原因呢就是因为一小时是60分钟, 如果60px的话太高了, 所以写了30px, 方便定位嘛, 毕竟我懒, 不想太复杂的计算;

    所以定位计算也就一行代码: const calcTop: (startTime: number) => number = (startTime) => moment(startTime).minute() / 2; 高度定位问题结了! 哈哈~~

    接下来呢, 还有一个问题就是高度问题, 如图:

    手把手带你用React撸一个日程组件

    高度计算其实也不难, 主要根据当前起止时间的区间范围去计算( 1px 两分钟 ), 具体实现看代码:

    const calcHeight: (timestampList: [number, number]) => number = (timestampList) =>
        timestampList.length > 1 ? (timestampList[1] - timestampList[0]) / 1000 / 60 / 2 : 30;
    

    首先会判断入参的时间戳是不是只有一个时间, 如果只有开始时间, 没有结束时间, 写死30px, 如果有起止时间, 就去转成分钟动态计算一下;

    最后还有一个问题, 业务数据是怎么传进去是如何渲染到组件的:

    先看一下我们传入data字段的JSON:

    [
      {
        startTime: 1626057075000, // 开始时间
        endTime: 1626070875000, // 结束时间
        value: 'any', // 业务数据
      },
      {
        startTime: 1626057075000,
        endTime: 1626070875000,
        value: 'any',
      },
      {
        startTime: 1626057075000,
        endTime: 1626070875000,
        value: 'any',
      },
      {
        startTime: 1626057075000,
        endTime: 1626070875000,
        value: 'any',
      },
    ];
    

    其实我们在循环渲染这个ScheduleItem组件的时候, 用那个写死的24h的list去循环, 之后, 循环的时候, 动态的去业务数据中去查找符合当次循环的时间范围内的业务数据, 把这个数据塞到组件内; 大致代码如下:

    for (let i = 0; i < HoursList.length; i++) {
          resule.push({
            timestampRange: [todayTime + i * 3600 * 1000, todayTime + (i + 1) * 3600 * 1000],
            dataItem: [ // 由于当前一个时间段, 日程可能冲突, 所以要有一个list传入组件
              ...data.filter((item) => {
                return (
                  item.startTime >= todayTime + i * 3600 * 1000 &&
                  item.startTime < todayTime + (i + 1) * 3600 * 1000
                );
              }),
            ],
          });
        }
    

    总结

    以上就是这个组件大部分的实现, 从接到需求, 到设计组件, 最后到实现细节, 说的不一定面面俱到, 但也算是基本的实现思想.

    也罗列了一下技术难点的实现细节, 其实看起来也并不难, 只要稍稍动动脑就可以了.

    我的实现方式也不一定很好, 程序的实现方式千万种, 我只是表达了一下自己的设计思想, 与大家共同学习, 如果有什么不好的地方, 欢迎大家评论区指出, 我们共同进步.

    到此这篇关于手把手带你用React撸一个日程组件的文章就介绍到这了,更多相关React日程组件内容请搜索NICE源码以前的文章或继续浏览下面的相关文章希望大家以后多多支持NICE源码!

    免责声明:
    1、本网站所有发布的源码、软件和资料均为收集各大资源网站整理而来;仅限用于学习和研究目的,您必须在下载后的24个小时之内,从您的电脑中彻底删除上述内容。 不得使用于非法商业用途,不得违反国家法律。否则后果自负!

    2、本站信息来自网络,版权争议与本站无关。一切关于该资源商业行为与www.niceym.com无关。
    如果您喜欢该程序,请支持正版源码、软件,购买注册,得到更好的正版服务。
    如有侵犯你版权的,请邮件与我们联系处理(邮箱:skknet@qq.com),本站将立即改正。

    NICE源码网 JavaScript 手把手带你用React撸一个日程组件 https://www.niceym.com/29957.html