☰
Vue3+TypeScript从零手写甘特图组件:数据模型到拖拽交互全解析
2026/10/1 16:20:51 网站建设 项目流程

前阵子接了个排产系统的需求,需求文档里赫然写着“类甘特图”。我盯着这三个字愣了半天——排产系统的核心界面,说白了就是一张能拖着走、能改日期、能连线的时间任务表。在vue3体系里做这种图表,市面上其实没有特别完美的现成方案:要么收费贵,要么功能冗余,要么样式丑到没法交差。于是我一咬牙,决定用Vue3 + TypeScript + Vite从零手搓一个。这篇文章就是把这个完整过程复盘出来,从数据模型设计、时间轴算法、DOM渲染方案,到拖拽交互、虚拟滚动和常见性能坑,全部拆开讲清楚。如果你也在Vue3项目里被安排做排产图、项目计划图、资源调度图这类时间轴可视化需求,这篇文章能让你少走不少弯路。

1. 为什么我会在Vue3里手搓一个甘特图

刚接到这个需求时,我第一反应是去GitHub翻开源库。甘特图这个领域其实很老,但有意思的是,真正好用的现代前端甘特图库屈指可数。大多数开源项目停留在jQuery时代,或者用Angular/React写的,Vue生态里能打的更少。我把主流方案快速过了一遍,发现一个共性痛点:它们都试图给你一个“完整的甘特图”,但你往往只需要其中30%的功能,剩下70%是在跟它的API和样式搏斗。

类甘特图这个词本身也很关键。它跟传统项目管理里的标准甘特图有区别,更多时候是“看着像甘特图的时间轴图表”。比如排产场景里,横轴是时间,纵轴是产线、工位或订单批次,任务条表示某道工序的占用时段。这种图不需要复杂的WBS层级、关键路径分析,但很需要:任务条能拖拽调整起止时间、能标记完成百分比、能画依赖线、能快速缩放时间粒度。这些需求如果靠改第三方库,改造成本远高于自己写。

真正让我下定决心自研的,是数据驱动和定制自由度。业务方今天要加个“完成率颜色反馈”,明天要加“点击任务条弹出操作面板”,这些需求在自研方案里就是一个组件的事情,但在第三方库里往往要绕很大一圈,甚至要改源码。而且Vue3的Composition API配合响应式数据,做这种定制化图表非常顺手。思路理清楚之后,剩下的就是用代码把骨架填起来了。

不过说句实在话,手搓甘特图的门槛不在渲染,而在数据结构和时间计算。后者如果没设计好,后期每加一个交互功能都会踩坑。这部分我放在下一章细说。

2. 选型对比:第三方库与自研的取舍

2.1 主流的甘特图开源方案速览

为了不让自己“闭门造车”,我把市面上叫得出名字的开源甘特图库都拉出来看了一遍,简单做了个考察记录:

dhtmlxGantt是老牌商业库,功能确实全面,任务层级、关键路径、依赖线、工时日历全都有。但它有两个硬伤:一是商业授权费用不低,二是它的DOM结构和样式体系非常“自我”,想融入现代前端项目的视觉体系要覆盖大量样式。如果你只是随便用用,他们的CDN版和vue封装能用,但深度定制时你会怀疑人生。

Frappe Gantt是轻量级代表,代码简洁,上手快,但功能边界很明显:不支持任务依赖拖拽、不支持多层级任务、不支持虚拟滚动。数据一多,性能就会出问题,样式也比较基础。

gantt-elastic曾经是我最看好的一个,它用TypeScript写的,支持SVG渲染,还能自定义模板。但维护频率一般,总感觉“试验性质”偏重,生产项目里用会有点心虚。

还有一堆个人维护的小库,我就不点名了。它们的共性问题是可以“画”甘特图,但很难“操作”甘特图——交互能力普遍薄弱,日期的边界处理也经常有bug。我花了大半天在demo里摆弄这些库,最终心里有了结论:如果项目里只是“展示”一张静态甘特图,用Frappe这种小库完全够了;但如果要“交互”加“定制”,自己写控制力最强。

2.2 我为什么最终选择自研

