排期类页面大概是后台管理系统里最容易让人又爱又恨的类型。说简单,它无非就是表格加图表;说复杂,是因为产品经理总会在评审会最后补一句:表格和旁边甘特图的滚动能不能自动跟上?我点一行,右边能不能定位过去?我点一个任务条,左边能不能滚回来?这篇就记录一下我用 Vue-Ganttastic 配 el-table 把这三件事一次性做掉的过程——滚动联动、点击定位、当前时间线,一条条说清楚。
这个组合适合谁?正在做项目管理、任务排期、生产工序、计划调度这类页面的 Vue 3 开发者都适用。你不需要从零造甘特图,也不用在 Element Plus 表格里写一堆 CSS hack 模拟时间轴,只要把两个组件按正确方式接起来,体验就能做到很顺滑。
1. 为什么要把 el-table 和甘特图绑在一起
1.1 页面诉求和两种视图的分工
先还原一下业务场景。某个项目管理系统里,用户要看一批任务的排期情况,表格里有任务名称、负责人、开始日期、结束日期、状态、优先级这些字段,甘特图里则是每条任务对应的横向时间条。如果只给表格,用户很难快速看出任务之间有没有重叠、哪几天最忙;如果只给甘特图,负责人、状态这些字段又没法在一根时间条上全部展示清楚,全部塞进 tooltip 又太重。
所以最自然的布局就是左表格右甘特图。表格负责“按行看细节”,甘特图负责“按时间看全貌”。但这样一分,新的问题就来了:表格可以往下滚,甘特图也可以往下滚,用户滚着滚着就分不清左右两边对应的是哪一行了。点击左边某一行,右边时间轴没有任何反应,任务条被挤在可视区外面,用户还得手动拖滚动条去找。这些交互断点一旦出现,整个页面就会显得很“拼凑”,不像一个完整产品。
滚动联动和点击定位本质上解决的是同一个问题:让两个组件共享同一个“当前关注点”。滚动联动是让用户在看任意一行时,甘特图能跟着走到同一行;点击定位是主动告诉对方“我现在要看这条任务,请把它带到我眼前”。当前时间线则是辅助定位的时间基准,让用户知道此刻对应的日期位置在哪。
1.2 选型思考:直接用现成的 Vue-Ganttastic
有人会问:为什么不用 Element Plus 自己画一个假甘特图?或者干脆自研一个?我踩过这个坑。自研甘特图听起来不复杂,但真正做起来要处理时间刻度换算、缩放、滚动定位、任务层级、拖拽、跨天跨月、不同日期格式的解析,一套下来至少十天半个月,而且边界情况特别多。哪怕只是做只读版本,光是把日期转成像素位置、再处理缩放变化,就够写几百行工具函数。
Vue-Ganttastic 在 Vue 3 生态里是比较合适的现成方案。它体积不算大,时间线渲染和任务条渲染都做了,还预留了插槽可以自定义 bar 内容,支持按天、按小时缩放,也有当前时间线能力。它和 Element Plus 的表格组合起来,就是“表格数据明细 + 甘特图时间可视化”的标准搭配。选择它的另一个原因是它不强绑定 UI 体系,样式可以覆盖,不会和 Element Plus 的视觉风格打架。
当然它也不是万能。如果你要做的不是排期视图,而是带复杂依赖关系的项目计划网络图,那它就不合适,那种需求应该去用更重的专业甘特库。但如果是“表格 + 时间条 + 联动”这种常见后台场景,Vue-Ganttastic 完全够用。
2. 搭一个能跑的页面骨架
2.1 安装引入和版本说明
先说明一下我用的版本环境:Vue 3.4 配合 Element Plus 2.x,Vue-Ganttastic 用 2.x。安装命令很简单:
npm install vue-ganttastic npm install element-plus引入方式按项目习惯选择,全局注册还是局部注册都行。我习惯在需要的页面里局部引入,减小首屏体积:
import { GanttChart } from 'vue-ganttastic' import 'vue-ganttastic/style.css'如果你在工程里发现引入后样式没有生效,先检查是不是漏了上面那行样式文件,再检查构建工具对 node_modules 里 CSS 的处理规则。有些项目会默认跳过 node_modules 的 CSS 编译,需要手动在 vite.config 或 webpack 配置里放开。
2.2 左右布局的 HTML 结构与样式要点
页面结构我用一套 flex 布局,左侧固定宽度放表格,右侧 flex 伸展放甘特图。关键点在于必须给外层容器一个确定的高度,不然左右两边的滚动区间高度对不齐,甘特图有滚动条而表格没有,或者反过来,联动就无从谈起。
<template> <div class="gantt-page"> <div class="gantt-page__table"> <el-table ref="tableRef" :data="tasks" row-key="id" highlight-current-row @row-click="onTableRowClick" > <el-table-column prop="name" label="任务名称" width="220" fixed /> <el-table-column prop="owner" label="负责人" width="120" /> <el-table-column prop="start" label="开始日期" width="140" /> <el-table-column prop="end" label="结束日期" width="140" /> </el-table> </div> <div class="gantt-page__chart"> <GanttChart ref="ganttRef" :tasks="tasks" :timeline-start="timelineStart" :timeline-end="timelineEnd" :row-height="46" :current-time="currentTime" :bar-style="barStyle" @bar-click="onBarClick" /> </div> </div> </template>样式上要注意几个细节。左右子容器都要设置 overflow: hidden,把滚动交给组件内部处理,不要让外部容器再意外长出滚动条。表格和甘特图的背景色要统一,否则左右之间有一条明显色差。左侧表格的右边框可以用来当作天然分隔线,视觉上比页面自己的 border 更干净。
.gantt-page { display: flex; height: 600px; border: 1px solid #e4e7ed; border-radius: 6px; overflow: hidden; } .gantt-page__table { width: 480px; border-right: 1px solid #e4e7ed; } .gantt-page__chart { flex: 1; min-width: 0; overflow: hidden; background: #fff; }这里给 gantt-page__chart 设置 min-width: 0 很重要,不然 flex 子项被甘特图内部大量节点撑破,会出现横向溢出。
2.3 任务数据怎么设计
两个组件共用同一份任务数组,这是联动逻辑能成立的前提。数据格式要兼顾两边:el-table 需要普通行数据,Vue-Ganttastic 需要 start 和 end 字段。
const tasks = ref([ { id: 'task-001', name: '需求调研', owner: 'A同学', start: '2025-03-01', end: '2025-03-08', status: '已完成' }, { id: 'task-002', name: '交互设计', owner: '某开发者', start: '2025-03-09', end: '2025-03-15', status: '进行中' } ])两个容易踩的坑提前说。id 不要用数字和字符串混着来,表格里是数字、甘特图里是字符串,点击定位时类型对不上,永远找不到。start 和 end 尽量统一成 YYYY-MM-DD 这种标准格式,Vue-Ganttastic 内部要解析日期,格式不统一会出现任务条位置错乱。如果任务有父子层级,两边的树形配置也要对齐,el-table 用 tree-props,Vue-Ganttastic 则要求在任务对象上用 children 字段。
3. 滚动联动:让左右两边像一个整体
3.1 纵向滚动的同步思路
纵向滚动联动的核心问题是:两个组件各自维护自己的滚动容器,我得找到让一个容器的滚动带动另一个容器滚动的方法。
先拿到 el-table 内部真正的滚动元素。Element Plus 的表格外面套了一层滚动条封装,直接监听 table 根节点的 scroll 事件是无效的,要取到 .el-scrollbar__wrap 这个元素:
function getTableScrollEl() { return tableRef.value?.$el.querySelector('.el-scrollbar__wrap') }Vue-Ganttastic 这边,不同版本暴露滚动事件的方式不同。有的版本在 GanttChart 根元素上就能直接监听原生 scroll,有的版本要往里找滚动容器。我建议先打开开发者工具,看甘特图区域滚动时到底是哪个 DOM 节点在动,拿到那个节点再绑定事件,这样最稳。
纵向联动的思路就是双向同步,但为了防止互相触发形成死循环,需要用一个锁变量控制:
let syncLock = false function syncScrollFromTable() { const tableScrollEl = getTableScrollEl() const ganttScrollEl = getGanttScrollEl() if (!tableScrollEl || !ganttScrollEl) return ganttScrollEl.scrollTop = tableScrollEl.scrollTop } function syncScrollFromGantt() { const tableScrollEl = getTableScrollEl() const ganttScrollEl = getGanttScrollEl() if (!tableScrollEl || !ganttScrollEl) return if (!syncLock) { syncLock = true tableScrollEl.scrollTop = ganttScrollEl.scrollTop requestAnimationFrame(() => { syncLock = false }) } }锁的原理并不复杂:当程序主动把 A 容器的 scrollTop 设置成 B 容器的值时,A 会触发一次 scroll 事件,如果这次事件再回过头去设置 B 的 scrollTop,就会无限循环。用锁变量在主动设置期间暂时屏蔽回调,等下一帧再解除。
3.2 横向滚动的同步和滚动条策略
横向滚动一般以甘特图为主导,因为甘特图的时间轴区域经常超过容器宽度,而表格左侧的列通常已经固定住了,表格本身未必需要横向滚动。用户在甘特图上左右拖时间轴,表格如果有横向溢出,也应该跟着滚动。
横向同步同样在甘特图的 scroll 事件里处理,把 scrollLeft 同步给表格的内部滚动容器:
function onGanttScroll(e) { const target = e.target const tableScrollEl = getTableScrollEl() if (!tableScrollEl) return if (!syncLock) { syncLock = true tableScrollEl.scrollLeft = target.scrollLeft requestAnimationFrame(() => { syncLock = false }) } // 同时更新当前滚动位置,用于时间线自定义线 ganttScrollLeft.value = target.scrollLeft }如果表格本身列不多,根本不会横向溢出,那横向滚动就不需要联动表格,只需要记录甘特图的 scrollLeft,供后续自定义时间线使用。
滚动条的处理也值得花点心思。默认情况下,表格右侧会有一个竖向滚动条,甘特图底部会有一个横向滚动条,看起来不是特别统一。我实际项目里把两侧的滚动条都做成了细条样式,颜色统一浅灰,鼠标悬停时变深。这样页面视觉更干净,也不会因为滚动条宽度差异导致左右内容宽度抖动。
.gantt-page ::-webkit-scrollbar { width: 6px; height: 6px; } .gantt-page ::-webkit-scrollbar-thumb { background: #c0c4cc; border-radius: 3px; } .gantt-page ::-webkit-scrollbar-track { background: transparent; }3.3 同步时的防抖与死循环问题
scroll 事件触发频率非常高,一秒可能触发几十上百次。每次触发都去操作另一个容器的 scrollTop,页面会明显卡顿。解决办法是用 requestAnimationFrame 合并同帧内的多次滚动更新,只保留最后一次状态。前面代码已经展示了这个思路。如果你们项目浏览器兼容要求不高,也可以用 throttle 函数包一层,每 50ms 最多执行一次。
死循环问题除了锁变量,还有一种更彻底的方案:只做单向同步。比如以表格为纵向主滚动源,用户在甘特图区域滚轮时,先把甘特图自己的 scrollTop 更新,然后单方向同步给表格,表格的 scroll 事件不再反向同步。这样虽然从甘特图滚动时表格会有轻微延迟,但绝对不会有循环问题。实际体验下来,用户 90% 的操作都发生在表格区或者希望通过表格定位到甘特图,单向同步足够应付大多数场景。我在生产环境里最终用了双向同步加锁,但如果你在本地测试发现双向有抖动,果断换单向,体验损失很小。
4. 点击定位:在表格和甘特图之间来回跳
4.1 点击表格行,定位到甘特图
需求是用户点表格里某一行,甘特图能把对应任务条滚到可视范围内。Vue-Ganttastic 提供了任务定位相关方法,我这边用的版本里是 scrollToTask,接收任务对象或 id。不同版本可能叫法不一样,有的叫 scrollToItem,有的叫 scrollIntoView,以你当前版本的文档为准。
写代码的时候建议先做能力判断再调用,而不是写死方法名,这样即使以后升级组件,也不至于页面直接白屏报错:
function onTableRowClick({ row }) { selectedId.value = row.id if (ganttRef.value) { if (typeof ganttRef.value.scrollToTask === 'function') { ganttRef.value.scrollToTask(row.id) } else if (typeof ganttRef.value.scrollToDate === 'function') { ganttRef.value.scrollToDate(row.start) } } }如果组件确实没提供定位方法,退路就是自己计算:把 row.start 和 row.end 转换成相对 timeline-start 的像素偏移,然后手动设置甘特图内部滚动容器的 scrollLeft 和 scrollTop。task 所在行对应的 scrollTop 可以用行索引乘行高估算。这种方法麻烦一点,但一定可行。
点击表格行之后,甘特图任务条如果处于折叠状态,或者父任务还没展开,定位可能找不到。这一点要在需求评审时跟产品确认清楚,通常做法是展开所有父级后再定位,或者在数据上保证甘特图渲染的是平铺视图。
4.2 点击甘特图任务条,回跳表格
反向联动同样重要。用户点击右侧进度条,左边表格要滚动到对应行并高亮。
Vue-Ganttastic 的事件回调里会返回 task 数据,拿到之后在表格里找到对应行索引,用 el-table 的 scrollTo 方法定位:
function onBarClick(task) { selectedId.value = task.id const index = tasks.value.findIndex(t => t.id === task.id) if (index < 0) return // 先设置当前行高亮 tableRef.value.setCurrentRow(tasks.value[index]) // 滚动到指定行,position 控制对齐方式 tableRef.value.scrollTo({ row: index, position: 'middle' }) }这里有两个容易出问题的点。setCurrentRow 接收的参数最好是从原数组里取出来的行对象,而不是任务对象本身或者一个新重建的对象,否则高亮可能不生效。行索引用 findIndex 找到的是数字,传给 scrollTo 的 row 字段就是索引值,Element Plus 支持这种用法,但如果表格做了筛选、排序,行索引和原始数组索引会对不上。解决方法是拿到筛选排序后的行索引,或者用 row-key 的方式传入行对象。
4.3 选中高亮怎么做得一致
保证点击表格和点击甘特图时视觉反馈一致,能明显提升联动感。表格那边用 highlight-current-row 就能有一个默认高亮,但甘特图默认只有 hover 效果,选中态需要自己加。
Vue-Ganttastic 支持通过 bar-style 或 bar-class 给任务条设置样式,我传一个函数进去,根据 selectedId 判断当前任务是否选中:
function barStyle(task) { if (selectedId.value === task.id) { return { backgroundColor: '#409eff', borderColor: '#409eff', borderRadius: '4px', boxShadow: '0 2px 8px rgba(64, 158, 255, 0.3)' } } return { backgroundColor: '#79bbff', borderColor: '#79bbff', borderRadius: '4px' } }注意一个细节:bar-style 函数返回的对象会和组件默认样式合并,如果组件内部某些样式有更高优先级,你的 borderRadius 或者颜色会被覆盖。处理办法是再加上一条全局 CSS 覆盖组件内部生成的类名,或者干脆用 bar-class,配合 deep 选择器压低样式层级。我实际使用中发现,用属性方式传入 style 的优先级其实已经够了,但如果覆盖失败,就用 deep 进来的 CSS 强制覆盖。
5. 当前时间线:显示与自动刷新
5.1 先用组件自带的时间线能力
Vue-Ganttastic 支持传入当前时间字符串,组件会在时间轴上对应位置渲染一条竖线。这比自己在页面上用绝对定位画线要省事很多,因为它天然跟随时间轴滚动和缩放,位置计算精确,不会因为甘特图横向移动而错位。
用法是在组件上绑定 current-time:
<GanttChart :current-time="currentTime" ... />currentTime 建议用 dayjs 格式化到分钟,比如 2025-03-15 14:30。如果你不确定组件内部如何解析,就用最完整的 YYYY-MM-DD HH:mm 格式,避免出现日期和时刻都对,但时间线画不出来这种诡异情况。
时间线默认样式一般是一条细线,颜色可能不够醒目。可以在全局样式里覆盖:
.gantt-page .gantt-current-time { background: #f56c6c; width: 2px; opacity: 0.9; }注意类名在不同版本里可能有差异,实际开发时打开检查器看组件用的是什么类名再对准。有的版本时间线不是一个单独的类,而是用内联 style 控制的边框颜色,那就用更深一层的覆盖方式处理。
5.2 定时刷新让时间线真正“当前”
currentTime 如果用 const 或者普通变量写死,初始化之后时间线就是一条固定不动的线,跟“当前时间”名不副实。要让时间线真正表达此时此刻,需要每分钟更新一次。
import dayjs from 'dayjs' const currentTime = ref(dayjs().format('YYYY-MM-DD HH:mm')) let timer = null function startCurrentTimeTicker() { timer = setInterval(() => { currentTime.value = dayjs().format('YYYY-MM-DD HH:mm') }, 60 * 1000) } onMounted(() => { startCurrentTimeTicker() }) onBeforeUnmount(() => { if (timer) clearInterval(timer) })为什么是一分钟而不是一秒?对于排期类页面,精度到分钟已经足够,一秒刷新一次反而会带来不必要的 DOM 更新。如果你确实需要到秒级,比如某些实时调度场景,那把间隔改成 1000 毫秒即可,但要注意性能,最好只在页面处于可见状态时刷新,可以用 document.visibilityState 判断。
5.3 自定义时间线:需要精确到分钟时怎么办
有时候组件自带的时间线不够用。比如需求是不仅要标记今天日期,还要标记此刻几点几分,而组件自带能力只在日期刻度上画线,横跨多个小时时位置不精确。这种场景就得自己在甘特图上方覆盖一层绝对定位元素。
思路分两步。第一步,计算当前时间在 timeline-start 和 timeline-end 之间的百分比位置;第二步,监听甘特图的横向滚动,用 translateX 抵消滚动,让时间线始终停留在正确的时间刻度位置。
const timelineStart = ref('2025-03-01') const timelineEnd = ref('2025-03-31') const ganttScrollLeft = ref(0) const nowLineLeft = computed(() => { const start = Date.parse(timelineStart.value) const end = Date.parse(timelineEnd.value) const now = Date.now() const pct = (now - start) / (end - start) // 先按时间轴宽度百分比定位,再加上横向滚动偏移 return `calc(${pct * 100}% + ${ganttScrollLeft.value}px)` })模板里在甘特图外层包一个相对定位容器,时间线用 pointer-events: none,避免挡住任务条的点击:
<div class="gantt-wrap"> <GanttChart ref="ganttRef" ... @scroll="onGanttScroll" /> <div class="now-line" :style="{ left: nowLineLeft }"></div> </div>.gantt-wrap { position: relative; height: 100%; } .now-line { position: absolute; top: 0; bottom: 0; width: 2px; background: #f56c6c; pointer-events: none; z-index: 20; }这个方案好用的地方在于完全不依赖组件内部的时间线实现,只要甘特图滚动事件能暴露出来,就能精确对齐。坏处是你得自己处理甘特图横向缩放变化,缩放时百分比会变,需要重新计算 left 值。如果项目需求里没有缩放,那这套做法非常稳。
6. 踩坑记录和调优小抄
6.1 高频问题速查表
做这套联动时,我前后试过不少方案,也踩了不少坑。整理一个速查表,方便你排查问题:
| 现象 | 原因 | 解决办法 |
|---|---|---|
| 表格和甘特图高度对不齐 | 左右子容器各自出现滚动条 | 外层固定高度,子容器 overflow hidden,滚动交给内部 |
| 设置 scrollTop 不生效 | 监听或设置的对象不是真正的滚动元素 | 用 querySelector 找到 .el-scrollbar__wrap |
| 双向滚动死循环 | 两个事件互相设置 scroll 值 | 加 syncLock 标志,或只保留单向同步 |
| scrollToTask 定位无效 | id 类型不一致,或甘特图用的是另一份数组 | 共用同一份任务数组,id 统一为字符串 |
| setCurrentRow 不生效 | 传入的 row 不是表格原数组里的对象 | 从 tasks 中按索引或 id 取原行对象传入 |
| 当前时间线不动 | currentTime 字符串没变化 | 启动 setInterval 定时刷新 currentTime |
| 甘特图任务条渲染不出来 | start 和 end 格式不统一导致日期解析失败 | 统一为 YYYY-MM-DD |
| 甘特图宽度超出页面 | flex 子项没有设置 min-width | 给 chart 容器加 min-width: 0 |
6.2 数据量大时如何避免卡顿
任务条一多,甘特图渲染就成了性能瓶颈。每条任务在甘特图里都是一个或几个 DOM 节点,几百条还好,上千条就会明显掉帧。
第一个优化思路是减少响应式依赖。甘特图其实只关心任务的 id、start、end 这几个字段,但如果你把整个任务数组都放进响应式对象里,任何字段变化都会引起重渲染。可以给传入甘特图的数据用 shallowRef 包裹,或者干脆用冻结的对象,避免深层响应式开销。
第二个思路是缩小渲染范围。如果甘特图支持按时间缩放,任务条在时间轴上密集堆叠时,可以只渲染当前时间范围内的任务,或者把超出可视区域的元素用 v-show 隐藏,而不是 v-if 直接销毁。v-if 频繁销毁重建 DOM 的代价更大。
第三个思路是滚动同步操作全部走 rAF。前面代码里已经多次出现 requestAnimationFrame,这不是炫技,而是切切实实的性能优化手段。把同步操作合并到每一帧的最后一次滚动,能减少几十倍的操作次数。
6.3 几个让页面更顺手的布局细节
最后分享几个从实际体验里攒出来的细节。
点击定位之后的平滑滚动,我之前试过给滚动容器加 scroll-behavior: smooth,发现两个组件同步滚动时,平滑动画会让左右两边看起来不齐,反而显得卡顿。现在我的做法是定位消息发出后不做平滑,直接瞬跳,用高亮色块把用户视线引导到目标位置,效果反而更好。
表格左侧的固定列宽度要控制好,如果固定列太多太宽,表格本身横向空间不足,会挤压右侧甘特图的显示区域。通常保留任务名称一列固定就够了,最多再加一个状态列。
甘特图容器顶部如果自带一个时间刻度表头,注意和表格的表头高度对齐。样式上可以给两边表头都设置相同的高度和背景色,让上下视觉线贯通,这样从左到右看过去时不会觉得中间断开了一层。
另外一个很容易忽略的点是 ResizeObserver。页面容器宽度变化后,甘特图组件内部可能不会自动重算任务条宽度,导致出现空白间隙。可以在容器尺寸变化掉用组件的重算方法,或者从外层直接触发一次 window resize 事件。我项目里是在侧边栏展开收起后手动调用了组件暴露的 repaint 方法,效果干净利落。
最后再分享一个小习惯。做完这种联动功能,我通常会把 scrollToTask、scrollTo 这些关键调用全部封装成独立的方法,页面里只负责事件绑定,不散落原始逻辑。这样后续产品要加“点击某列跳转到指定任务”的入口,或者要在甘特图上加右键菜单,都只需要复用同一个定位方法,改起来非常顺手。做联动功能,最重要的就是把“从A定位到B”和“从B定位到A”这两种动作收敛到单一入口,这样代码才不会越写越乱。