React 日历组件完全指南:从网格生成到农历转换

本文详细介绍如何从零实现一个功能完整的 React 日历组件,包括日历网格生成、农历显示和月份切换功能。

前言

在开发排班管理系统时,我们需要实现一个功能完整的日历组件。这个组件不仅要显示标准的月历网格,还要支持农历显示和流畅的月份切换。经过实践,我总结了一套完整的实现方案,适用于任何 React 项目。

一、日历网格生成

1.1 核心需求

一个标准的月历网格需要满足以下要求:

  • 显示当前月份的所有日期
  • 补齐上月末尾的日期(填充第一周)
  • 补齐下月开头的日期(填充最后一周)
  • 总是显示完整的 6 周(42 天)
  • 周日为每周的第一天

1.2 实现思路

我们使用 date-fns 库来处理日期计算,整个算法分为三个步骤:

typescript 复制代码
// DateService.ts
getMonthCalendarGrid(date: Date): Date[] {
  // Step 1: 获取月份的起止日期
  const monthStart = startOfMonth(date);
  const monthEnd = endOfMonth(date);
  
  // Step 2: 扩展到完整的周
  const calendarStart = startOfWeek(monthStart, { weekStartsOn: 0 });
  const calendarEnd = endOfWeek(monthEnd, { weekStartsOn: 0 });
  
  // Step 3: 生成日期数组
  return eachDayOfInterval({
    start: calendarStart,
    end: calendarEnd
  });
}

关键点解析:

  1. 获取月份边界 :使用 startOfMonthendOfMonth 获取当月的第一天和最后一天
  2. 扩展到完整周 :使用 startOfWeekendOfWeek 确保日历从周日开始,到周六结束
  3. 生成连续日期 :使用 eachDayOfInterval 生成两个日期之间的所有日期

1.3 实际案例

以 2024 年 11 月为例:

ini 复制代码
输入:new Date(2024, 10, 15)  // 2024-11-15

Step 1: 月份边界
  monthStart = 2024-11-01 (周五)
  monthEnd   = 2024-11-30 (周六)

Step 2: 扩展到周
  calendarStart = 2024-10-27 (周日)
  calendarEnd   = 2024-11-30 (周六)

Step 3: 生成日期
  共 35 天 (5周)

渲染结果:
日  一  二  三  四  五  六
27  28  29  30  31   1   2   ← 10月27-31 + 11月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

1.4 为什么是 6 周?

大多数月份只需要 5 周(35 天)就能显示完整,但某些特殊情况需要 6 周(42 天)。

需要 6 周的条件:

  • 月份有 31 天
  • 月初是周六(需要补充前面 6 天)

为了保持布局一致性,我们统一使用 6 周布局,这样月份切换时高度不变,动画过渡更流畅。

二、农历(阴历)显示

2.1 实现原理

农历转换使用预定义数据表 + 算法计算的方式,无需外部依赖,支持 1900-2100 年。

2.2 数据结构

农历信息表

typescript 复制代码
private static lunarInfo = [
  0x04bd8, 0x04ae0, 0x0a570, 0x054d5, 0x0d260,  // 1900-1904
  // ... 共 201 个元素(1900-2100年)
];

每个十六进制数编码了一年的农历信息:

ini 复制代码
0x04bd8 的二进制表示:
0000 0100 1011 1101 1000

解析:
├─ 后 4 位 (1000 = 8):闰月位置(8月)
├─ 第 5 位 (1):闰月天数(1=30天,0=29天)
└─ 第 6-17 位:每月天数(1=30天,0=29天)

农历日期文本

typescript 复制代码
private static lunarDays = [
  '初一', '初二', '初三', ..., '廿九', '三十'
];

2.3 转换算法

公历转农历分为四个步骤:

vbnet 复制代码
Step 1: 计算与基准日期的天数差
  基准:1900-01-31(农历1900年正月初一)
  
Step 2: 从1900年开始,逐年累减天数,确定农历年份

Step 3: 逐月累减天数,确定农历月份(处理闰月)

Step 4: 剩余天数 + 1 = 农历日期

2.4 实际案例

以 2024-11-24 为例:

yaml 复制代码
Step 1: 天数差
  (2024-11-24 - 1900-01-31) = 45590 天

Step 2: 确定农历年
  1900年:354天,剩余 45236天
  1901年:354天,剩余 44882天
  ...
  2023年:384天,剩余 324天
  → 农历2024年

