react实现简易xiaomi日历

12,043 阅读15分钟

自我介绍

大家好,我是 思哲Lee 一名前端开发人员,从业快两年,主要在公司写vue

前言声明

由于我的经验和水平有限,可能这篇文章对于你不会有太大帮助,可能不是因为这篇文章水,而是你的能力已经远超了我的水平极限请理性讨论,不要带有言语攻击,谢谢

为什么会复刻小米日历

出于对技术栈的探索,我又重拾了react,之前在学习阶段写过一个管理系统项目(那个时候还是类组件流行的时候,现在已经变为函数组件了) , 然后又因光神的react小册中日历组件启发,不满足简单的实现,想让日历变得高大上起来,正巧看到了小米的日历效果还不错,因此便进行了一些拙劣的模仿,在技术上是基本完成了日历组件的功能,但和原本小米日历还是有一些功能差距(比如记忆时间点时间段的指定安排等);因为是重拾原因,会留有我一些简单的学习记录,废话不多说,让我们开始进入正题吧

正文

精彩效果预览

image-20240212113536197

你将了解到什么

  • 了解一个日历组件是如何完成的
  • 了解日历月和年的切换是如何进行的
  • 了解日历的每日详细信息是如何获取的(剧透一下,使用lunar库)
  • 得到笔者的源码一份(调皮一下haha)

技术栈

使用主要技术栈如下:

  • react (version:18.2.0)
  • typescript (version:18.2.0)
  • lunar-typescript (version:1.7.1)
  • tailwindCss

react

  • 介绍

    react是前端主流框架之一,是我们今天的主角,他和vue有着类似的响应架构,通过响应状态的改变来驱动视图更新,同时在细节上存在功能的映射,比如生命周期,响应状态驱动,虚拟dom,插槽,整体渲染流程等,这里就不展开了,如果你已经是react&vue的高手,应该已经完成了整体的回顾,为了便于大家对未知知识的不足,附上官网链接: react.docschina.org/

typescript

  • 介绍

    ts是js的上层封装,让只能在运行状态显示和执行的语言变成了和java等一众强语言类型一样拥有了编译时特性,即类型约束(下面我会对类型约束展开,说说我对它的理解),是一个在编码阶段即可发现程序存在错误问题可能性的有效工具, 附上官网链接:www.tslang.cn/index.html

  • ts的优秀约束特性

    继承了java的优秀特性,如接口,泛型,抽象类,类属性和方法的访问约束

    扩展了具有js特性的类型,如 字面量约束 例如: const str1:‘1’ | ‘2’ = ‘1’ (表示这个类型只能指向这两个常量)

    提供了具有ts风格的工具类型函数

    ….

  • 类型约束的秘密

这里简单说一下类型约束是什么,类型约束指一个变量在程序编写时允许指向的数据类型,这里说一下我的理解:每一个变量名可以看做是一个指针(或是大篮子),类型约束就是约束这个指针允许指向的数据类型(或是大篮子允许存放的物品),在程序层面,这个指针可以指向它所有有关联的子类实例对象(如果指向了不允许存储的约束类型则会进行报错),根据需要使用指向实例的不同属性和方法的访问权限来决定类型约束范围,如果想使用指向实例的所有属性和方法那么就用对应子类实例的类型进行约束,否则就使用对应的父类类型进行约束,比如以下场景

// 以下是一个简单例子,实际场景可以使用类的不同特性完成对应的封装工作,简单封装可以使用函数搞定,如果是高复杂度的,那么就应该使用类了,如果类里面还有重复可以使用 类的继承特性完成上层封装行为 , 这里也简单说一下类的第三个特性,多态,多态指调用不同实例的相同方法达到的执行行为不同,多态特性如果需要良好的运用需要引入抽象类或者接口以便更好的管理程序,保证整洁性
class Father{
    public fatherName:string
    constructor(name){
        this.fatherName = name
    }
    sayFather(){
        console.log('i am father of children')
    }
}
class Child1 extends Father{
    public childName:string
    constructor(childName,fatherName){
        super(fatherName)
        this.childName = childName
    }
    
    play(){
        console.log('i have ability of play')
    }
}
class Child2 extends Father{
    public childName:string
    constructor(childName,fatherName){
        super(fatherName)
        this.childName = childName
    }
    coding(){
        console.log('i have ability of coding')
    }
}

// 由于Child1继承Father所以Father可以引用这个有关联的子类实例,但由于类型访问的限制,只能使用这个实例对象的部分属性和方法,即Father和其继承链上的属性和方法
const father:Father = new Child1('child1','father1')
// 创建了 Child2类的实例对象 并使用Child2进行约束,此时child2可以使用Child2类即父类上的属性和方法
const child2:Child2 = new Child2('child2','father2')

// 结论
// 创建了一个子类实例的属性和方法后,如果想使用全部的属性和方法则使用对应的子类实例类型约束即可(在ts中可以不约束,因为其会自动推断,在没有预设值的时候,变量的约束类型就为第一次指向实例的类型,如果手动约束了那么就不会进行类型推断),如果只想使用部分属性和方法,那么使用对应的类型进行约束控制即可,因此类型约束就相当于是限制了访问一个指向实例的数据范围,根据想使用的属性和方法的范围大小,使用对应的类型进行约束即可(在程序正式运行的时候,属性和方法的使用还是会从当前子类实例开始查找的)

lunar

  • 介绍

    lunar是本次文章的主角之一,用于获取当前日期的所有信息,包括阴历,阳历等等的信息,相当于日期信息的集合,通过这个库我们可以知道日期已知的全部信息,如今天是多少号,是农历的什么日历,有什么节日,是否休息,是否调休等等,包含你想要的关于日期的一切,官网如下 6tail.cn/calendar/ap…

tailwindcss

  • 介绍

    tailwindcss 是一个原子化的css库,这个库对所有css进行对应类的封装,这个时候我们只需要使用对应类名的组合即可达到预期样式效果,是一个提高编程效率的有效扩展工具 ; 官网如下 www.tailwindcss.cn/

核心问题解决