自研的决定做出来之后,团队里有人问:这样会不会太慢了?我的回答是:前两周会慢,之后就会越来越快。为什么?因为甘特图的核心引擎就那么几块——时间轴刻度计算、任务条定位、拖拽逻辑、依赖线绘制。这些基础能力一旦沉淀成组件,后面的业务需求都是在上面堆功能。

对比一下:用第三方库,你有60分的底子,但要往90分定制时每一分都要对抗它的设计思路;自己写,虽然起步是0分,但突破60分以后,每加一分都是纯积累。而且vue3的响应式系统天然适合“数据—视图”联动,我定义好任务数组,改某个任务的start或end,视图自动刷新,这种体验用第三方库反而要写很多同步代码。

所以即便自研的代码量一开始会多一些,我还是按“基础设施”的标准来写这个组件。设计目标很明确:数据驱动、可插拔、能虚拟滚动、能拖拽、能自定义任意单元格渲染。最后这套东西在项目里的表现比预期好,代码沉淀下来,后面新项目直接用,省下的时间远远超过当初的投入。

3. 数据模型与日期计算:最容易翻车的部分

3.1 任务数据结构的字段设计

甘特图表面上是图表问题,本质上是数据建模问题。如果你一上来就写渲染组件,后面十有八九会返工。我先把任务的数据结构定清楚。这里我参考了实际排产系统的模型,设计了一个Task类型,用TypeScript写出来:

export interface GanttTask { id: string name: string /** 任务条的开始日期,使用时间戳统一存储 */ start: number /** 任务条的截止日期,注意这里采用“开区间”设计,不包含end当天 */ end: number /** 完成百分比 0-100 */ progress: number /** 自定义数据,业务方需要啥往里塞啥 */ raw?: Record<string, any> /** 价格/权重/优先级等,用于着色 */ color?: string /** 是否显示为里程碑(零时长标记) */ milestone?: boolean /** 依赖任务id列表,画箭头用这组数据 */ dependencies?: string[] }

这里有个细节要说清楚:日期的存储统一用时间戳数字,不要用字符串,也不要存Date对象。为什么?因为在计算横向偏移、比较大小、做加减法时,时间戳是最纯粹的数字,直接做算术就行,不会出现时区、格式化的干扰。显示上要转字符串时,再通过dayjs这类库去格式化。

“end采用排他性设计”这一点也很重要。很多人习惯把end理解为“包含这一天”,但在甘特图里,一个任务从3月1日到3月3日,应该显示成两个格子宽还是三个格子宽?如果end含当天,3月1日到3月3日是三天;如果按开区间,3月1日到3月3日是两天(3月1日、3月2日)。实际业务上任务在3月3日结束,当天就不该占用产能,所以我统一用开区间:[start, end),渲染时宽度 = (end - start) / 天。这样能避免“多出来一天”的经典bug。

3.2 时间轴与定位的计算逻辑

接下来是甘特图最核心的算法:给定日期,求它在时间轴上的x坐标。别觉得简单,这里面的坑比你想象的多。

我把时间轴定义为一条从整体开始日到整体结束日的线段,然后按粒度(日、周、月)切成刻度,每个刻度占固定的像素宽度,比如一天 = 40px。那么任意一个任务条的left坐标就是:

const left = (task.start - timelineStart) / ONE_DAY * dayWidth

这个公式看着简单,但有几个前置条件必须处理到位:

第一,timelineStart必须是某一天的0点。也就是用dayjs的startOf('day')归一化。如果不归一化,数据里带了时分秒,计算出来的left会有几像素的误差,任务条边缘对不齐网格线,特别难看。

第二,跨时区问题。如果项目部署在国外服务器,或者团队分散在不同时区,new Date('2025-03-01')在不同时区解析出来的时间戳可能差8小时。统一打法是用dayjs解析字符串,并且显式指定 UTC 或本地时区,我的习惯是全项目统一“本地时间戳 + dayjs显示”,避免服务器时区干扰计算。

第三,计算任务条宽度时也要注意:

const width = (task.end - task.start) / ONE_DAY * dayWidth

如果你把end当包含当天,那这个宽度公式得写成 (end - start + ONE_DAY),否则每天少算一格。我的做法是后端接口在返回任务时就把end统一成“截止日期后一天的0点”,这样前端完全不用处理“加一天”逻辑,后端反而更贴近业务语义。