Step 3: 确定农历月
  正月:30天,剩余 294天
  二月:29天,剩余 265天
  ...
  十月:30天,剩余 29天
  → 农历十月

Step 4: 确定农历日
  29 + 1 = 30
  → 三十

结果:2024-11-24 = 农历2024年十月三十

2.5 使用方法

typescript 复制代码
// 获取农历日期文本
const lunarText = LunarUtil.getLunarDateText(new Date(2024, 10, 24));
console.log(lunarText);  // 输出:三十

// 在日历中应用
<div className="day-cell">
  <div className="day-text">{date.getDate()}</div>
  <div className="lunar-text">
    {LunarUtil.getLunarDateText(date)}
  </div>
</div>

渲染效果:

复制代码
┌─────┬─────┬─────┬─────┬─────┬─────┬─────┐
│ 24  │ 25  │ 26  │ 27  │ 28  │ 29  │ 30  │
│廿四 │廿五 │廿六 │廿七 │廿八 │廿九 │三十 │
└─────┴─────┴─────┴─────┴─────┴─────┴─────┘

三、月份切换功能

3.1 核心思路

月份切换的本质是改变当前显示的月份,然后重新生成日历网格。

核心要素:

  1. 维护一个 currentDate 状态
  2. 提供切换方法(上一月/下一月)
  3. 根据 currentDate 重新生成日历网格

3.2 状态管理

typescript 复制代码
const [currentDate, setCurrentDate] = useState(new Date());

currentDate 的作用:

  • 决定显示哪个月份
  • 作为生成日历网格的输入

3.3 切换方法

使用 date-fns 实现月份切换:

typescript 复制代码
import { addMonths, subMonths } from 'date-fns';

// 下一个月
const goToNextMonth = () => {
  setCurrentDate(prevDate => addMonths(prevDate, 1));
};

// 上一个月
const goToPrevMonth = () => {
  setCurrentDate(prevDate => subMonths(prevDate, 1));
};

// 通用方法
const handleMonthChange = (direction: 'next' | 'prev') => {
  setCurrentDate(prevDate => {
    return direction === 'next' 
      ? addMonths(prevDate, 1)
      : subMonths(prevDate, 1);
  });
};

3.4 自动处理边界

JavaScript Date 构造函数会自动处理月份溢出:

typescript 复制代码
// 12月 → 1月(跨年)
new Date(2024, 12, 1)  // 自动变为 2025-01-01

// 1月 → 12月(跨年)
new Date(2024, -1, 1)  // 自动变为 2023-12-01

3.5 响应式更新

使用 useMemo 实现响应式更新:

typescript 复制代码
const MonthView: React.FC<MonthViewProps> = ({ currentDate }) => {
  const currentMonthDates = useMemo(() => {
    return DateService.getMonthCalendarGrid(currentDate);
  }, [currentDate]);  // 依赖 currentDate
  
  // currentDate 变化 → useMemo 重新计算 → 生成新的日历网格
};

3.6 完整数据流

scss 复制代码
用户点击"下一月"
  ↓
setCurrentDate(新月份)
  ↓
useMemo 重新计算
  ↓
生成新的日历网格
  ↓
渲染新月份

四、完整实现

4.1 日历组件

typescript 复制代码
import React, { useState, useMemo } from 'react';
import { addMonths, subMonths, format } from 'date-fns';

function Calendar() {
  const [currentDate, setCurrentDate] = useState(new Date());
  
  // 切换月份
  const handleMonthChange = (direction: 'next' | 'prev') => {
    setCurrentDate(prev => {
      return direction === 'next' 
        ? addMonths(prev, 1)
        : subMonths(prev, 1);
    });
  };
  
  // 生成日历网格
  const dates = useMemo(() => {
    return generateCalendarGrid(currentDate);
  }, [currentDate]);
  
  return (
    <div className="calendar">
      {/* 标题 */}
      <h2>{format(currentDate, 'yyyy年MM月')}</h2>
      
      {/* 切换按钮 */}
      <button onClick={() => handleMonthChange('prev')}>上一月</button>
      <button onClick={() => handleMonthChange('next')}>下一月</button>
      
      {/* 日历网格 */}
      <CalendarGrid dates={dates} />
    </div>
  );
}

4.2 渲染网格