月和日的计算

  • 如何得到当月的天数,和上一个月的天数

    使用moment的api可以得到当前月有多少天,当前月的第一天是星期几,基于此可以完成空余天数的补足

    import moment from 'moment'
    moment().daysInMonth(); // 获取当月的天数
    moment('2024-1').dayInmonth() // 得到2024年1月有多少天
    
    momennt().startOf('month').format('d') // 得到当月的第一天是星期几
    

    使用 原始的Date Api可以拿到距离当前月份的上一个月的倒数天数

    // new Date() 可以传入三个参数分别为 年,月(默认从0开始,0表示一月,1表示二月),日
    // 在日的天数不足或超额时,会自动计算对应的上月或下月的天数
    new Date(2024,0,0) // 返回 2023-12-31号
    new Date(2024,0,-1) // 返回 2023-12-30号
    new Date(2024,0,-2) // 返回 2023-12-29号
    
    new Date(2024,0,32) // 返回 2024-2-1
    new Date(2024,0,33) // 返回 2024-2-2
    

    以下为对应的获取例子和区分当月和非当月的日期补全策略代码

    function buildDetailDayInfoParams(timeInfo: TimeInfo & { offset: number, timeInfo: TimeInfo }) {
      const { year, offset, day, month } = timeInfo;
      if (timeInfo.viewMode == ViewMode.YEAR) {
        return new Date(
          year + offset, month, day)
      }
      if (timeInfo.viewMode == ViewMode.MONTH) {
        return new Date(
          year, month + offset, day)
      }
    }
    
    function getMonthList(timeInfo: TimeInfo, dayPosition: number): DayInfo[] {
      const { yearOnView, viewMode, monthOnView } = timeInfo;
      let curDate = null
      if (viewMode == ViewMode.YEAR) {
        curDate = moment(`${yearOnView + dayPosition}-${monthOnView} `)
      }
      if (viewMode == ViewMode.MONTH) {
        curDate = moment(`${yearOnView}-${monthOnView + dayPosition} `)
      }
    
      if (viewMode == ViewMode.MONTH) {
        if (monthOnView + dayPosition == 0) {
          curDate = moment(`${yearOnView - 1}-${12} `)
        } else if (monthOnView + dayPosition == 13) {
          curDate = moment(`${yearOnView + 1}-${1} `)
        }
      }
      const daysOnCurMonth = curDate.daysInMonth();
      const startDayOnCurMonth = parseInt(curDate.startOf('month').format('d'))
      let dayList = []
      let i = 0
      for (i = 0; i < daysOnCurMonth + startDayOnCurMonth; i++) {
        if (!dayList.length || dayList[dayList.length - 1]) {
          dayList = dayList.concat(new Array(7).fill(null))
        }
        const day = i - startDayOnCurMonth + 1
    
        dayList[i] = getDetailDayInfo(
          buildDetailDayInfoParams({
            year: yearOnView,
            month: monthOnView - 1,
            day,
            offset: dayPosition,
            viewMode: timeInfo.viewMode,
            timeInfo,
          })
          , timeInfo)
      }
      let index = dayList.findIndex(dayInfo => !dayInfo)
      if (index !== -1) {
        for (index; index < dayList.length; index++) {
          const day = index - startDayOnCurMonth + 1
          dayList[index] = getDetailDayInfo(
            buildDetailDayInfoParams({
              year: yearOnView,
              month: monthOnView - 1,
              day,
              offset: dayPosition,
              viewMode: timeInfo.viewMode,
              timeInfo,
            })
            , timeInfo)
        }
      }
      return dayList
    }
    

lunar库使用

  • 以下是lunar库的基本数据获取操作

    /**
     * @description: 用于组装日历详细信息,包含对应日期和节气
     * @param {Date} date
     * @return {*}
     */
    export function getDetailDayInfo(date: Date, timeInfo: TimeInfo): DayInfo {
      const lunar = Lunar.fromDate(date)
      const solar = Solar.fromDate(date)
      const dateForMoment = moment(date)
      const weekDay = dateForMoment.format('dddd')
      const { year, month, day, yearOnView, monthOnView, dayOnView } = timeInfo
      const dayInfo: DayInfo = {
        day: Solar.fromDate(date).getDay(),
        chineseDay: lunar.getDayInChinese(),
        isWeekend: weekDay == "Saturday" || weekDay == "Sunday",
        weekDay,
        fullDate: dateForMoment.format('YYYY-MM-DD'),
        dateFromTheMonth: date.getMonth() + 1 == timeInfo.monthOnView,
        isToday: dateForMoment.isSame(moment(`${year}-${month}-${day}`), 'day'),
        isSelected: dateForMoment.isSame(moment(`${yearOnView}-${monthOnView}-${dayOnView}`), 'day'),
        yiList: lunar.getDayYi(),
        jiList: lunar.getDayJi(),
        chineseDateName: '农历' + lunar.getMonthInChinese() + '月' + lunar.getDayInChinese(),
        chineseYearName: lunar.getYearInGanZhi() + lunar.getShengxiao() + '年',
        chineseMonthName: lunar.getMonthInGanZhi() + '月',
        chineseDayName: lunar.getDayInGanZhi() + '日',
      }
    
      if (dayInfo.chineseDateName.includes('腊月廿三')) {
        dayInfo.chineseDay = '北方小年'
      } else if (dayInfo.chineseDateName.includes('腊月廿四')) {
        dayInfo.chineseDay = '南方小年'
      }
      // 用于区分法定节假日和调休
      const holiday = HolidayUtil.getHoliday(dateForMoment.format('YYYY-MM-DD'))
      if (holiday) {
        dayInfo.isRestDay = !holiday.isWork()
        dayInfo.isWorkDay = holiday.isWork()
      }
      const season = lunar.getJieQi()
      const festivalList = []
      const festivalsForLunar = lunar.getFestivals()
      const festivalsForSolar = solar.getFestivals()
      festivalList.push(...festivalsForSolar, ...festivalsForLunar)
      dayInfo.festivalList = festivalList
      /**
       * 中文名规则,如果当前包含节气,月初,法定节价值的,优先响应
       */
      if (festivalList.length && festivalList[0].length < 4) {
        dayInfo.chineseDay = festivalList[0]
      } else if (season) {
        dayInfo.chineseDay = season
      } else if (lunar.getDay() == 1) {
        dayInfo.chineseDay = lunar.getMonthInChinese() + '月'
      }
      return dayInfo
    }
    