时间轴的刻度生成,我封装了一个函数,按天、周、月三档粒度输出刻度数组,每个刻度包含位置坐标和格式化后的label。粒度切换时,只需要重新生成刻度数组,视图自动响应更新。

4. 动手实现:从空项目到能跑起来的甘特图

4.1 初始化项目与整体布局

我用Vite快速初始化了一个Vue3 + TypeScript项目,装了两个必需依赖:dayjs用来处理日期,vitest后面做单测。UI组件库一个都没装,因为甘特图的DOM结构完全自定义,组件库反而碍事。

npm create vite@latest gantt-demo -- --template vue-ts npm install dayjs

布局上用了一个常见的双栏结构:左侧是任务列表,显示任务名称和基础信息;右侧是时间轴区域,包括表头刻度层和任务条层。核心难点在于左右两侧的垂直滚动同步:左侧滚动时右侧要跟着滚,反之亦然。我的做法是给两侧容器都绑定同一个scrollTop,通过一个scroll事件同步到对方。

这个布局和Excel表头冻结非常像,但甘特图还多一层水平滚动——时间轴无限向右延伸,任务条层也随之移动。我的实现是:左侧任务列表固定宽度并设overflow-y: scroll,右侧图表区overflow: auto,表头层position: sticky固定顶部。右侧滚动时,左侧同步scrollTop;左侧滚动时,右侧同步scrollTop。双向绑定各写一句,实测很稳。

4.2 渲染任务条和时间轴

表头刻度层的渲染逻辑是:根据整体时间范围,按当前粒度生成刻度数组。如果是按日粒度,每格显示“MM-DD”格式;按周粒度,每格显示“MM-DD”加“第W周”辅助文案;按月粒度,显示“YYYY-MM”。每个格子宽度 = dayWidth * 天数。

我给出核心的timeGrid计算函数:

function buildTimeGrid(startTs: number, endTs: number, gran: 'day' | 'week' | 'month', dayWidth: number) { const grid: { x: number; label: string; ts: number }[] = [] let cursor = dayjs(startTs).startOf(gran === 'day' ? 'day' : gran === 'week' ? 'week' : 'month') const end = dayjs(endTs) let x = 0 while (cursor.isBefore(end)) { const next = cursor.add(1, gran) grid.push({ x: x * dayWidth * (gran === 'day' ? 1 : gran === 'week' ? 7 : 30), label: cursor.format(gran === 'month' ? 'YYYY-MM' : 'MM-DD'), ts: cursor.valueOf() }) cursor = next x++ } return grid }

注意我这里的month粒度用的是近似值30天计算x位置,严格来说每个月天数不一样,会产生累计误差。更严谨的写法是给dayjs对象存一个monIndex,然后用与起始日的实际差值来定位单元格。上面这段代码是我简化过的,实际项目里建议按月粒度时直接用“当月第几天”来定位,避免跨月时长不同导致的错位。

任务条的渲染,我用的是绝对定位div方案。为什么不用canvas?因为甘特图需要和DOM交互(hover显示tooltip、点击弹窗、拖拽),DOM方案在这些场景下天然顺手,而且数据量在几千条以内时性能完全够。如果用canvas,所有交互都要自己通过坐标换算命中检测,开发成本翻倍,收益却不明显。

<div v-for="task in visibleTasks" :key="task.id" class="gantt-task-bar" :style="{ left: px(task.start), width: px(task.end - task.start), background: task.color || '#4f8cff' }" > <span class="gantt-task-bar-label">{{ task.name }}</span> </div>

px是一个工具函数,把时间戳差值转为像素宽度。这行代码虽然简单,但它是整个图表正确性的根基。我专门为它写了单测:随便挑几个日期,验证跨天、跨月、跨年的计算结果,避免季节类需求(比如“春节前后产能下调”)改坏基础逻辑。

4.3 拖拽改期、进度调整和依赖连线

甘特图不能光看,能拖才是灵魂。我给任务条绑定了mousedown事件,拖拽时通过document上的mousemove和mouseup配合,计算鼠标横向移动了多少像素,再换算成天数,更新任务的start和end。

