实际项目中日期选择器是最容易被低估的组件。页面上就是一块小小的输入框看起来点两下就完事真到了双日历这种并排选择日期范围的场景一堆边界情况才会冒出来。我最近在一个Vue项目里封装了一个双日历组件两张日历并排显示直接选开始和结束日期酒店预订、机票筛选、报表起止时间这些场景全都能用。组件封装完跑了两个迭代复用起来确实顺手这里把整体设计、核心实现和踩过的坑一并整理出来给做Vue组件封装的同学一个参考尤其是第一次碰日期类组件的朋友。1. 双日历组件要解决的是什么问题1.1 最典型的三个落地场景双日历组件不是炫技纯粹是被业务逼出来的。我接触到的第一个需求是酒店业务的后台订单筛选运营要查某一段时间的入住数据界面上需要选入住日期到离店日期。如果用单日历得先点开第一个日历选开始再点开第二个日历选结束来回切换月份非常痛苦尤其是跨月甚至跨年的区间用户体验很差。机票的往返日期选择也是同一个道理去程和回程往往要一起看。还有一类是管理后台的报表筛选老板要9月1日到9月30日的营收你给他一个单日历让他翻来翻去他大概率会直接吐槽这个系统难用。这些问题本质上都是日期区间选择的诉求而双日历组件用左右两块并排的面板把两个月份同时展示出来用户在一个视图里就能完成全部操作视觉上清楚操作上也连贯。这类场景有共同特征区间跨度通常在一周到三个月之间很少像订酒店那样连住半年所以双面板默认展示当前月和下个月基本够用少数情况再通过翻页扩展。1.2 为什么放着现成的UI库不用非要自己封装很多人第一反应是Element Plus和Ant Design Vue里都有RangePicker双日历早就有了代码抄过来改改不就完了这个说法部分对但实际落地时会发现几个绕不开的问题。第一UI风格统一问题。很多中后台项目有自己的设计规范组件要做到视觉统一直接引入一套组件库的皮肤往往不搭。尤其是指标看板这类定制化程度高的项目日期控件经常要融入暗色主题或者特殊配色改Element的样式有时比从零写一个还费劲。第二定制逻辑的复杂度。比如禁用日期规则可能是周末不能选今天之前全部禁用某些特殊日期是旺季不能用这些业务规则混在一起框架自带的disabledDate接口反而写起来各种别扭。第三组件体积和依赖关系。如果项目里就为了一两个场景用日期控件引一整个UI库性价比很低尤其移动端H5对包体积敏感自己封装一个轻量组件bundle体积能小不少。我个人观点是如果项目已经深度使用Element或Antd那就直接用RangePicker维护成本低坑少。如果项目只有零星几个表单场景或者视觉定制要求高、团队对组件封装有沉淀需求这时候自研双日历组件就是合理选择。我这个双日历组件就是在一种表单组件只差日期选择器的情况下动手写的整体规模可控也不至于背上维护一整套UI框架的负担。2. 封装前的三个关键设计决策2.1 双日历的数据模型怎么定写代码之前先想清楚数据怎么组织这比写组件模板重要得多。双日历组件看起来是两个日历面板但数据模型不能是两个独立面板各管各的否则就会出现左边切到了10月右边还在7月这种错位状态。我的做法是只维护一个源头状态左侧面板的年月。右侧面板永远通过左侧面板计算得到左面板在10月右面板必然是11月左面板切到12月右面板就自动变明年的1月。这样整个组件内部只有一份月份状态不存在同步问题从根上杜绝了两个面板状态不一致的bug。具体的数据结构是这样const leftYear ref(2025); const leftMonth ref(8); // 0-110表示1月 // 右侧面板通过计算属性自动跟随 const rightYear computed(() { return leftMonth.value 11 ? leftYear.value 1 : leftYear.value; }); const rightMonth computed(() { return (leftMonth.value 1) % 12; });这样设计的另一个好处是切月逻辑非常简单不管往前翻还是往后翻只要操作leftYear和leftMonth右侧自动跟随不会出现一个月被翻漏或者两个面板月份差出两三个月的情况。2.2 范围选择的交互模型双日历组件的核心交互是选择一段日期区间常规模型是先点开始日期再点结束日期。这个模型里有两个细节值得提前定清楚。第一是第二次点击的日期如果早于第一次点击的日期怎么处理我采用的方案是自动颠倒把两次点击中较早的日期当作开始较晚的当作结束。比如第一次点了20号第二次误点了15号组件自动识别为15号到20号。这个交互比较符合直觉用户不用主动清空重选。另外还有一种方案是第二次点击必须先取消第一次选择再重新开始这个交互对用户来说使用成本高一些容易让人困惑。第二是hover预览。用户点了开始日期之后鼠标在面板上悬停时应该实时显示从开始日期到当前悬停日期的区间效果让用户在点击结束日期之前就能预判结果。这个功能看起来不复杂但体验提升非常明显没有预览的双日历总感觉少了点什么。2.3 对外API怎么设计组件设计的重要一步是确定外部怎么使用它。我最终对外暴露的API非常少核心就三个绑定的日期范围值、禁用的日期规则、日期展示格式。使用方式是这样DualCalendar v-modeldateRange :disabled-datesdisabledRules formatYYYY-MM-DD /其中dateRange是一个数组范例如[2025-09-10, 2025-09-18]。v-model在这里生效的关键是组件内部通过emit(update:modelValue, newRange)把数据传回父组件父组件再用v-model语法糖自动接收。自定义事件我保留了一个change在范围完整选择结束后触发因为有些场景父组件需要在用户选完日期之后去查一次数据change事件比watch v-model的写法更直观。总体上坚持对外API越少越好的原则能通过props配置的绝不多开事件组件用起来才会简单。3. 核心实现与逐步代码解析3.1 6行代码生成一个月历网格日历组件的地基是把一个月的日期排成6行7列的网格。这个算法我写了几次之后固定下来每次都是同一个套路。首先要拿到两个基本信息这个月1号是星期几这个月一共有多少天。然后按顺序填充一个长度为42的数组前面补空位后面也补空位function getMonthDays(year, month) { // month范围是0-11这里直接复用原生Date的参数规则 const firstWeekday new Date(year, month, 1).getDay(); // 0周日1周一... const daysInMonth new Date(year, month 1, 0).getDate(); const cells []; // 前面的空位 for (let i 0; i firstWeekday; i) { cells.push(null); } // 当前月的日期 for (let d 1; d daysInMonth; d) { cells.push(d); } // 补齐到6行42格 while (cells.length 42) { cells.push(null); } return cells; }这里有个关键点new Date(year, month 1, 0)的day传0取到的是上个月最后一天所以getDate()拿到的就是这个月的总天数。这个技巧看起来很绕但确实是JS里获取某月天数的标准姿势。还有一个细节getDay()返回的0表示星期日。国内习惯周一作为一周第一天需要在渲染时做shift处理否则周一会被排到第二列。我为了省事直接用周日开头然后通过CSS把周日、周六的样式做区分一般业务也够用。3.2 左右面板联动切换的核心逻辑前面已经说了数据模型这里再补上切换逻辑的完整实现。双日历需要一个上一月、下一月的控制按钮有时还需要上一年的快捷跳转。我的做法是按钮只管改左面板的月份状态function goPrevMonth() { if (leftMonth.value 0) { leftYear.value--; leftMonth.value 11; } else { leftMonth.value--; } } function goNextMonth() { if (leftMonth.value 11) { leftYear.value; leftMonth.value 0; } else { leftMonth.value; } } function goPrevYear() { leftYear.value--; } function goNextYear() { leftYear.value; }由于右面板的年月是基于左面板计算出来的这里不需要任何额外同步代码。我在初始版本里曾经用左面板和右面板各维护一份月份切换时同时修改两份的方案结果出现了肉眼可见的闪烁和错乱改成单一数据源之后这个bug消失了。实际开发中能通过计算得出的状态绝不额外存储一份这条原则帮我省了大量调试时间。3.3 日期范围选择与hover预览实现核心交互逻辑。先定义两个响应式状态一个存放已确认的日期范围一个存放鼠标当前悬停的日期const selectedRange ref([]); // [2025-09-10, 2025-09-18] const hoverDate ref();点击日期时按两段式逻辑处理function handleSelect(dateStr) { if (isDisabled(dateStr)) return; const [start] selectedRange.value; // 第一次点击还没开始日期 if (!start) { selectedRange.value [dateStr, ]; return; } // 有开始日期但还没结束日期这次点的是结束 if (!selectedRange.value[1]) { let newStart start; let newEnd dateStr; // 如果结束日期小于开始日期自动颠倒 if (dateStr start) { newStart dateStr; newEnd start; } selectedRange.value [newStart, newEnd]; emit(update:modelValue, [...selectedRange.value]); emit(change, [...selectedRange.value]); return; } // 已经选完整区间再点击则重新开始新的区间 selectedRange.value [dateStr, ]; }这里直接用了字符串比较日期因为日期都格式化成YYYY-MM-DD了字符串按字典序比较的结果和日期先后顺序一致。这个技巧省去了来回转Date对象再比较的麻烦。hover预览用计算属性实现const previewRange computed(() { const [start, end] selectedRange.value; if (!start || end || !hoverDate.value) return null; if (isDisabled(hoverDate.value)) return null; // 同样做颠倒处理 return hoverDate.value start ? { start: hoverDate.value, end: start } : { start, end: hoverDate.value }; });渲染的时候每个日期格子的样式通过一个函数统一判断传入单元格的日期字符串返回该日期属于开始日、结束日、区间内、禁用、普通中的哪一种从而控制高亮样式。这个函数是所有视觉逻辑的收口点后续要加新状态改动集中在一处即可。3.4 禁用日期与非法状态处理禁用日期是业务里最常见的定制需求。我把禁用逻辑收敛到一个方法里function isDisabled(dateStr) { const dayStr dateStr.slice(8, 10); // 去掉年份月份取日 const dateObj new Date(dateStr); // 1. 指定禁止的精确日期比如节假日调休 if (disabledDates.value.includes(dateStr)) return true; // 2. 工作日/周末规则 const weekday dateObj.getDay(); // 0周日6周六 if (disableWeekends.value (weekday 0 || weekday 6)) return true; // 3. 早于最小日期或晚于最大日期 if (minDate.value dateStr minDate.value) return true; if (maxDate.value dateStr maxDate.value) return true; return false; }disallowPast这种需求也不用单独写直接把minDate设为今天即可。禁用规则的可配置性通过props暴露组件内部只做判断不关心业务规则为什么这样定。还有一个容易被忽略的边界如果v-model传来的日期范围本身包含禁用日期组件需要自动修正或至少不崩。例如父组件传入[2025-09-10, 2025-09-12]但9月12号是禁用日这时候我会在onMounted和watch里各做一次校验如果发现结束日期被禁用就把结束日期往前推到距开始日期最近的可用日期。这个逻辑叫自动降级用户感知不到但不做的话会出现高亮灰色日期这种明显bug。3.5 样式结构与主题拆分双日历的布局核心是两块面板水平排列单面板宽度建议260px到340px之间两面板加起来加上间距整体组件宽度大概560px到700px这是桌面端最常见的双日历尺寸。小于这个宽度建议直接降级成单日历移动端塞两个面板必定挤压。样式上我坚持用组件内的scoped样式加CSS变量让外部可以覆盖主色调.dual-calendar { --cal-primary: #3b82f6; --cal-bg: #fff; --cal-text: #1f2937; --cal-disable-text: #c0c0c0; display: inline-flex; border: 1px solid #e5e7eb; border-radius: 8px; background: var(--cal-bg); padding: 12px; gap: 16px; }每个日期格子用按钮实现天然支持键盘访问比div加click事件更适合做通用组件。按钮的禁用态直接使用原生 disabled 属性可以避免额外的点击事件处理同时也能让辅助技术正确识别。4. 实战中踩过的坑与排查思路4.1 月份从0开始这个坑我整整踩了一下午JavaScript的Date对象月份索引从0开始new Date(2025, 8, 1)表示的是9月1日而不是8月1日。这个问题老手都熟但实际编码时还是会疏忽。我在写日期显示的时候界面上一度出现点8月显示9月的诡异现象排查来排查去最后发现是渲染模板里直接把month传给了Date构造器而没有做month - 1的转换。建议在组件内部建立一个统一的转换机制所有向Date构造器传参的地方都经过同一套工具函数function createDate(year, monthIndex) { return new Date(year, monthIndex, 1); }让月份索引的语义在组件里保持统一避免有的地方传0代表1月有的地方传1代表1月混着写迟早出问题。另一个类似的问题是用new Date(2025-09-10)解析字符串日期时不同浏览器对ISO格式的处理基本一致但如果是new Date(2025/09/10)这种格式部分环境下会按本地时区解析导致日期偏差一天。所以在组件内部我完全不用字符串构造Date统一用new Date(year, month, day)这种数字传参方式。4.2 跨年边界与月份错乱的典型场景跨年的双日历很容易出错。场景是左侧面板到了12月右侧面板应该是明年的1月。如果按照手动维护两份月份的写法右侧面板需要多做一次年份加一的操作一旦忘记处理右侧面板就会显示今年的1月和左侧重复。我的计算属性方案天然规避了这个问题。leftMonth在11月时rightYear等于leftYear加1rightMonth等于0正好对应明年的1月。跨年切换时点击下一月按钮左面板从11月切到0月年份加一右面板同步变为后天年份的2月整个逻辑是自洽的不需要额外的跨年分支判断。排查这类问题还有个通用技巧开发阶段在组件上打印年月的计算值切换月份时观察两侧面板的year和month是否始终满足右侧比左侧晚一个月的约束。只要约束成立渲染层就不会错。4.3 组件内部状态与父组件数据不同步v-model用多了偶尔会有一个误解父组件改了绑定的数据子组件应该自动更新。其实子组件内部如果自己维护了一份selectedRange状态它不会自动跟着props变化。我在早期版本遇到过这样的情况外部清空筛选条件把dateRange置空数组但组件内部selectedRange还残留着上一次的日期范围界面依旧高亮着旧日期。解决办法是在组件里watch传入的modelValue检测到父组件的值和内部状态不一致时强制同步内部状态watch( () props.modelValue, (newVal) { // 外部数据变化时同步到内部 if (JSON.stringify(newVal) ! JSON.stringify(selectedRange.value)) { selectedRange.value [...newVal]; } } );这里用JSON.stringify比较两个数组是否一致是因为引用类型用比较永远不相等简单粗暴但有效。这种内部状态外部同步的模式在做Vue封装时很常见记住props是单向的内部状态变更请emit通知外部外部变更请watch同步内部这条口诀能少踩很多坑。4.4 渲染性能与重复计算问题双日历每次渲染涉及42个日期格子的循环如果两个面板都各自循环一遍单次渲染就是84次函数调用看起来不多但加上每个格子的样式判断、禁用判断、范围判断算下来其实有不少计算量。在低端设备上快速切换月份时偶尔能感觉到卡顿。我做的第一个优化是把格式化日期字符串这个操作提前做一次避免渲染阶段在每个格子里重复调formatDate。当前月的日期、补位日期所对应的日期字符串可以在生成网格时一并生成好渲染时直接读取减少格式化开销。第二个优化是给预期稳定不变的部分加缓存。例如当前面板的年份和月份变化后网格才需要重新计算可以用computed缓存网格结果而不是在模板里每次render都重新调用函数。Vue的computed天然带缓存所以把网格生成逻辑放进computed是个性价比很高的优化。另外hover预览如果在mousemove事件里直接更新状态会导致整个组件频繁重新渲染。我实际测试的感受是在普通桌面上还好但在集成显卡的旧笔记本上会明显掉帧。优化方案是对hoverDate的更新做一次节流例如限制在100毫秒以内更新一次视觉上几乎无感渲染频率却大幅下降。4.5 移动端适配和点击穿透问题双日历组件默认是桌面端形态放到移动端就会出问题。首先是宽度两个面板并排至少550px手机屏幕装不下。其次是点击交互桌面端hover是鼠标悬停手机上不存在hover触摸事件会直接触发click。我在移动端做了一个降级处理通过媒体查询判断屏幕宽度小于640px时让组件变成单日历模式左右切换改为显示一个月份通过按钮切换上/下月用户先选开始日期再切月份选结束日期交互上还是成立的只是不如双日历直观。这样一套组件在两种场景下都能用避免了再写一套移动端组件的成本。还有一个点击穿透的实际问题日期格子内部的按钮如果有内边距点击到padding区域时可能触发两次选择逻辑。我排查后发现其实是事件冒泡——按钮点击后冒泡到了父级容器的click监听器上。解决方式很简单日期单元格本身用button包裹监听只放在button上父容器不做任何click处理或者在使用时给内部元素加click.stop。5. 关于组件封装最后想说的几句做这个双日历组件最大的收获不是实现了功能而是明白了组件封装的边界感。刚开始写的时候总想把所有功能都塞进去农历、节日、周视图、月视图、快捷键、动画……后来逐个删删到只保留最核心的日历网格、月份联动、范围选择、禁用规则组件才真正变得好用了。功能膨胀对组件是灾难因为每多一个配置项后续维护和测试的成本都会翻倍。具体到封装手法上我的建议是不要一开始就抽象。第一次遇到这个需求时先在一个页面里把逻辑写死跑通所有交互确认数据结构合理然后再抽成独立组件。从具体到抽象比从抽象到具体容易得多这是我在项目里验证过很多次的经验。另外组件内部的语言风格保持中文注释还是英文注释不是关键关键是核心逻辑必须写清楚为什么这么做比如右侧由左侧计算得出避免状态不同步这种注释对后面接手的人帮助巨大。最后分享一个测试上的小技巧日期组件太适合边界值测试了。1月1日、12月31日、闰年2月29日、平年2月28日、每月1号和最后一天把这些日期写进测试用例基本能覆盖大部分边界bug。我当时偷懒没写自动化测试结果在闰年2月29日的场景上栽了一次跟头。如果时间允许这个组件的日期计算工具函数值得单独写一份单元测试后面迭代会安心很多。
