Vue2老项目手写日历组件全攻略:从日期算法到踩坑实录

发布时间:2026/10/5 4:30:46
Vue2老项目手写日历组件全攻略:从日期算法到踩坑实录 在Vue2老项目里接到一个日历需求页面上要有一个年份、月份可切换能点击选中某个日期并且默认定位到今天的日历组件。说实话第一反应是装一个element-uiDatePicker开箱即用。但打开需求文档一看设计稿不要求日期输入框只要一个纯面板日历样式还有定制要求第三方库反而要写一堆覆盖样式还带出一堆多余功能。这种情况下手写一个Vue2日历组件反而更可控。这篇文章就分享我这边的完整实现过程从数据模型怎么设计、日历网格怎么生成到月份切换和日期选中的细节处理最后把踩过的坑也一并整理出来适合正在改造老项目、想快速实现自定义日历的同学直接参考。1. 手写之前先想清楚什么场景下值得自己写日历1.1 第三方组件库在真实需求面前的尴尬大多数团队项目里都装了element-ui这类组件库DatePicker功能确实全但遇到“纯面板日历定制样式自定义底部按钮”这类需求时它的面板是一个内置的复杂组件能定制外观的方向有限要么用CSS去跟它的内部类名做钩子比如el-date-table、el-day-panel这些要么就得借它的popover能力再包一层后续维护成本并不低。如果项目是Vue2老项目用的组件库版本还比较旧或者项目本身就没什么组件库为了一个日历去引一整个UI库明显不划算。我在多个老项目里碰到的情况是UI设计稿里的日历往往需要完全贴合产品风格比如周六日高亮紫色、今天用品牌色圆角底、选中态和hover态两套效果。第三方组件在这些细节上虽然能做到但每改一处都是一场和内部样式死磕真不如自己写一个几十行的日历算法再加一些模板和样式想怎么调整都行。1.2 手写组件的投入产出比判断自己写日历最担心的就是那个“日期计算”听起来很玄。用5分钟梳理一下其实核心就三个问题某年某月有多少天、某年某月的第一天是星期几、怎么把这个月的日期拼到一个6行7列的网格里。这三个问题在JS里都可以用内置的Date对象两行算完后面我会把代码完整贴出来。只要这个逻辑想明白了剩下的就是Vue的模板渲染和状态管理问题难度并不高。如果项目里日历的使用频率很高或者后面要扩展区间选择、多个节假日标注、农历信息那么手写组件的前期投入是非常值得的因为组件在你自己手里扩展点都是你设计的改起来没有黑盒。如果你只是需要一个简单的日期弹窗不想碰样式那直接上第三方库肯定更快。判断标准很简单是“页面需要一个固定样式的日历面板”而不是“需要一个标准日期输入框”。前者适合手写后者适合直接引库。2. 数据模型先行年份、月份、日期一次定清2.1 props和data的职责划分组件写之前先把数据模型定下来。我做的是受控组件模式——父组件可以通过v-model传入一个Date对象子组件内部再维护一份“当前面板”的展示状态。这样组件的每一次展示变化都能被外部感知也方便父组件做回填。具体来说组件接收一个value prop类型是Date默认值是今天。data里维护year、month、selectedDay三个变量。注意year和month代表的是“当前面板显示的年和月”selectedDay代表“用户选中的日”。三个字段拆开的好处是切换月份时year和month变了但selectedDay可以保持不变而选中日期后只需要更新selectedDay并emit出去面板位置不用跳来跳去。Vue2中props不能直接修改所以我不会在组件内部直接改value而是用data维护内部状态再通过emit(input)同步给父组件。这样做还有一个好处就是父组件传过来的value如果一开始不是当天组件也会以value的年月作为面板初始值。props: { value: { type: Date, default: () new Date() } }, data() { return { year: this.value.getFullYear(), month: this.value.getMonth(), // 注意0-11 selectedDay: this.value.getDate() } }这里有个非常易踩的坑Date对象的getMonth返回值是0到110代表一月。我建议在data初始化时就把它存成0到11的索引界面上显示的时候加1不要混用。这样后续直接拿去new Date(year, month, day)不会出错就不用到处做转换了。不过每个团队的约定不同重要的是在一个组件里保持统一不然后面维护的人会疯。2.2 JS日期API的隐藏细节计算每月天数的万能公式某年某月有多少天最容易想到的办法是写一个数组[31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]然后单独判断闰年2月。但JS其实有一个更优雅的算法function getDaysInMonth(year, month) { // month传0-11new Date(year, month 1, 0)表示下个月的第0天也就是本月的最后一天 return new Date(year, month 1, 0).getDate(); }这个写法巧妙在它利用了“第0天”这个特殊语义。比如2024年2月new Date(2024, 2, 0)得到的是2024年2月29日getDate()返回29闰年自动算对完全不用自己判断闰年。类似的如果想知道某月的第一天是星期几用new Date(year, month, 1).getDay()getDay()返回0到60代表周日1代表周一依次类推。注意这个返回值并不能直接作为日历第一列的偏移量要根据你的日历排列习惯做处理一般周日开头的话周日就是0偏移为0周一开头的话周一应该是0所以要把getDay()的结果做一个映射。我自己的日历习惯是周一作为一周的第一天排列更符合国内大多数周历习惯。映射关系是周一0、周二1……周日6可以用(new Date(year, month, 1).getDay() 6) % 7算出来。这里我想说一个经验真正写日历的代码量其实不多但大多数人卡就卡在“不知道getMonth从0开始”“不知道第0天就是上月最后一天”这些小细节上。先把这两个API吃透后面的生成逻辑就是套公式。2.3 为什么日历一定要渲染42个格子日历最常见的呈现方式是6行7列固定42格无论这个月是28天还是31天面板高度保持一致。这样做有两个原因第一是避免切换月份时网格高度跳动用户看着会闪第二是补位逻辑统一前置就是从上一个月的末尾借几天后置就是从下一个月的开头借几天算法非常整洁。如果是5行就能排下的月份比如2月1日是周一只有四周用5行也不是不行但42格固定式能让样式更稳定也省得每个月份单独判断行数。实际开发中42格的每一格需要知道三个信息年、月、日。我把每个格子定义成一个对象。下面的generateCalendar方法会返回一个长度为42的数组generateCalendar() { const year this.year; const month this.month; const daysInMonth new Date(year, month 1, 0).getDate(); const firstDay new Date(year, month, 1).getDay(); // 如果周一作为第一天用 ((firstDay 6) % 7) 作为前置偏移 const offset (firstDay 6) % 7; const prevMonthDays new Date(year, month, 0).getDate(); const result []; // 前置补上一个月的末尾 for (let i offset - 1; i 0; i--) { const y month 0 ? year - 1 : year; const m month 0 ? 11 : month - 1; result.push({ year: y, month: m, day: prevMonthDays - i, inCurrentMonth: false }); } // 本月 for (let d 1; d daysInMonth; d) { result.push({ year, month, day: d, inCurrentMonth: true }); } // 后置补下一个月的开头 let nextMonthDay 1; while (result.length 42) { const y month 11 ? year 1 : year; const m month 11 ? 0 : month 1; result.push({ year: y, month: m, day: nextMonthDay, inCurrentMonth: false }); } return result; }这段代码里的前置补位用了倒序循环offset等于0的时候不会进入循环说明这个月的第一天正好在周一的位置前面不用补。注意前置补位取上个月的最后几天我这里用prevMonthDays - i的顺序i从offset-1倒到0最后把上个月的倒数第offset天加进来。顺序不能反否则日期的从小到大顺序就反了渲染出来会乱。这个细节我一开始就写错过排出来的格子日期是倒的当时排查了半天。3. 手把手搭建组件模板、交互、样式三段式落地3.1 头部区域年月切换按钮与显示逻辑日历的头部就三块左侧的年份切换和月份切换按钮中间的“年 月”显示右侧的月份切换和年份切换按钮。常见的布局是“上一年”“上一月”“年 月”“下一月”“下一年”。按下切换按钮时只需要更新data里的year和month日历网格会通过计算属性自动重新生成。div classcalendar-header button typebutton clickchangeYear(-1)#171;/button button typebutton clickchangeMonth(-1)#8249;/button span classcalendar-title{{ year }}年{{ month 1 }}月/span button typebutton clickchangeMonth(1)#8250;/button button typebutton clickchangeYear(1)#187;/button /div这里用#171;、#8249;这些HTML实体比输入符号更稳定在某些HBuilderX打包工程里如果直接用中文符号也可能出现编码问题HTML实体则完全不会因为字体问题显示成别的样子。切换逻辑的代码要特别处理边界跨年的时候month为0减1会变成-1year要跟着减1反过来month为11加1变成12year要加1month要从12转回0。changeMonth(delta) { let month this.month delta; let year this.year; if (month 0) { month 11; year - 1; } else if (month 11) { month 0; year 1; } this.year year; this.month month; }, changeYear(delta) { this.year delta; }顺便一提如果你希望组件支持“禁用未来日期”这类能力可以在data里维护一个maxDate在changeMonth里判断目标月份是否超出禁用范围超出就直接return不切换。这种边界约束最好放在数据层面而不是靠CSS遮罩因为用户从三天后切换到四天后如果不被拦截后面的选中逻辑就要写一堆判断不如在源头上挡掉。3.2 日期网格渲染每个单元格到底绑定什么网格部分我会用CSS Grid布局一周七列直接写一个class为calendar-days的容器v-for遍历generateCalendar()返回的42个格子对象。每个格子绑定的class至少包含三组信息is-current-month是否本月、is-today是否今天、is-selected是否选中。这三组判断全部通过比字符串来实现比较稳妥。isToday(item) { return ${item.year}-${item.month}-${item.day} ${this.today.getFullYear()}-${this.today.getMonth()}-${this.today.getDate()}; }, isSelected(item) { return ${item.year}-${item.month}-${item.day} ${this.year}-${this.month}-${this.selectedDay}; }之所以用字符串拼接再比较而不是分别比较year、month、day三个字段是因为一次比较可以挡住“面板月份变了但selectedDay没动”导致选中态错乱的情况。比如我这个月选中的是2024年2月15日切到3月之后如果只比较day等于15就高亮那3月15日也会被高亮这显然不对。字符串比较会精确到年月日切月之后选中高亮自动消失这正是我们想要的。模板跟class绑定的语法比较简单但要注意一个性能点42个格子的v-for里每个格子都要执行isToday和isSelected这个开销其实很小不用过度优化。反而是key的选择要重视不能随手写index。因为前置补位和后置补位的格子内容会随着月份变化同一行同一列的格子对应的其实是不同日期如果把index当keyVue会复用错误的DOM节点偶尔会出现样式闪烁。更稳妥的做法是用日期字符串作为keydiv v-foritem in days :key${item.year}-${item.month}-${item.day} classcalendar-cell :class[ { is-disabled: !item.inCurrentMonth }, { is-today: isToday(item) }, { is-selected: isSelected(item) } ] clickselectDay(item) {{ item.day }} /div3.3 选中当前日与默认高亮交互反馈的实现点击单元格时的逻辑是这类组件的核心交互。这里要做一个产品决策点击非本月日期时到底只是显示一个选中样式还是让面板自动跳到那个月我个人建议是“点击非本月日期时面板跳到目标月份并选中目标日期”因为用户看到日历上的某个日期想去点他不管是不是本月他潜意识里都是想选这个日期。如果你只给个样式不跳转用户很可能以为没点上会再点一遍体验很割裂。有一种例外组件面板只允许浏览不允许选中非本月日期。这种情况要在selectDay里直接return并且给格子加上禁用样式和禁用点击事件。我实际项目里遇到过这种情况最后的做法是把非本月格子加上pointer-events: none比在JS里判断更早拦截鼠标事件还省了一次无谓的点击判断。不过这个方案会影响hover样式传递如果后续想给非本月格子加一些装饰性样式再单独开一层来处理。选中逻辑写起来很简单但要注意父子同步。用$emit(input)可以让组件支持v-model用$emit(select)可以让外部监听具体某一天的选择。selectDay(item) { this.year item.year; this.month item.month; this.selectedDay item.day; this.$emit(input, new Date(this.year, this.month, this.selectedDay)); this.$emit(select, { year: this.year, month: this.month, day: this.selectedDay }); }这里有一个我在代码评审里反复强调的点emit出去的时间对象最好新建一个Date实例不要直接把内部data里的数据拼出去。因为你内部selectedDay只是一个数字day拼接出来的Date可以保证年月日规范。但如果哪天你想把时分秒也存进去比如选中的是今天18:30直接new Date(year, month, day)会丢掉时分秒。要么在value传入时额外把时分秒取出来保留要么在emit时手动设置时分秒否则外部拿到的时间就不对。这种问题和日历本身无关但容易在交接时被忽略。4. 交互细节与边界处理切换月份时最容易翻车4.1 跨年切换的越界判断前面changeMonth和changeYear的代码已经把month和year的联动处理了。但还有一个常见需求面板不允许超出某个日期范围。比如“起止日期限制在未来三个月内”如果现在是2024年6月那7月可以看超出8月就不允许切换。这种情况下changeMonth不仅要处理month的进位还要判断新的年月是否超出maxLimit。代码里可以先算出目标年月再做比较changeMonth(delta) { const target this.getTargetMonth(delta); if (this.maxDate new Date(target.year, target.month 1, 0) this.maxDate) { return; } this.year target.year; this.month target.month; }注意这里比较的时候用了“目标月份的最后一天”而不是第一天。因为一个月的最后一天如果都还在maxDate之内那这个月一定在范围内如果用第一天比较可能产生误判。这种细节只有真上手写日历才会注意到网上很多示例代码都偷懒直接比较第一天结果到月末就出现可以进入但显示禁用的怪现象。4.2 小月遇到31日日期进位问题这个坑特别典型也特别容易被忽略。假设当前面板显示2024年1月31日用户选中了31日然后点“下一月”切到2月。此时this.month变成了1但this.selectedDay仍然是31。如果grid月份里2月根本没有31日那选中状态不会显示这还好说。但如果此时外部value还是1月31日而内部面板已经显示2月就会出现“面板在2月value却是1月31日”的不一致。更严重的版本是你在切换月份时用new Date(year, month, selectedDay)去生成一个日期比如new Date(2024, 1, 31)JS会自动把它进位成2024年3月2日date直接跳到下个月去了。我的处理方案是切换月份前把selectedDay钳制到目标月份的最大天数范围内。比如1月31日切换到2月就把选中日改成2月最后一天28或29然后再更新数据。changeMonth(delta) { // 先计算目标年月 let year this.year; let month this.month delta; if (month 0) { month 11; year--; } if (month 11) { month 0; year; } const maxDay new Date(year, month 1, 0).getDate(); if (this.selectedDay maxDay) { this.selectedDay maxDay; } this.year year; this.month month; }这个处理逻辑也推荐用在watch里因为父组件可能直接修改value的值内部状态同步时要做同样的日期钳制。不然就会出现“value明明是2月28日面板却显示3月2日”这类离奇现象。4.3 点击非本月日期的两种产品策略我在3.3节提到了推荐点击非本月日期时跳转月份。这里再把两种策略的系统影响讲透。第一种是“只选不跳”适合把日历当作展示盘的场景格子里的非本月日期只是占位用户不应该也不能选中。第二种是“点击跳转并选中”适合把日历当作选择器的场景用户看到一个跨越月份的日期点击后想直接选这个反馈更流畅。两种策略代码量差别不大但会影响用户心智所以需求评审时就要跟产品确认清楚。如果是通用型组件建议做成配置项比如props里放一个jumpOnDisabled默认true这样不同页面可以复用同一个组件而不需要改内部逻辑。selectDay(item) { if (!item.inCurrentMonth !this.jumpOnDisabled) return; this.year item.year; this.month item.month; this.selectedDay item.day; this.$emit(input, new Date(this.year, this.month, this.selectedDay)); this.$emit(select, { year: this.year, month: this.month, day: this.selectedDay }); }另外还有一个注意点当用户点击的是一位数日期比如5号显示时可能会是“2024-6-5”这种情况下我们内部比较字符串时month和day建议都用原始数字不补零。因为补零后的“2024-06-05”和“2024-6-5”字符串不一致会造成isToday或isSelected判断失败。这算是字符串比较方案的一个隐形坑我处理的办法是维护一个formatKey方法统一把年月日格式化成不带前导零的key所有比较都走同一个方法就不会出错。5. 我踩过的坑与排查实录附问题速查表5.1 坑一month从0开始引发的“整体偏移一个月”这个坑几乎每个手写日历的人都会踩一遍。第一次写完组件我打开页面发现日历显示的日期是“1月份”但系统日期明明是2月1日后来才意识到getMonth()返回的是1显示时应该加1。更隐蔽的坑是从父组件传入的value是某个固定日期比如2024年2月15日我用value.getMonth()拿到1然后以为“1月”直接new Date(2024, 1, 15)其实正好是2月15日但如果我显示的时候没加1显示成1月网格里却是2月的天数就会出现奇怪的错位。最终的解决办法是约定组件内部所有month变量都存储0-11的索引在模板显示时统一month 1在算法计算时直接用索引值。这个约定从一开始就要定清楚否则后面每写一处都会想一次“要不要加1”特别容易被绕晕。5.2 坑二new Date自动进位选中2月却变成3月某次测试发现面板在2月selectedDay保留着从1月带过来的30切换月份时直接用new Date(this.year, this.month, this.selectedDay)去emit结果这个Date对象变成了3月1日。原因是JS不报错地做了进位new Date(2024, 1, 30)会返回3月1日因为2月month索引1没有30号。组件用户感知到的就是“我明明坐在2月的面板里代码却说我选了3月1日”。解决方案就是在selectDay之前确认这个day一定存在于当前的月份中如果是非本月日期的格子跳转后要重新用目标月份的最大天数做一次钳制而不是直接拿格子的day去构造Date。我后来把这段校验抽成了一个sanitizeDate方法每次emit之前都过一遍从根上杜绝日期进位问题。5.3 坑三父组件改了value子组件不刷新组件写完通过v-model绑定后父组件的data里存了selectedDate。某天需求变了父组件要支持“点击某个列表项日历自动跳转到列表项的日期”结果发现列表点击后日期变了日历面板纹丝不动。排查下来是因为组件data里的year和month只在created时初始化了一次后面不会跟随value变化。修复方式是加一个watchwatch: { value: { handler(newVal) { this.year newVal.getFullYear(); this.month newVal.getMonth(); this.selectedDay newVal.getDate(); }, deep: false } }这里我故意把deep设成false因为Date对象本身没有嵌套结构监听它的引用变化就够了。如果你要支持父组件原地修改同一个Date对象比如date.setDate(20)那必须用deep监听或者让父组件重新赋值一个新对象我更推荐后者因为Vue2里原地修改Date对象触发不了依赖收集deep监听反而是给setter加负担没必要。另外还要注意这个watch触发时同样要做maxDay钳制防止把某个月的31号同步到只有30天的月份。5.4 问题排查速查表现象可能原因解决办法日历整体显示成上个月或下个月month未加1或减1统一用0-11索引模板显示month1网格日期排序倒着或错位前置补位顺序写反倒序遍历offset从上一个月的末尾开始往前补选中2月30日变成3月1日new Date自动进位emit前做日期钳制确保day在当月范围内面板年份、月份不跟随父组件data没同步添加value的watch重新初始化year/month/selectedDay切换月份后选中态落到错误日期isSelected只比较day拼接year-month-day再判断精确匹配连续切换月份出现样式闪烁key用了index用日期字符串做唯一key首次进入组件默认高亮不生效初始selectedDay不是当天data初始化时默认取new Date()的date非本月日期点击无响应交互策略没定按需求配置jumpOnDisabled这张表是我在交接项目时一定会附上的因为手写日历组件的bug高度雷同很多问题靠经验扫一眼就能定位。提醒一句如果你把日历组件封装成了公共组件这张表一定要写进注释或文档里。手写组件的bug大多是“经验型bug”别人没踩过很难想到。6. 老项目里接入与扩展的实战建议6.1 在Vue2老项目中注册组件的两种方式如果你是在一个已经有vue.config.js配置的工程里直接把组件文件放到components目录然后import注册就行。但如果项目比较老可能连webpack配置都不太完整只是通过HBuilderX这类工具打包运行那组件注册方式更推荐“局部注册”。在每个需要使用的页面里这样写import Calendar from /components/calendar.vue; export default { components: { Calendar } }这种写法的好处是不会污染全局注册表哪怕项目里存在同名组件冲突也不会互相覆盖。如果确实要在多个页面复用可以封装成一个plugin在main.js里执行Vue.use(CalendarPlugin)内部用Vue.component(v-calendar, Calendar)全局注册。我建议老项目优先用全局注册因为日历这类组件几乎每个页面都可能用局部注册遇到新页面忘了import查起来又慢。但要注意全局注册的组件名最好带前缀比如VCalendar这样即使接手的同事也在项目里放了一个同名日历组件不会冲突。6.2 扩展方向范围选择、徽标插槽、节假日后端数据手写组件最大的扩展红利后期可以按需加功能。我实际项目里接得最多的三个需求是范围选择、带标记日期、外部禁用日期。范围选择需要把单个selectedDay改成selectedRange里面存[start, end]两个Date然后用一个isInRange方法判断当前格子是否在区间内再给两端的格子加范围起止样式。带标记日期需要给日历组件加一个dates配置比如一个数组里面是“YYYY-MM-DD”的字符串渲染格子时判断一下当前日期是否命中命中就显示一个小圆点或文字徽标。外部禁用日期则更简单通过disabledDates数组传入在isDisabled方法里做判断命中就加禁用class并拦截点击。这三个扩展都只需要改data和class绑定完全不需要动网格生成的算法。这也是当时坚持“格子对象统一带year/month/day三个字段”的原因后续任何扩展只要基于这个三元组去判断都会很顺手。如果你接到需求是农历日历那就需要在格子对象里额外挂农历字段再在模板里渲染一行小字插槽也能发挥作用。比如给单元格加一个slot外部可以自定义格子内容而组件的选中、禁用计算逻辑依然保留这种设计兼顾开放和稳定。6.3 关于“今天”的处理方式组件默认高亮今天我用的是一个局部变量today在组件实例化的时候new Date()一次。有同事问过如果页面从凌晨挂到深夜today要不要动态更新我的建议是不要。日历组件不是时钟用户打开页面时看到的“今天”就应该固定为打开那一刻的日期如果跨到第二天突然自动跳到新的一天反而会让用户困惑“我怎么又选中了”。如果需要更强的实时性可以加一个定时器每分钟更新today但绝大多数场景没有这个必要反而增加复杂度。模板里isToday方法用字符串比较也要固定引用this.today而不是每次调用都new Date()否则可能出现用户在23:59:59打开页面、00:00:00时点击格子前后两次new Date取值不一致造成今天的判断偏差。这种极低概率但真实存在的边界问题如果一开始就用固定today变量就完全避开了。手写日历组件这件事算法本身并不难难的是把一堆边界情况想清楚。我每年都会重新打开自己写的日历组件改功能每次都会发现新的细节值得打磨比如键盘方向键操作、比如大年份切换时按钮长按加速、比如移动端下的触摸滚动。这个组件像是一个微型项目把Vue2的数据流、计算属性、组件通信、样式作用域都过了一遍对理解组件化开发很有帮助。最后再分享一个小技巧给日历组件写单元测试时不要测那些“看起来正常”的月份优先测1月、2月、11月、12月这几个跨年月再测每个月的第一天和最后一天基本能把90%的日期边界问题一网打尽。这个习惯我一直保留到现在每次改完日历组件都是先跑一遍边界测试再上线。