关键代码如下,注意要同步更新start和end保持时长不变,否则拖拽一次任务就会被拉长或缩短:

function onDragStart(task: GanttTask, e: MouseEvent) { const startX = e.clientX const startTs = task.start const onMove = (ev: MouseEvent) => { const dx = ev.clientX - startX const dDays = Math.round(dx / props.dayWidth) const nextStart = startTs + dDays * ONE_DAY_MS if (nextStart < props.minDate) return task.start = nextStart task.end = task.end + (nextStart - startTs) } const onUp = () => { document.removeEventListener('mousemove', onMove) document.removeEventListener('mouseup', onUp) } document.addEventListener('mousemove', onMove) document.addEventListener('mouseup', onUp) }

这里有个经验:拖拽过程中不要实时保存到后端,而是拖完再统一保存。否则一次拖拽可能触发几十个接口请求,后端会非常感谢你。我一般是在mouseup时把任务变更记录收集起来,批量提交一次。

拖拽还有一个常见的细节:dayWidth小的时候(比如一天只有15px宽),鼠标移动1像素就对应0.06天,任务条会变得非常“敏感”,拖不均匀。这时候应该做吸附处理。我的做法是让移动天数四舍五入到半天或整天,吸附粒度可以通过props灵活配置。

进度调整我用的是任务条内部填充层:任务条底部覆盖一层半透明色块,宽度等于进度百分比。然后给这层填充区域单独加一个“细条”手柄,允许鼠标横向拖动来调整百分比。这个交互和拖拽整体改期不冲突,手柄区域需要设置cursor: ew-resize,给用户明确反馈。

依赖连线是甘特图里视觉上最唬人的部分。我的实现是一个SVG覆盖层,绝对定位在任务条层之上,pointer-events: none避免挡住拖拽。每一条依赖线,从依赖任务的右边缘中点,画一条折线到后续任务的左边缘中点。折线先去终点同y处,再折90度到达任务左侧,形成经典的“L”型箭头。

这里有个画线顺序问题:如果有多个任务互相依赖,直接按数组顺序绘制会让某些线重叠。业界通用的办法是先做拓扑排序,把任务按依赖层级排好序再画线,这样骨架线会被后画的线压在下面,视觉更清晰。如果任务量不大,简单按“依赖者id数组的索引”排序也能用。

5. 性能优化与大数据量场景

5.1 虚拟渲染的思路

甘特图数据量一大,性能问题立刻暴露。几千个任务全部渲染成DOM,哪怕每个任务只有几个元素,浏览器也会卡到飞起。解决方案是虚拟滚动——只渲染可视区域内的任务条。

实现虚拟滚动,核心是知道当前滚动容器的可视高度是多少。我用一个定时器在挂载后测量容器的clientHeight,再配合scrollTop事件,实时计算当前可见的任务范围。任务列表本身按顺序排列,所以可以在数组中用二分法找到第一个大于scrollTop的任务索引,再取可视高度除以行高得到可见数量,截取slice渲染。

const visibleTasks = computed(() => { const startIdx = binarySearch(taskList, scrollTop.value) const count = Math.ceil(viewHeight.value / rowHeight) + 1 return taskList.slice(startIdx, startIdx + count) })

注意虚拟渲染必须给任务条容器设置一个总的高度,让scrollTop能滚动到正确位置。做法是把所有任务占用的总高度设置在外层div的height上,内部绝对定位的可见任务条再相对该容器定位。这样浏览器滚动条长度是基于总高度的,视觉节奏正常。

大数据量下还有一个优化点:不要给每个任务条都绑定独立的事件监听,而是用事件委托。我在图表容器上统一监听click和mousedown,通过event.target的dataset.id找到对应的任务数据。这样即便渲染几千个任务条,事件数量也只有一个,性能差距非常明显。

5.2 响应式数据与渲染优化的几个细节

Vue3的响应式系统在甘特图场景里需要小心。任务数组如果用reactive包住,每一项的改动都会被深层监听,这在任务达到几千条时会产生巨大的代理开销。我的做法是:外层任务数组用ref存储,但内部任务对象不做深响应式;拖拽任务时,直接替换整个数组引用,触发computed重新计算。

