
去年接手一个内部管理系统里面有个请假审批模块需要按日历日期选择起始日和结束日。我直接引了成熟日历组件结果那个组件整个打包下来快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是12月时month 1就是2第0天即3月0号也就是2月最后一天。它会自动处理闰年比如new Date(2024, 2, 0)返回29因为2024是闰年。获取“这个月1号是星期几”用getDay()注意它返回的是0到60代表周日。这一步很关键因为后面生成格子的时候要靠它算出第一个格子该从哪天开始。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 classcalendar div classcalendar-header button clickgoPrevMonth‹/button span classcalendar-title{{ currentYear }}年{{ currentMonth 1 }}月/span button clickgoNextMonth›/button /div ul classcalendar-week li v-forw in weekNames :keyw{{ w }}/li /ul ul classcalendar-body li v-for(cell, index) in curCells :keyindex classcalendar-cell :class{ other-month: !cell.isCurrentMonth, today: cell.isToday, selected: cell.isSelected } clickselectDate(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 classbase-calendar div classcalendar-header button typebutton classcalendar-btn clickgoPrevMonth‹/button span classcalendar-label{{ currentYear }} 年 {{ currentMonth 1 }} 月/span button typebutton classcalendar-btn clickgoNextMonth›/button /div div classcalendar-week span v-forw in weekNames :keyw classcalendar-week-item{{ w }}/span /div div classcalendar-grid span v-for(cell, index) in cells :keycell.date.getTime() classcalendar-day :class{ is-other-month: !cell.isCurrentMonth, is-today: cell.isToday, is-selected: cell.isSelected } clickselectDate(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这段代码我在本地用一个vue2.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到60是周日。如果你把周一当作一周的第一天那么要偏移的量应该是(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”这个经验就行了。日历组件本身不难难的是把边界情况和数据流的坑都考虑进去。我这版代码已经过几个项目的检验直接拿过去改改颜色就能用。你如果在自己项目里实现了或者遇到什么新问题欢迎一起交流。