从零开始用Vue3写一个实用日历组件难吗?有哪些避坑点?
从零开始用Vue3写日历组件真的不难,难的是把“能用”的日历做成“好用、实用、适配性强”的那一种——毕竟现在不管是移动端的预约健身、选课,还是PC端的工作计划、排班表,对日历的需求早就不是“看看日期是周几”这么简单了,写过两次不同场景用的Vue3日历,踩了不少原生API和响应式逻辑的坑,今天就把完整的实现思路和避坑指南分享出来,连刚接触Vue3半年的朋友跟着走也能写出来。
先理清楚:一个“入门即能用”的Vue3日历,核心逻辑是什么?
很多人一开始写日历,直接盯着界面画表格、加星期几,然后就卡壳在“怎么生成当月的日期块”“怎么把上个月/下个月的占位日期加进去”上,其实核心逻辑非常简单,就三步:获取当月第一天的星期几、当月的总天数、上个月最后几天的占位天数,剩下的就是用循环生成表格行和列,再给日期块加个点击、hover这些基础交互就行。
那具体怎么用Vue3的原生工具和JS原生API实现这三步?别着急用第三方库的工具类,先试试用原生的Date对象,懂了底层逻辑,以后不管改什么功能都顺手。 首先是获取当月的关键参数:我们得找一个基准日期,一般就是今天,但也可以允许用户传个props指定初始展示月份对吧?然后对这个基准日期做几个小操作:
- 第一步,获取当月第一天的星期几:JS的Date对象里,getDay()方法返回的是0到6,0代表周日,1到6代表周一到周六,这个得注意,因为很多国内的习惯是把周一放第一天,后面可以加个props让用户切换起始周几,这一步先默认周日吧,后面再说调整的坑。
- 第二步,获取当月的总天数:很多人会写个switch判断1、3、5、7、8、10、12月31天,4、6、9、11月30天,再判断2月的闰年——其实有个更巧妙的原生方法:把基准日期的月份加1,日期设为0,这时候返回的就是上个月(也就是我们原本的基准月份)的最后一天,再getDate()就拿到总天数了,比如基准是2024年3月15日,new Date(2024, 3, 0).getDate()就是29,因为2024是闰年嘛,这个方法自动帮你处理了月份边界和闰年,超省心。
- 第三步,计算上个月的占位天数:如果基准是周日开头,占位就是0;如果是周一开头,就要补上个月的6天?不对不对,等一下,getDay()返回的基准第一天是周日是0,那我们要补的天数应该就是基准第一天的getDay()值本身,对吧?比如基准第一天是周三(getDay()=3),那就要补上周日、一、二,共3天,正好把表格第一行填满。
拿到这三个参数,生成日期数组就简单了:先往数组里push上个月的占位日期——怎么算上个月的最后几天?同样用Date对象的小技巧:基准月份减1,日期设为0拿到上个月的最后一天,然后从lastDayOfPrevMonth - placeholderDays + 1开始循环到lastDayOfPrevMonth;接着push当月的1号到当月总天数;最后看看整个日期数组的长度是不是7的倍数?如果不是,就要补下个月的占位日期,从1号开始补,补到长度是76(一般日历都是6行,避免有的月份只有28天占4行,有的占5行,高度跳来跳去不好看)或者75也行,不过6行更通用,不会有高度波动。
基础交互加起来:点击切换月份、选中日期、高亮今天,避坑点在哪?
基础界面的表格和日期块生成完了,接下来就是最常用的几个交互,这里踩的坑最多,尤其是Vue3的响应式陷阱。 第一个交互:点击上个月/下个月的箭头切换展示月份,这里要注意不要直接修改基准日期的props或者reactive对象里的year/month属性值,如果是用reactive包裹的一个对象比如dateInfo = { year: 2024, month: 3 },千万不要直接写dateInfo.month += 1或者dateInfo.month -= 1——哦不对,reactive的对象直接修改属性值是可以触发响应式的,但是如果用的是解构赋值的ref,比如const { year, month } = toRefs(dateInfo),那直接修改year.value或者month.value也是可以的?哦,刚才差点说错,reactive对象的属性如果用toRefs解构出来的是ref,直接.value没问题,但要注意的是处理月份边界的问题:比如当前是1月,点上个月箭头,年份要减1,月份要变成12;当前是12月,点下个月箭头,年份要加1,月份变成1,这个别用if else套太多,太啰嗦,可以用原生的Date对象自动处理:比如想切换到上个月,就new Date(dateInfo.year, dateInfo.month - 2, 1),因为month是0-11的索引,对吧?哦对哦!刚才第二步的原生技巧里,Date构造函数的月份参数是0到11,1月是0,12月是11,刚才忘记说了!这是第一个超级大的避坑点!!很多新手第一次写日历,生成的月份总是晚一个月,就是因为没搞清楚Date构造函数的month索引! 刚才纠正一下,前面的基准日期获取参数的步骤,month都要注意是0-11的范围,比如用户传的props如果是初始月份3(代表3月),那我们要把它转成2来用;切换完月份之后,再把索引月份加1展示给用户看,这个细节一定要记牢,不然整个日历的逻辑都会乱。
第二个交互:选中日期,这里的需求一般有两种:单选(比如预约某天的健身课)和多选(比如选连续的请假日期,或者选多个不连续的工作备注日),不管是单选还是多选,都要把选中的日期转成字符串或者时间戳存起来,千万不要直接存Date对象!因为Date对象是引用类型,两个看起来完全一样的Date对象(比如new Date('2024-03-20')和new Date(2024,2,20)),用===比较是false,因为它们的内存地址不一样,那转成什么格式的字符串最好?建议转成YYYY-MM-DD的格式,不管是传给后端还是本地存储,或者是用来和其他日期比较,都超级方便,比如写一个辅助函数formatDateToString,接收一个Date对象,返回padStart处理过的年、月、日拼接起来的字符串,padStart是ES6的方法,用来补前导0的,比如3月变成'03',5号变成'05',这样排序、比较都很标准。
第三个交互:高亮今天,这里的避坑点是不要只比较月和日,一定要比较完整的YYYY-MM-DD!不然比如现在是2025年3月20日,你打开日历切换到2024年3月,20号也会被高亮,这就错了,还有就是,今天这个基准日期不要用组件里的reactive或者ref存,直接每次渲染的时候动态获取new Date()转成YYYY-MM-DD就行,不然如果用户打开日历之后过了一天(比如从23:59用到00:01),高亮不会自动更新——当然如果你的日历是在实时更新的场景下用的,比如医院的分诊台,可以加个setInterval每小时或者每天更新一次今天的日期,但一般的预约、计划场景,每次打开或者每次切换月份的时候重新获取就够了。
进阶功能怎么加?比如农历、节假日、标记事件?
基础的日历写出来了,用户肯定会提进阶需求,比如国内的用户要看农历,企业用户要看法定节假日,个人用户要看自己添加的事件标记,这几个功能实现起来其实也不难,关键是找对数据来源和优化渲染性能。
怎么加农历显示?
很多人会自己写农历转换的算法,其实没必要,而且自己写的算法很容易出错(比如闰月的判断),现在有很多轻量的开源农历转换库,比如lunar-javascript或者dayjs-plugin-lunar,后者更常用,因为dayjs本身就是Vue3项目里几乎都会用到的轻量日期库(比moment小太多了,只有2KB左右),如果用dayjs的话,直接安装dayjs和对应的lunar插件,然后在Vue3的main.js或者组件里引入并注册插件就行。 注册完之后,把刚才生成的每个日期块对应的Date对象转成dayjs对象,然后调用.lunar()方法就能拿到农历的年、月、日、生肖、节气这些信息了——比如想展示农历的日,就用dayjs(date).lunar().day,如果是初一的话,展示农历的月,三月初一”,其他时候展示农历的日,十五”“廿三”,这样界面更简洁。 这里的避坑点是插件的版本要和dayjs的版本匹配,不然会报错;还有就是,如果要展示节气的话,春分”“清明”,要先判断当天是不是节气,如果是的话,优先展示节气,再展示农历日,这样更符合国内用户的习惯。
怎么加法定节假日和休息日标记?
法定节假日的数据每年都会变,所以不要硬编码在组件里,最好是让用户传一个props,或者在组件挂载的时候从接口获取——接口获取的话,要注意缓存机制,比如把当年的节假日数据存在localStorage里,下次打开组件的时候先看localStorage里有没有,没有的话再请求接口,这样能减少服务器的压力,也能提升组件的加载速度。 拿到节假日数据之后,怎么判断某个日期是不是节假日或者休息日?同样把日期转成YYYY-MM-DD的格式,然后去数据里找对应的key就行——一般节假日数据的结构是这样的:每个日期是key,value是一个对象,包含type(holiday'法定节假日,'workday'调休上班,'restday'普通周末)、name(清明节')这些信息。 找到对应的type之后,给日期块加不同的class就行,比如法定节假日加红色的class,调休上班加灰色的class,普通周末加橙色的class,这样用户一眼就能看出来。 这里的避坑点是不要只判断普通周末,一定要把调休上班的普通周末标记成工作日,不然比如今年2024年的清明节是4月4日周四,4月5日周五放假,4月6日周六调休上班,如果你只判断getDay()是0或6就加周末的class,4月6日就会被标记成周末,这就错了。
怎么加个人事件标记?
个人事件标记的需求比节假日多一点,比如有的事件是全天的,有的是有开始和结束时间的,有的是要展示在日期块的下方,有的是要展示一个小图标,不管是哪种需求,都要把事件数据转成按YYYY-MM-DD分组的对象,这样渲染每个日期块的时候,直接从分组对象里拿当天的事件就行,不用每次都遍历整个事件数组,能大大提升渲染性能——尤其是事件很多的时候,比如一个月有几百个事件,如果不分组,每次渲染6行42个日期块,每个日期块都遍历几百次,性能会很差。 分组的方法也很简单,用ES6的reduce方法就行:比如事件数组是events,每个事件都有startDate和endDate(或者只有date,代表全天事件),然后用reduce把它们按YYYY-MM-DD分组,
const groupedEvents = events.reduce((acc, event) => {
// 先处理event的日期,不管是startDate还是date,都转成YYYY-MM-DD的数组
// 如果是全天事件只有date,就直接push到acc[date]里
// 如果是有startDate和endDate的跨天事件,就要循环从startDate到endDate的每一天,push到对应的acc里
// 这里的跨天事件要注意边界处理,比如endDate的时间是00:00:00,要不要算当天?
return acc;
}, {});
渲染每个日期块的时候,就判断groupedEvents里有没有当天的key,如果有的话,就展示对应的事件——比如当天有3个事件,就展示3个小圆圈,或者展示第一个事件的标题,剩下的用“+2”表示,这样界面不会太乱。 这里的避坑点是跨天事件的边界处理,一定要和产品经理或者用户确认清楚,比如结束时间是2024-03-20 00:00:00,算不算3月20日?一般来说是不算的,只算到2024-03-19;还有就是事件数据的日期格式一定要统一,最好也是YYYY-MM-DD或者时间戳,不然分组的时候会出错。
响应式适配怎么做?从PC端到移动端一键切换?
现在的前端项目几乎都是响应式的,所以日历组件也要能适配PC端和移动端,响应式适配的核心思路其实不是用很多的媒体查询,而是用flex布局和rem/em/vw/vh这些相对单位——当然媒体查询也可以用,但不要太多,不然代码会很乱。
PC端的布局:一般是左中右三栏,左边是年份和月份的选择器,中间是大的日历表格,右边是当天的事件详情展示;或者是只有中间的大日历表格,年份和月份的选择器放在表格的上方,左右箭头放在选择器的两边,不管是哪种布局,都用flex布局,表格的每一行和每一列都用flex: 1,这样宽度会自动分配。
然后是移动端的布局:一般是全屏或者半屏的弹窗,年份和月份的选择器放在表格的上方,左右箭头换成更大的按钮,方便手指点击;表格的每一个日期块也要更大,一般高度是50px左右,手指点击的范围要足够大(至少44px*44px,这是移动设备的最小点击范围标准);事件详情展示可以做成点击日期块之后弹出的小弹窗,或者是底部滑出的抽屉,抽屉的高度可以用vh单位,比如占屏幕的50vh,这样不会挡住整个屏幕。
这里的避坑点是不要用固定的像素值来定义表格的宽度和日期块的高度,一定要用相对单位;还有就是移动端的最小点击范围,日期块的padding或者margin要足够大,让手指能轻松点击,不会点错相邻的日期块;移动端的农历显示可以稍微小一点,或者只在有空间的时候显示,不然日期块会太挤。
优化渲染性能:Vue3的v-for和key怎么用?
不管是基础界面还是进阶功能,渲染性能都是很重要的,尤其是事件很多的时候,Vue3的v-for和key用得不好,会导致整个页面卡顿。 首先是v-for的key:千万不要用index作为key!! 这是Vue3开发中最常见的一个坑,尤其是在涉及到数组元素的添加、删除、重新排序的时候,用index作为key会导致Vue3的虚拟DOM diff算法出错,重新渲染整个数组,而不是只渲染变化的元素,那用什么作为key?对于日期块来说,可以用刚才转成的YYYY-MM-DD的字符串作为key,因为每个日期块的YYYY-MM-DD都是唯一的;对于事件来说,可以用事件的id作为key,因为每个事件的id也是唯一的。
然后是虚拟列表:如果你的日历要展示的不是一个月,而是一年或者几年的日期,那用普通的v-for渲染整个日期数组会导致DOM节点太多,页面卡顿——这时候就要用虚拟列表了,比如用Vue3官方推荐的vue-virtual-scroller库,或者自己手写一个简单的虚拟列表,虚拟列表的核心思路是只渲染可视区域内的DOM节点,当用户滚动的时候,动态替换可视区域内的节点,这样不管有多少个日期,DOM节点的数量都是固定的(比如只渲染可视区域内的20个日期块),性能会大大提升。 不过一般的场景下,只展示一个月的42个日期块,用普通的v-for就够了,不需要用虚拟列表,只有展示大量日期的时候才需要。
这里还有一个小的优化点:用Vue3的计算属性computed来生成日期数组和分组事件数组,而不是用methods或者watch,因为computed有缓存机制,只有依赖的响应式数据发生变化的时候,才会重新计算,而methods每次渲染都会重新调用,watch虽然可以监听响应式数据,但需要手动赋值,不如computed方便。
写在最后:从零开始写Vue3日历组件的意义是什么?
看到这里,可能有人会问:现在网上有这么多现成的Vue3日历组件,比如element-plus的el-calendar,ant-design-vue的a-calendar,还有v-calendar这种专门的日历库,功能都很全,为什么还要从零开始写? 其实从零开始写的意义有三个:一是能更深入地理解Vue3的响应式原理、虚拟DOM diff算法、computed和watch的区别这些核心知识点;二是现成的组件库虽然功能全,但有时候不符合我们的业务需求,比如我们需要把周一放第一天,需要添加自定义的农历显示,需要添加特定的事件标记,现成的组件库可能很难修改,或者修改的成本很高;三是能提升自己的代码能力和解决问题的能力,以后不管遇到什么复杂的组件,都能自己动手写出来。 最后提醒大家,写组件的时候一定要先理清楚需求,再设计整体的架构,不要一上来就写代码,不然写着写着就会发现逻辑混乱,要推倒重来,还有就是要多测试,测试不同的月份、不同的年份、不同的场景,比如闰年的2月、12月到1月的切换、调休上班的日期、跨天的事件,确保每个功能都能正常运行。
版权声明
本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。
code前端网



