首页 / 资讯中心 / 文章详情

element-ui日历组件实战:日程展示与排期面板实现

element-ui日历组件实战:日程展示与排期面板实现 ★ FEATURED ARTICLE
1. 为什么日历加日程这件事值得单独拿出来说但凡做过中后台系统的前端大概率都绕不开 element-ui 这套组件库。表格、表单、弹窗这些高频组件大家用得滚瓜烂熟但一提到calendar日历组件很多人就有点发怵——官方文档给的示例太素了就是一个光秃秃的日期格子点一下切换月份连个像样的插槽用法都讲得含糊。可实际业务里日历从来不是拿来看的是要拿来用的排班表、会议室预订、课程表、内容排期、设备巡检计划这些场景全都要求你在日历格子里塞进具体的日程条目还得能点、能看、能改。我最近刚交付的一个内部排期系统就踩了这块的坑。需求方要的是一个月视图为主、能一眼看到每天有哪些安排、点击某天能弹出详情、不同状态用颜色区分的日历面板。听起来不复杂但真动手才发现element-ui 的 calendar 组件在往格子里塞自定义内容这件事上文档几乎没怎么展开讲网上的帖子又大多是复制粘贴官方 demo真正能落地的完整方案少得可怜。所以我把这次从零到一实现的过程整理出来包括数据结构怎么设计、date-cell插槽怎么用、日程怎么按日期聚合、样式怎么调、性能怎么兜底全部讲透。这篇文章适合两类人一类是正在用 element-ui或者 Vue 2 技术栈做排期、排班、预订类功能的前端可以直接抄作业另一类是虽然用的是别的组件库但想搞清楚日历 日程这类交互的通用设计思路也能从中拿到可迁移的经验。我会尽量把每个决策背后的为什么讲清楚而不是甩一段代码让你自己悟。2. 整体方案设计与技术选型思路2.1 先搞清楚 calendar 组件到底给了我们什么element-ui 的el-calendar本质上是一个日期渲染器 月份切换器的组合。它内部帮你处理了三件麻烦事当前月份的日期矩阵计算包括补齐上月末和下月初的日期、月份的前后切换、以及选中日期的状态管理。它暴露出来的核心能力其实就两个一个是v-model绑定当前选中的日期另一个是date-cell这个作用域插槽让你自定义每个日期格子里渲染什么。很多人第一次用会懵是因为默认状态下它长这样每个格子里就一个孤零零的数字连今天的高亮都很克制。但只要你把date-cell插槽用起来整个组件的能力边界就打开了。插槽的作用域参数里会给你一个data对象包含date当前格子的日期字符串、day几号、type是当月还是上/下月、isSelected等字段。你要做的就是基于data.date去你的日程数据里查这天有没有安排有就渲染出来。这里有个关键认知calendar 组件不负责存日程它只负责给你一个按天渲染的钩子。日程的存储、查询、聚合全是你自己的事。想明白这一点后面的设计就顺了。2.2 为什么不用第三方日历插件市面上确实有 FullCalendar、v-calendar 这类功能更全的库我一开始也评估过。但最后选了原生 element-ui 的 calendar理由有三条。第一是技术栈一致性。项目本身就是 Vue 2 element-ui引入一个重型日历库意味着额外的样式冲突风险、额外的体积、额外的学习成本。FullCalendar 功能是强但它自带一整套 CSS 和交互逻辑想让它长得跟 element-ui 的视觉风格统一改样式的功夫可能比自己写还多。第二是需求匹配度。我们的需求是月视图 日程展示 点击详情这个复杂度用 calendar 插槽完全能覆盖。FullCalendar 的拖拽调整、周视图、资源视图这些高级功能我们一个都用不上属于杀鸡用牛刀。第三是可控性。自己基于插槽实现每一处渲染逻辑都在自己手里出了问题好排查需求变了也好改。用第三方库遇到坑往往要读它的源码或者等它发版节奏不在自己手上。这里不是说第三方库不好而是选型要看场景。如果你的需求涉及跨天日程的拖拽、多资源视图、时区处理那老老实实上 FullCalendar 更划算。但如果就是月历 打点 弹窗自己写反而更轻快。2.3 数据结构的核心设计日程数据怎么存直接决定了后面渲染顺不顺畅。我见过有人把日程存成一个数组每次渲染格子的时候用filter去筛数据量小的时候没问题一旦日程上百条、格子四十二个每次切换月份都做四十二次全量遍历性能就开始抖了。我的做法是按日期做一次聚合转成以日期字符串为 key 的映射表。后端返回的原始数据大概长这样// 后端返回的原始日程列表 const rawSchedules [ { id: 1, title: 周会, date: 2024-06-03, status: confirmed }, { id: 2, title: 需求评审, date: 2024-06-03, status: pending }, { id: 3, title: 版本发布, date: 2024-06-15, status: confirmed } ]我会在拿到数据后立刻做一次转换// 聚合成 { 2024-06-03: [...], 2024-06-15: [...] } const scheduleMap rawSchedules.reduce((acc, item) { if (!acc[item.date]) acc[item.date] [] acc[item.date].push(item) return acc }, {})这样在插槽里渲染时只需要scheduleMap[data.date]一次取值O(1) 复杂度四十二个格子就是四十二次哈希查找几乎无感。这个转换的时机很关键——在数据进入组件之前就转好而不是在插槽里现算。插槽函数在一次渲染中会被调用几十次任何在里面做的重计算都会被放大。2.4 日期格式的对齐问题这里有个特别容易翻车的细节data.date给你的格式是2024-06-03这种带前导零的字符串但如果你后端返回的日期是2024-6-3或者时间戳直接拿去当 key 查是查不到的。我踩过这个坑排查了半天才发现是格式没对齐。稳妥的做法是写一个统一的日期格式化函数所有进入scheduleMap的 key 和插槽里查询用的 key都走同一个函数function formatDate(date) { const d new Date(date) const y d.getFullYear() const m String(d.getMonth() 1).padStart(2, 0) const day String(d.getDate()).padStart(2, 0) return ${y}-${m}-${day} }注意new Date(2024-06-03)在不同浏览器里对时区的处理有细微差异稳妥起见可以手动解析字符串避免因为时区导致日期偏移一天。这个坑在跨时区协作的项目里尤其致命。3. 核心实现细节与插槽实操要点3.1 date-cell 插槽的正确打开方式先上最基础的骨架把插槽跑通el-calendar v-modelcurrentDate template slotdate-cell slot-scope{ data } div classcalendar-cell span classcell-day{{ data.day.split(-).slice(2).join() }}/span div classcell-schedules div v-foritem in getSchedules(data.date) :keyitem.id classschedule-item click.stophandleClick(item) {{ item.title }} /div /div /div /template /el-calendar这里有几个点必须说清楚。data.day在 element-ui 里其实返回的是完整的日期字符串比如2024-06-03不是单纯的几号所以想只显示数字得自己截取。click.stop里的.stop很重要不加的话点击日程会冒泡到格子上触发日历的选中切换体验会很怪。getSchedules就是前面说的查表函数getSchedules(date) { return this.scheduleMap[date] || [] }3.2 日程条目的视觉分层设计一个格子里如果塞了五六条日程全用同样的样式堆着用户根本分不清主次。我的做法是按状态做视觉分层已确认的用实心色块待定的用描边样式已取消的用灰色加删除线。这样一眼扫过去信息密度高但不乱。.schedule-item { font-size: 12px; line-height: 18px; padding: 0 4px; margin-top: 2px; border-radius: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; cursor: pointer; } .schedule-item.confirmed { background: #ecf5ff; color: #409eff; border-left: 2px solid #409eff; } .schedule-item.pending { background: #fdf6ec; color: #e6a23c; border-left: 2px solid #e6a23c; } .schedule-item.cancelled { background: #f4f4f5; color: #909399; text-decoration: line-through; }white-space: nowrap配合text-overflow: ellipsis是必须的否则长标题会把格子撑变形整个日历的网格就乱了。border-left那条竖线是我个人很喜欢的细节它能在不增加太多视觉重量的前提下快速区分状态。3.3 超出数量的折叠处理一个格子高度是有限的塞太多日程会溢出。我的策略是最多显示三条超出的用N 更多表示点击这个更多再弹出当天的完整列表。这个交互在移动端尤其重要因为手机屏幕小格子更矮。getVisibleSchedules(date) { const list this.scheduleMap[date] || [] return list.slice(0, 3) }, getMoreCount(date) { const list this.scheduleMap[date] || [] return Math.max(0, list.length - 3) }实操心得这个3不要写死最好根据当前视图是月视图还是其他模式动态调整。月视图格子矮显示 2 到 3 条如果以后扩展出更大的格子视图可以显示更多。把它抽成一个配置项改起来方便。3.4 点击交互与弹窗联动点击日程条目弹出一个详情弹窗这是最自然的交互。但这里有个细节弹窗里要展示的是这条日程的完整信息同时最好还能让用户在同一天内切换查看其他日程。我的做法是点击时把当天所有日程和当前点击的这条一起传给弹窗。handleClick(item, date) { this.dialogSchedules this.scheduleMap[date] || [] this.dialogCurrent item this.dialogVisible true }弹窗内部用一个简单的列表做切换用户点哪条就展示哪条的详情。这样比点一条弹一个独立弹窗体验更连贯也避免了频繁开关弹窗的闪烁感。3.5 今天与选中态的强化element-ui 默认对今天和选中日期的区分不够明显用户经常分不清自己选的是哪天。我会在插槽里手动加一层判断div classcalendar-cell :class{ is-today: data.isToday, is-selected: data.isSelected } .calendar-cell.is-today .cell-day { color: #409eff; font-weight: 700; } .calendar-cell.is-selected { background: #f0f7ff; }data.isToday和data.isSelected都是插槽作用域里现成的字段直接用就行。把今天用蓝色加粗标出来把选中用浅蓝背景铺一层两个状态叠加时也不会打架。4. 完整实操流程与关键环节实现4.1 从零搭建的完整步骤我把整个实现拆成六步按顺序走下来基本不会卡壳。第一步确认 element-ui 版本。calendar 组件在 2.5.0 之后才比较稳定date-cell插槽的字段也是那个版本之后才齐全的。如果你的项目还在用很老的版本先升级否则插槽里拿不到isToday这些字段会白白多写很多判断逻辑。第二步设计后端接口的数据契约。这一步很多人会跳过直接拿现有接口硬套结果就是前端做一堆脏活。我的建议是接口至少返回id、title、date、status四个字段date统一用YYYY-MM-DD格式。如果后端给的是时间戳前端在进入聚合逻辑前先转一次。第三步实现日期聚合函数。就是前面那段reduce把它封装成一个纯函数方便单测。第四步接入 calendar 组件并绑定插槽。先不加样式把数据能渲染出来为准确认scheduleMap查得到、格子渲染得出来。第五步补样式和交互。状态色、折叠、点击弹窗一层层加。第六步处理边界情况。跨月日期、空数据、超长标题、同一天日程过多这些都要单独测一遍。4.2 跨月日期的处理陷阱calendar 组件默认会把上个月末和下个月初的日期也渲染在格子里这些格子的data.type是prev-month或next-month。问题来了这些日期上如果有日程要不要显示我的处理是显示但降低透明度。因为用户在看月历时其实很关心这个月开头那几天有没有安排如果直接不显示用户会以为没日程切到上个月才发现有体验割裂。div classcalendar-cell :class{ is-other-month: data.type ! current-month } .calendar-cell.is-other-month { opacity: 0.45; }但这里有个坑data.type的取值在不同版本里可能是prev-month/next-month也可能是别的字符串最好打印出来确认一下别硬编码判断。4.3 月份切换时的数据加载如果日程数据是按月从后端拉的那切换月份时就要重新请求。calendar 组件的v-model变化时能监听到但要注意防抖——用户快速连点切换箭头时会触发一连串请求。watch: { currentDate: { handler() { this.debouncedLoad() } } }debouncedLoad用 lodash 的debounce包一下延迟 300 毫秒。这样用户连点五下实际只发一次请求。注意如果日程数据量不大其实可以一次性把整年的数据拉回来前端自己按需渲染省掉每次切换的请求。这个取舍看数据规模几百条以内建议一次性拉体验最顺滑。4.4 性能兜底大数据量下的渲染优化假设某天有五十条日程虽然我们只显示三条但v-for如果直接遍历全部五十条再slice其实还是渲染了五十个 DOM 节点只是被 CSS 藏了。正确做法是在数据层面就slice只把要显示的三条交给v-for。getVisibleSchedules(date) { const list this.scheduleMap[date] || [] return list.length 3 ? list.slice(0, 3) : list }另外getSchedules这类函数在模板里被调用时每次组件重渲染都会重新执行。如果日历格子多、日程多可以考虑用computed预先算好一个渲染用的数据结构把每个格子的可见日程都提前算出来。不过实测下来只要聚合表建好了模板里直接查表的开销完全可以接受不必过度优化。4.5 一个完整的可运行示例把前面的碎片拼起来给一个能直接跑的完整组件template div classschedule-calendar el-calendar v-modelcurrentDate template slotdate-cell slot-scope{ data } div classcalendar-cell :class{ is-today: data.isToday, is-selected: data.isSelected, is-other-month: data.type ! current-month } span classcell-day{{ data.day.split(-)[2] }}/span div classcell-schedules div v-foritem in getVisibleSchedules(data.date) :keyitem.id classschedule-item :classitem.status :titleitem.title click.stophandleClick(item, data.date) {{ item.title }} /div div v-ifgetMoreCount(data.date) 0 classschedule-more click.stophandleMore(data.date) {{ getMoreCount(data.date) }} 更多 /div /div /div /template /el-calendar el-dialog :visible.syncdialogVisible title日程详情 width420px div v-foritem in dialogSchedules :keyitem.id classdialog-item span :class[dot, item.status]/span span{{ item.title }}/span /div /el-dialog /div /templateexport default { data() { return { currentDate: new Date(), scheduleMap: {}, dialogVisible: false, dialogSchedules: [] } }, created() { this.loadSchedules() }, methods: { async loadSchedules() { const raw await this.fetchFromApi() this.scheduleMap raw.reduce((acc, item) { const key this.formatDate(item.date) if (!acc[key]) acc[key] [] acc[key].push(item) return acc }, {}) }, formatDate(date) { const d new Date(date) const y d.getFullYear() const m String(d.getMonth() 1).padStart(2, 0) const day String(d.getDate()).padStart(2, 0) return ${y}-${m}-${day} }, getVisibleSchedules(date) { const list this.scheduleMap[date] || [] return list.length 3 ? list.slice(0, 3) : list }, getMoreCount(date) { const list this.scheduleMap[date] || [] return Math.max(0, list.length - 3) }, handleClick(item, date) { this.dialogSchedules this.scheduleMap[date] || [] this.dialogVisible true }, handleMore(date) { this.dialogSchedules this.scheduleMap[date] || [] this.dialogVisible true } } }这套代码我实际跑过月视图下几十条日程渲染毫无压力切换月份也很跟手。5. 常见问题排查与避坑经验实录5.1 日程不显示或显示错位这是最高频的问题八成是日期格式没对齐。排查顺序建议这样走先在插槽里console.log(data.date)看看它到底长什么样再打印Object.keys(this.scheduleMap)看看你的 key 长什么样。两边一对比问题立刻现形。我遇到过后端返回2024/06/03而插槽给的是2024-06-03的情况就是斜杠和横杠的区别肉眼扫代码根本看不出来。5.2 点击日程触发了日期切换前面提过click不加.stop就会冒泡。但还有一种情况即使加了.stop如果日程条目本身在 DOM 结构上被包在了日历的点击区域里某些版本下依然会触发。这时候可以在格子的点击处理里加一层判断或者干脆把日程条目的pointer-events单独处理。实测.stop在绝大多数版本下够用遇到顽固情况再考虑更重的方案。5.3 样式被 element-ui 默认样式覆盖element-ui 的 calendar 默认样式优先级不低你写的.schedule-item有时候不生效。解决办法是提高选择器特异性比如写成.schedule-calendar .el-calendar-table .schedule-item或者用::v-deepVue 2 里是/deep/或穿透 scoped 样式。我一般直接给最外层容器加个类名所有样式都挂在这个类名下面既提高了优先级又避免了污染全局。5.4 切换月份后数据没更新如果日程是按月加载的切换月份后要记得重新请求。但要注意v-model绑定的currentDate在切换时值可能是当月的任意一天不一定是 1 号。所以请求参数里的月份要从currentDate里取getFullYear和getMonth别想当然地以为它是月初。5.5 常见问题速查表问题现象可能原因排查方向日程完全不显示日期格式不匹配对比插槽 date 与 map 的 key点击日程切换了日期事件冒泡未阻止检查click.stop样式不生效选择器优先级不够提高特异性或用深度选择器切换月份数据不刷新未监听 currentDate加 watch 并防抖格子被长标题撑变形缺少溢出处理加nowrapellipsis跨月日期日程丢失未处理 prev/next-month检查 data.type 判断5.6 几个我踩过的坑第一个坑是时区。有次测试环境好好的上线后部分用户反馈日程日期差了一天。查了半天发现是new Date(2024-06-03)在某些时区下会被解析成前一天。后来改成手动解析字符串问题消失。这个坑在跨地域团队里特别容易触发。第二个坑是空数据时的渲染。scheduleMap[date]在没数据时返回undefined如果直接v-for会报错。一定要加|| []兜底这个习惯能省掉很多莫名其妙的报错。第三个坑是弹窗里的日程列表没去重。有次日程数据里出现了重复 id弹窗里渲染出两条一模一样的用户以为系统出 bug 了。后来在聚合的时候加了一层按 id 去重虽然根源在后端但前端兜一层总没错。6. 后续可扩展的方向与个人体会这套方案落地之后我又陆续加了几个小功能成本都不高但体验提升明显。一个是日程搜索在日历上方加个输入框输入关键词后把匹配的日期高亮出来一个是状态筛选用几个 checkbox 控制只显示某几种状态的日程还有一个是导出把当前月的日程导成表格给运营用。这些都是在现有数据结构上做加法不用动核心逻辑。如果以后要支持拖拽调整日程日期那就得引入拖拽库并且要处理拖到跨月格子的边界复杂度会上一个台阶。到那个阶段可能就要重新评估是不是该换 FullCalendar 了。技术选型从来不是一锤子买卖跟着需求走就行。我个人在实际操作中的体会是日历加日程这类需求难点从来不在组件本身而在数据怎么组织、状态怎么分层、边界怎么兜底。把聚合表建好、把格式对齐、把溢出和空值处理掉剩下的就是调样式和加交互的体力活了。element-ui 的 calendar 组件虽然朴素但插槽给得足够开放稍微花点心思就能做出很实用的排期面板没必要一上来就上重型库。
阅读完成 · 觉得有帮助?
咨询建站