月视图是怎么做到的

在前面,我们已经了解了如何获取月信息日信息,如何获取本月,上月,下月的信息,以及每天的详细信息,这里我将正式介绍如何将他们组织起来,进行月视图的构建

首先在组件的根部分创建了上下文对象,用于声明月的详细信息等

约束如下

import { ViewMode } from "../config/dayEnum";

export type TimeInfo = {
  // 今年
  year?: number;
  // 本月
  month?: number;
  // 本日
  day?: number;
  // 视图上的年
  yearOnView?: number;
  // 视图上的月
  monthOnView?: number;
  // 视图上的日
  dayOnView?: number;
  // 当前视图的模式
  viewMode?: ViewMode
  // 当前所处的生肖年
  shengXiaoForYear?: string
  // 视图中选中日的详细信息对象
  selectDateDetailInfoOnView?: DayInfo
  // 当前的详细信息对象
  todayDateDetailInfo?: DayInfo
}

export type TimeInfoContextType = TimeInfo & {
  setSelectedDate?: (date: DayInfo) => void
  setTimeInfoState?: (date: TimeInfo) => void
}

export type DayInfo = {
  // 数字日期
  day?: string | number;
  // 中文农历标识
  chineseDay?: string | string[]
  // 调休
  isWorkDay?: boolean
  // 法定节假日
  isRestDay?: boolean
  // 周末
  isWeekend?: boolean
  // 星期几
  weekDay?: string | number,
  // 日期全路径(yyyy-mm-dd) fullDate?: string
  fullDate?: string
  // 当前数据是否来自本月
  dateFromTheMonth?: boolean
  // 判断当前日期是否为今天(今天的字体为浅蓝色)
  isToday?: boolean
  // 判断当前日期是否被选中(如果选中的是当前字体变为白色,添加浅蓝色背景)
  isSelected?: boolean
  // 事宜
  yiList?: string[]
  // 事忌
  jiList?: string[]
  // 农历当日全名称
  chineseDateName?: string
  // 农历当年全名称
  chineseYearName?: string
  chineseMonthName?: string
  chineseDayName?: string
  // 当前日期的节日集合
  festivalList?: string[]
}

对应的上下文和创建如下


import { Lunar } from "lunar-typescript";
import moment from "moment";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import CalendarDetail from "./components/CalendarDetail";
import CalendarHeader from "./components/CalendarHeader";
import { ViewMode } from "./config/dayEnum";
import { getDetailDayInfo } from "./services/dateHandler";
import { DayInfo, TimeInfo } from "./types/types";

export const TimeInfoContext = React.createContext({})
export default function Calendar() {
  const now = new Date()
  const [timeInfo, setTimeInfoState] = useState<TimeInfo>({
    year: now.getFullYear(),
    month: now.getMonth() + 1,
    day: now.getDate(),
    yearOnView: now.getFullYear(),
    monthOnView: now.getMonth() + 1,
    dayOnView: now.getDate(),
    viewMode: ViewMode.MONTH,
    shengXiaoForYear: "",
    selectDateDetailInfoOnView: {},
  })
  useEffect(() => {
    const lunar = Lunar.fromDate(new Date(timeInfo.yearOnView, 10, 1))
    const dayInfo = getDetailDayInfo(new Date(timeInfo.yearOnView, timeInfo.monthOnView - 1, timeInfo.dayOnView), timeInfo)
    setTimeInfoState({
      ...timeInfo,
      shengXiaoForYear: lunar.getYearInGanZhi() + lunar.getShengxiao() + '年',
      selectDateDetailInfoOnView: dayInfo,
      todayDateDetailInfo: dayInfo
    })
  }, [])
  const setSelectedDate = useCallback((dayInfo: DayInfo) => {
    const selectedDate = new Date(dayInfo.fullDate)
    setTimeInfoState({
      ...timeInfo,
      yearOnView: selectedDate.getFullYear(),
      monthOnView: selectedDate.getMonth() + 1,
      dayOnView: selectedDate.getDate(),
      selectDateDetailInfoOnView: dayInfo
    })
  }, [timeInfo])
  const showGoTodayBtn = useMemo(() => {
    const now = moment(`${timeInfo.year}-${timeInfo.month}-${timeInfo.day}`)
    const diffDate = moment(`${timeInfo.yearOnView}-${timeInfo.monthOnView}-${timeInfo.dayOnView}`)
    const diffDays = diffDate.diff(now, 'days')
    return diffDays !== 0 && timeInfo.viewMode === ViewMode.MONTH
  }, [timeInfo])
  return <TimeInfoContext.Provider value={{ ...timeInfo, setSelectedDate, setTimeInfoState }}>
    <div className=" flex-col p-4 w-screen h-screen">
      <CalendarHeader />
      <CalendarDetail />
      {showGoTodayBtn && <div className=" w-20 h-20 rounded-full bg-blue-500 text-white fixed bottom-8 right-8 text-2xl flex justify-center items-center" onClick={() => setSelectedDate(timeInfo.todayDateDetailInfo)}>今</div>}
    </div>
  </TimeInfoContext.Provider>

}

现在我们已经能得到今天的所有边界信息了,包括年,月,日,通过moment即可得到本月的详细信息,如有多少天,第一天是星期几,在结合new Date()计算边界,不足视图的月份,得到上一月和下一月的信息,在基于Lunar得到没日的详细信息,此时月视图就完成了

月视图的解析代码如下

