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 生态项目中。

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


相关资源:

相关推荐
程序员鱼皮3 小时前
Gemini 3.0 发布!
前端·ai编程·gemini
程序员鱼皮3 小时前
Gemini 3.0 炸裂发布!前端又死了???
前端·ai·程序员·互联网·代码
xiangxiongfly9153 小时前
CSS svg
前端·css·svg
山依尽3 小时前
如何将一个 React SPA 项目迁移到 Next.js 服务端渲染
前端·next.js
3 小时前
使用 svgfmt 优化 SVG 图标
前端·svg·icon
Watermelo6173 小时前
href 和 src 有什么区别,它们对性能有什么影响?
前端·javascript·vue.js·性能优化·html·html5·用户体验
hqk3 小时前
鸿蒙零基础语法入门:开启你的开发之旅
android·前端·harmonyos
AAA阿giao3 小时前
大厂面试之反转字符串:深入解析与实战演练
前端·javascript·数据结构·面试·职场和发展·编程技巧
专业抄代码选手4 小时前
告别“屎山”:用 Husky + Prettier + ESLint 打造前端项目的代码基石
前端