const tasks = ref<GanttTask[]>([]) // 拖拽结束后更新某个任务 function updateTask(payload: GanttTask) { tasks.value = tasks.value.map(t => t.id === payload.id ? { ...t, ...payload } : t) }

这样每次只生成一个浅拷贝,性能开销可以忽略,而且永远不会出现深层响应式的递归陷阱。

另一个优化细节是日期格式化。在虚拟渲染里,每一帧都要格式化大量任务的名字、日期字符串,如果直接在模板里写dayjs().format(),每渲染一次就是几十上百次dayjs调用,很拖速度。我通常的做法是:任务数据进来时,在map阶段就预格式化好显示字符串,渲染时直接读字段。比如name显示、开始日期显示、结束日期显示都提前拼好,渲染时只做字符串拼接,性能会好很多。

最后说一个很多教程不会提的冷门优化:给任务条容器开启will-change: transform,让它变成合成层,滚动时能走GPU加速路径。Vue渲染的DOM样式频繁变动,合成层能避免大部分重绘开销。实测极端场景下滚动帧率从40帧提回到60帧,效果显著。

6. 常见问题与排查心得

6.1 经典翻车现场

我来把几个最典型的坑集中列一下,每个都是我实际踩过的:

时间差一天。任务明明写的是3月1日到3月5日,怎么就显示成4格宽度,又恢复成6格?十有八九是end定义问题。排查方法是把任务的时间戳打印出来,直接看date字符串。如果end打印出来是3月5日0点,而start是3月1日0点,那就是“含当天”和“开区间”混用了。这个问题的解法就是统一用开区间,让end=截止日+1天。

时区导致任务条偏移。本地调试一切正常,部署到服务器后所有任务条整体右移了一些像素。这是由于服务器时区不同,dayjs解析Date对象时取的是UTC时间,与本地存在时差。我在项目里用了统一的“时间戳代表本地时刻”约定,后端也返回的是本地时间戳,就彻底消除了这个问题。

拖拽卡顿。拖拽任务条时整个页面都卡,有时候鼠标都跟不上。绝大多数原因是对任务数组做了深度响应式,拖拽时每一帧都触发整个数组的更新。我的解决办法是拖拽过程中直接操作一个深拷贝的任务对象,不进入响应式系统;只在mouseup时一次性赋值回tasks.value。

数据从后端返回时瞎排序。甘特图的任务列表是有顺序的,如果后端没按滚动顺序排序,虚拟滚动就会出现内容错位。我用了二分查找的前提就是数组按滚动顺序排好序,后端返回时如果没经过排序,前端先在mounted里补一次sort,然后就不再动顺序了。

6.2 我的排查心法

遇到复杂问题,我习惯先画一个简化版复现demo,把其他业务逻辑全部剥掉,只看甘特图本身。大部分问题其实都是数据边界条件引发的,比如空数组、只有一条任务、所有任务跨年等。这些边界条件在业务数据里不常见,但恰恰是它们会导致算法异常。

另一个经验是关注格式化函数。甘特图里出现视觉错位,先怀疑时间格式化,再怀疑布局CSS。因为CSS相对简单直接,反而是日期计算里隐藏的“半天”“1毫秒”等问题很难一眼看出来。我写了一个打印函数,可以快速输出时间轴的可视化调试信息:每个刻度的x坐标、label、对应时间戳。排列出来一眼就能看出“哪个刻度错了”。

还有就是依赖线消失的问题。画依赖线时如果发现箭头没了,先检查ID匹配。很多后端返回的依赖字段是字符串,而任务id是数字,或者相反,一套严格的数据约束Service就能避免这类低级错误。我在前端做了一个依赖检查函数,专门找出“指向不存在任务”的依赖id,渲染前过滤掉,避免拖垮画线性能。

从项目立项到上线,这套自研甘特图组件大概花了两周多。中间踩过的坑一个没少,但也正是这些坑让组件越来越顺手。你如果也要做类似的东西,建议直接复制我的数据结构设计和虚拟渲染思路,这会让你省掉一大半的调试时间。剩下的细节问题,无非是兵来将挡、水来土掩,多写几个测试用例,跑通之后就没那么容易坏了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询