const DateDetail = forwardRef(function (props: any, ref: any) {
  const timeInfo = useContext<TimeInfoContextType>(TimeInfoContext); // 获取日历上下文
  const dayPosition = props.dayPosition;
  const dayList: DayInfo[] | DayInfo[][] = getDayList(timeInfo, dayPosition)


  const getLeftForStyle = () => {
    if (dayPosition == -1) { return '-100vw' }
    if (dayPosition == 0) { return '0' }
    if (dayPosition == 1) { return '100vw' }
  }
  if (timeInfo.viewMode == ViewMode.MONTH || timeInfo.viewMode == ViewMode.WEEK) {
    return <div className=" w-full grid grid-cols-7 gap-2 my-4 absolute" style={{ left: getLeftForStyle() }} ref={ref}>
      {
        dayList.map((dayInfo, i) => <DayItem dayInfo={dayInfo} key={i} />)
      }
    </div>
  }

  if (timeInfo.viewMode == ViewMode.YEAR) {
    return <div className=" w-full grid grid-cols-3 gap-4 my-4 absolute" style={{ left: getLeftForStyle() }} >
      {
        dayList.map((dayInfo, i) => <MonthItem dayInfo={dayInfo as DayInfo[]} month={i + 1} key={i} />)
      }
    </div>
  }
  return <></>
})

function DayItem({ dayInfo }: { dayInfo: DayInfo }) {
  const { viewMode } = useContext<TimeInfoContextType>(TimeInfoContext); // 获取日历上下文
  if (viewMode == ViewMode.MONTH) {
    return <DayItemForMonth dayInfo={dayInfo} />
  }
  if (viewMode == ViewMode.YEAR) {
    return <DayItemForYear dayInfo={dayInfo} />
  }
  return <></>
}

function DayItemForMonth({ dayInfo }: { dayInfo: DayInfo }) {
  const { setSelectedDate } = useContext<TimeInfoContextType>(TimeInfoContext); // 获取日历上下文
  let dayStyles = "flex-col justify-center items-center text-sm border border-transparent rounded solid"
  if (dayInfo.isWorkDay) {
    dayStyles += " bg-gray-100  relative"
  } else if (dayInfo.isRestDay) {
    dayStyles += " bg-blue-200  relative"
  } else if (dayInfo.isWeekend || !dayInfo.dateFromTheMonth) {
    dayStyles += " text-gray-400"
  }

  if (dayInfo.isToday) {
    dayStyles += dayInfo.isSelected ? " text-white  bg-blue-400" : " text-blue-400"
  } else if (dayInfo.isSelected) {
    // 透明背景的优先级太高,需要手动把选中的日期剔除掉
    const styleList = dayStyles.split(' ')
    const index = styleList.findIndex(className => className == 'border-transparent')
    styleList.splice(index, 1)
    dayStyles = styleList.join(' ')
    dayStyles += " border-blue-400  bg-white"
  }
  const isHoliday = dayInfo.isWorkDay || dayInfo.isRestDay
  const getHolidayColor = () => {
    if (dayInfo.isRestDay)
      return '#3194e8'
    if (dayInfo.isWorkDay)
      return '#c0443e'
  }
  return <div className={dayStyles} onClick={() => setSelectedDate(dayInfo)}>
    {isHoliday && <div style={{ fontSize: 10, color: getHolidayColor() }} className=" absolute right-0 -top-1">{dayInfo.isWorkDay ? '班' : dayInfo.isRestDay ? '休' : ''}</div>}
    <div style={{ fontSize: 14 }}>{dayInfo.day}</div>
    <div style={{ fontSize: 11 }}>{dayInfo.chineseDay}</div>
  </div>
}

组装月视图的信息,注入每日的信息

function getMonthList(timeInfo: TimeInfo, dayPosition: number): DayInfo[] {
  const { yearOnView, viewMode, monthOnView } = timeInfo;
  let curDate = null
  if (viewMode == ViewMode.YEAR) {
    curDate = moment(`${yearOnView + dayPosition}-${monthOnView} `)
  }
  if (viewMode == ViewMode.MONTH) {
    curDate = moment(`${yearOnView}-${monthOnView + dayPosition} `)
  }

  if (viewMode == ViewMode.MONTH) {
    if (monthOnView + dayPosition == 0) {
      curDate = moment(`${yearOnView - 1}-${12} `)
    } else if (monthOnView + dayPosition == 13) {
      curDate = moment(`${yearOnView + 1}-${1} `)
    }
  }
  const daysOnCurMonth = curDate.daysInMonth();
  const startDayOnCurMonth = parseInt(curDate.startOf('month').format('d'))
  let dayList = []
  let i = 0
  for (i = 0; i < daysOnCurMonth + startDayOnCurMonth; i++) {
    if (!dayList.length || dayList[dayList.length - 1]) {
      dayList = dayList.concat(new Array(7).fill(null))
    }
    const day = i - startDayOnCurMonth + 1

    dayList[i] = getDetailDayInfo(
      buildDetailDayInfoParams({
        year: yearOnView,
        month: monthOnView - 1,
        day,
        offset: dayPosition,
        viewMode: timeInfo.viewMode,
        timeInfo,
      })
      , timeInfo)
  }
  let index = dayList.findIndex(dayInfo => !dayInfo)
  if (index !== -1) {
    for (index; index < dayList.length; index++) {
      const day = index - startDayOnCurMonth + 1
      dayList[index] = getDetailDayInfo(
        buildDetailDayInfoParams({
          year: yearOnView,
          month: monthOnView - 1,
          day,
          offset: dayPosition,
          viewMode: timeInfo.viewMode,
          timeInfo,
        })
        , timeInfo)
    }
  }
  return dayList
}


/**
 * @description: 用于组装日历详细信息,包含对应日期和节气
 * @param {Date} date
 * @return {*}
 */
