☰
Vue2日历组件手写指南:从核心算法到踩坑实战
2026/10/5 4:30:33 网站建设 项目流程

去年接手一个内部管理系统,里面有个请假审批模块,需要按日历日期选择起始日和结束日。我直接引了成熟日历组件,结果那个组件整个打包下来快200KB,而且为了一个日期选择功能,还得额外配置语言包、主题变量,最后跟项目的旧版ElementUI样式打架,光是调样式就花了两个下午。后来我把组件一删,自己花一个多小时手搓了一个Vue2日历组件,代码只有3KB,月底切换、点击选中、默认高亮今天全都齐了。这件事给我的教训是:Vue2老项目里,一个边界清晰的日历组件,自己写往往比引库更划算。这篇文章就把我的完整实现思路、核心算法和踩坑记录分享出来,代码可直接复制改一改就上项目。

1. 为什么不用现成库,非要自己手搓一个日历

1.1 现成日历组件库的痛点

先说个扎心的事实:在Vue2项目里,你装一个日历组件,大多数情况下装的不是“日历”,而是一整套组件体系。比如某些知名日期库,底层依赖一大堆工具函数,你只是想要一个能点、能切月份的日历,结果连弹层、多选、范围选、时间选择的功能代码全被打包进来了。

我那个项目用的还是webpack4老配置,没开按需加载,一个日历组件让首屏bundle多了小100KB。这还不算完,老项目的全局样式经常会把组件内部的样式冲掉,特别是.el-date-table这种类名,一旦项目里有人写了同名的全局样式,日历表格的间距直接乱掉。我当时调了整整一个下午,最后发现是样式冲突,气得不行。

还有一个痛点:版本匹配问题。Vue2生态里很多日历组件不再维护,有的有兼容性bug,比如在某版本下month传0或者传12会出现日期错乱。这种隐藏问题排查起来成本极高,远比自己写几十行算法要费时间。

1.2 手搓日历的收益边界

手搓日历并不是说所有场景都应该手搓,我给自己定的判断标准是这样的:

场景建议
只需要单选日期,不需要范围、时间选择手搓,20分钟搞定
需要范围选择且带快捷选项可以用库,比如vue2-datepicker
需要高度定制样式,和产品风格一致手搓,样式完全可控
老项目打包体积敏感、部署流程锁定手搓,不新增依赖
团队里对日期算法不熟悉的人来维护用库,但做好样式隔离

我这次的需求正好落在第一和第三象限:单选日期、样式要和后台管理系统的极简风格一致。手搓的收益非常明确:没有额外依赖、样式可控、逻辑透明,出了bug自己两分钟就能定位。而且日历组件的核心算法其实就那么三四个,搞懂之后你会发现它一点都不神秘。

2. 日历组件的核心算法拆解

2.1 先搞清楚“某年某月有几天”和“1号星期几”

手搓日历的第一步,不是写DOM,而是先把数据层的算法想清楚。这里有一个JavaScript Date对象的小技巧,我用了很多年,强烈建议记下来:

// 这个月的第一天 const firstDay = new Date(year, month, 1) // 这个月的第0天,也就是上一个月的最后一天 const lastDayOfPrevMonth = new Date(year, month, 0) // 下个月的第0天,也就是这个月最后一天 const lastDayOfThisMonth = new Date(year, month + 1, 0)

JS的new Date()天生支持日期溢出自动进位,比如new Date(2023, 1, 31)会自动变成2023-03-03,因为2月没有31号。这个特性不仅不是坑,反而帮了大忙。我们用new Date(year, month + 1, 0),当month是1(2月)时,month + 1就是2,第0天即3月0号,也就是2月最后一天。它会自动处理闰年,比如new Date(2024, 2, 0)返回29,因为2024是闰年。

获取“这个月1号是星期几”用getDay(),注意它返回的是0到6,0代表周日。这一步很关键,因为后面生成格子的时候,要靠它算出第一个格子该从哪天开始。

2.2 生成42个格子里的日期数据