typescript 复制代码
function CalendarGrid({ dates }) {
  // 分组为周
  const weeks = [];
  for (let i = 0; i < dates.length; i += 7) {
    weeks.push(dates.slice(i, i + 7));
  }
  
  return (
    <div className="calendar-grid">
      {/* 星期头部 */}
      <div className="week-header">
        {['日', '一', '二', '三', '四', '五', '六'].map(day => (
          <div key={day} className="week-day">{day}</div>
        ))}
      </div>
      
      {/* 日历网格 */}
      {weeks.map((week, weekIndex) => (
        <div key={weekIndex} className="week-row">
          {week.map((date, dayIndex) => (
            <div key={dayIndex} className="day-cell">
              {/* 公历日期 */}
              <div className="day-text">{date.getDate()}</div>
              
              {/* 农历日期 */}
              <div className="lunar-text">
                {LunarUtil.getLunarDateText(date)}
              </div>
            </div>
          ))}
        </div>
      ))}
    </div>
  );
}

五、性能优化

5.1 使用 useMemo 缓存计算

typescript 复制代码
// 缓存日历网格
const dates = useMemo(() => {
  return generateCalendarGrid(currentDate);
}, [currentDate]);

5.2 使用 useCallback 缓存回调

typescript 复制代码
const handleDatePress = useCallback((date: Date) => {
  onDatePress(date);
}, [onDatePress]);

5.3 使用 React.memo 避免无效渲染

typescript 复制代码
export default React.memo(MonthView);

六、关键要点总结

6.1 日历网格生成

核心 API:

  • startOfMonth / endOfMonth - 获取月份边界
  • startOfWeek / endOfWeek - 扩展到完整周
  • eachDayOfInterval - 生成连续日期

数据结构:

scss 复制代码
Date[] (35-42个元素)
  ↓ 分组
Date[][] (5-6个数组,每个7个元素)
  ↓ 渲染
6行 × 7列的网格

6.2 农历转换

核心算法:

  1. 计算天数差 - 与基准日期(1900-01-31)的差值
  2. 确定农历年 - 逐年累减天数
  3. 确定农历月 - 逐月累减天数,处理闰月
  4. 确定农历日 - 剩余天数 + 1

数据结构:

  • 预定义表 - 201个十六进制数(1900-2100年)
  • 位运算 - 高效解析农历信息
  • 文本数组 - 30个农历日期名称

6.3 月份切换

核心流程:

复制代码
状态变化 → 网格重新生成 → 数据重新加载 → 组件重新渲染

关键技术:

  • useState - 状态管理
  • useMemo - 缓存计算结果
  • useEffect - 监听变化,自动加载数据
  • JavaScript Date - 自动处理月份边界

七、总结

通过本文,我们实现了一个功能完整的 React 日历组件,包括:

✅ 标准的月历网格生成(支持 5-6 周布局) ✅ 农历显示(支持 1900-2100 年) ✅ 流畅的月份切换(自动处理跨年) ✅ 响应式数据更新(状态驱动) ✅ 性能优化(useMemo、useCallback、React.memo)

核心思想是利用 date-fns 处理日期计算,使用 React Hooks 实现响应式更新,通过预定义数据表实现农历转换。整个实现简洁高效,易于维护和扩展。

这套方案不仅适用于 Web 应用,也可以轻松移植到 React Native 等其他 React 生态项目中。

希望这篇文章能帮助你理解日历组件的实现原理,并应用到自己的项目中。


相关资源:

相关推荐
JarvanMo1 天前
Flutter 版本的 material_ui 已经上架 pub.dev 啦!快来抢先体验吧。
前端
恋猫de小郭1 天前
AI 可以让 WIFI 实现监控室内人体位置和姿态,无需摄像头?
前端·人工智能·ai编程
哀木1 天前
给自己整一个 claude code,解锁编程新姿势
前端
程序员鱼皮1 天前
GitHub 关注突破 2w,我总结了 10 个涨星涨粉技巧!
前端·后端·github
UrbanJazzerati1 天前
Vue3 父子组件通信完全指南
前端·面试
是一碗螺丝粉1 天前
5分钟上手LangChain.js:用DeepSeek给你的App加上AI能力
前端·人工智能·langchain
wuhen_n1 天前
双端 Diff 算法详解
前端·javascript·vue.js
UrbanJazzerati1 天前
Vue 3 纯小白快速入门指南
前端·面试
雮尘1 天前
手把手带你玩转Android gRPC:一篇搞定原理、配置与客户端开发
android·前端·grpc
光影少年1 天前
说说闭包的理解和应用场景?
前端·javascript·掘金·金石计划