export function getDetailDayInfo(date: Date, timeInfo: TimeInfo): DayInfo {
  const lunar = Lunar.fromDate(date)
  const solar = Solar.fromDate(date)
  const dateForMoment = moment(date)
  const weekDay = dateForMoment.format('dddd')
  const { year, month, day, yearOnView, monthOnView, dayOnView } = timeInfo
  const dayInfo: DayInfo = {
    day: Solar.fromDate(date).getDay(),
    chineseDay: lunar.getDayInChinese(),
    isWeekend: weekDay == "Saturday" || weekDay == "Sunday",
    weekDay,
    fullDate: dateForMoment.format('YYYY-MM-DD'),
    dateFromTheMonth: date.getMonth() + 1 == timeInfo.monthOnView,
    isToday: dateForMoment.isSame(moment(`${year}-${month}-${day}`), 'day'),
    isSelected: dateForMoment.isSame(moment(`${yearOnView}-${monthOnView}-${dayOnView}`), 'day'),
    yiList: lunar.getDayYi(),
    jiList: lunar.getDayJi(),
    chineseDateName: '农历' + lunar.getMonthInChinese() + '月' + lunar.getDayInChinese(),
    chineseYearName: lunar.getYearInGanZhi() + lunar.getShengxiao() + '年',
    chineseMonthName: lunar.getMonthInGanZhi() + '月',
    chineseDayName: lunar.getDayInGanZhi() + '日',
  }

  if (dayInfo.chineseDateName.includes('腊月廿三')) {
    dayInfo.chineseDay = '北方小年'
  } else if (dayInfo.chineseDateName.includes('腊月廿四')) {
    dayInfo.chineseDay = '南方小年'
  }
  // 用于区分法定节假日和调休
  const holiday = HolidayUtil.getHoliday(dateForMoment.format('YYYY-MM-DD'))
  if (holiday) {
    dayInfo.isRestDay = !holiday.isWork()
    dayInfo.isWorkDay = holiday.isWork()
  }
  const season = lunar.getJieQi()
  const festivalList = []
  const festivalsForLunar = lunar.getFestivals()
  const festivalsForSolar = solar.getFestivals()
  festivalList.push(...festivalsForSolar, ...festivalsForLunar)
  dayInfo.festivalList = festivalList
  /**
   * 中文名规则,如果当前包含节气,月初,法定节价值的,优先响应
   */
  if (festivalList.length && festivalList[0].length < 4) {
    dayInfo.chineseDay = festivalList[0]
  } else if (season) {
    dayInfo.chineseDay = season
  } else if (lunar.getDay() == 1) {
    dayInfo.chineseDay = lunar.getMonthInChinese() + '月'
  }
  return dayInfo
}

视图是怎么滚动的

动画

在处理滑块时我采用的策略为 启用是三个视图的形式,即上个月视图(-1)本月视图(0)下个月视图(1)进行分类,基于主视口进行定位,并以此进行逻辑编写,动画部分使用transform:translateX() 完成页面横向滚动,且滑动的是主视图,当主视图变化的时候由于定位其他视图也会跟着变化,同时制定了策略,当只有满足滑动要求的行为才会完成月切换,具体代码如下

export default function CalendarDetail() {
  const timeInfo = useContext<TimeInfoContextType>(TimeInfoContext); // 获取日历上下文
  const [innerContextHeight, setInnerContextHeightState] = useState<number>(0)
  const [divEleTranslateX, setDivEleTranslateXState] = useState<number>(0)
  const divEleRef = useRef<HTMLDivElement>(null); // 创建一个ref来获取日历元素
  const DateDetailDivEleRef = useRef<HTMLDivElement>(null);
  // 监听页面滚动
  useEffect(() => {
    let timeOnTouchStart = 0
    let touchInfoOnTouchStart: any = {}
    let timeOnTouchEnd = 0
    const notifyTimeInfoContextUpdate = (slideDistance) => {
      if (timeInfo.viewMode == ViewMode.YEAR) {
        handleSlideForYear(slideDistance)
      } else if (timeInfo.viewMode == ViewMode.MONTH) {
        handleSlideForMonth(slideDistance)
      }
    }
    const handleSlideForYear = (slideDistance) => {
      const yearOnView = timeInfo.yearOnView + (slideDistance > 0 ? -1 : 1)
      timeInfo.setTimeInfoState({
        ...timeInfo,
        yearOnView,
        selectDateDetailInfoOnView: getDetailDayInfo(new Date(yearOnView, timeInfo.monthOnView - 1, timeInfo.dayOnView), timeInfo)
      })
    }
    const handleSlideForMonth = (slideDistance) => {
      const monthOnView = timeInfo.monthOnView + (slideDistance > 0 ? -1 : 1)
      if (monthOnView == 0) {
        timeInfo.setTimeInfoState({
          ...timeInfo,
          monthOnView: 12,
          yearOnView: timeInfo.yearOnView - 1,
          selectDateDetailInfoOnView: getDetailDayInfo(new Date(timeInfo.yearOnView - 1, 11, timeInfo.dayOnView), timeInfo)
        })
      } else if (monthOnView == 13) {
        timeInfo.setTimeInfoState({
          ...timeInfo,
          monthOnView: 1,
          yearOnView: timeInfo.yearOnView + 1,
          selectDateDetailInfoOnView: getDetailDayInfo(new Date(timeInfo.yearOnView + 1, 0, timeInfo.dayOnView), timeInfo)
        })
      } else {
        const totalDays = moment(`${timeInfo.yearOnView}-${monthOnView}`).daysInMonth()
        // 得到现在的dayOnView值,如果比最新月份的最大值大则dayOnView变为当前月份最大值
        const newState = {
          ...timeInfo,
          monthOnView,
        }
        if (totalDays < timeInfo.dayOnView) {
          newState.dayOnView = totalDays
        }
        newState.selectDateDetailInfoOnView = getDetailDayInfo(new Date(timeInfo.yearOnView, monthOnView - 1, newState.dayOnView), timeInfo)
        timeInfo.setTimeInfoState(newState)
      }
    }

    const initCacheTouchParams = () => {
      timeOnTouchStart = 0
      touchInfoOnTouchStart = {}
      timeOnTouchEnd = 0
    }

    const divEleRefTouchStartEvent = (e: TouchEvent) => {
      touchInfoOnTouchStart = e.changedTouches[0]
      timeOnTouchStart = e.timeStamp
    }

    const divEleRefTouchMoveEvent = (e: TouchEvent) => {
      const touchInfo = e.changedTouches[0]
      const xOffset = touchInfo.pageX - touchInfoOnTouchStart.pageX
      setDivEleTranslateXState(xOffset)
    }

    const divEleRefTouchEndEvent = (e: TouchEvent) => {
      // 处理是否进行滚动操作(如果小于1s的滑动则进行日历切换,否则只有超过了屏幕的一半的距离时我才进行对应的滚动)
      timeOnTouchEnd = e.timeStamp
      const touchInfo = e.changedTouches[0]
      // 如果是正值,说明需要回到上一个月份,否则反之
      const slideDistance = touchInfo.pageX - touchInfoOnTouchStart.pageX

      // 碰到闭包引用导致状态无法正常更新问题,解决方法一:依赖对应的响应数据,使当前timeInfo指向永远为最新,解决方法二:使用函数设置方式,永远获取最新的响应数据
      if (timeOnTouchEnd - timeOnTouchStart < 500 && Math.abs(slideDistance) > 20) {
        notifyTimeInfoContextUpdate(slideDistance)
      } else if (Math.abs(slideDistance) > window.innerWidth / 2) {
        notifyTimeInfoContextUpdate(slideDistance)
      }
      // 还原初始状态
      setDivEleTranslateXState(0)
      initCacheTouchParams()
    }

    setInnerContextHeightState(DateDetailDivEleRef.current?.clientHeight)

    divEleRef.current?.addEventListener('touchstart', divEleRefTouchStartEvent, { passive: true })
    divEleRef.current?.addEventListener('touchmove', divEleRefTouchMoveEvent, { passive: true })
    divEleRef.current?.addEventListener('touchend', divEleRefTouchEndEvent, { passive: true })

    return () => {
      divEleRef.current?.removeEventListener('touchstart', divEleRefTouchStartEvent)
      divEleRef.current?.removeEventListener('touchmove', divEleRefTouchMoveEvent)
      divEleRef.current?.removeEventListener('touchstart', divEleRefTouchEndEvent)
    }
  }, [timeInfo])
  return <div className="my-2 flex-1 ">
    {(timeInfo.viewMode == ViewMode.MONTH || timeInfo.viewMode == ViewMode.WEEK) && <WeekHeader />}
    <div className=" relative " ref={divEleRef} style={{ transform: `translateX(${divEleTranslateX}px)`, height: innerContextHeight }}>
      <DateDetail dayPosition={-1} />
      <DateDetail dayPosition={0} ref={DateDetailDivEleRef} />
      <DateDetail dayPosition={1} />
    </div>
    {timeInfo.viewMode == ViewMode.MONTH && <SelectDayItemDetailInfo />}
  </div>
}

