做过排期、进度管理类后台系统的人应该都有这个体感:列表和表格搭起来很快,真正卡住工期的是中间那块甘特图。需求方要拖拽调时间、要显示依赖箭头、要按周月切换粒度,最好还能看到关键路径和基线对比,可预算又卡死在“不采购商业授权”这一条上。这时候摆在面前的选择基本就三个:找一个现成的开源库改,硬啃一套商用方案,或者自己动手在 vue 项目里从零撸一个多功能的甘特图组件。我在两三个版本迭代里把三条路都踩过一遍,最后落地的是一套纯前端自研、零依赖、可完全掌控的甘特图方案,核心逻辑加起来不到两千行,功能却比市面上不少轻量库要全。这篇就按实战的顺序,把选型判断、数据模型、时间刻度换算、拖拽吸附、依赖线、虚拟滚动、打包部署踩坑这些东西全部摊开讲,代码能直接抄,参数能直接改用,适合正在做 vue 项目管理、生产排程、资源调度类模块的开发者参考,前端基础一般也能跟着走完全程。
1. 选型之前先把需求盘清楚
很多人一上来就搜“vue 甘特图组件”,装完发现功能对不上,改源码又改不动,最后时间全耗在适配层上。我的经验是先花两个小时把需求拆成三类:必须有的、最好有的、可以砍的。这个动作看起来浪费时间,实际是省时间最狠的一步,因为它直接决定了你是“改造轮子”还是“造轮子”。
1.1 三条技术路线的横向对比
我把当时评估的三种路线整理成表,你可以直接对照自己的项目打个分。评判维度我选了五个:上手成本、功能覆盖度、定制难度、长期维护、隐性风险。注意“定制难度”和“上手成本”经常是反着走的,上手快的往往改起来最难受。
| 路线 | 上手成本 | 功能覆盖 | 定制难度 | 长期维护 | 典型隐性风险 |
|---|---|---|---|---|---|
| 商用授权库 | 低 | 全,含关键路径、资源视图 | 低,但受配置项限制 | 依赖厂商版本 | 授权按年计费,人数一多成本陡增 |
| 开源库二次开发 | 中 | 中等,基础拖拽基本都有 | 中高,需要读懂内部结构 | 上游停更就砸手里 | 源码风格差异大,升级会覆盖你的改动 |
| 纯前端自研 | 高 | 按需,做什么有什么 | 低,代码全在自己手里 | 完全自主 | 前期投入大,边界情况要自己填 |
我当时的需求里有几条是开源库给不了的:任务自带多段进度、子任务折叠后父任务自动汇总、依赖线要能跨越折叠层级、还要和已有的成本模块联动。硬凑的结果就是在外层套一层转换,数据来回映射,debug 的时候得同时看懂两套模型,非常痛苦。所以最后选了自研,但这里必须说清楚:如果你的需求只是“展示时间条 + 简单拖拽”,千万别自研,直接用成熟库,两天上线,别学我折腾。
1.2 多功能到底该包含哪些功能
标题里说“超多功能”,这个词很容易被误解成功能越多越好。实际做下来,真正被高频使用的功能其实就十来个,其余都是锦上添花。我把最终版本的能力列一下,你可以当作需求清单对照勾选:
- 多粒度时间轴:日、周、月、季度四档切换,缩放级别连续可调
- 任务层级:父子嵌套,折叠展开,父任务自动按子任务聚合起止时间和进度
- 拖拽交互:整体平移改起止日期、拖两侧改工期、拖进度点改进度
- 依赖关系:FS、SS、FF、SF 四种类型,自动避让,支持跨层级连线
- 里程碑:零工期的菱形节点,独立渲染
- 关键路径:按最长路径算法自动高亮
- 基线对比:保存一版计划作为基线,显示偏差
- 今日线与节假日底色
- 泳道分组:按负责人或项目分组显示
- 右键菜单与快捷键
- 筛选、搜索、定位到指定任务
- 导出 CSV / 图片
- 暗色主题
看着多,其实不少是共享同一套渲染管线的,真正增加复杂度的只有依赖线、关键路径和基线三块。先把主干(时间轴 + 任务条 + 拖拽)做扎实,其余都是往上加图层,这个顺序很重要,反过来做会反复重构。
1.3 数据模型定错,后面改十次
我第一版数据模型是照着后端接口直接用的,字段名、时间格式全是后端那套,结果前端做拖拽计算时到处转换,代码里塞了一堆dayjs的 format 和 parse。第二版我做了彻底分离:后端接口层保持原样,中间加一个适配层,内部统一使用一套自己的结构。内部结构大概是这样:
// 内部任务模型 { id: 't-1001', parentId: null, // 用于构建层级 name: '结构件加工', start: 1735689600000, // 时间戳,毫秒,所有时间统一用时间戳 end: 1736294400000, progress: 0.35, // 0~1 milestone: false, groupId: 'g-1', // 泳道归属 deps: [ // 依赖,指向其他任务 { target: 't-1002', type: 'FS', lag: 0 } // lag 单位:天 ], collapsed: false, baseline: { start: 1735603200000, end: 1736208000000 } // 可空 }两个决定值得展开说。第一,全部用毫秒时间戳,不用字符串。字符串在跨时区、跨夏令时的环境里做加减法很容易出问题,而甘特图的核心运算就是时间的加减和比较,用数字能省掉大量心智负担。第二,依赖关系挂在源任务上,而不是单独一张边表。单独边表在增删任务时要同步维护,容易漏;挂在任务上虽然查“谁依赖我”要遍历一次,但配合索引缓存完全够用,代码量少一半。
2. 核心算法与参数计算
甘特图看着是画图,本质上是几组数学换算:日期换像素、像素换日期、任务换行号、依赖换折线。这几组换算想清楚了,渲染层就只是把计算结果画出来而已,几乎不需要动脑。这一节把公式和边界情况都写清楚,尤其是缩放时的舍入误差,这是最容易埋雷的地方。
2.1 时间刻度到像素的换算
最基础的公式只有两条:
// 时间戳 -> 横坐标 function dateToX(ts, timelineStart, pxPerDay) { return ((ts - timelineStart) / 86400000) * pxPerDay } // 横坐标 -> 时间戳 function xToDate(x, timelineStart, pxPerDay) { return timelineStart + (x / pxPerDay) * 86400000 }看着简单,实际有三个坑。坑一是 86400000 这个常数,它只在没有夏令时的地区成立,如果系统要服务多地区用户,稳妥做法是用日期库按自然日累加,或者干脆全部用 UTC 零点对齐。坑二是浮点误差,pxPerDay在连续缩放时会是 37.4129 这种小数,来回转换几次后像素值会出现 0.0001 级别的漂移,表现为任务条边缘偶尔抖动一个像素。解决办法是在最终渲染前统一Math.round,并且拖拽过程中累积的增量用原始时间戳算,不要每帧从像素反推时间。坑三是最小宽度,工期只有一天的任务在月视图下宽度可能小于 4 像素,直接看不见,必须设一个 MIN_BAR_WIDTH,同时通过虚线延伸的方式提示真实跨越范围。
缩放的参数档位我实测下来这套比较顺手,你可以直接拿去用:
| 视图粒度 | pxPerDay | 单列宽度来源 | 适用场景 |
|---|---|---|---|
| 日 | 48 ~ 80 | 每列一天 | 排产、短期冲刺 |
| 周 | 24 ~ 40 | 每列一周 | 常规项目排期 |
| 月 | 6 ~ 14 | 每列一月 | 季度级规划 |
| 季度 | 2 ~ 5 | 每列一季度 | 年度路线图 |
缩放不是把 pxPerDay 一改就完事,刻度头也要跟着换组件:日视图画“日 + 周”,周视图画“周 + 月”,月视图画“月 + 年”。我的做法是把刻度头抽成一个独立的TimeScaleHeader组件,接收pxPerDay和timelineStart,内部自己决定渲染几层、每格多宽,主组件完全不用关心它的内部逻辑。这样后面加“工作日模式”(跳过周末导致列宽不均)时,改动只落在这一个文件里。
2.2 拖拽吸附与最小粒度
拖拽的本质是监听指针移动,把像素增量换算成天数增量,再作用到任务的起止时间上。这里面最影响手感的是吸附粒度。完全不吸附的话,指针稍微一动任务就落在 3 点 17 分这种奇怪的时间上,用户会觉得“这东西不准”;吸附太粗(比如一天)又没法做小时级排程。
我最终采用的方案是:吸附粒度跟着视图走,日视图吸附到 1 小时,周视图吸附到 1 天,月视图吸附到 1 天,季度视图吸附到 1 周。实现上就是一个取整:
const SNAP = { day: 3600000, week: 86400000, month: 86400000, quarter: 604800000 } function snapDate(ts, snapMs, baseTs) { // 以时间轴起点为基准做吸附,避免全局时间戳取整导致整列错位 const offset = ts - baseTs return baseTs + Math.round(offset / snapMs) * snapMs }这里有个细节,吸附基准要用时间轴的起点,不能用时间戳 0。如果直接用Math.round(ts / snapMs) * snapMs,在你想把任务对齐到每天 09:00 开始时就会完全对不上,因为整天的边界是 00:00。用时间轴起点做基准,就能保证所有任务落在同一套网格上。
拖拽还要处理几个边界:不允许结束时间早于开始时间(拖左侧时如果越过了右边界,就把右边界推着走,而不是让工期变负数);不允许任务移出时间轴可视范围(或者允许移出但要自动扩展时间轴,我选的是自动扩展,用户体验更好);按下 Esc 要能取消当前拖拽回到初始状态。这几个逻辑看似琐碎,但缺一个都会被用户当成 bug 报上来,我第一版就漏了 Esc 取消,直接被测试同学挂了三个 issue。
2.3 依赖关系的绘制与避让
依赖线是甘特图里视觉效果最讨喜、实现最烦的部分。四种依赖类型的连接点不一样:
- FS(完成-开始):从源任务右端连到目标任务左端,最常见
- SS(开始-开始):源左端连到目标左端
- FF(完成-完成):源右端连到目标右端
- SF(开始-完成):源左端连到目标右端
连线本身用 SVG 画折线就行,难点在避让。当两个任务挨得很近时,一根直线穿过任务条会非常难看,也看不清指向。我的做法是统一走“三段式”路径:源点先水平出来一小段(固定 12 像素),再垂直走到目标所在行,最后水平进入目标点。如果目标点在源点左边(回环依赖),就改成五段式,从下方绕行,留出 16 像素的缓冲带。
function buildPath(from, to, isBackward) { const GAP = 12, BEND = 16 if (!isBackward) { const midX = Math.max(from.x + GAP, to.x - GAP) return `M${from.x},${from.y} H${midX} V${to.y} H${to.x}` } // 反向依赖从下方绕 const y = Math.max(from.y, to.y) + BEND return `M${from.x},${from.y} V${y} H${to.x - GAP} V${to.y}` }箭头用marker元素定义一次,全局复用,不要每根线单独画三角形,几十根线的时候性能差距很明显。另外依赖线要画在任务条下面一层,z-index 顺序是:网格底色 < 依赖线 < 任务条 < 拖拽代理 < 右键菜单。顺序错了会出现线压住任务文字的情况,属于典型的低级视觉 bug,但排查起来要花时间。
3. Vue 组件落地实战
理论讲完,进入动手环节。这一节从创建项目开始,把组件拆分、状态管理、关键交互代码都过一遍,你照着搭能直接跑起来。我用的技术栈是 vue 3 +<script setup>+ pinia,不用 vuex 的原因后面会说。
3.1 项目初始化与依赖取舍
如果是新项目,建项目这一步没什么好犹豫的:
npm create vite@latest gantt-demo -- --template vue cd gantt-demo npm install npm install dayjs pinia npm run dev老项目里做 vue 安装及环境配置时有几个点值得提醒。Node 版本建议 18 以上,低版本在构建时可能报crypto.hash is not a function之类的错。如果你是从 vue 2 迁移过来,注意setup语法和 Options API 混用时的this指向问题,甘特图这种交互密集的组件建议整体用 Composition API 重写,别混着写,否则响应式丢失会非常难查。
依赖我有意控制得极少,只有 dayjs 和 pinia。不建议引入 UI 框架的表格或树组件来拼甘特图,它们的 DOM 结构和定位方式是为列表优化的,你很难在它们上面叠加自由定位的任务条,最后就是一堆position: absolute和层级打架。自研的好处就在这里,DOM 结构完全按需要设计,一个滚动容器 + 一个内容层 + 若干行,简单直接。
状态管理这块,pinia 和 vuex 的选择在甘特图场景里答案比较明确。甘特图的状态特点是读多写少但写入频繁(拖拽时每帧都在改),pinia 的原子化 store 配合$patch批量更新能明显减少无意义的重渲染,而且去掉 mutation 之后代码短了很多。如果你项目里已经全是 vuex,也别为了甘特图单独引一套,用 vuex 的 module 一样能做,只是拖拽期间记得把更新合并到一个 action 里,别一个字段一个 commit。
3.2 组件拆分与数据流设计
组件我拆成六块,每块职责单一,方便单独调试:
GanttChart.vue 总容器,负责尺寸测量、滚动同步、事件分发 ├── TimeScaleHeader.vue 时间刻度头 ├── TaskTree.vue 左侧任务列表(可独立滚动,与右侧垂直同步) ├── GridCanvas.vue 网格底色 + 今日线 + 节假日 ├── BarLayer.vue 任务条、里程碑、进度、基线 ├── DepLayer.vue SVG 依赖线 └── ContextMenu.vue 右键菜单数据流是单向的:原始任务数组从 store 进来,经过一个computed转成带x / y / width / rowIndex的渲染对象,各图层只读这个渲染对象。关键原则是渲染层不持有业务状态,拖拽产生的临时位移放在一个独立的dragState里,松手时再一次性提交回 store。这样做的直接好处是撤销重做特别好做,只要记录每次提交前后的快照就行。
// 渲染对象由原始数据派生,不要在原始数据上挂 x/y const renderTasks = computed(() => { const rows = flattenVisibleTasks(tasks.value) // 处理折叠 return rows.map((task, i) => ({ ...task, rowIndex: i, x: dateToX(task.start, timelineStart.value, pxPerDay.value), w: Math.max(MIN_BAR_W, (task.end - task.start) / 86400000 * pxPerDay.value) })) })这里flattenVisibleTasks要处理折叠逻辑:父节点收起时,其所有后代都不进入渲染数组,同时父任务的start / end / progress用子节点的聚合值覆盖。聚合要递归,不能只算一层,否则多层级项目里折叠后时间条会显示错误。
3.3 拖拽、缩放与进度的关键实现
拖拽我统一用 Pointer Events,不用 mouse + touch 两套。原因是 Pointer Events 天然支持触屏、笔、鼠标,还有setPointerCapture可以避免鼠标移出元素后事件丢失,这一点在做快速拖拽时体验差别很大。
function onBarPointerDown(e, task, mode /* 'move' | 'start' | 'end' | 'progress' */) { e.preventDefault() const el = e.currentTarget el.setPointerCapture(e.pointerId) dragState.value = { id: task.id, mode, startX: e.clientX, originStart: task.start, originEnd: task.end, originProgress: task.progress } window.addEventListener('pointermove', onMove) window.addEventListener('pointerup', onUp, { once: true }) } function onMove(e) { const d = dragState.value if (!d) return const deltaPx = e.clientX - d.startX const deltaMs = (deltaPx / pxPerDay.value) * 86400000 if (d.mode === 'move') { const ns = snapDate(d.originStart + deltaMs, snapMs.value, timelineStart.value) preview.value = { start: ns, end: ns + (d.originEnd - d.originStart) } } else if (d.mode === 'end') { const ne = snapDate(d.originEnd + deltaMs, snapMs.value, timelineStart.value) preview.value = { end: Math.max(ne, d.originStart + snapMs.value) } } // start / progress 同理,省略 }这段代码有三个值得注意的地方。第一,位移全程基于 originStart 计算,而不是累加每帧增量,这样即使中途吸附导致位置跳变,也不会产生累积误差。第二,预览值单独放preview,渲染层优先读 preview,松手时才写回 store,天然支持 Esc 取消。第三,setPointerCapture之后仍然要监听 window,因为某些浏览器在捕获状态下移出窗口边界时事件行为不一致,双保险更稳。
进度拖拽稍微特殊,它不改时间,只改progress。判断落点在进度点附近(比如 8 像素内)就进入进度模式。另外值得加一个双击任务条快速定位并聚焦改名的能力,实际使用频率非常高,用户改名字的次数远超你的想象。
3.4 导出与 Excel 方案的取舍
很多团队真正的落地流程是:前端甘特图用来拖拽排期,最终交付物还是 Excel。所以导出能力别省。我有两种实现,按需求选:
| 导出格式 | 实现方式 | 优点 | 局限 |
|---|---|---|---|
| CSV | 拼字符串直接下载 | 极简单,Excel 可打开 | 没有图形,只有日期 |
| PNG | canvas 重绘或截图 | 保留视觉效果 | 大图清晰度和字体要处理 |
| Excel 带条形 | 用条件格式或在单元格填色 | 能做出简易甘特效果 | 需要模板,改模板麻烦 |
CSV 那条路一分钟就能写完,直接Blob+a.download就行。PNG 那条我建议用 canvas 重绘而不是截 DOM,因为截 DOM 要引额外库,还会受字体、缩放、跨域图片影响。canvas 重绘的代码量不小但可控,核心是把前面算好的x / y / w直接搬到 canvas 的fillRect上,网格和文字用fillText,一套逻辑两套渲染,实际维护成本可以接受。
顺带说一句,如果你只是想要个能看的甘特图给领导汇报,别写代码了,直接用表格软件的条件格式做一版,十分钟搞定,效果也不差。工具选对场景比技术先进重要。
4. 构建部署与样式异常排查
功能跑通只是前半程,真正让很多人翻车的是打包上线那一刻。vue 打包后布局异常是搜索量特别高的一个问题,而甘特图恰好是最容易中招的组件类型,因为它依赖大量绝对定位、固定像素宽度和滚动容器。
4.1 打包后错位、抖动、白屏的排查顺序
我遇到过三次不同原因的布局异常,整理成排查顺序,从最常见往下找:
| 现象 | 大概率原因 | 排查方法 |
|---|---|---|
| 任务条整体偏移 | 容器宽度测量时机太早 | 用 ResizeObserver 而非 onMounted 一次性读取 |
| 文字和条错位 | 字体加载导致的宽度变化 | 等document.fonts.ready后再测量 |
| 滚动不同步 | 两侧滚动容器高度不一致 | 检查是否有边框、内边距差 1px |
| 打包后白屏 | 资源路径 base 配置不对 | 检查 vite 的base或 webpack 的 publicPath |
| 生产环境条极窄 | pxPerDay 被某些样式覆盖 | 检查是否用了 rem 换算或全局缩放 |
其中最隐蔽的是第一条。开发环境因为热更新和数据量小,onMounted里读到的容器宽度往往是正确的;生产环境首屏渲染快、样式未完全应用,读到的可能是 0 或错误值,于是所有坐标全错。改用ResizeObserver监听容器尺寸变化,并且在宽高为 0 时跳过渲染,这个问题基本就绝迹了。
const ro = new ResizeObserver(([entry]) => { const { width } = entry.contentRect if (width > 0) viewportW.value = width }) onMounted(() => ro.observe(containerRef.value)) onBeforeUnmount(() => ro.disconnect())顺便提醒,如果项目里用了 CSS 缩放或者移动端适配的transform: scale,getBoundingClientRect拿到的是缩放后的值,而clientX也是缩放后的值,两者能对上,但计算出的 pxPerDay 会失真。稳妥做法是记录一个scaleFactor,在换算时统一除一次。
4.2 路由切换与多标签页的状态保留
甘特图通常是详情页里的一个模块,用户点进别的页面再回来,期望是滚动位置和展开状态还在。这里有两件事要做。
一是滚动位置和折叠状态放进 store 而不是组件内部 state,组件卸载时不会丢。二是路由如果开了keep-alive,注意onActivated时重新测量容器尺寸,因为缓存恢复时容器的实际宽高可能和之前不同,尤其是响应式布局下的侧边栏展开收起。如果不加这一步,用户会遇到“切回来图表挤成一团”的问题。
还有一个容易被忽略的点:如果甘特图挂在带参数的动态路由上(比如/project/:id/schedule),切换 id 时组件不会重新挂载,需要在watch里监听路由参数变化,重新拉数据并清空拖拽临时状态。我见过有人在这里忘了清dragState,结果切换项目后残留的预览位移还挂着,显示出一条错位的任务条,排查了半天才定位到。
4.3 大数据量下的性能表现
任务数超过 300 条以后,不做优化的话拖拽会明显掉帧。我的实测数据是:不优化时 500 条任务拖拽在 25fps 上下,优化后稳定 60fps。优化的手段按性价比排序是三件事。
第一件,虚拟滚动。只渲染可视区域加前后各 10 行缓冲,DOM 数量从 500 降到 40 以内,这一步收益最大。
const startRow = computed(() => Math.max(0, Math.floor(scrollTop.value / ROW_H) - BUFFER)) const endRow = computed(() => Math.min(totalRows.value, Math.ceil((scrollTop.value + viewportH.value) / ROW_H) + BUFFER))注意左右两侧必须用同一个滚动源,左侧任务列表和右侧图表共享一个scrollTop,否则虚拟滚动的行号对不上,会出现左树右图错行。我的做法是右侧容器负责滚动,左侧只做transform: translateY(-scrollTop)跟随。
第二件,用 transform 代替 left/top。拖拽时如果每帧改left,会触发重排;改成transform: translate3d只触发重绘和合成,性能差一个量级。
第三件,把依赖线拆成独立 SVG 层并在拖拽期间隐藏。依赖线数量是任务数的好几倍,是纯视觉元素,拖拽过程中隐藏它们用户根本不会注意,但省下的计算量很可观,松手后再统一重算并显示。
5. 常见问题速查与踩坑心得
前面讲的是主干,这一节把散落的坑集中收拢,方便你遇到问题时直接查表。这些都是我在真实项目里被用户和测试同学一遍遍打磨出来的,文档里基本不会写。
5.1 高频问题速查表
| 问题 | 根因 | 处理方式 |
|---|---|---|
| 拖拽后任务跳动一格 | 吸附基准用了时间戳 0 | 改用时间轴起点做吸附基准 |
| 折叠父任务时间不对 | 聚合只算了一层 | 递归聚合所有后代 |
| 依赖线穿过任务文字 | 层级顺序错 | 依赖线层压在任务条层下面 |
| 缩放后条宽为 0 | 未设最小宽度 | 设 MIN_BAR_W 并加虚线提示 |
| 触屏上拖不动 | 用了 mouse 事件 | 换成 Pointer Events |
| 拖拽松手事件丢失 | 没做指针捕获 | setPointerCapture 加 window 双监听 |
| 中文字体下刻度错位 | 字体加载晚于测量 | 等 fonts.ready 后再测量 |
| 任务多时拖拽掉帧 | DOM 未虚拟化 | 虚拟滚动 + transform |
| 切换项目残留位移 | 未清临时状态 | 监听路由参数并重置 dragState |
| 导出的图模糊 | 未做 DPR 缩放 | canvas 按 devicePixelRatio 放大再缩回 |
5.2 几条真金白银换来的经验
经验一,别在拖拽过程中写 store。我第一版是pointermove里直接改 store 里的任务时间,结果每帧都触发整棵树重渲染,500 条任务时卡到没法用。改成拖拽期间只更新本地preview,pointerup时一次提交,帧率立刻正常,而且撤销重做变得特别简单,因为一次拖拽就是一次快照。
经验二,给用户留一个“撤销”。排期这类操作误触成本很高,拖错一天可能导致下游计划全乱。我加了一个简单的历史栈,每次提交前压入快照,Ctrl+Z回退,实现不到五十行,但用户满意度提升非常明显。历史栈别用深拷贝整个列表,只存变更的字段就行。
经验三,时间和进度分开存,别用百分比反推日期。有些实现为了省事,只存开始时间和进度百分比,工期和结束时间是算出来的。这在甘特图里是灾难,因为用户会同时拖结束时间和进度点,两者互相影响,最后数据完全不自洽。正确做法是start、end、progress三个字段独立存储,工期用end - start派生。
经验四,节假日和工作日模式要提前想好。如果你的排期需要跳过周末,那列宽就不再均匀,前面所有“日期乘列宽”的公式都会失效,需要改成预计算每列宽度并做前缀和。这个改动如果做完了主干再回头加,几乎等于重写,所以一开始就要确定要不要工作日模式。我的建议是除非业务明确要求,否则别做,均匀时间轴简单太多。
经验五,给渲染层加一个渲染次数统计。我在开发环境加了个简单的计数器,显示当前帧渲染了多少次 BarLayer,一旦发现数字异常增长,基本就是某处响应式依赖写错了,比如在模板里调用了函数导致每次重渲染都重新计算。这个小工具帮我定位了两次很难查的性能问题。
经验六,测试数据要造得极端一点。我自己造了三组数据:一条跨度三年的任务、前后各一个零工期里程碑夹着一条普通任务、以及两个任务开始时间完全相同。这三组数据几乎覆盖了所有边界情况,每天跑一遍,能挡掉大部分视觉 bug。
6. 后续还可以往哪些方向扩展
基础版本稳定之后,我陆续加了几个扩展,都是基于同一套渲染管线,成本不高但价值不小,列出来给有类似需求的同学参考。
资源泳道与负载视图。把任务按负责人分组后,在同一行内可以顺带画一个每日工时柱状图,直观看到谁哪天超载了。实现上就是多算一个按人按天的工时聚合,然后用一层额外的 canvas 或 div 画柱子。这个功能在排产场景里使用频率很高,因为“排得下”和“排得开”是两回事。
基线对比。保存一版计划作为基线后,在任务条下方用半透明的另一条显示基线的起止范围,两者不一致时用户一眼就能看出偏差。数据上就是在任务对象里多存一个baseline字段,渲染上多画一层矩形,几乎零成本,但汇报场景下非常有用。
与后端接口的协作要点。甘特图的数据量大,建议后端提供“按项目 + 时间范围”拉取的接口,别一次性返回全部任务。更新时用批量提交,把一次会话里的多次拖拽合并成一个PATCH请求,否则拖十次发十个请求,网络和事务都会很难受。时间字段统一用时间戳传输,前后端约定清楚时区基准,这个如果不约定,跨时区团队协作时必然出问题。
打印与 PDF。如果最终要输出纸质排期表,印刷前记得把时间轴调整到能完整放下所有任务的粒度,否则打印出来会被裁掉。打印样式里把滚动容器的高度固定、超出部分隐藏,配合@media print单独写一套,比在线样式直接打印效果好很多。
最后分享一个小技巧:如果你发现拖拽在某些笔记本触控板上不跟手,先别怀疑代码,去看看是不是pointermove触发频率太高导致的。加一个基于requestAnimationFrame的节流,把每帧的处理次数限制为一次,手感会立刻顺滑起来,这个改动只有几行,但效果立竿见影。