Vue日历组件实现农历与节日显示
在旅游预订、假期规划或节庆活动报名这类业务场景中,用户选择日期时往往不只是看公历数字。他们更关心“那天是初几?”、“是不是春节?”或者“清明节放假吗?”。这种需求背后其实是一个典型的本地化交互问题:如何让一个看似简单的日历组件,既能承载现代时间体系,又能体现传统文化的温度?
我们最近在一个出境游产品中就遇到了这个挑战——用户需要选择出发和返回时间,但很多人会下意识地避开农历新年期间,或是特意选在中秋前后出行。如果日历只显示阳历,显然不够友好。于是我们决定从零打造一个支持农历与节日标注的Vue日历组件。
整个实现过程并不复杂,核心在于两点:一是准确的公农历转换算法,二是清晰的状态管理与渲染逻辑。接下来我们就一步步拆解这个组件的设计思路。
技术选型:轻量级 + 零依赖
市面上有不少成熟的日历库,比如element-ui的 DatePicker 或vue2-datepicker,但它们大多对农历支持有限,且定制成本高。我们最终选择了一条更灵活的路径:基于开源项目 jinzhe/vue-calendar 中提取出的calendar.js算法模块,自行封装UI。
为什么选它?
- ✅纯函数计算:不依赖任何外部服务,所有逻辑都在前端完成。
- ✅高精度农历转换:包含闰月处理、节气推算、干支纪年等完整规则。
- ✅体积小:压缩后不足10KB,适合嵌入式使用。
引入方式也很简单:
import calendar from '@/common/js/calendar.js'关键接口就是这一个方法:
calendar.solar2lunar(2025, 4, 5) // 返回对象包含农历年月日、天干地支、生肖、节气等信息这套算法虽然没有AI模型那么“聪明”,但它解决的是另一种类型的推理问题——确定性的数学逻辑。就像我们写代码一样,只要规则明确,就能得出唯一正确的结果。
组件结构设计:数据驱动视图
我们创建了一个名为TourDays.vue的单文件组件,整体采用“月份为单位”的滚动布局,一次展示未来六个月的日历数据。
模板结构如下:
<template> <div class="tourDaysBox" ref="dayBox"> <!-- 顶部导航 --> <div class="visaDetailTop"> <span @click="$emit('close')">✕</span> <h3>选择出发时间</h3> </div> <!-- 月份标签栏 --> <ul class="mothBox"> <li v-for="(m, i) in dat" :key="i" @click="scrollToMonth(i)"> {{ m.y }}年{{ m.m + 1 }}月 </li> </ul> <!-- 可滑动日历主体 --> <scroll class="dayScroll" :style="{ height: h + 'px' }"> <div v-for="(item, index) in dat" :key="index" class="itemBox"> <div class="title">{{ item.y }}年{{ item.m + 1 }}月</div> <div class="dayBox"> <ul class="weekBox">日 一 二 三 四 五 六</ul> <ul class="daysBox"> <li v-for="(day, idx) in item.days" :key="idx" :class="computeClasses(day, item, idx)" @click="selDay(item.y, item.m, day, idx)"> <p>{{ formatDayText(day) }}</p> </li> </ul> </div> </div> </scroll> </div> </template>这里的<scroll>是一个自定义滚动容器,用于解决移动端固定高度下的滚动穿透问题。你可以用better-scroll或原生overflow-y: auto实现。
数据初始化:预计算胜过实时渲染
性能优化的第一原则是:能提前算的,绝不等到渲染时再算。
我们在created()钩子中调用init()方法,一次性生成未来6个月的完整日历数据。这样做的好处是避免在模板中频繁调用solar2lunar()这类耗时操作。
methods: { init() { const now = new Date(); const currentY = now.getFullYear(); const currentM = now.getMonth(); const currentD = now.getDate(); this.dat = []; for (let i = 0; i < 6; i++) { const date = new Date(); const y = date.getFullYear(); let m = date.getMonth() + i; const realY = y + Math.floor(m / 12); m = m % 12; const lastDay = new Date(realY, m + 1, 0).getDate(); const firstWeekDay = new Date(realY, m, 1).getDay(); const days = []; // 补齐前面空白格子(非本月) for (let j = 0; j < firstWeekDay; j++) { days.push({ day: '', flag: -1 }); } // 填充每一天 for (let d = 1; d <= lastDay; d++) { const lunarInfo = this.getLunarInfo(realY, m + 1, d); const isPast = (realY === currentY && m === currentM && d < currentD); const isToday = (realY === currentY && m === currentM && d === currentD); days.push({ day: d, flag: isPast ? -1 : (isToday ? 0 : 1), feast: lunarInfo.isFestival ? lunarInfo.lunar : '', lunar: lunarInfo.lunar }); } this.dat.push({ y: realY, m: m, days: days }); } } }这里有个细节值得注意:跨年处理。当m = currentM + i超过11时,必须正确进位到下一年。Math.floor(m / 12)和m % 12就是用来处理这个边界情况的。
农历与节日识别:哈希查找提升效率
真正的难点来了:怎么判断某一天是不是节日?
我们的策略是分两步走:
- 使用
calendar.solar2lunar()获取当天的农历信息; - 查表匹配是否为已知节日。
为此我们维护两个映射表:
data() { return { festival: { lunar: { "1-1": "春节", "1-15": "元宵节", "2-2": "龙头节", "5-5": "端午节", "7-7": "七夕节", "7-15": "中元节", "8-15": "中秋节", "9-9": "重阳节" }, gregorian: { "1-1": "元旦", "3-8": "妇女节", "4-5": "清明节", "5-1": "劳动节", "10-1": "国庆节" } }, start: null, end: null, s: '', // 选中项标识符 e: '', // 结束项标识符 dat: [], h: 0, monthTxt: ['一月', '二月', '三月', '四月', '五月', '六月', '七月', '八月', '九月', '十月', '十一月', '十二月'] } }然后编写getLunarInfo方法进行判断:
getLunarInfo(y, m, d) { const lunar = calendar.solar2lunar(y, m, d); let lunarValue = lunar.IDayCn; // 默认显示“初五”、“廿三”等 let isFestival = false; // 匹配农历节日 const lunarKey = lunar.lMonth + '-' + lunar.lDay; if (this.festival.lunar[lunarKey]) { lunarValue = this.festival.lunar[lunarKey]; isFestival = true; } // 匹配公历节日 else if (this.festival.gregorian[m + '-' + d]) { lunarValue = this.festival.gregorian[m + '-' + d]; isFestival = true; } return { lunar: lunarValue, isFestival }; }注意这里用了字符串拼接作为键名,比如"5-5"对应端午节。这种方式比遍历数组快得多,属于典型的以空间换时间。
渲染优化:动态样式绑定的艺术
为了让用户一眼看出哪些日子可点、哪些已选、哪些是节日,我们需要一套清晰的视觉反馈系统。
我们将日期分为几种状态:
| 状态 | 样式类 | 视觉表现 |
|---|---|---|
| 已过日期 | .txtColor | 灰色文字,不可点击 |
| 今天 | .txtBg | 浅灰背景,圆角边框 |
| 可选日期 | —— | 正常黑色字体 |
| 选中日期 | .clickColor | 橙色背景+白色文字 |
| 区间内日期 | .bg | 淡橙背景+橙色文字 |
这些样式通过 Vue 的动态:class绑定来控制:
<li :class="[ items.flag === -1 ? 'txtColor' : '', isInSelectedRange(item.y, item.m, items.day) ? 'bg' : '', isSelectedDay(item.m, index) ? 'clickColor' : '' ]"> <p :class="{ txtColor: items.flag === -1, txtBg: items.flag === 0, clickColor: isSelectedDay(item.m, index) }"> {{ items.flag === 0 ? '今天' : (items.feast || items.day) }} </p> </li>其中isInSelectedRange和isSelectedDay是辅助方法:
methods: { isSelectedDay(monthIndex, dayIndex) { const key = this.monthTxt[monthIndex] + dayIndex; return this.s === key || this.e === key; }, isInSelectedRange(year, month, day) { if (!this.start || !this.end) return false; const targetTime = new Date(year, month, day).getTime(); const startTime = new Date(this.start.y, this.start.m, this.start.day).getTime(); const endTime = new Date(this.end.y, this.end.m, this.end.day).getTime(); return targetTime >= startTime && targetTime <= endTime; }, formatDayText(day) { return day.flag === 0 ? '今天' : (day.feast || day.day); } }这种将逻辑抽离成独立方法的做法,不仅提高了可读性,也便于后续测试和维护。
交互设计:双击选择行程区间
我们支持类似酒店预订的“入住-离店”模式:第一次点击设为出发日,第二次设为返回日。如果已经选择了两个日期,再次点击则重新开始。
selDay(y, m, val, index) { if (val.flag === -1) return; // 禁选过去日期 const key = this.monthTxt[m] + index; if (!this.s) { // 第一次选择出发时间 this.s = key; this.start = { y, m, day: val.day }; } else if (!this.e) { // 第二次选择返回时间 this.e = key; this.end = { y, m, day: val.day }; this.judgeDays(); // 自动校正顺序 } else { // 已有完整区间,重新选择出发时间 this.s = key; this.start = { y, m, day: val.day }; this.e = ''; this.end = {}; } }其中judgeDays()会自动判断并交换起止时间,确保出发日在返回日之前:
judgeDays() { if (!this.start || !this.end) return; const startTime = new Date(this.start.y, this.start.m, this.start.day).getTime(); const endTime = new Date(this.end.y, this.end.m, this.end.day).getTime(); if (startTime > endTime) { [this.start, this.end] = [this.end, this.start]; [this.s, this.e] = [this.e, this.s]; } }这种“无感纠偏”设计能有效减少用户误操作带来的困扰。
样式适配:移动端优先
移动端屏幕有限,我们必须合理利用空间。每周七天采用flex布局,每项宽度设为14.2857%(即 100%/7),保证整齐排列。
.weekBox li, .daysBox li { float: left; width: 14.2857%; text-align: center; } .txtColor { color: #b1b1b1; } .txtBg { background: #eee; border-radius: 4px; padding: 2px 0; } .clickColor { background: #fc6d23; color: #fff; border-radius: 4px; } .bg { background: #fdb691; color: #fc6d23; }高度方面,通过mounted阶段动态计算可用区域:
mounted() { this.h = this.$refs.dayBox.offsetHeight - 68; // 减去顶部导航高度 }这样即使页面布局变化,也能自适应填充剩余空间。
扩展建议:不止于节日
当前功能已经满足基本需求,但如果想进一步增强实用性,还可以加入以下特性:
节气标注
const term = calendar.solar2lunar(y, m, d).Term; if (term) { lunarValue = term; // 如“立春”、“谷雨” isFestival = true; }生肖提示
const animal = calendar.getAnimal(year); // 返回“龙”、“蛇”等干支纪年
const ganzhi = calendar.toGanZhiYear(year); // 返回“甲辰年”这些都可以作为 hover 提示或小图标展示,既丰富信息又不干扰主视觉。
总结:小而美的工程实践
这个日历组件看起来只是多了几行中文,但背后涉及的知识点却不少:
- 公农历转换的非线性周期算法;
- 节日匹配的哈希查找优化;
- 时间区间的边界条件处理;
- 移动端滚动与样式的兼容性适配。
它不像大模型那样炫技,但却体现了前端开发的本质:用最小的成本,解决最实际的问题。正如一个好的算法,不一定需要庞大的参数量,只要逻辑严谨、设计精巧,就能在特定场景下发挥巨大价值。
如果你也在做类似的本地化功能,不妨试试这条路:不要盲目追求全功能库,有时候自己动手封装一层,反而更轻便、更可控。