日历表格通常显示6行7列,一共42个格子,这样才能保证不管1号是周几、这个月有28天还是31天,表格始终稳定,不会跳行。当然你也可以做动态行数,有项目只渲染需要的行数来省高度,但我建议普通场景直接42格,省心。

核心算法思路是:以“这个月1号的星期几”作为偏移量,让表格第一个格子等于“1号往前推偏移量天”,然后依次加1。举个例子:2025年1月1号是周二,getDay()返回5?不对,我说一下,2025年1月1号其实是周三,getDay()返回3。那么第一个格子就是1号往前推3天,也就是12月29号。第二个格子是12月30号,第三个是12月31号,第四个才是1月1号。

代码可以这样写:

buildCells(year, month) { const cells = [] // 本月1号是星期几 const firstDayOfWeek = new Date(year, month, 1).getDay() // 生成42个格子 for (let i = 0; i < 42; i++) { // 利用日期自动进位,直接构造每一天的Date对象 const date = new Date(year, month, i - firstDayOfWeek + 1) cells.push({ date: date, day: date.getDate(), month: date.getMonth(), isCurrentMonth: date.getMonth() === month, // 是否本月 isToday: this.isSameDay(date, new Date()), isSelected: this.isSameDay(date, this.selectedDate) }) } return cells } isSameDay(a, b) { return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate() }

这一段是整日历组件的心脏,搞懂它,其他都是皮肉。注意i - firstDayOfWeek + 1这个表达式:当i等于firstDayOfWeek时,正好是1号,那之前的格子自然就是0号、-1号、-2号,恰好是上个月的日期,自动被new Date纠正成上个月的最后几天和倒数第几天。非常优雅。

2.3 年月切换的核心逻辑

有了buildCells,切换年月就十分简单了:改掉currentMonth和currentYear这两个响应式变量,然后重新计算格子。这里有个细节要注意,就是跨年边界:

goPrevMonth() { this.currentMonth-- if (this.currentMonth < 0) { this.currentMonth = 11 this.currentYear-- } this.curCells = this.buildCells(this.currentYear, this.currentMonth) } goNextMonth() { this.currentMonth++ if (this.currentMonth > 11) { this.currentMonth = 0 this.currentYear++ } this.curCells = this.buildCells(this.currentYear, this.currentMonth) }

你可能会问:为啥不直接把new Date(year, month - 1, 1)交给Date自动处理?因为那样虽然也能算出正确月份,但currentYear和currentMonth这两个状态没有同步更新,后续UI上的“2025年1月”文案和状态会错位。所以我更倾向于把年份和月份拆成两个独立变量管理,切换时手动处理跨年,逻辑一眼就懂。

3. 年月日切换与选中态的完整实现

3.1 基础布局与状态设计

我习惯把日历组件拆成三块:顶部操作栏(上个月、年月文案、下个月)、星期表头(一到日)、日期网格。Vue2组件结构大致是这样的:

<template> <div class="calendar"> <div class="calendar-header"> <button @click="goPrevMonth">‹</button> <span class="calendar-title">{{ currentYear }}年{{ currentMonth + 1 }}月</span> <button @click="goNextMonth">›</button> </div> <ul class="calendar-week"> <li v-for="w in weekNames" :key="w">{{ w }}</li> </ul> <ul class="calendar-body"> <li v-for="(cell, index) in curCells" :key="index" class="calendar-cell" :class="{ 'other-month': !cell.isCurrentMonth, 'today': cell.isToday, 'selected': cell.isSelected }" @click="selectDate(cell, index)" > {{ cell.day }} </li> </ul> </div> </template>

状态部分我建议这样设计:

data() { return { currentYear: new Date().getFullYear(), currentMonth: new Date().getMonth(), // 注意:0表示一月 selectedDate: new Date(), // 默认选中今天 curCells: [] } }

很多新人会把currentYear和currentMonth合并成一个Date对象来存,这样也没错。但我拆开写的原因是:月份切换时只需要变更一个数字,不用反复new Date(),而且渲染“2025年1月”这种文案时候不用再取值,直接模板拼接就行。对于组件内部状态,能简单就别绕。

3.2 今天、选中日、其他月的格子如何区分

在buildCells里,我给每个格子打了四个标记:isCurrentMonth、isToday、isSelected,再加一个原始的date对象。模版里做三态区分:

  • 非本月的格子:背景置灰、文字颜色变浅,但仍允许点击选中。因为很多产品经理会要求用户能从日历上看到前后月日期并直接点击切换,这种交互很顺手。
  • 今天:用描边或者加粗数字来标识,不强高亮,否则和选中态混淆。
  • 选中日:加背景色块,圆形或者圆角都行,要显眼但不能刺眼。

我给选中态和今天态用过这样的样式,简洁实用:

.calendar-cell.today { color: #409eff; font-weight: bold; } .calendar-cell.selected { background-color: #409eff; color: #fff; border-radius: 50%; } .calendar-cell.other-month { color: #c0c4cc; background-color: transparent; }

选中态和今天态可以同时存在,比如今天就是选中日时,背景变蓝、文字白色、再加粗,视觉信息不冲突。需要说明的是,isSameDay比较时我只比较年月日,不比较时分秒,避免什么初始化时间、午夜边界造成的高亮错乱。这个坑下面还会细说。

3.3 切换年月时如何保持边界安全

月份切换中最容易忽略的是“边角料场景”:从1月切到去年12月,从12月切到明年1月,还有闰年2月。我的做法是:

goPrevMonth() { const targetMonth = this.currentMonth - 1 if (targetMonth < 0) { this.currentYear -= 1 this.currentMonth = 11 } else { this.currentMonth = targetMonth } this.refresh() }

另一个边界是初始化时,如果组件接收一个外部传入的defaultDate,要做一次非法值兜底:

const d = this.defaultDate ? new Date(this.defaultDate) : new Date() if (isNaN(d.getTime())) { d = new Date() } this.currentYear = d.getFullYear() this.currentMonth = d.getMonth() this.selectedDate = d

这里isNaN(d.getTime())是用来识别非法日期的关键,比如new Date('2025-13-40')这种字符串,Date对象会解析失败,getTime()会返回NaN,不拦截的话后面日历全乱。

4. Vue2数据流与组件通信的几个坑

4.1 不要让子组件直接改props

如果这个日历是嵌在别的页面的子组件,你大概率会通过props传一个初始值或者外部受控的选中日期。Vue2里有一个铁律:子组件里不能直接改props。

比如我不小心写过这样的代码,然后被自己坑了一晚上:

// 错误示范 props: { value: { type: Date, default: () => new Date() } }, data() { return { selectedDate: this.value // 作为data的初始值是可以的 } }, methods: { selectDate(cell) { // 错误:直接尝试改props // this.value = cell.date // 正确:派发事件让父组件改 this.$emit('select', cell.date) this.selectedDate = cell.date } }

正确的做法是:props只作为初始值的来源,或者始终作为单向数据流。对于日历这种“父组件想拿到选择结果”的场景,最清晰的通信方式是子组件内部维护selectedDate,然后通过$emit('select', date)通知父组件。父组件如果用自己的状态来覆盖,可以用watch监听props变化去同步内部状态,但注意别写成无限循环。

4.2 compute、watch、methods怎么分工

很多Vue2新手会把这三种工具用混,结果代码又长又乱。这个日历组件是个特别好的练习例子:

  • methods:负责事件处理,比如goPrevMonth、goNextMonth、selectDate,它们修改状态。
  • computed:适合做从已有状态派生出的展示数据。比如顶部显示的“2025年1月”,就可以是一个monthLabel计算属性。
  • data:负责真正的响应式状态源,比如currentYear、currentMonth。

我见过有人把buildCells写成一个computed,依赖currentYear和currentMonth自动重新计算。这确实更优雅,但有一个副作用:如果这个计算量大,每次切换动画时会重复执行。但实际来说42个格子的计算根本不是性能瓶颈,用computed完全没问题。我自己的代码之所以用methods + refresh,是因为旧项目里我需要在切换月份后做一些额外的副作用操作(比如重置滚动条、收起面板),用methods调用更直观。

4.3 重新渲染性能问题

Vue2的响应式系统对这样一个简单组件完全够用,真正要注意的反而是渲染的细节:

  • 给li绑定key时不要用cell.day,因为上个月和下个月会出现相同的日期数字,比如12月31号和1月31号都是31,用day做key会导致渲染错乱,应该用index或者cell.date.getTime()。
  • 如果日历数量多,比如一个页面出现十几个日历面板,可以考虑把buildCells改成computed并加上缓存,避免重复创建Date对象。
  • 点击切换月份时,如果样式里有transition动画,注意DOM没有实时销毁重建,CSS动画可能因为复用DOM而不生效,这时候可以用:key强制整个网格重渲。

顺便说一句,这也是Vue3和Vue2在渲染机制上一个很大的区别:Vue3的Proxy响应式更细粒度,而且Fragment支持让组件模板少一层包裹。但Vue2这套只要不滥用、不写几千行的巨型渲染函数,性能是没有任何问题的。

5. 完整代码与实操演示

5.1 代码文件和目录组织

我在实际项目中是这样组织的,你可以根据自己项目的习惯调整:

src/components/BaseCalendar/ ├── index.vue // 组件模板、样式、逻辑全部在里 └── README.md // 简单说明props和事件

项目小,不需要再拆什么calendar-utils.js,但如果你有多个地方用到日期格式化、是否同一天这类工具,我建议抽到一个src/utils/date.js里:

export function isSameDay(a, b) { if (!a || !b) return false return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate() } export function formatCalendarDate(date) { const y = date.getFullYear() const m = String(date.getMonth() + 1).padStart(2, '0') const d = String(date.getDate()).padStart(2, '0') return `${y}-${m}-${d}` }

formatCalendarDate在把选中日期回传给父组件时很常用,父组件可拿到“2025-01-15”这样的字符串直接给后端,或者再new Date转一次。注意String.prototype.padStart在部分老浏览器上有兼容性问题,如果你需要兼容IE11之类,用('0' + (month + 1)).slice(-2)代替。

5.2 核心代码解析与关键变量说明

完整的index.vue我把它精简了一下,保留了最核心的功能:

<template> <div class="base-calendar"> <div class="calendar-header"> <button type="button" class="calendar-btn" @click="goPrevMonth">‹</button> <span class="calendar-label">{{ currentYear }} 年 {{ currentMonth + 1 }} 月</span> <button type="button" class="calendar-btn" @click="goNextMonth">›</button> </div> <div class="calendar-week"> <span v-for="w in weekNames" :key="w" class="calendar-week-item">{{ w }}</span> </div> <div class="calendar-grid"> <span v-for="(cell, index) in cells" :key="cell.date.getTime()" class="calendar-day" :class="{ 'is-other-month': !cell.isCurrentMonth, 'is-today': cell.isToday, 'is-selected': cell.isSelected }" @click="selectDate(cell.date)" >{{ cell.day }}</span> </div> </div> </template> <script> export default { name: 'BaseCalendar', props: { defaultDate: { type: Date, default: () => new Date() } }, data() { const d = this.getValidDate(this.defaultDate) return { currentYear: d.getFullYear(), currentMonth: d.getMonth(), selectedDate: d, weekNames: ['日', '一', '二', '三', '四', '五', '六'] } }, computed: { cells() { return this.buildCells(this.currentYear, this.currentMonth) } }, methods: { getValidDate(input) { const d = input instanceof Date ? new Date(input) : new Date() return isNaN(d.getTime()) ? new Date() : d }, buildCells(year, month) { const cells = [] const firstDayOfWeek = new Date(year, month, 1).getDay() for (let i = 0; i < 42; i++) { const date = new Date(year, month, i - firstDayOfWeek + 1) cells.push({ date: date, day: date.getDate(), isCurrentMonth: date.getMonth() === month, isToday: this.isSameDay(date, new Date()), isSelected: this.isSameDay(date, this.selectedDate) }) } return cells }, isSameDay(a, b) { return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate() }, selectDate(date) { this.selectedDate = date this.$emit('select', date) this.$emit('select-date', this.formatDate(date)) }, formatDate(date) { const y = date.getFullYear() const m = ('0' + (date.getMonth() + 1)).slice(-2) const d = ('0' + date.getDate()).slice(-2) return y + '-' + m + '-' + d }, goPrevMonth() { if (this.currentMonth === 0) { this.currentMonth = 11 this.currentYear -= 1 } else { this.currentMonth -= 1 } }, goNextMonth() { if (this.currentMonth === 11) { this.currentMonth = 0 this.currentYear += 1 } else { this.currentMonth += 1 } } } } </script> <style scoped> .base-calendar { width: 300px; border: 1px solid #e4e7ed; border-radius: 6px; padding: 12px; user-select: none; font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', Arial, sans-serif; } .calendar-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; } .calendar-btn { width: 28px; height: 28px; line-height: 1; border: none; background: #f4f4f5; border-radius: 4px; cursor: pointer; font-size: 18px; color: #606266; } .calendar-btn:hover { background: #e4e7ed; } .calendar-label { font-size: 15px; font-weight: 600; color: #303133; } .calendar-week { display: grid; grid-template-columns: repeat(7, 1fr); text-align: center; margin-bottom: 4px; } .calendar-week-item { font-size: 12px; color: #909399; padding: 6px 0; } .calendar-grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 2px; text-align: center; } .calendar-day { height: 36px; line-height: 36px; font-size: 14px; color: #303133; border-radius: 50%; cursor: pointer; transition: background-color 0.2s; } .calendar-day:hover { background-color: #f5f7fa; } .calendar-day.is-other-month { color: #c0c4cc; } .calendar-day.is-today { color: #409eff; font-weight: 600; } .calendar-day.is-selected { background-color: #409eff; color: #fff; font-weight: 600; } .calendar-day.is-selected:hover { background-color: #409eff; } </style>

这段代码我在本地用一个vue@2.6.14 + @vue/cli-service搭建的demo项目里跑过,直接能用。有几个点我特意解释一下:

  • 为什么computed里用cell.date.getTime()作为key?因为日期是唯一的,而且如果切换月份后格子内容变化,Vue能精准复用或替换DOM,不会出现日期错位的诡异bug。
  • gap: 2px在grid布局中会让每一列之间的空隙均匀,比margin一个个调省事得多。
  • transition: background-color 0.2s提升选中手感,但这个动画在快速切月时可能会出现“颜色闪一下”的错觉,无伤大雅,介意的话去掉。

5.3 实际运行效果与演示说明

运行起来之后,面板默认显示当前月份,并高亮今天。点击任意日期,该日期会变成蓝色圆形高亮,同时父组件收到一个select事件(Date对象)和一个select-date事件(格式化字符串)。点击上下个月的箭头,年份月份标题联动切换,日历网格重新渲染。如果点击的是非本月日期,比如1月面板上的12月30号,它会直接选中并高亮,但月份标题不会自动跳转到12月。这在很多业务场景里会让用户困惑,所以我在真实项目里会在selectDate里加一个逻辑:如果点击的非本月,就自动把面板切换到那个月。

selectDate(date) { this.selectedDate = date // 如果点击的不是本月,切换面板 if (date.getMonth() !== this.currentMonth || date.getFullYear() !== this.currentYear) { this.currentYear = date.getFullYear() this.currentMonth = date.getMonth() } this.$emit('select', date) this.$emit('select-date', this.formatDate(date)) }

这个细节很加分,推荐加上。用户点着点着就自然翻页了,体验接近完整版日历组件。

6. 常见问题与排查技巧实录

6.1 日期错位、星期对不上的排查

如果日历第一天不是从正确位置开始,最常见原因是getDay()取值范围理解错了。getDay()返回0到6,0是周日。如果你把周一当作一周的第一天,那么要偏移的量应该是(getDay() + 6) % 7,而不是直接用getDay()。我遇到过一次把周一当第一天、却直接用getDay()的情况,日历整体往左错了一列。排查方法很简单:在buildCells里临时打印前三个格子的日期,对照系统自带日历看一眼就知道偏移量对不对。

另外,如果发现某个月的1号显示在上个月的最后一列,而不是正确的位置,一定是你生成42格时的起点公式写错了。记住模板:new Date(year, month, i - firstDayOfWeek + 1),i从0到41。如果你写成了i + firstDayOfWeek + 1,保证错位。

6.2 当天高亮、选中态失效

高亮失效通常有这几个原因:

  • 时间部分不一致。比如你在某处new Date('2025-01-15'),它会解析成UTC零点,本地时区可能变成前一天,而另一种写法new Date(2025, 0, 15)是本地时间,两者再getDate()比较时可能相差一天。这就是为什么我在比较日期时只用年月日三个字段,而不直接比较时间戳。
  • 修改了响应式数据后用index做key导致DOM复用,选中态样式停留在旧元素上。改用cell.date.getTime()做key后问题消失。
  • 把selectedDate初始化成了当前时间,但列表里的isToday也用了当前时间,理论上一致,但如果你在页面里改过currentMonth,通过computed重新生成的cells会重新调用new Date(),如果跨秒跨天会出现不一致。稳妥做法是在data里缓存一个today变量,整个组件生命周期内固定使用:
data() { const today = new Date() return { today: today, selectedDate: today, ... } }

6.3 月份天数边界、闰年问题

JS Date自动处理闰年和大小月,但我还是见过有人自己在代码里写“判断2月28天还是29天”的逻辑,完全没必要。你只需要记住:new Date(year, month + 1, 0)拿当月最后一天,new Date(year, month, 0)拿上个月最后一天。这样计算天数永远是对的。

但有一个容易出错的场景:如果外部传入的defaultDate是字符串而不是Date对象,比如父组件从后端拿到的值是"2025-02-30",这种字符串在不同浏览器上解析结果不一致。我的建议是:在父组件就把字符串转成Date并做合法性校验,子组件里getValidDate做兜底,解析失败直接用今天,而不是让日历白屏。

6.4 快速排错清单

我把这几年做日历类功能总结下来的排错顺序整理成了一张表,碰到问题直接按这个顺序查:

症状优先排查项处理办法
全部格子是同一个日期buildCells里循环里没有传入i检查new Date(year, month, i - offset + 1)参数
星期表头错位一天一周起始日判断错误统一用getDay(),周一作为起始日时偏移用(getDay()+6)%7
跨月点击后不跳转面板selectDate只选中未切换年月在selectDate里判断月份并同步currentMonth/currentYear
样式冲突,格子间距乱scoped样式被全局样式覆盖给根节点加独有的base-calendar类名,不用通用类名
组件在弹窗里打开时不刷新弹窗复用了组件实例给组件:key绑定当前日期字符串,强制重建
日期文案变成了昨天时区导致的UTC解析问题统一用new Date(year, month, day)构造,不用字符串解析

最后一条“时区导致的UTC解析问题”真的是很多人的噩梦。举个具体例子:在印度时区,new Date('2025-01-15')解析为UTC零点,本地时间会是当天的5点30分,还在同一天没事。但在西半球某些时区,比如美国西部,它会解析为前一天的下午4点或5点,此时getDate()直接就比预期少1。所以在日历组件里,我坚决不推荐用日期字符串去构造Date对象,全部走new Date(year, month, day)这个参数形式,它生成的是本地时间,安全。

我个人在实际项目中踩过最值钱的一个坑,就是组件在弹窗里第二次打开时,因为Vue复用了组件实例,日历还停留在第一次关闭时的月份和选中态。解决办法很粗暴但有效:在父组件弹窗的:visible和日历组件绑定的key上挂一个时间戳,每次打开弹窗强制重新渲染日历。这个技巧在多个项目里都救过我,其实你没必要去深究Vue实例复用的底层细节,记住“弹窗里放有状态组件就绑个key”这个经验就行了。

日历组件本身不难,难的是把边界情况和数据流的坑都考虑进去。我这版代码已经过几个项目的检验,直接拿过去改改颜色就能用。你如果在自己项目里实现了,或者遇到什么新问题,欢迎一起交流。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询