const DateDetail = forwardRef(function (props: any, ref: any) {
  const timeInfo = useContext<TimeInfoContextType>(TimeInfoContext); // 获取日历上下文
  const dayPosition = props.dayPosition;
  const dayList: DayInfo[] | DayInfo[][] = getDayList(timeInfo, dayPosition)


  const getLeftForStyle = () => {
    if (dayPosition == -1) { return '-100vw' }
    if (dayPosition == 0) { return '0' }
    if (dayPosition == 1) { return '100vw' }
  }
  if (timeInfo.viewMode == ViewMode.MONTH || timeInfo.viewMode == ViewMode.WEEK) {
    return <div className=" w-full grid grid-cols-7 gap-2 my-4 absolute" style={{ left: getLeftForStyle() }} ref={ref}>
      {
        dayList.map((dayInfo, i) => <DayItem dayInfo={dayInfo} key={i} />)
      }
    </div>
  }

  if (timeInfo.viewMode == ViewMode.YEAR) {
    return <div className=" w-full grid grid-cols-3 gap-4 my-4 absolute" style={{ left: getLeftForStyle() }} >
      {
        dayList.map((dayInfo, i) => <MonthItem dayInfo={dayInfo as DayInfo[]} month={i + 1} key={i} />)
      }
    </div>
  }
  return <></>
})

月到年的切换是如何完成的

看到这里,机智的同学已经发现了,我在上面定义了以下类型,用于区别当前视图的操作状态,即可完成月和年的视图切换,后续也可以完成扩展,如周视图,三日图的切换等

 // 当前视图的模式
  viewMode?: ViewMode
  
 export enum ViewMode {
  YEAR = 'year',
  MONTH = 'month',
  WEEK = 'week',
  DAY = 'day',
}

在上面代码中,已经多次出现了当前视图状态的判断逻辑了,这里我就不过多赘述了,仅贴一个切换视图状态的代码

import { useCallback, useContext, useEffect, useMemo } from "react"
import { TimeInfoContext } from "../Calendar"
import { TimeInfo, TimeInfoContextType } from "../types/types"
import moment from "moment"
import { ViewMode } from "../config/dayEnum"
import { Lunar } from "lunar-typescript"

export default function CalendarHeader() {
  const timeInfo = useContext<TimeInfoContextType>(TimeInfoContext)
  const disTanceDayInfo = useMemo(() => {
    const now = moment(`${timeInfo.year}-${timeInfo.month}-${timeInfo.day}`)
    const diffDate = moment(`${timeInfo.yearOnView}-${timeInfo.monthOnView}-${timeInfo.dayOnView}`)
    const diffDays = diffDate.diff(now, 'days')
    return {
      day: Math.abs(diffDays),
      distanceChinese: diffDays > 0 ? '后' : '前',
      show: diffDays !== 0
    }
  }, [timeInfo])

  const toggleDateMode = () => {
    timeInfo.setTimeInfoState({
      ...timeInfo,
      viewMode: ViewMode.YEAR,
    })
  }
  useEffect(() => {
    const activeDate = Lunar.fromDate(new Date(timeInfo.yearOnView + "-12-25"))
    timeInfo.setTimeInfoState({
      ...timeInfo,
      shengXiaoForYear: activeDate.getYearInGanZhi() + activeDate.getShengxiao() + '年',
    })
  }, [timeInfo.yearOnView])
  return <div className=" flex justify-between ">
    {
      timeInfo.viewMode === ViewMode.MONTH &&
      <div className=" relative top-7 mb-7" onClick={() => toggleDateMode()}>
        <span className=" text-4xl">
          {timeInfo.yearOnView}/{timeInfo.monthOnView}
        </span>
        {disTanceDayInfo.show && <span>
          {disTanceDayInfo.day}天{disTanceDayInfo.distanceChinese}
        </span>}
      </div>
    }
    {
      timeInfo.viewMode === ViewMode.YEAR &&
      <div>
        {timeInfo.yearOnView}
        <span className=" ml-2 text-red-400" style={{ fontSize: 10 }}>{timeInfo.shengXiaoForYear}</span>
      </div>
    }
    <div className="flex ">
      <div className="mx-2">+</div>
      <div className="mx-2">C</div>
      <div className="mx-2">M</div>
    </div>
  </div>
}

