做Vue2后台项目最头疼的莫过于选一个合适的甘特图组件。表格可以选的开源库很多,但真正能把“左侧表格+右侧时间轴”结合得顺手的并不多。我们这边一直用vxe-table系列,所以顺理成章尝试了配套的vxe-gantt。最初的需求很朴素:一个迭代任务行,希望在它后面的一条泳道里同时画上设计、开发、测试三个时间块,而不是通过展开子行变成三行。这个“一行渲染多个子任务”的配置,当时查了不少资料,官方文档例子简单,实际用起来远没想象中直接。这篇文章就把我的配置过程、踩过的坑和最终方案完整写出来,给同样在vue2项目里折腾甘特图的开发者一个参考。
1. 需求拆解:为什么需要“一行渲染多个子任务”
1.1 业务场景:一个任务行里的多个独立时间块
先还原一个真实场景。某产品迭代后台里,一条数据叫“3.2.0版本迭代”,它下面有需求分析、界面设计、前端开发、后端联调、测试验收这几个子任务。常规的甘特图展示方式是把子任务放在父任务的下一层,用户点击展开箭头,一行一行显示。这在子任务只有两三个时还好,但当任务数量多、页面又要同时展示几条主业务线时,左侧表格瞬间被撑出十几行,右侧时间轴密密麻麻,滚动起来非常吃力。
当时的业务方提了个很直观的需求:“能不能就在一条行里,把所有子任务的时间块都画出来,一眼看全整个迭代的时间安排?”这就是“一行渲染多个子任务”的由来。更准确地说,是希望一个资源(或者一个父级事件)对应一个水平泳道,泳道内部横向排列多个并行或串行的时间条,比如设计条从1号到3号,开发条从4号到12号,测试条从13号到15号。这样三块信息都在同一行内呈现,视觉上紧凑,信息密度高,也更符合管理者的阅读习惯。
1.2 与树形展开方式的区别
vxe-gantt本身提供了一个类似树表的父子节点结构,父任务可以展开子任务,子任务在下一行显示。这种模式适合任务层级深、子任务需要独立维护进度的场景。但“一行多任务”完全不同,它并不是层级嵌套,而是把多个时间区间“压缩”到同一个行容器里。你可以把它想象成把原本三行的内容合并成一行,但时间轴上依旧是三个独立的条形。
这两种模式的区别很关键。树形展开时,每个条形对应独立的行数据,vxe-gantt可以直接根据行的开始时间和结束时间去绘制,不需要额外的布局计算。而一行多任务时,当前行数据里没有“一个开始时间”和“一个结束时间”,它需要你把自己的子任务数组交给前端,再由前端在渲染层把每个子任务的时间转换成坐标,然后摆到正确的位置。说白了,从数据模型到渲染方式,都要自己重新组织一遍。
1.3 选型思考:为什么在Vue2仍然坚持用vxe-gantt
当时也评估过其他方案。有些知名甘特图组件确实开箱即用,但商业授权费用不低,而且和已有的vxe-table表格风格很难完全统一。另一些开源库虽然功能强,但严重依赖ES6+新特性或者强制使用某个UI框架,接到Vue2老项目里折腾成本很高。vxe-gantt的优势首先在于和vxe-table同一套设计语言,左侧表格用vxe-table,右侧时间轴用vxe-gantt,视觉一致;其次它对虚拟滚动、大数据渲染支持得不错,在几千行任务下也能保持流畅;最后因为它是普通Vue组件,可以灵活使用插槽和自定义渲染,正好适合“一行多任务”这种不走常规路的需求。
当然,用vxe-gantt不是没有代价。它的文档相对精简,很多进阶用法需要自己看源码或者摸索。但如果你原本就在用vxe-table,学习成本会低很多,因为数据格式、事件模型、插槽用法都带着vxe系列的风格。
2. vxe-gantt核心配置解析:数据、渲染、行高
2.1 基本数据模型与自定义扩展
vxe-gantt的标准数据模型一般包含id、name、startTime、endTime这几个常用字段。组件会读取每一行的开始和结束时间,计算出条形的横向位置和宽度。官方文档里也有父子数据的示例,父行的children数组里放子任务,通过展开折叠来切换。
要做到一行渲染多个子任务,我们不能再依赖固定的startTime和endTime。我采用的方式是保留行的id、name和children字段用于左侧表格展示,但在每一行数据里额外增加一个自定义字段,比如叫bars,这个字段是一个数组,数组里每一项是一个子任务的时间块:
{ "id": 1001, "name": "3.2.0版本迭代", "startTime": "2024-01-01", "endTime": "2024-01-15", "bars": [ { "name": "需求分析", "start": "2024-01-01", "end": "2024-01-03" }, { "name": "界面设计", "start": "2024-01-04", "end": "2024-01-06" }, { "name": "系统开发", "start": "2024-01-07", "end": "2024-01-12" } ] }这样设计的好处是左侧表格可以正常显示行名,右侧时间轴通过自定义插槽读取bars数组,分别绘制多个条形。父级startTime和endTime只是为了在时间轴上给整行一个可点击的区域或者背景框,真正视觉上的多个任务块,全部由bars决定。
2.2 关键配置项与作用范围
在vxe-gantt的组件绑定中,有几个配置项会对后续实现产生直接影响。conf对象里可以配置时间轴的刻度单位、行高、左侧表格宽度、时间轴背景颜色等。比如把刻度单位设为day,让每天一列,同时把行高设得稍高一些,给多个条形留出空间。rowHeight影响左侧表格的行高,一般和右侧甘特图区域的每个泳道高度保持一致。
还有一个很重要的配置是showField,用于指定左侧表格显示哪个字段作为任务名称。默认可能是name,如果我们想展示其他字段,比如title,可以在这里改。
对于矩形条的渲染,vxe-gantt提供了插槽机制来控制。不同版本里的插槽名可能有差异,我用的版本里叫bar,有的版本叫barSlot或者task。使用方式是在组件内部插入一个带有slot="bar"的模板,然后通过作用域插槽拿到当前行的数据。这个插槽允许你完全自定义条形区域的HTML结构,这是我们实现一行多任务的核心入口。
2.3 一行多任务的核心思路
理解了配置项之后,核心思路就很清晰了:不再让vxe-gantt根据单一行数据绘制一个条形,而是借用它的自定义插槽,让插槽里的内容输出多个条形。
具体来说,当vxe-gantt渲染到某一行时,它会为这一行划定一个矩形区域,这个区域在时间轴上的横向范围就是整个时间条。我们可以在这个区域内,通过CSS定位每一个子任务的时间块。每个子任务的时间块用绝对定位的div实现,left根据该子任务的开始时间相对整行开始时间的偏移计算,width根据子任务的持续天数占整行时间范围的比例计算。
比如行范围是1月1日到1月15日,共15天。子任务“需求分析”是1月1日到1月3日,那么它的left占行宽的0%,width占行宽的20%(3除以15)。如果“系统开发”是1月7日到1月12日,那么它的left是6100/15=40%,宽度是5100/15约33.3%。通过这样的换算,把时间量变成像素比例,再配合CSS定位,就能让多个子任务整齐地排在同一行内。
这就是“一行渲染多个子任务”最基本的思路,剩下的事情就是把它落进vxe-gantt的插槽里,并处理一些边界情况。
3. 实操步骤:从安装到“一行多任务”
3.1 安装与引入
vxe-gantt并不是一个独立的重量级框架,它是vxe-table系列生态的一部分。安装时我直接同时安装了vxe-table和vxe-gantt,避免版本不匹配。命令如下:
npm install vxe-table vxe-gantt --save然后在入口文件里引入并注册组件。注意顺序,一般先把vxe-table的样式和组件注册好,再注册甘特图组件。
import Vue from 'vue' import VxeTable from 'vxe-table' import 'vxe-table/lib/style.css' import VxeGantt from 'vxe-gantt' import 'vxe-gantt/lib/style.css' Vue.use(VxeTable) Vue.use(VxeGantt)如果你的项目里用了按需加载,也可以使用import { VxeGantt }之类的按需引入写法。不过甘特图本身不轻,直接全量引入问题也不大。
3.2 基础模板结构
先写一个最简单的模板,确保甘特图能正常显示,再慢慢加入自定义渲染。
<template> <div> <vxe-gantt ref="gantt" height="600" :data="ganttData" :conf="ganttConf" ></vxe-gantt> </div> </template>对应的数据:
export default { data() { return { ganttData: [ { id: 1001, name: '迭代开发', startTime: '2024-01-01', endTime: '2024-01-15' } ], ganttConf: { startDate: '2024-01-01', endDate: '2024-01-31', scale: 'day', rowHeight: 48, cellHeight: 24 } } } }这里的startDate和endDate是甘特图时间轴的全局范围。scale设为day表示按天列。行高设成48像素,是为了后面一行内有空间放下三个子任务条形。
3.3 自定义插槽:一段核心代码
接下来是重头戏。在<vxe-gantt>标签内部增加插槽,然后使用作用域插槽拿到当前行数据,循环渲染bars数组。
<vxe-gantt ref="gantt" height="600" :data="ganttData" :conf="ganttConf" > <template v-slot:bar="{ row, range }"> <div class="bar-multi-wrapper"> <div v-for="(bar, index) in row.bars" :key="bar.name + index" class="bar-multi-item" :style="getBarStyle(bar, range)" :title="bar.name + ':' + bar.start + ' ~ ' + bar.end" > {{ bar.name }} </div> </div> </template> </vxe-gantt>这里的row就是当前行数据,range是vxe-gantt传入的行时间范围对象,通常包含start和end,也就是这一行在时间轴上的起止日期。getBarStyle是我们自定义的计算方法,用来把每个子任务的时间转成百分比定位。
getBarStyle的实现如下:
methods: { getBarStyle(bar, range) { const rangeStart = new Date(range.start).getTime() const rangeEnd = new Date(range.end).getTime() const barStart = new Date(bar.start).getTime() const barEnd = new Date(bar.end).getTime() const totalSpan = rangeEnd - rangeStart const left = (barStart - rangeStart) / totalSpan * 100 const width = (barEnd - barStart) / totalSpan * 100 return { left: left + '%', width: width + '%' } } }这里有个细节需要注意,range.end如果是行结束时间,那么它可能比最后一个子任务结束时间更大,也可能更小。为了保证子任务能完整显示,最好把range设置为这一行时间范围的“最大值”。我一般在构造ganttData时,把行的startTime设置为所有子任务开始时间的最小值,把行的endTime设置为所有子任务结束时间的最大值,这样整行时间范围就覆盖了全部子任务。
3.4 CSS处理:多个条形在行内排列
有了计算出的left和width,还需要给每个子任务条形一个垂直位置,防止它们全部叠在一起。在高度足够的情况下,最简单的方案是垂直堆叠,每个条形占一行内的一部分高度。
.bar-multi-wrapper { position: relative; width: 100%; height: 100%; } .bar-multi-item { position: absolute; top: 4px; height: 14px; line-height: 14px; border-radius: 3px; background: #409eff; color: #fff; font-size: 12px; text-align: center; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }如果只有一条子任务,它固定在最上方。如果有第二条,需要给第二条一个top偏移。这里我没有用第二、第三个CSS类去硬编码,而是根据index动态计算top。比如每个条形高16px,间距4px,那么第index个条形的top就是index * 20 + 4像素。
把getBarStyle调整一下:
getBarStyle(bar, range, index) { // ... 前面的 left 和 width 计算 return { left: left + '%', width: width + '%', top: index * 20 + 4 + 'px' } }在模板里调用时传入index:
<div v-for="(bar, index) in row.bars" :key="bar.name + index" class="bar-multi-item" :style="getBarStyle(bar, range, index)" >这样多个子任务条形会在同一行内从上到下依次排列,每一条都处在正确的时间坐标上。
3.5 时间轴缩放与百分比计算
我在第一次做的时候踩了一个坑:时间轴支持按天、按周、按月缩放,但行的时间范围不会变,导致getBarStyle计算出的百分比是正确的,但条形宽度会随缩放一起变化。本来这是甘特图应该有的表现,但如果某个子任务的开始时间早于整行范围的开始时间,或者结束时间晚于整行结束时间,百分比就会超出100%,条形被裁掉。
解决方案是在数据层面保证整行的时间范围完全包裹所有子任务。如果一个父任务下面有子任务时间跨度超出了父任务预设的起止时间,我会在加载数据时做一次“合并范围”的处理。用代码表达就是:
const min = Math.min(startTime, ...bars.map(b => b.start)) const max = Math.max(endTime, ...bars.map(b => b.end))然后把这个min和max赋给行数据的startTime和endTime。这样无论时间轴怎么缩放,所有子任务条都能在行范围内被正确映射。
4. 踩坑与优化:真实项目的调优记录
4.1 条形重叠与错位问题
一开始我假设同一行的多个子任务不会在时间上重叠,比如上一个子任务结束,下一个才开始。但实际业务里经常出现并行子任务,比如“前端开发”和“后端联调”同时进行,时间重叠。如果仅仅按数组索引垂直堆叠,重叠的时间条会上下排列,虽然能看清,但当并行任务很多时,条形高度有限,显示会很拥挤。
更好的做法是给重叠任务做动态分轨,类似电视节目排期算法。简单来说,按时间排序后,把每个任务尝试放入已有的轨道(track),如果与轨道内最后一个任务时间重叠,就放到下一个轨道。这样同一时间内重叠的任务会占用不同垂直位置,不会互相遮盖。轨道总数会动态影响行高,如果行高不够,需要相应增加。
我在组件里临时用了这个策略。数据结构是二维数组tracks,遍历bars时,找到第一个与当前任务不冲突的轨道放进去。冲突判断就是当前任务的开始时间是否小于该轨道最后一个任务的结束时间。如果所有轨道都冲突,新增一个轨道。最后根据每个任务所属的轨道下标计算top。
这样渲染出来效果很好,重叠任务有了明确的层次。代价是每个bar需要预先计算并保存一个track字段,好在数据量不大,性能上没有压力。
4.2 虚拟滚动与大数据量表现
vxe-gantt对基础条形有虚拟滚动优化,但自定义插槽会破坏一部分优化。当数据量达到几千行,每行又有多个子任务时,渲染的DOM数量会暴增。我在一次压测中,用1000行、每行平均3个子任务的数据测试,首屏加载明显卡顿,滚动时也会有掉帧。
排查后发现主要瓶颈不是vxe-gantt本身,而是我的循环渲染里没有合理使用key,导致每次数据更新都重建大量DOM。优化方式是给每个子任务条增加一个真正稳定的唯一标识,比如bar.id,不要用数组索引。同时把bar插槽内部的模板拆成更小的子组件,利用Vue组件级的更新粒度来减少不必要的渲染。
另外,如果只是需要展示任务里程碑,而不是精细交互,可以不用插槽,而是用vxe-gantt自带的多条形功能。我查过一些版本里可能有multiBar之类的配置,或者通过数据格式支持一个行内多个起始时间字段,但在我的版本里没有直接找到稳定配置,最终还是选用插槽方案。性能问题靠优化DOM结构解决后,滚动流畅度提升明显。
4.3 时间轴缩放联动
vxe-gantt一般会提供时间轴缩放的按钮或事件,比如从“日”切到“周”。组件内部会根据时间范围重新计算刻度,但自定义插槽里的DOM不会自动重算,因为插槽的内容最终是靠我们自己的数据和样式生成的。
我遇到了一个场景:用户把时间轴从“日”缩放到“月”,左侧表格不变,右侧每个格子的宽度变大,但子任务的条形宽度还是按原来的百分比渲染,导致条形的实际像素宽度没有随格子变宽而变宽。原因是我的百分比计算是基于行范围的,而行范围不变,所以百分比不变;但甘特图内部每个格子的像素宽度变了,vxe-gantt并没有强制自定义插槽内的元素使用新的像素基准。
解决办法是监听时间轴变化事件,然后强制刷新当前组件。在vxe-gantt上监听scale-change之类的回调,事件触发后调用this.$refs.gantt.refresh()或者this.$forceUpdate()。同时把缩放后的格子宽度作为变量传入样式计算中,这样条形宽度就能正确缩放。实际操作时,我直接用了一个响应式变量记录当前的dayWidth(每天宽度),缩放时更新这个值,然后getBarStyle里通过barEnd与barStart的天数差乘以dayWidth计算像素宽度,替代百分比计算。这样更直接,也不容易出误差。
4.4 与左侧表格的滚动联动
甘特图项目通常左边是任务表格,右边是时间轴,两边需要同步滚动。vxe-gantt如果单独使用,它自身可以处理横向和纵向滚动。但如果你的左侧表格不是vxe-gantt自带的,而是用vxe-table单独做的,就需要通过事件同步。
我采用的方案是监听右侧时间轴的滚动事件,获取scrollTop,然后赋值给左侧表格的滚动区域。反过来左侧表格滚动时,也把scrollTop同步给甘特图。这样两个区域看起来是一个整体。这里有个细节,同步滚动会触发浏览器原生滚动事件,造成抖动,所以需要加一个防抖,比如在requestAnimationFrame里执行同步操作,避免同步频率过高。
vxe-gantt本身也支持配置左侧表格区域,如果项目里能用它自带的表格,就不需要额外的联动逻辑。但自带的表格列定制能力有限,复杂的操作按钮、状态标签还是要靠外部表格来实现。两套方案各有利弊,我后来选择了自带的表格列定制,因为项目左侧需要展示进度条、优先级标记等,通过自定义列模板也能实现,而且省掉了滚动同步的麻烦。
4.5 浏览器兼容性
Vue2时代的老项目,很多时候还要兼容某些老旧浏览器。自定义条形用了绝对定位和百分比宽度,这些都是CSS2.1就支持的特性,问题不大。主要是Date解析的兼容性,比如把new Date('2024-01-01')这种字符串直接解析,在某些浏览器上会得到Invalid Date。我统一用一个工具函数把日期字符串转成时间戳,使用原生Date.parse,并且在方法内部做了NaN判断,避免因为一条脏数据导致整个甘特图崩溃。
另一个兼容性点是行内overflow: hidden; text-overflow: ellipsis,在老旧浏览器上如果容器宽度没设置好,会不生效。解决办法是给每个自定义条形设置maxWidth: 100%,并加上display: block,确保文字超长时能够省略。
5. 常见问题速查表
我把实际使用中被问得比较多的问题整理成了一张表,方便大家按图索骥。
| 问题现象 | 可能原因 | 处理方法 |
|---|---|---|
| 多个子任务显示到同一行,但位置错乱 | 行开始时间不是所有子任务的最小值 | 用数据预处理合并子任务范围,确保行范围包含所有子任务 |
| 子任务互相重叠,无法区分 | 没有给每个子任务计算垂直轨道 | 按时间排序,使用轨道分配算法为每个子任务分配track,再算top |
| 时间轴缩放后,条形容器不跟随 | 自定义插槽没有重新计算像素宽度 | 监听时间轴变化事件,用当前的dayWidth重新计算条形宽度,并刷新组件 |
| 左侧表格和右侧甘特图滚动不同步 | 两套组件独立滚动 | 监听滚动事件,在requestAnimationFrame中同步scrollTop |
| 大量数据下页面卡顿 | 自定义插槽渲染了过多DOM | 给每个子任务条设置稳定key,拆分子组件,避免全量更新 |
| 某条数据的条形消失 | 开始时间或结束时间解析失败 | 使用统一的日期转换函数,并为非法值设置默认的0%位置或隐藏该条 |
| 文字显示过长,超出条形 | 条形宽度不够,没有省略号 | 增加max-width:100%和text-overflow:ellipsis,并设置overflow:hidden |
| 行高不够,多个条显示不全 | 行高固定,没有随子任务数量动态计算 | 根据每行最大轨道数动态设置行高,至少满足每个条形高度加间距 |
这张表只记录了常见问题。实际上每个项目的数据构成都不一样,遇到奇怪的现象,建议先打开浏览器开发者工具,查看插槽内渲染的元素实际占位,再配合range数据打印判断。自定义插槽的问题多数不是组件bug,而是坐标换算或者样式覆盖的问题。
6. 定制化扩展空间
一行多任务实现之后,还有很多值得扩展的地方。比如给每个子任务条加颜色状态,根据任务完成度、优先级或负责人不同显示不同背景色。我就在每个bar对象里加了一个status字段,渲染时根据status映射到CSS类名。这样甘特图不仅展示时间,还能传递“哪个任务延期”“哪个任务已完工”这类信息。
再比如鼠标悬停提示,原生title属性可以显示概要信息,但样式简陋。如果项目里用了弹层组件,可以把悬停内容换成popover,展示负责人、完成百分比、依赖关系等更多字段。由于是自定义插槽,这些都很容易接入。
还有一个方向是让一行内的子任务支持拖拽调整时间。vxe-gantt自带条形拖拽,但主要面向单条形。要支持多个子任务的拖拽,需要把拖拽事件绑定到每个自定义条上,并在拖拽过程中实时更新对应bar.start和bar.end。这个功能复杂度会上升不少,但业务价值很高。我们在实际项目中做了简化版,只允许拖动整个行范围,不允许拖动单个子任务,因为涉及到的依赖联动和校验逻辑确实比较多。
“一行渲染多个子任务”并不只是画图技巧,还改变了产品交互的逻辑。原来用户需要展开父任务才能看到子任务,现在平铺到一行,反而促使我们把子任务的数据抽出来单独管理。每次操作完成后,合并范围、轨道分配、状态计算可以统一走一套数据整理函数,保证表格区和甘特图区始终一致。
如果你也正在vxe-gantt里做类似配置,我的建议是先在小范围数据上把日期计算和插槽模板跑通,然后再加入轨道分配和缩放联动。不要一上来就直接接真实接口,否则数据里的特殊情况会让你很难分清是组件问题还是数据问题。等本地demo稳定了,再逐步接入真实数据,你会对这个组件的脾性熟悉很多。