从零手写Vue2日历组件:日期计算核心逻辑与避坑指南

发布时间:2026/10/10 0:16:21
从零手写Vue2日历组件:日期计算核心逻辑与避坑指南
做后台管理系统这类中后台项目日历组件几乎是绕不开的东西。预约、排班、报表筛选、活动日历……需求千奇百怪但最基础的日视图日历永远是地基。之前有段时间接了个运营后台的需求里面要用到日历。我当时第一个念头是直接用组件库里的日期面板但这需求有点特殊需要自定义某些日期上的数据标记还得支持特殊的禁选逻辑val组件自带的日历面板压根满足不了。改源码又太费劲想想还是自己写一个算了。与其去翻文档研究怎么二次封装不如老老实实手搓一个基础组件。这里我头一回觉得把底层逻辑自己捏一遍心态反而踏实。当时就选了两个核心功能来做年月切换选中当前日期。做完之后发现日历这玩意儿确实不难只要捋清楚日期计算的几条核心逻辑链再注意几个平时根本不会留意的细节剩下就是体力活。这篇不聊组件库怎么封装也不上什么复杂设计就完整记录一下怎么用Vue2从零手写一个支持年月切换、可自由选中任意日期的日历组件。代码是核心思路但更重要的是藏在代码背后的那份思考过程尤其是那几个容易翻车的点我都会标出来。1. 手写日历组件的动机与整体设计先说说为什么非要自己写而不是直接引一个现成的。第三方日历组件库功能确实齐全偏差年、范围选择、周视图啥都有但恰恰是太齐全了在特定业务场景下反而束手束脚。比如我那个项目需要每个日期格子里展示该天的订单量数字还需要把某几天的背景色标成特殊颜色。这种高度定制化的渲染逻辑用第三方组件靠插槽和样式覆盖去搞定效率和可维护性都很差代码写出来也特别绕。自己手写还有一个好处就是可控性强。项目的Vue2版本整体偏老很多新版日历组件是用Vue3写的强行降级用会有兼容性问题。与其冒着引进问题依赖的风险不如把核心逻辑握在自己手里出任何问题自己都能随时调整。1.1 组件需要实现的完整功能链路按照需求拆解我需要这个日历组件完成以下几件事渲染一个标准的7列结构视图每一列对应星期一到星期日表达清晰顶部支持月份切换点击左箭头切上个月右箭头切下个月切换年份点击顶部的年份区域可以进入年份选择视图快速跳到任意年份点击任意一个非禁选的日期格子能够选中它并且在高亮状态上明确标识出来组件被初始化时默认选中今天并定位到今天的月份今日的日期需要特殊样式标识这样即使你切到了别的月份也能方便地找到今天的位置功能看起来不复杂但其实每一环都有内部逻辑需要处理。尤其是日历渲染和日期计算这一块编程初学者如果没有做过类似的东西很容易一头雾水。接下来我会把每一步拆开完整展开怎么实现。1.2 为什么不直接使用现成的第三方日历库有时候咱们项目里的组件库自带日历面板比如某个UI库里就有日期选择器。但在实际场景中让日历面板作为一种独立的、全天候展示的组件存在时光是它内部的月份切换动画、自定义标记插槽、禁用逻辑等就够你写一整套覆盖样式了。再加上如果UI设计稿里面格子的大小、配色、选中态样式和组件库默认风格大相径庭你要覆盖的样式规则会非常恐怖还很容易被组件的内部结构限制住。对于这种重度定制的使用场景自研组件看上去前期投入多但实际上花费的总时间大部分情况比硬套现成的库还要少。这里也说清楚手搓日历组件核心不是造轮子而是理解轮子的原理。一旦理解了原理无论是接第三方库还是自研你都能准确地评估嫁接成本也不会再被这个库能不能实现这个效果这类问题卡住。2. 日历组件最核心的日期计算逻辑日历组件的灵魂在于日期计算。只要能算清楚某个月的第一天是星期几、这个月有多少天、上个月末尾有几天需要补位整个视图就能正确渲染。这块内容没有太深奥的算法就是几个基础的Date对象操作但特别容易写出绕来绕去的逻辑。我先说原理再给代码。先统一口径本组件所有计算都在浏览器本地时区进行不考虑时区切换和夏令时。这个东西对普通企业应用来说够用了没必要因为时区问题把代码搞得很庞大。要做多时区的同学建议自己封装时间处理库不要在这种基础组件里面去讨论时区转换。2.1 三件关键的事某月天数、某月首日星期、补位天数展开讲之前先把三个核心函数列出来。JavaScript的Date对象有一个坑——月份索引是从0开始的。1月是02月是112月是11。这个特性用得好能让代码简化但记不住的话写出来的代码就要到处加1减1很容易出错。首先是获取某年某月总天数的方法。最简洁的办法是利用第0天这个特性// 获取某年某月的总天数month的实际范围是0-11 function getDaysInMonth(year, month) { // new Date(year, month, 0) 表示month的上一个月的最后一天 // 那么 new Date(year, month 1, 0) 就是当前月的最后一天 return new Date(year, month 1, 0).getDate(); }这段思路是利用JS Date对象构造时的溢出机制当你传入 year 和 month 1同时把日设为0时得到的日期就是 month 这个月的最后一天。比如 new Date(2024, 2, 0)其实是2024年2月29日因为闰年getDate() 返回29。如果写成 2024年3月那就是31天。然后是获取某月第一天是星期几// 返回0-60代表周日1代表周一依次类推6代表周六 function getFirstDayOfWeek(year, month) { return new Date(year, month, 1).getDay(); }这里同样要注意month是0-11的索引。比如 new Date(2024, 0, 1).getDay() 返回1表示2024年1月1日是周一。最后是补位天数。这点很关键标准的日历视图是从周日开始的7列结构那如果某个月的第一天不是周日月初的位置前面就需要用上一个月的日期来填充填充的数量就是 getFirstDayOfWeek(year, month) 的值。这样核心的计算逻辑就齐了。接下来我把这些逻辑放进一个完整的日历数据生成函数里直接返回42格或者35格的数据数组// 生成日历视图数据通常情况渲染6行42格 function generateCalendar(year, month) { const daysInMonth getDaysInMonth(year, month); // 本月天数 const firstDay getFirstDayOfWeek(year, month); // 本月第一天星期几 const prevMonthDays getDaysInMonth(year, month - 1); // 上个月天数 const days []; // 月份切换时要注意年份边界比如当前是1月上个月应该是去年的12月 const prevYear month 0 ? year - 1 : year; const prevMonth month 0 ? 11 : month - 1; const prevMonthDayCount getDaysInMonth(prevYear, prevMonth); // 补上个月的日期 for (let i firstDay - 1; i 0; i--) { days.push({ day: prevMonthDayCount - i, month: prevMonth, year: prevYear, isCurrentMonth: false }); } // 补本月的日期 for (let i 1; i daysInMonth; i) { days.push({ day: i, month: month, year: year, isCurrentMonth: true }); } // 补下个月的日期补满42格 const nextYear month 11 ? year 1 : year; const nextMonth month 11 ? 0 : month 1; let nextDay 1; while (days.length 42) { days.push({ day: nextDay, month: nextMonth, year: nextYear, isCurrentMonth: false }); nextDay; } return days; }这里还有个细节值得注意为什么要补到42格而不是35格如果需要彻底理解可以看个实际例子。一种情况是某个月的1号是周六同时这个月又是平年的2月只有28天。这个月本身在第4行左右就已经走完了为了让视图结构保持6行的高度一致尾部必然会出现补位日期。如果不补满42格视图每次渲染高度都可能跳动用户视觉上会感觉组件在上下抖动这是很糟糕的体验。还有另一种做法是固定渲染6行最后一行全都是下个月日期视觉上不太好看但能保证高度稳定。我采用的是补到42格策略这样不管是视觉还是交互上都更好处理。2.2 状态设计与年月切换时的边界判断日期生成函数归生成函数组件的状态又是另一套逻辑。状态这块我先理清几个需要追踪的数据currentYear当前视图展示的年份currentMonth当前视图展示的月份0-11selectedDate用户选中的日期这里用一个Date对象存today当天日期解析结果用来标识今日格子这里有个容易困惑的点currentYear和currentMonth是视图状态控制的是用户当前看到的是哪一个月selectedDate是业务状态控制的是用户选中的是哪一天。两者必须分开不然用户切到别的月份选中状态就会被冲掉。比如今天是1月15日用户把视图切到2月选中2月10日这时候currentMonth是12月索引1selectedDate保存的是2月10日。如果selectedDate直接跟着currentMonth走切月份就丢失选中态了这肯定不行。月份切换的边界判断是另一个容易出错的点。从12月切换到1月时年份要加1从1月切换到12月时年份要减1。代码里我用一个函数统一处理methods: { goToPrevMonth() { if (this.currentMonth 0) { this.currentMonth 11; this.currentYear - 1; } else { this.currentMonth - 1; } }, goToNextMonth() { if (this.currentMonth 11) { this.currentMonth 0; this.currentYear 1; } else { this.currentMonth 1; } } }年份切换视图时逻辑也类似只是把月份归到0即1月goToYear(year) { this.currentYear year; this.currentMonth 0; }有些日历组件会保留当前月份作为切换后的月份但这取决于产品需求我这里设计成切年回到1月逻辑最简单也不容易出现不可控的状态。3. 组件模板与交互实现的完整过程原理层面理清了下面开始落地代码。我选用Vue2 模板字符串级别的模板结构不使用JSX方便大多数开发者阅读修改。整个组件分为三个视觉区域头部操作区、星期栏、日期格网格区。3.1 头部操作区年/月状态展示与切换按钮头部这个区域要放三个元素左侧的上一月按钮中间的年月展示右侧的下一月按钮。年月展示部分我额外加了一个切换年份视图的入口这样就不再需要做单独的年份输入框了。表格结构可以参考下面这段模板div classcalendar-header span classnav-btn clickgoToPrevMonth‹/span div classdate-display clicktoggleYearView {{ currentYear }}年{{ currentMonth 1 }}月 /div span classnav-btn clickgoToNextMonth›/span /div样式上面按钮是可点击区域鼠标悬停给个浅灰色背景。关于中间这块显示的文字点击之后切换到年份选择模式这时候整个日历主体会切换成年份网格。年份视图就简单得多不需要考虑复杂的交互逻辑渲染一个可以上下翻动的年份列表点击年份后设置currentYear并退出年份视图即可。3.2 星期栏设计从周日开始还是从周一开始是个产品问题中文环境的日历默认是从周一到周日的还是从周日到周六的其实全国各地习惯不一样这主要是产品设计问题。我这次按期初需求设置从日开始即第一列是周日。如果你希望从周一开始那只需要对getFirstDayOfWeek的结果做一次转换// 转成以周一为一周开头的索引 const weekIndex (new Date(year, month, 1).getDay() 6) % 7;这个6再%7的写法是日历处理里常用的技巧用来把星期偏移量换算成自定义的起始星期。星期栏用固定数组渲染div classweek-grid div classweek-cell v-forweek in weekList :keyweek{{ week }}/div /divdata() { return { weekList: [日, 一, 二, 三, 四, 五, 六] }; }如果你需要中英文双语切换把weekList做成计算属性根据当前语言环境切换数组内容即可日历主体逻辑完全不受影响。3.3 日期网格的渲染与选中判断日期格子这块是日历交互的核心模板部分像这样div classday-grid div classday-cell v-for(item, index) in days :keyindex :class{ day-cell--current-month: item.isCurrentMonth, day-cell--today: isToday(item), day-cell--selected: isSelected(item), day-cell--disabled: isDisabled(item) } clickselectDate(item) {{ item.day }} /div /div然后days计算属性直接用generateCalendar函数生成computed: { days() { return generateCalendar(this.currentYear, this.currentMonth); } }选中判断的逻辑如下methods: { isSameDay(dateA, dateB) { return dateA.getFullYear() dateB.getFullYear() dateA.getMonth() dateB.getMonth() dateA.getDate() dateB.getDate(); }, isToday(item) { const today new Date(); return item.year today.getFullYear() item.month today.getMonth() item.day today.getDate(); }, isSelected(item) { if (!this.selectedDate) return false; return item.year this.selectedDate.getFullYear() item.month this.selectedDate.getMonth() item.day this.selectedDate.getDate(); } }注意这里isSelected的判断不能直接用isSameDay(this.selectedDate, new Date(item.year, item.month, item.day))因为item里的month已经是0-11索引而new Date()构造时的月份参数也是0-11索引两者一致。但如果你在别的地方把item.month加1了又拿去new Date就惨了一不留神月份就往后偏了一个月。这种错误非常隐蔽因为Calendar组件在大部分月份下都不会暴露问题直到你点到1月的边界时才发现不对。我建议这种做法从头到尾统一使用Date对象或者统一使用{year, month: 0-based, day}对象中间尽量不要混用。项目里我是全部生成{year, month, day}结构只有涉及选中状态时用selectedDate保存Date对象这样可以复用new Date相关的便捷方法。3.4 禁选逻辑的设计不只是能不能点禁选逻辑是实际业务中必有的需求。有的日期不允许被选中比如过去的日期、节假日、或者某个资源已被预约走的日期。为了演示方便我这版组件内置了一个简单禁选规则禁止选中早于今天的日期。methods: { isDisabled(item) { const itemDate new Date(item.year, item.month, item.day); const today new Date(); // 将时间归零只比较日期部分 today.setHours(0, 0, 0, 0); return itemDate.getTime() today.getTime(); }, selectDate(item) { if (this.isDisabled(item)) return; this.selectedDate new Date(item.year, item.month, item.day); } }这里说一下setHours(0,0,0,0)的作用如果只比较日期部分两个Date对象直接比较可能会因为时间粒度不一致而导致偏差。today默认带当前时分秒itemDate默认是0点如果今天是2024-03-05 14:30:00那itemDate为2024-03-05 00:00:00用比较时itemDate确实小于today但其实不应该禁选今天。所以先把时间归零再比较逻辑才正确。实际项目中如果禁选逻辑很复杂可以把isDisabled改成接收一个item参数并交给外部通过prop传入的回调函数处理这样组件就非常通用了。我这次为了演示完整逻辑先内置了一种策略你可以自由替换成你自己的业务规则。3.5 年份视图的快速切换年份视图用到的数据相对简单这里的实现是年份视图显示以currentYear为中心往前推6年、往后推6年的13个年份点击某个年份后设置currentYear并退出年份视图。这样一次最多跳转任意年份也不必像传统做法一样做一个input输入框要用户手工输入。div classyear-grid v-ifisYearView div classyear-cell v-foryear in yearList :keyyear :class{ year-cell--active: year currentYear } clickselectYear(year) {{ year }} /div /divcomputed: { yearList() { const startYear this.currentYear - 6; return Array.from({ length: 13 }, (_, i) startYear i); } }, methods: { selectYear(year) { this.currentYear year; // 切年份后可以保留当前月份也可以重置到1月 // 这里保留当前月份用户从年份视图切回后直接显示该年份对应的月份 this.isYearView false; }, toggleYearView() { this.isYearView !this.isYearView; } }值得注意的是有些产品设计年份视图只允许选年份然后回月视图月份还是保留切之前的月份这个体验很顺畅有些则直接回1月。依产品偏好来定都不是大问题。4. 踩坑记录调试过程中最折磨人的几类边界问题写日历组件大部分时间不是花在写主体代码上而是花在调试各种边界情况上。下面这几类问题我在开发调试过程中都实打实地遇到过。大家以后再写类似组件时可以直接避开。4.1 跨年切换时出现的幽灵补位日期跨年是最容易暴露问题的场景。比如当前显示的是2024年1月这时点击上一月进入2023年12月。如果代码里没有处理好年份边界上一月生成的补位日期用的还是2024年的数据那1月1日之前的格子就出现了2024年12月这种根本不存在的日期用户看到感觉像是出现了幽灵数据。我当时排查这个问题时一度怀疑是generateCalendar函数内部数据错了后来仔细一查发现是goToPrevMonth方法里少了年份回退的逻辑。这种bug的特点就是单独看generateCalendar函数本身逻辑没有问题它拿到的year和month参数就已经错了自然算出的结果也不对。所以写给这种包含状态切换的组件时我建议把状态变更和数据生成分开测试。先测goToPrevMonth和goToNextMonth的纯逻辑确认切换后的year和month值正确再去看日历渲染结果。如果渲染结果不对优先检查state不要一头扎进渲染函数里挖。4.2 月份索引的诸多心智负担之前简单提过Date对象中month从0开始。这种设计让不少从其他语言转过来的开发者栽过跟头。写这个组件时我全程在代码注释里标明了month的取值范围。实践中只要超过10行代码还在用month运算就非常建议写注释或者封装成工具函数不然过了几天再看这段代码自己都要重新推导一遍。还有一处容易混的是跟后端接口对接时后端返回的月份通常是1-12的普通格式。如果组件里存的 currentMonth 是0-11索引那传给后端之前就要记得加1。我在这个组件里封了一个格式化函数formatMonthWithZeroPadding(month) { // 0-11 - 01-12 return String(month 1).padStart(2, 0); }凡是往外输出日期字符串都必须经过这个函数不允许在业务代码里自己拼接这样能大幅降低出错率。前端代码里拼日期字符串最容易拼出来的就是2024-2-5这种不补零的格式虽然有些后端能解析但那种不规范格式坑的是下一个接手的人。4.3 切换视图后今天高亮的判断失效今天的高亮是通过isToday方法比较日期来的实现在前文已经写过了。这里一定不要用字符串比较的方式去判断比如获取当前日期的getFullYear getMonth getDate拼成2024-3-5再去和item拼接的字符串比较因为月份或者日期的补零问题会导致2024-03-05和2024-3-5不一致。正确做法就是全部转成数字逐项比较这样永远不怕格式问题。我见过有人在isToday里用了模板字符串然后怎么测都对不上最后发现原来是补零问题这种问题浪费的时间完全没必要。4.4 v-for里key的选择日期网格里如果只是用index作为v-for的key在月份切换时这个格子对应的日期数据变了但key没变可能引发渲染状态错乱特别是在格子内嵌了组件或有位图样式的场景下。虽然我这个简单的格子场景直接用id也不会有明显视觉问题但严谨的做法是对每个日期格生成一个全局唯一的keygetDayKey(item) { return ${item.year}-${item.month 1}-${item.day}; }然后在模板里:div classday-cell v-foritem in days :keygetDayKey(item) 这样每次切换月份所有格子的key都会整体变化Vue会重新构建整个网格。在9x7的网格规模下重构性能损失几乎可以忽略不计但换来的状态一致性是很有价值的也避免后续如果在格子内嵌Tooltip之类的组件时出现奇怪的状态错乱。5. 组件验收与单元测试的注意点写代码只是第一步日历这类和时间相关的组件必须反复验证各个月份、年份的渲染正确性。手工测试虽然直观但重复劳动多而且容易漏测边界。我之前在类似组件上总结了一份验收清单这里列出来大家可以直接抄走用。5.1 必测的边界日期清单下面这些情况必须全部过一遍再自我验收才算过关场景期望结果1月1日作为当月首日星期为周日第一格就显示1号前面无补位1月首日为周六1月有31天补位6天前面显示上月12月的最后6天2月平年如2023年2月只有28天尾部补位到下一月2月闰年如2024年2月有29天尾部补位正常从12月切换上一月年份回退显示11月从1月切换下一月年份前进显示2月选中某一个日期后切换月份再切回来selectedDate保持选中状态不清空我当时为了把这些case全测一遍直接写了一段临时的Node脚本对1900年到2100年的每一年每一个月调用generateCalendar校验生成的天数总和与当月实际天数一致性跑通了后才完全放心。这种暴力校验方法比单纯靠肉眼爽快多了也让我更信任这个组件的输出结果。5.2 对Date对象格式化方法的封装建议日历组件内部始终会涉及到各种日期格式化。实际项目中极容易出现多处拼接日期的代码。为了统一建议组件内部封装几个小工具函数function formatDate(year, monthIndex, day) { // monthIndex 是 0-11 const month String(monthIndex 1).padStart(2, 0); const dayStr String(day).padStart(2, 0); return ${year}-${month}-${dayStr}; }推荐使用Date.parse以外的方式去解析这种字符串确保项目内所有日期字符串都采用统一的YYYY-MM-DD格式能省掉大量后续的解析麻烦。有些人习惯拿日期字符串直接new Date(2024-03-05)在多数浏览器可行但在部分环境会有解析兼容问题相对稳妥的方案是完全不用字符串直接使用date.getFullYear()等数值方法取出来。5.3 可访问性与键盘操作的小补充这个算是加强项如果时间充裕建议做上给每个日期格子设置tabindex0监听Enter键或Space键执行选中。这样键盘使用者也可以操作日历。再配合上aria-label标注比如2024年3月5日 星期二选中对读屏软件会更友好一点。这块内容很多组件库都不一定做得好自己写的时候顺手做了体验好感度能加分很多。键盘操作这部分注意不要干扰滚动行为。空格键默认会触发页面滚动在日期格子里需要用e.preventDefault()阻止默认行为。同时也要注意日期格子不能出现多个可聚焦元素避免tab遍历混乱。6. 组件样式布局的细节处理写到这里组件逻辑和验收都讲完了但一个日历组件视觉是否顺眼和样式处理的细节也分不开。这块我不会给一整份大段CSS代码因为每个人的设计稿不同重点聊聊几个容易忽略的布局要点。6.1 格子尺寸与响应式策略日历网格如果是定宽布局每个格子建议写死宽高比如60px或72px适配7列正好。如果容器宽度不固定网格可以做flex布局让每个格子等比放大。.day-grid { display: grid; grid-template-columns: repeat(7, 1fr); }这种写法下格子宽度完全由所在容器宽度自适应不必再关心具体px数值。而格子高度方面保证每行高度一致即可。日期的数字建议居中等宽字体更好对齐。另外单元格要设置box-sizing: border-box否则padding和border会把格子的宽度撑出问题。6.2 选中态与今日态的视觉层级选中态和今日态的样式不能混淆。有些同学把两个态都用蓝色背景导致用户分不清今天和我选中的日期。我建议这样处理今日用一个比较突出的边框加亮色文字不加背景比如蓝色边框选中填充背景色加白色文字比如蓝色背景如果今天恰好也被选中则同时保留边框和背景这样即便两个状态重合层级依然清晰。样式上再配合一个hover态能让交互感好很多。6.3 弹层定位的小陷阱这个日历组件如果要封装成弹窗形式点击输入框再弹出日历就涉及定位问题。简单直接的做法是给日历根节点设置position: absolute并定位到触发按钮的下方。因为日历的宽度通常远小于弹层容器建议宽度控制在240px到320px之间太宽了视觉很散太窄了日期数字挤在一起。如果项目里日历要被多个入口触发建议封装时用teleportVue2.6以下没有这能力或者手动挂载到一个固定挂载点避免被父元素的overflow: hidden裁掉。Vue2里可以用手动创建组件实例、挂载到body下的方式来实现。经典做法是const CalendarConstructor Vue.extend(Calendar); const instance new CalendarConstructor(); instance.$mount(); document.body.appendChild(instance.$el);这种模式下组件的位置就需要根据触发元素的getBoundingClientRect动态计算同时还要监听window的resize和scroll事件做位置修正。为免复杂示例组件只做内嵌式展示但大家如果做弹层封装上述思路可以直接参考。7. 组件裁剪与低成本扩展的几种方向写好的组件是一个比较通用的地基如果后续遇到复杂需求再继续加功能也不是特别困难的事。我按实际工作中可能会遇到的扩展方向列一下方便大家有需求时按图索骥。7.1 支持周起始日配置有些场景希望周一开始这里只需要把weekList顺序改成周一至周日并同步在生成dates时对firstDay做偏移换算。前面已经给过转换方法简单很实用。组件外部可以通过一个startOfWeek的prop配置默认0表示周日。7.2 支持日期范围选择范围选择是高频需求实现思路是在原组件基础上增加一个rangeStart和rangeEnd状态。点击当前日期时如果rangeStart为空则赋值给rangeStart如果rangeStart有值且rangeEnd为空则比较两个日期先后顺序确保小的做start大的做end。区间内的日期格子在渲染时增加一个range选中的class即可。难在日期比较时要按时间戳判断function compareDate(a, b) { const timeA new Date(a.year, a.month, a.day).getTime(); const timeB new Date(b.year, b.month, b.day).getTime(); return timeA - timeB; }7.3 支持日期数据标记在很多业务后台中某些日期会有关联的业务数据比如每笔订单量。最难的部分不是渲染而是把外部传入的数据映射到对应日期的格子上。我比较推荐外部的传入格式是Map或者对象键为YYYY-MM-DD字符串。组件内部通过formatDate生成同样的key去查表。如果匹配到就在格子内渲染标记内容。这样杜绝了深层比较的麻烦。我自己在项目里就是这么做的将每天的订单量放在一个对象中然后渲染时直接通过formatDate(item.year, item.month, item.day)取key来查找方便快捷也不会因时区问题导致偏移。8. 最后分享两件小事这次自研日历组件完成后感受还是挺多的。第一件事代码里遇到Date对象月份索引从0开始这类约定一定要养成写注释的好习惯否则很容易坑到接手的人。我写月度展示时currentMonth 1传给后端时也循环复用像这种加1减1的转换点都是易错地带建议都单独抽函数。第二件事手写日历的核心不是把模板UI写出来而是把日期计算和状态管理这两层彻底分离。如果你发现自己写的日历组件代码一团乱麻90%的情况是把这两层耦合在一起了。我最终单看generateCalendar函数它是一个纯函数给它任意合法的年份月份都能拿到确定的42格数据这样既好测也不会被组件的this状态干扰。大家在写自己的版本时也尽量保证这个函数的纯粹性。还有一点是从一次真实的需求中得到的教训有时候你自研的日历组件虽然看起来只是花了一两天时间做出来的小东西但它在业务中的稳定性和长期维护收益往往会超出预期。当你需要改一个细小样式或者定制一个行为时你会庆幸当初没有在黑盒组件库里靠覆盖样式硬扛。如果接下来你想在这个组件上面继续扩展我个人建议先做两件事。一件是给组件补充一套完整的单元测试覆盖年份边界、闰年、平年、跨世纪这个对日历组件来讲价值极大几乎没有其他组件像日历这样在意边界条件。另一件是做一下对移动端的适配。现在的项目多少都要兼顾移动端日历组件在触屏设备上的点击区域是否足够大、是否支持简单的手势滑动切换月份都可以作为后续打磨点。日历这个东西真正理解了核心去写它只是个体力活。畏难的人怕的是不知道从哪里下手希望这篇拆解能帮你把那层窗户纸捅破。