年的月和日的展示是如何做到的

image-20240219202248633

上面,我已经提到了月视图是怎么构建测出来的,以及视图的切换是怎么做成的,那么进行年的切换也变的简单了,此时我们仅需要思考,年是由什么组成的? 不就是由月和日组成吗,月在上面我们已经实现了并使用 DayInfo[] 约束进行标识,表示每个月下的每个日信息的详细对象,那么我们的年呢,不就是由12个月组成吗,此时我们只需要获取当年的12个月信息即可完成年视图的渲染你了,对应的约束就为DayInfo[][],现在就是页面行为绘制了,这里不论是月视图还是年视图我都采用了grid布局,简单直观的进行页面展示

注意:这里会有页面性能问题,因为年收集了12个月的每日信息,并且乘以了3(因为需要完成页面滚动信息),所以页面可能会出现卡顿(因为资源消耗过大,每日就为一个大对象信息,此时视图上有1000多天的详细信息),视电脑性能决定

年视图的信息收集行为

import moment from "moment";
import { DayInfo, TimeInfo } from "../types/types";
import { ViewMode } from "../config/dayEnum";
import { Lunar, Solar, HolidayUtil } from 'lunar-typescript'
export function getDayList(timeInfo: TimeInfo, dayPosition: number): DayInfo[] | DayInfo[][] {
  if (timeInfo.viewMode == ViewMode.YEAR) {
    return getYearList(timeInfo, dayPosition);
  } else if (timeInfo.viewMode == ViewMode.MONTH) {
    return getMonthList(timeInfo, dayPosition);
  } else if (timeInfo.viewMode == ViewMode.WEEK) {
    return getWeekList(timeInfo);
  }
  return []
}

function getYearList(timeInfo: TimeInfo, dayPosition: number): DayInfo[][] {
  const dayInfoList = []
  for (let i = 1; i <= 12; i++) {
    const dayList = getMonthList({
      ...timeInfo,
      monthOnView: i
    }, dayPosition).map(day => day.dateFromTheMonth ? day : {})

    dayInfoList.push(dayList)
  }
  return dayInfoList
}

function buildDetailDayInfoParams(timeInfo: TimeInfo & { offset: number, timeInfo: TimeInfo }) {
  const { year, offset, day, month } = timeInfo;
  if (timeInfo.viewMode == ViewMode.YEAR) {
    return new Date(
      year + offset, month, day)
  }
  if (timeInfo.viewMode == ViewMode.MONTH) {
    return new Date(
      year, month + offset, day)
  }
}

function getMonthList(timeInfo: TimeInfo, dayPosition: number): DayInfo[] {
  const { yearOnView, viewMode, monthOnView } = timeInfo;
  let curDate = null
  if (viewMode == ViewMode.YEAR) {
    curDate = moment(`${yearOnView + dayPosition}-${monthOnView} `)
  }
  if (viewMode == ViewMode.MONTH) {
    curDate = moment(`${yearOnView}-${monthOnView + dayPosition} `)
  }

  if (viewMode == ViewMode.MONTH) {
    if (monthOnView + dayPosition == 0) {
      curDate = moment(`${yearOnView - 1}-${12} `)
    } else if (monthOnView + dayPosition == 13) {
      curDate = moment(`${yearOnView + 1}-${1} `)
    }
  }
  const daysOnCurMonth = curDate.daysInMonth();
  const startDayOnCurMonth = parseInt(curDate.startOf('month').format('d'))
  let dayList = []
  let i = 0
  for (i = 0; i < daysOnCurMonth + startDayOnCurMonth; i++) {
    if (!dayList.length || dayList[dayList.length - 1]) {
      dayList = dayList.concat(new Array(7).fill(null))
    }
    const day = i - startDayOnCurMonth + 1

    dayList[i] = getDetailDayInfo(
      buildDetailDayInfoParams({
        year: yearOnView,
        month: monthOnView - 1,
        day,
        offset: dayPosition,
        viewMode: timeInfo.viewMode,
        timeInfo,
      })
      , timeInfo)
  }
  let index = dayList.findIndex(dayInfo => !dayInfo)
  if (index !== -1) {
    for (index; index < dayList.length; index++) {
      const day = index - startDayOnCurMonth + 1
      dayList[index] = getDetailDayInfo(
        buildDetailDayInfoParams({
          year: yearOnView,
          month: monthOnView - 1,
          day,
          offset: dayPosition,
          viewMode: timeInfo.viewMode,
          timeInfo,
        })
        , timeInfo)
    }
  }
  return dayList
}

function getWeekList(timeInfo: TimeInfo): DayInfo[] {
  return []
}

/**
 * @description: 用于组装日历详细信息,包含对应日期和节气
 * @param {Date} date
 * @return {*}
 */
