尧图精选

Vue 3 双日历组件封装实战:日期范围选择与交互设计

🕒 发布时间:2026/9/26 21:33:58 📁 来源:尧图网络
1. 项目概述1.1 双日历组件到底在解决什么问题先说个场景你就懂了。现在不少产品里都有日期范围选择的需求订酒店要选入住和离店日期买机票要选去程和回程后台系统做报表统计经常要筛一个时间段。这类需求最常见的交互就是弹出两个月并排的日历面板左边选开始日期右边选结束日期选中后用高亮色块把整个区间标出来用户一眼就能看清楚自己选了多久。市面上成熟的日历组件其实不少Element UI 的 DatePicker、Ant Design 的 RangePicker 都做得挺完善。但问题在于很多业务场景不是装个组件库就能解决的。公司内部的后台系统有自己的视觉规范产品经理可能要求默认显示最近7天、禁用掉周末、特殊日期要打上小红点标记。组件库的 API 能满足80%的需求剩下20%就得靠自己去扩展、去魔改改到最后发现还不如自己封装一个。用 Vue 封装双日历组件本质上是把月份面板渲染、日期计算、区间选择逻辑、键盘操作这些重复劳动收敛到一个独立模块里。业务侧只需要传一个v-model传几个配置项就能拿到完整可用的日期范围选择能力而且视觉和交互完全自己掌控。这个项目适合谁看如果你手头有 Vue 2 或 Vue 3 项目正被现成组件库的样式覆盖和扩展能力折磨或者你想系统性地理解日期组件内部是怎么运作的这篇文章都能给你提供一个可以照着抄的完整方案。1.2 自己封装的核心收益从实际项目角度看自研双日历组件带来的收益是实打实的第一视觉完全可控。组件库的样式用 scoped 样式覆盖也好用 CSS 变量换肤也好总有照顾不到的角落。自己封装的话所有 DOM 结构和 CSS 都握在自己手里设计稿长什么样就能做成什么样。第二交互完全可控。默认情况下你可能想点开始日期后第二个日期之前的所有日期禁用掉区间内的颜色要分选中、半选中、预览三种状态。这些细节在组件库里往往需要通过自定义cellRender或者样式回调去实现自己封装就是直接在模板里写条件判断的事。第三依赖可控。这是一个很现实的问题。你不想为了一个日期组件把整个 Element Plus 引进来或者你项目里已经有好几个组件库了再引入一个体积不小、风格冲突的库想想就头疼。自己封装的双日历组件核心逻辑加样式压缩后几 KB 搞定没有任何多余开销。第四锻炼封装能力。这一点可能有点虚但确实是我推荐每个人都动手做一遍的理由。日历组件的状态管理不算简单涉及当前面板月份、悬停预览日期、选中区间、禁用日期集合还要处理从一个月滚动到下一个月时状态的衔接。把这些逻辑梳理清楚你对 Vue 响应式数据的理解会不一样。2. 整体设计与方案选型2.1 为什么不做单日历而是双面板很多第一次接触这个需求的人会问为什么非得一次显示两个月单日历加个左右切换不是也能实现区间选择吗答案是交互体验的差异。双日历的核心优势在于同时可见。用户选完开始日期后能直接在同一屏的右侧面板里看到结束日期所在的月份不需要来回点箭头切换。尤其是跨月区间比如 3 月 28 号到 4 月 3 号单日历方案至少要点两次切换用户心智负担明显更重。从实现层面看双面板并没有让逻辑复杂度翻倍。左侧面板显示第 N 个月右侧面板固定显示第 N1 个月两者共享同一套日期计算逻辑只是传递的 year 和 month 参数不同。联动关系也很简单左侧面板 month 变化时右侧面板自动跟着变化因为有v-model的响应式绑定。还有个容易被忽略的点是移动端适配。双面板在窄屏设备上确实会显得拥挤但如果组件设计得当可以通过配置项切换成单面板模式。在封装时保留一层面板数量的抽象成本很低收益却很实在。2.2 技术栈选型Vue 2 还是 Vue 3这个项目我建议直接上 Vue 3 Composition API。原因不仅是 Vue 3 已经是主流更关键的是 Composition API 把日期选择逻辑从组件生命周期里解放了出来。举个例子。双日历的月份切换逻辑如果用 Options API通常就是data里放一个currentMonth然后在方法里写this.currentMonth。一旦逻辑复杂起来比如要同时处理左右两个面板、回退上一月、前进下一月、禁用边界月份代码就会堆在methods里越来越长。Composition API 的写法下可以把月份推移、日期生成、区间状态管理分别抽成独立的函数。比如useCalendar负责生成某个月的日期格子数组useRange负责管理开始日期和结束日期以及悬停预览状态组件本身只做数据组装和模板渲染。这样不但单文件内部清爽了测试和维护也容易得多。如果你项目还在 Vue 2同样可以参考这套思路把逻辑写成 Vue 2 的 mixin 或者抽到独立的 JS 模块里只是响应式依赖追踪那块需要花点心思。2.3 组件 API 设计封装组件的第一步是定好对外暴露的接口。我的建议是按配置项 事件 插槽三个维度设计。配置项方面核心的有这几个配置项类型默认值说明modelValue[Date, Date][]绑定的日期区间数组形式disabledDate(Date) booleannull禁用特定日期的回调函数firstDayOfWeeknumber1一周起始日1 为周一0 为周日isRangebooleantrue是否启用范围选择模式showHoverPreviewbooleantrue是否显示悬停区间预览clearablebooleantrue是否显示清空按钮事件方面核心是update:modelValue配合v-model:modelValue实现双向绑定。另外可以暴露一个change事件在区间选择完成后触发方便业务侧做额外处理。插槽的话日期单元格建议做成可扩展的。比如你想在选了日期的左上角加一个小圆点或者在特殊日期上显示一个红点标记都可以通过cellContent插槽覆盖。这个设计能避免很多后续需求的打扰。2.4 为什么不使用第三方库轮子这里要说清楚一点不是所有场景都该自己造轮子。如果项目时间紧、团队成员对日期处理不熟悉直接用 Element Plus 的 RangePicker 是最稳妥的选择。但如果你遇到以下情况中的任何一种自研就是一个合理决策公司内部有成熟的设计系统组件库的视觉无法对齐产品需求里有大量自定义交互比如区间悬停预览、特殊日期标记、多周模式项目对包体积敏感不想为了日期功能引入整个组件库你希望组件在不同项目间复用不想被某个组件库版本绑架我的建议是先评估需求复杂度如果只是标准区间选择用现成的如果有明显的定制空间自研的ROI会更高。本项目的定位就是后者。3. 核心算法与日期计算细节3.1 日期计算的三板斧日历组件的核心无非是三个计算某个月的第一天是星期几、这个月有多少天、某一天在年份中处于第几周。这三个问题解决了日历面板的骨架就能搭起来。先看当月第一天是星期几。JS 原生 Date 对象的getDay()返回的是周日到周六0-6。要得到某月第一天是星期几只需要构造这个月的第一天然后调用getDay()。这里有一个经典坑月份从 0 开始计数也就是new Date(2024, 0, 1)代表 2024 年 1 月 1 日而new Date(2024, 11, 1)代表 12 月 1 日。传参时如果从 UI 传入的月份是 1-12记得先做减一处理。再看这个月有多少天。看起来复杂其实一行代码就能拿到new Date(year, month 1, 0).getDate()。原理是 Date 构造函数会自动处理溢出传第 0 天就相当于上个月的最后一天。比如new Date(2024, 1, 0)会得到 2024 年 1 月 31 日getDate()返回 31即 1 月份有 31 天。这里同样要注意月份从 0 开始。掌握了这两个基础算法接下来就是把日期排列成 7 列 x 若干行的网格数组。习惯做法是固定渲染 42 个格子6 周不管这个月实际需要多少格。这样做的好处是日历高度在月份切换时保持稳定不会因为某个月有 31 天、下个月只有 28 天就出现跳动。多余格子用前后月的日期填充CSS 上可以弱化显示。function getMonthMatrix(year, month, firstDayOfWeek 1) { // month 这里约定传入 1-12内部转成 Date 需要的 0-11 const firstDate new Date(year, month - 1, 1); // 计算第一天距离面板起始日偏移几格 let offset (firstDate.getDay() - firstDayOfWeek 7) % 7; // 上个月的最后一天 const prevLastDate new Date(year, month - 1, 0); // 这个月的总天数 const daysInMonth new Date(year, month, 0).getDate(); const matrix []; for (let i 0; i 42; i) { const date new Date( year, month - 1, i - offset 1 ); matrix.push(date); } return matrix; }上面这段代码只做了一件事从面板的第一个格子开始往后推 42 天每个格子对应一个 Date 实例。i - offset 1里这个1是因为格子序列从 1 号开始如果 offset 是 0表示 1 号正好在第一个格子那么第 i 个格子的日期就是i 1号。反过来如果 offset 是 31 号在第四个格子第一个格子就是上个月倒数第三天。new Date(year, month - 1, 0)能算出上个月最后一天但它本质上依赖的是 Date 构造函数对超出范围日期的自动纠正。3.2 日期比较与格式化操作日历的时候直接比较两个 Date 实例容易踩坑。dateA dateB判断的是引用相等就算两个日期都是今天只要不是同一个实例就不相等。所以比较日期时统一按年月日转成时间戳或者直接比格式化的字符串。我习惯的做法是定义一个formatDay(date)函数输出YYYY-MM-DD格式的字符串。这样有两个好处一是可以作为对象 key 来建立日期映射表方便 O(1) 查找某个日期是否被选中二是字符串比较大小就等价于日期先后比较因为2024-03-05和2024-03-06这种格式的字典序和时间序是天然一致的。function formatDay(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}; } function isSameDay(a, b) { return formatDay(a) formatDay(b); }这里要注意时区的影响。如果用new Date(2024-03-05)这种方式构造日期JS 会把它解析为 UTC 时间的零点在东八区下会变成当地时间的早上 8 点。如果你在组件里用到getHours()这类方法就会踩坑。稳妥做法是只用new Date(2024, 2, 5)这种三参数构造或者对字符串解析后的日期只读取年月日不读时分秒。3.3 悬停预览区间计算的技巧双日历范围选择中悬停预览是体验的关键。用户已经点了开始日期鼠标移到结束日期区域时应该把开始日期到当前悬停日期之间的范围用浅色高亮让用户预览即将选中的区间。实现思路并不复杂。维护三个状态startDate、endDate和hoverDate。当startDate已选而endDate未选时每次鼠标进入一个日期单元格就更新hoverDate。判断一个日期是否在预览区间内只需要检查它是否在startDate和hoverDate之间function isInPreviewRange(date) { if (!startDate.value || endDate.value || !hoverDate.value) return false; const ts date.getTime(); const startTs startDate.value.getTime(); const hoverTs hoverDate.value.getTime(); return ts Math.min(startTs, hoverTs) ts Math.max(startTs, hoverTs); }这里有个细节如果用户把鼠标移到开始日期之前的日期上区间应该自动反转变小。所以比较时用了Math.min和Math.max而不是假设 startDate 一定小于 hoverDate。这对跨月反选场景很重要。比如选了 4 月 20 号作为开始鼠标往左边移hoverDate 变成 4 月 10 号那么预览区间就自动变为 4 月 10 号到 4 月 20 号而不是一个空区间。另外一个值得注意的点是鼠标移出整个日历区域时hoverDate要重置否则预览区间会一直停留在最后一次悬停的位置。3.4 双面板月份联动双日历的双面板联动本质上是一个永远相差 1 个月的约束。左面板显示 2024 年 3 月右面板就一定是 2024 年 4 月。如果左面板切换到 12 月右面板自动变成次年 1 月月份跨年也无需额外处理因为 Date 构造函数自动进位。实现方式可以这样设计。组件内部维护一个leftYear和leftMonth的响应式变量。右侧面板不单独维护状态而是计算属性const rightPanelDate computed(() { const date new Date(leftYear.value, leftMonth.value, 1); date.setMonth(date.getMonth() 1); return date; });这里有个细节setMonth在月末场景下要谨慎使用。比如 1 月 31 日调用setMonth(1)由于 2 月没有 31 日结果是 3 月 3 日。不过在本项目中leftYear和leftMonth保存的是年 月的组合面板渲染时总是以每月 1 号为基准不会遇到月末溢出的问题。如果你改用panelDate: Date这个单一状态就一定要提防这个坑。左侧面板点击月份切换按钮时leftMonth直接加一或减一。如果月份变成 12就在leftYear上加一leftMonth回归 1。反向同理。计算时稍微注意边界即可。4. 组件实现与封装细节4.1 组件目录结构与职责划分项目里我建议把双日历作为一个独立目录来维护而不是塞在页面组件里。推荐结构如下src/components/range-calendar/ ├── index.js # 组件入口注册逻辑 ├── src/ │ ├── RangeCalendar.vue # 主组件模板结构 │ ├── components/ │ │ ├── MonthPanel.vue # 单个月份面板 │ │ └── DateCell.vue # 日期单元格 │ ├── composables/ │ │ ├── useCalendar.js # 月份面板数据生成 │ │ ├── useRange.js # 区间选择状态管理 │ │ └── useHover.js # 悬停预览逻辑 │ ├── utils/ │ │ ├── date.js # 日期工具函数 │ │ └── format.js # 格式化辅助 │ └── style/ │ └── index.scss # 样式定义这种结构的好处是每个模块职责单一后续扩展单日历模式、周模式、年视图都只需要在这个目录内部新增组件。4.2 MonthPanel 组件的实现MonthPanel 是复用单元。左右两个面板用同一个组件只是传入的year和month不同。MonthPanel内部接收一个由父组件计算好的 42 个日期的数组然后渲染成 7 x 6 的网格。模板部分核心结构如下template div classmonth-panel div classmonth-panel__header {{ year }}年{{ month }}月 /div div classmonth-panel__weekdays span v-forweekday in weekdays :keyweekday{{ weekday }}/span /div div classmonth-panel__grid DateCell v-forcell in cells :keyformatDay(cell.date) :datecell.date :is-current-monthcell.inCurrentMonth :stategetCellState(cell.date) hoverhandleHover clickhandleClick template #cell-content{ date } slot namecell-content :datedate/slot /template /DateCell /div /div /template这里的关键设计是DateCell只管渲染不管业务。它接收一个state属性状态值可以是selected-start、selected-end、in-range、preview-start、preview-end、in-preview、disabled、normal这些枚举值。CSS 根据不同的状态给单元格添加不同的类名实现高亮效果。把状态计算收敛到父组件RangeCalendar的getCellState方法里能让 DateCell 保持傻瓜化。不同的日期区间选择逻辑只会影响状态计算结果不会渗透到视图层。4.3 日期单元格状态计算这是整个组件最核心的方法。给定一个日期需要判断它属于哪个状态。完整的判断顺序如下function getCellState(date) { // 1. 禁用优先判断 if (isDisabled(date)) return disabled; const dayKey formatDay(date); // 2. 判断是否为选中端点 if (startDate.value formatDay(startDate.value) dayKey) return selected-start; if (endDate.value formatDay(endDate.value) dayKey) return selected-end; // 3. 判断是否在已选区间的内部 if (startDate.value endDate.value) { if (isInRange(date, startDate.value, endDate.value)) { return in-range; } return normal; } // 4. 判断悬停预览状态 if (showHoverPreview.value startDate.value !endDate.value hoverDate.value) { if (formatDay(startDate.value) dayKey) return selected-start; if (formatDay(hoverDate.value) dayKey) { // 需要知道 hover 是位于 start 之前还是之后决定展示 preview-start 还是 preview-end return hoverDate.value startDate.value ? preview-end : preview-start; } if (isInRange(date, startDate.value, hoverDate.value)) { return in-preview; } } return normal; }状态优先级要特别留意。禁用状态的判断必须放在最前面因为一个日期即使被选中了如果处于禁用区间内也应该优先显示为禁用。比如禁止选择过去日期时如果开始日期和结束日期都是有效的但某个in-range日期落在禁用范围内这个日期应当以禁用样式展示因为它的颜色语义是不能悬停在这个日期上而不是它属于选中区间。状态命名上我故意把selected-start和preview-start分开目的是让样式完全独立。选中区间的端点颜色就是实心主题色而预览端点颜色可以是虚线描边或半透明底色这样用户能区别已经选定的和正在预览的。4.4 v-model 双向绑定实现范围选择组件的v-model绑定的是一个二元数组。Vue 3 中定义如下const props defineProps({ modelValue: { type: Array, default: () [] } }); const emit defineEmits([update:modelValue, change]);用户点击日期时的处理逻辑function handleDateClick(date) { if (isDisabled(date)) return; // 还没有开始日期或者开始和结束都已经选定则重置选择 if (!startDate.value || (startDate.value endDate.value)) { startDate.value date; endDate.value null; } else { // 已有开始日期点另一个日期作为结束 endDate.value date; // 如果结束日期早于开始日期自动交换 if (endDate.value startDate.value) { [startDate.value, endDate.value] [endDate.value, startDate.value]; } emitChange(); } emit(update:modelValue, [startDate.value, endDate.value]); }这里使用内部变量startDate和endDate来管理交互状态每次变化后再整体 emit 给父组件。为什么不在模板里直接操作props.modelValue因为 Vue 的 props 是单向数据流组件内部改 props 会触发警告。正确的做法是props 传入初始值后用ref初始化为内部状态内部状态变化时同步 emit。这里还有一个细节是如果父组件异步更新了modelValue比如从接口拉取一个默认区间组件需要watch这个 prop 来同步内部状态watch(() props.modelValue, (val) { if (Array.isArray(val) val.length 2) { startDate.value val[0]; endDate.value val[1]; } });同步时注意防止死循环内部状态变化 emit 出去父组件更新后传回来又触发 watch。一个简单的处理是在 watch 回调中判断formatDay(val[0])是否等于formatDay(startDate.value)如果一样就不做任何更新。4.5 禁用日期逻辑与边界条件禁用日期需要支持多种配置方式。最简单的是传一个disabledDate函数接收一个 Date 参数返回布尔值。判断逻辑可以写在组件内部也可以做成一个 composablefunction useDisabledDate(disabledDateFn) { function isDisabled(date) { if (!disabledDateFn.value) return false; return disabledDateFn.value(date); } return { isDisabled }; }业务侧最常见的几个禁用场景禁用今天以前的日期(date) date.getTime() todayStart.getTime()禁用周末(date) [0, 6].includes(date.getDay())禁用某个日期段(date) date startDate date endDate这里有一个容易出错的点。用户传给disabledDate的日期通常是当日零点的 Date但DateCell在循环渲染时用的日期实例可能是从面板矩阵中直接生成的其中还包含了前后月的日期。判断禁用时要注意new Date(2024, 2, 31)这种在 3 月矩阵中出现的4 月 1 号的占位格也应该走一遍禁用判断逻辑否则会出现面板中某天看起来可以点击但点中了不该被选的日期的问题。4.6 月份切换与边界月份限制月份切换不只是leftMonth那么简单有几个边界逻辑必须处理好。限制起始月份和结束月份。比如产品要求只能选最近三个月到未来三个月范围内的日期那么左面板月份不能小于startYear * 12 startMonth - 3右面板月份不能大于endYear * 12 endMonth 3。切换按钮需要根据边界条件做禁用处理而不是靠点击后计算发现超界再弹回。实现时可以给组件传startMonth和endMonth两个配置项默认都是null表示不限制。切换时判断function canGoPrev() { const minDate new Date(startYear.value, startMonth.value - 1, 1); const currentDate new Date(leftYear.value, leftMonth.value - 1, 1); const current currentDate.getFullYear() * 12 currentDate.getMonth(); const min minDate.getFullYear() * 12 minDate.getMonth(); return current min; } function canGoNext() { const maxDate new Date(endYear.value, endMonth.value - 1, 1); const currentDate new Date(leftYear.value, leftMonth.value - 1, 1); // 右面板月份不能超出最大限制所以用右面板的月份判断 const rightDate new Date(leftYear.value, leftMonth.value, 1); const current rightDate.getFullYear() * 12 rightDate.getMonth(); const max maxDate.getFullYear() * 12 maxDate.getMonth(); return current max; }注意这里的比较方法是把年月统一转成年 x 12 月的线性计数避免跨年比较时出现负数或借位的问题。还有一个细节如果用户在左面板选中了一个日期切到右面板看了下月份再切回来面板不应该回到初始位置而是停留在用户上次浏览的位置。所以leftYear和leftMonth的初始值应该是包含开始日期所在月份的那个月而不是函数刚执行时的当前月。这样用户的浏览痕迹不会被突然打断。5. 完整实现流程与代码示例5.1 环境准备与项目初始化这个项目基于 Vue 3 Vite 搭建如果已经有一个 Vue 项目直接跳过本步骤。创建一个演示项目npm create vuelatest # 选择 TypeScript 可选个人建议开启日期逻辑涉及的 Date 类型用 TS 表达更清晰然后安装并启动npm install npm run dev如果你希望看到的效果更直观可以准备一个简单的 App.vue渲染一个按钮点击后弹出日历浮层。浮层展示部分用 teleport 到 body 下避免被父容器的 overflow 裁剪。5.2 日期工具函数封装先把日期工具函数整理到一个模块里后续所有逻辑都依赖这些基础函数。这里我按utils/date.js的写法给出完整实现// 格式化日期为 YYYY-MM-DD export function formatDay(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}; } // 判断两个日期是否为同一天 export function isSameDay(a, b) { return formatDay(a) formatDay(b); } // 生成某个月的面板矩阵42 格 export function getMonthMatrix(year, month, firstDayOfWeek 1) { const firstDate new Date(year, month - 1, 1); const offset (firstDate.getDay() - firstDayOfWeek 7) % 7; const totalCells 42; return Array.from({ length: totalCells }, (_, i) { return new Date(year, month - 1, i - offset 1); }); } // 获取某个月的星期六日集合等扩展场景可复用 export function getMonthDays(year, month) { const daysInMonth new Date(year, month, 0).getDate(); const result []; for (let day 1; day daysInMonth; day) { result.push(new Date(year, month - 1, day)); } return result; } // 将一个日期戳转成当日零点的 Date便于区间比较 export function startOfDay(date) { return new Date(date.getFullYear(), date.getMonth(), date.getDate()); }需要注意getMonthMatrix中new Date(year, month - 1, i - offset 1)的边界i从 0 开始offset表示月初第一天前面需要补几天。比如 2024 年 3 月 1 日是周五如果一周从周日开始firstDayOfWeek 0offset 为 5则i - offset 1 -4即第一个格子是 2 月 25 日。Date 构造函数会自动进位到下个月我们不需要手动处理跨月逻辑。5.3 双日历主组件完整代码下面是主组件RangeCalendar.vue的核心实现。为了可读性去掉了样式细节只留结构和逻辑。template div classrange-calendar div classrange-calendar__toolbar slot nametoolbar span classrange-calendar__range-text {{ displayRangeText }} /span button v-ifclearable modelValue?.length clickhandleClear 清空 /button /slot /div div classrange-calendar__body MonthPanel :yearleftYear :monthleftMonth :cellsleftCells :state-mapcellStateMap prevhandlePrevMonth nexthandleNextMonth cell-clickhandleDateClick cell-hoverhandleDateHover cell-leavehandleDateLeave template #cell-content{ date } slot namecell-content :datedate/slot /template /MonthPanel MonthPanel :yearrightYear :monthrightMonth :cellsrightCells :state-mapcellStateMap prevhandlePrevMonth nexthandleNextMonth cell-clickhandleDateClick cell-hoverhandleDateHover cell-leavehandleDateLeave template #cell-content{ date } slot namecell-content :datedate/slot /template /MonthPanel /div /div /templateleftCells和rightCells是计算属性基于leftYear和leftMonth生成const leftCells computed(() { return getMonthMatrix(leftYear.value, leftMonth.value, props.firstDayOfWeek); }); const rightYear computed(() { const date new Date(leftYear.value, leftMonth.value, 1); date.setMonth(date.getMonth() 1); return date.getFullYear(); }); const rightMonth computed(() { const date new Date(leftYear.value, leftMonth.value, 1); date.setMonth(date.getMonth() 1); return date.getMonth() 1; }); const rightCells computed(() { return getMonthMatrix(rightYear.value, rightMonth.value, props.firstDayOfWeek); });注意rightCells依赖rightYear和rightMonth两个计算属性而这两个计算属性各自都执行了一次new Date。为了性能可以额外计算一个rightPanelDate然后复用但对于 42 个格子的量级逐次 new Date 的开销几乎可以忽略。cellStateMap是为了渲染性能优化而引入的映射表。如果不做映射每次渲染时DateCell组件都调用getCellState(date)性能也没问题但 map 化之后有个额外的好处可以让状态判断逻辑集中在一个地方方便写单元测试。const cellStateMap computed(() { const map {}; for (const cell of [...leftCells.value, ...rightCells.value]) { map[formatDay(cell)] getCellState(cell); } return map; });5.4 月份切换逻辑月份切换的方法挂在主组件里两个面板共用。左侧面板的prev和next会影响整个组件状态右侧面板的prev和next实际上也在操作左侧面板的月份。这是因为双面板是联动的任何一侧的切换都意味着整个日历向前或向后滚动一个月。function handlePrevMonth() { if (!canGoPrev()) return; if (leftMonth.value 1) { leftYear.value - 1; leftMonth.value 12; } else { leftMonth.value - 1; } } function handleNextMonth() { if (!canGoNext()) return; if (leftMonth.value 12) { leftYear.value 1; leftMonth.value 1; } else { leftMonth.value 1; } }很多文档上会直接用date.setMonth(date.getMonth() - 1)这种写法但我建议用上面的拆开年月单独计算方案。原因还是月末溢出问题虽然面板基准日总是 1 号但如果你有一天改成了以当前选中日期为基准构建面板旧代码就会在 3 月 31 号这种日子出现 bug到时排查很痛苦。拆开计算虽然多写两行但逻辑最直白边界情况一目了然。5.5 月份面板组件MonthPanel组件负责渲染一个完整的月份面板块包含星期表头和日期网格。它本身不持有业务状态只接收 props 并通过事件向上通信这样保证了复用性。template div classmonth-panel div classmonth-panel__header button classmonth-panel__nav click$emit(prev) :disabled!canPrev ‹ /button span classmonth-panel__title{{ title }}/span button classmonth-panel__nav click$emit(next) :disabled!canNext › /button /div div classmonth-panel__weekdays div v-forday in weekdays :keyday{{ day }}/div /div div classmonth-panel__grid DateCell v-forcell in cells :keyString(cell) :datecell :statestateMap stateMap[formatDay(cell)] || normal click() $emit(cell-click, cell) hover() $emit(cell-hover, cell) leave() $emit(cell-leave) template #content{ date } slot namecell-content :datedate/slot /template /DateCell /div /div /template排在模板底部的stateMap取值逻辑要注意顺序问题。如果某个日期既不在左面板的stateMap里也不在右面板里就会退化为 normal。因为stateMap已经包含了两边全部 84 个格子的状态所以正常情况每个格子都能拿到值。weekdays是根据firstDayOfWeek计算出来的。当firstDayOfWeek 1时数组为[一, 二, 三, 四, 五, 六, 日]当firstDayOfWeek 0时为[日, 一, 二, 三, 四, 五, 六]。实现时注意排序const weekdays computed(() { const all [日, 一, 二, 三, 四, 五, 六]; const start props.firstDayOfWeek; return [...all.slice(start), ...all.slice(0, start)]; });画外音很多格式化库内部处理一周起始日时也有类似逻辑但自己实现并不复杂保持零依赖是一个目标。5.6 日期单元格组件最后是DateCell组件。它接收date和state两个 props然后根据状态渲染对应的样式类名。template div classdate-cell :class[ date-cell--${state}, { date-cell--today: isToday } ] mouseenter$emit(hover) mouseleave$emit(leave) click$emit(click) span classdate-cell__day{{ date.getDate() }}/span div classdate-cell__suffix slot namecontent :datedate/slot /div /div /template script setup import { computed } from vue; import { formatDay } from ../utils/date; const props defineProps({ date: { type: Date, required: true }, state: { type: String, default: normal } }); const emit defineEmits([click, hover, leave]); const isToday computed(() { const now new Date(); return formatDay(now) formatDay(props.date); }); /script这里绕开了getDay()直接判断星期几的写法而是用格式化字符串做比较。因为formatDay函数保证不考虑时分秒所以今天的判断非常可靠。date-cell--disabled状态下需要禁用点击事件。这个可以在 CSS 里设置pointer-events: none也可以保持鼠标事件但不触发逻辑。我更推荐前者但需要在悬停预览时排除禁用日期——比如鼠标悬停到一个禁用日期上不应该更新hoverDate。所以MonthPanel在hover事件上还需要检查一下状态function handleCellHover(date) { if (cellStateMap.value[formatDay(date)] disabled) return; // 更新 hoverDate }6. 样式方案与交互状态管理6.1 双面板布局与响应式适配双面板的布局最简单的方式是display: flex两个面板等宽排列。外层宽度建议由业务侧决定组件内部通过百分比自适配。双面板在容器宽度小于 560px 的场景需要降级成单面板。实现方式有两种一是通过 CSS 媒体查询直接隐藏第二个面板总体日期选择逻辑不变只是视野变窄二是通过 component state 动态切换渲染数量。我建议用前者因为逻辑层不需要感知 UI 变化纯视觉适配就行。一种实用的做法是给外层容器加一个--columns的 CSS 变量默认 2窄屏下改成 1.calendar-body { display: grid; grid-template-columns: repeat(var(--columns), 1fr); gap: 24px; } media (max-width: 560px) { .calendar-body { --columns: 1; } }JS 层不用动右侧面板就算渲染了在单列布局下也会自然换行用户需要滚动才能看到第二个月。这种做法的缺点是不够优雅但对于一个内部系统组件来说完全够用。如果想要更精细的适配可以在matchMedia回调里动态修改渲染的面板数量代价是要处理左右面板联动逻辑的切换复杂度会上一个台阶。6.2 选中态、区间态、预览态的 CSS 设计日期单元格的视觉状态是本项目的门面。我的推荐设计是六种状态对应六套类名每种状态可以再细分端点位置。date-cell--selected-start实心主题色背景白字左上角圆角date-cell--selected-end实心主题色背景白字右上角圆角date-cell--in-range浅色背景主题色文字date-cell--preview-start虚线描边半透明底色date-cell--preview-end虚线描边半透明底色date-cell--in-preview更浅的背景色比 in-range 更透明端点圆角处理是视觉细节中的细节。比如区间是 3 月 10 号到 3 月 20 号10 号因为是起点它左上角应该做成直角右侧背景延续到下一个格子20 号是终点右上角直角。普通 in-range 格子则没有圆角需求。CSS 可以这样处理.date-cell--selected-start { background-color: var(--rc-primary); color: #fff; border-top-left-radius: 6px; border-bottom-left-radius: 6px; } .date-cell--selected-end { background-color: var(--rc-primary); color: #fff; border-top-right-radius: 6px; border-bottom-right-radius: 6px; } .date-cell--in-range { background-color: var(--rc-primary-light); color: var(--rc-primary); border-radius: 0; }这里有个小技巧如果两个面板之间间距大于 0比如 24px选中区间的起点和终点如果跨越两边的面板中间的 in-range 背景应该在面板交接处断开这是正常的。如果希望视觉连续可以把面板间距调小或在容器背景上做文章。不过一般不需要毕竟中间有个明显的月份边界用户理解没有障碍。6.3 动态样式与主题定制组件不应该把颜色写死。用 CSS 变量定义主题色是最轻量的方案.range-calendar { --rc-primary: #409eff; --rc-primary-light: rgba(64, 158, 255, 0.15); --rc-text-normal: #333; --rc-text-disabled: #bbb; --rc-bg-hover: #f5f7fa; }业务侧如果想换肤只需要覆盖这 5 个变量不需要深入修改组件内部样式。如果项目本身用了设计系统的 token在引入组件时把这些变量赋值过去即可。6.4 键
上一篇/下一篇内容由系统自动关联 返回资讯列表 →