日历系统前端是整个项目中交互最复杂的部分。月视图、周视图、日视图三套布局,每套都有独立的事件渲染和交互逻辑。这篇记录我用Vue3实现日历组件的思路和关键问题。
整体架构
前端采用Vue3 + Composition API,组件结构:
CalendarApp
├── CalendarHeader # 导航栏(月份切换、视图切换)
├── CalendarMonth # 月视图
├── CalendarWeek # 周视图
├── CalendarDay # 日视图
├── EventPopover # 事件详情弹窗
└── EventEditor # 事件编辑表单
三个视图组件共享一个useCalendarStore状态管理,核心状态包括当前日期、视图类型、事件列表、选中事件等。
月视图
月视图相对简单,核心是计算当月的日期网格:
每个月固定显示6行7列共42个格子。第一行从当月1号所在周的周一开始,如果1号是周三,则前面补上个月的29、30、31号。然后依次填充到42格。
事件在月视图中以横条形式显示。每个日期格子最多显示3条事件,超出的显示"+N more"。跨天事件需要横跨多个格子,这个布局计算是月视图最复杂的部分。
我的实现方式是:先把当周的所有事件按开始时间排序,分配到不同的"行位"(slot),确保同一slot不会重叠。跨天事件占据一个slot后,该slot在其跨越的所有日期中都被保留。
周视图:核心挑战
周视图是最复杂的视图。左侧是24小时的时间轴,7列对应7天,事件以色块的形式显示在对应的时间位置。
事件位置计算
每个事件的位置由四个值决定:top、height、left、width。
top和height比较直观——事件开始时间映射到y坐标,持续时长映射到高度。一天24小时对应一个固定高度(我设定为1152px,即每小时48px),那么上午9:30开始的事件top就是9.5 * 48 = 456px。
left和width就复杂了,因为要处理事件重叠。
重叠事件的布局算法
当多个事件在时间上重叠时,需要并排显示。我参考了Google Calendar的布局逻辑,实现了一个贪心算法:
- 将当天所有事件按开始时间排序,开始时间相同的按持续时间降序
- 维护一组"列"(columns),每列记录最后一个事件的结束时间
- 遍历每个事件,找到第一个"空闲"的列(即该列最后事件的结束时间早于当前事件的开始时间),将事件放入该列
- 如果没有空闲列,新增一列
- 最后,根据每个事件所在的列和总列数计算left和width
举个例子:A(9:00-10:30)、B(9:30-10:00)、C(10:00-11:00)
- A放入第0列
- B开始于9:30,第0列被A占据到10:30,放入第1列
- C开始于10:00,第0列被A占据到10:30,第1列空闲(B在10:00结束),放入第1列
- 总列数=2,A的width=50%,left=0;B的width=50%,left=50%;C的width=50%,left=50%
但这个基础算法有个问题:C虽然不与A重叠,却因为A和B重叠而被限制成50%宽度。Google Calendar的做法是让C扩展宽度占满右边的空间。我也实现了这个优化——在确定列数后,检查每个事件右侧是否有"邻居",如果没有就扩展width。
当前时间线
周视图和日视图中有一条红色的"当前时间线",跟随系统时间实时移动。用一个定时器每分钟更新一次位置就行,注意组件卸载时清理定时器。
拖拽交互
日历组件支持两种拖拽操作:
拖拽创建事件:在空白区域按住鼠标拖动,实时创建一个事件。mousedown记录起始时间,mousemove更新结束时间并显示预览色块,mouseup完成创建并弹出编辑表单。
拖拽修改事件:拖动已有事件改变开始时间,或拖动事件底边改变持续时长。这里用了HTML5 Drag API配合自定义的ghost element。
拖拽过程中最重要的是"时间吸附"——鼠标位置需要对齐到15分钟的整数倍。计算方式是将鼠标y坐标除以每15分钟对应的像素数,四舍五入后乘回来。
一个细节:拖拽过程中需要实时检测目标时间段是否与其他事件冲突,如果冲突要给视觉反馈(比如预览色块变红)。
响应式设计
日历在不同屏幕尺寸下的表现:
- 桌面(>1024px):完整的周视图/月视图
- 平板(768-1024px):周视图缩减为3天视图
- 手机(<768px):默认显示日视图,月视图简化为日期选择器
周视图在小屏幕上不可用是因为7列实在太挤了。3天视图是一个折中方案,显示昨天、今天、明天。
时间轴的高度也做了响应式调整,移动端每小时36px(而不是48px),减少滚动距离。
Composition API的组织
用Vue3的Composition API把逻辑按功能拆分成多个composable:
useCalendarNavigation:日期导航(前进/后退/今天/切换视图)useCalendarGrid:网格计算(月视图日期、周视图时间轴)useEventLayout:事件布局算法useEventDrag:拖拽创建和修改useEventStore:事件CRUD和缓存
每个composable都是独立可测试的函数,互相通过响应式引用通信。比之前用Options API把所有逻辑堆在一个组件里,清晰了很多。
性能优化
日历组件的性能瓶颈主要在事件渲染。如果一天有几十个事件,每次视图切换都全量重新布局计算,会明显卡顿。
我做的优化:
- 事件布局结果缓存,只有事件数据变化时才重新计算
- 月视图中不在可视区域的周不渲染(虚拟滚动的思路)
- 拖拽过程中的布局计算用requestAnimationFrame节流
- 事件色块用CSS transform而不是改top/left,触发GPU加速
下一篇写时区处理,那才是真正的噩梦。