export function getDetailDayInfo(date: Date, timeInfo: TimeInfo): DayInfo {
  const lunar = Lunar.fromDate(date)
  const solar = Solar.fromDate(date)
  const dateForMoment = moment(date)
  const weekDay = dateForMoment.format('dddd')
  const { year, month, day, yearOnView, monthOnView, dayOnView } = timeInfo
  const dayInfo: DayInfo = {
    day: Solar.fromDate(date).getDay(),
    chineseDay: lunar.getDayInChinese(),
    isWeekend: weekDay == "Saturday" || weekDay == "Sunday",
    weekDay,
    fullDate: dateForMoment.format('YYYY-MM-DD'),
    dateFromTheMonth: date.getMonth() + 1 == timeInfo.monthOnView,
    isToday: dateForMoment.isSame(moment(`${year}-${month}-${day}`), 'day'),
    isSelected: dateForMoment.isSame(moment(`${yearOnView}-${monthOnView}-${dayOnView}`), 'day'),
    yiList: lunar.getDayYi(),
    jiList: lunar.getDayJi(),
    chineseDateName: '农历' + lunar.getMonthInChinese() + '月' + lunar.getDayInChinese(),
    chineseYearName: lunar.getYearInGanZhi() + lunar.getShengxiao() + '年',
    chineseMonthName: lunar.getMonthInGanZhi() + '月',
    chineseDayName: lunar.getDayInGanZhi() + '日',
  }

  if (dayInfo.chineseDateName.includes('腊月廿三')) {
    dayInfo.chineseDay = '北方小年'
  } else if (dayInfo.chineseDateName.includes('腊月廿四')) {
    dayInfo.chineseDay = '南方小年'
  }
  // 用于区分法定节假日和调休
  const holiday = HolidayUtil.getHoliday(dateForMoment.format('YYYY-MM-DD'))
  if (holiday) {
    dayInfo.isRestDay = !holiday.isWork()
    dayInfo.isWorkDay = holiday.isWork()
  }
  const season = lunar.getJieQi()
  const festivalList = []
  const festivalsForLunar = lunar.getFestivals()
  const festivalsForSolar = solar.getFestivals()
  festivalList.push(...festivalsForSolar, ...festivalsForLunar)
  dayInfo.festivalList = festivalList
  /**
   * 中文名规则,如果当前包含节气,月初,法定节价值的,优先响应
   */
  if (festivalList.length && festivalList[0].length < 4) {
    dayInfo.chineseDay = festivalList[0]
  } else if (season) {
    dayInfo.chineseDay = season
  } else if (lunar.getDay() == 1) {
    dayInfo.chineseDay = lunar.getMonthInChinese() + '月'
  }
  return dayInfo
}

image-20240219210850211

image-20240219210958814

现在我们已经拥有了视图中每个年的具体月下的详细信息了,现在进行视图渲染即可

const DateDetail = forwardRef(function (props: any, ref: any) {
  const timeInfo = useContext<TimeInfoContextType>(TimeInfoContext); // 获取日历上下文
  const dayPosition = props.dayPosition;
  const dayList: DayInfo[] | DayInfo[][] = getDayList(timeInfo, dayPosition)
  const getLeftForStyle = () => {
    if (dayPosition == -1) { return '-100vw' }
    if (dayPosition == 0) { return '0' }
    if (dayPosition == 1) { return '100vw' }
  }
  if (timeInfo.viewMode == ViewMode.MONTH || timeInfo.viewMode == ViewMode.WEEK) {
    return <div className=" w-full grid grid-cols-7 gap-2 my-4 absolute" style={{ left: getLeftForStyle() }} ref={ref}>
      {
        dayList.map((dayInfo, i) => <DayItem dayInfo={dayInfo} key={i} />)
      }
    </div>
  }

  if (timeInfo.viewMode == ViewMode.YEAR) {
    return <div className=" w-full grid grid-cols-3 gap-4 my-4 absolute" style={{ left: getLeftForStyle() }} >
      {
        dayList.map((dayInfo, i) => <MonthItem dayInfo={dayInfo as DayInfo[]} month={i + 1} key={i} />)
      }
    </div>
  }
  return <></>
})

function MonthItem({ dayInfo, month }: { dayInfo: DayInfo[], month: number }) {
  const todayInMonth = dayInfo.find(day => day.isToday)
  const timeInfo = useContext<TimeInfoContextType>(TimeInfoContext)
  const setViewForDate = () => {
    const totalDays = moment(`${timeInfo.yearOnView}-${month}`).daysInMonth()
    // 得到现在的dayOnView值,如果比最新月份的最大值大则dayOnView变为当前月份最大值
    const newState = {
      ...timeInfo,
      monthOnView: month,
      viewMode: ViewMode.MONTH,
    }
    if (totalDays < timeInfo.dayOnView) {
      newState.dayOnView = totalDays
    }
    newState.selectDateDetailInfoOnView = getDetailDayInfo(new Date(timeInfo.yearOnView, newState.monthOnView - 1, newState.dayOnView), timeInfo)
    timeInfo.setTimeInfoState(newState)
  }
  return <div className=" flex-col " onClick={setViewForDate}>
    <div className=" text-lg font-bold " style={todayInMonth && { color: "rgb(59, 130, 246)" }}>{month}月</div>
    <WeekHeader />
    <div className="w-full grid grid-cols-7 gap-1">
      {dayInfo.map((info, i) => <DayItem dayInfo={info} key={i} />)}
    </div>
  </div>
}

function DayItem({ dayInfo }: { dayInfo: DayInfo }) {
  const { viewMode } = useContext<TimeInfoContextType>(TimeInfoContext); // 获取日历上下文
  if (viewMode == ViewMode.MONTH) {
    return <DayItemForMonth dayInfo={dayInfo} />
  }
  if (viewMode == ViewMode.YEAR) {
    return <DayItemForYear dayInfo={dayInfo} />
  }
  return <></>
}



function DayItemForYear({ dayInfo }: { dayInfo: DayInfo }) {

  const isRest = dayInfo.isRestDay || dayInfo.isWeekend
  let dayStyles = ""
  if (isRest) {
    dayStyles = " text-gray-200"
  }
  if (dayInfo.isToday) {
    dayStyles = " text-white rounded bg-blue-500 "
  }
  return <>
    <div className={dayStyles} style={{ fontSize: '.5rem' }}>{dayInfo.day}</div>
  </>
}

实现效果

动画

源码地址

we-del/react-xiaomi-calendar (github.com)

最后

感谢你能看到这里,笔者能力有限在文章存在的逻辑或表达问题请多包涵,如果对某个细节点存在疑问欢迎评论区讨论,如果文章对你有帮助请用点赞和收藏回应我 :)

精彩文章

Vue3实现简易typora - 掘金 (juejin.cn)