一个 vue2 项目里用 vxe-gantt 让一行渲染多个子任务,我是这样配置的
之前做某设备调度平台的时候,遇到一个很具体的需求:页面上每一行代表一台设备,但同一台设备在同一天里可能要执行多个工单任务。vxe-gantt 默认的用法是“一行一个任务”,子任务需要纵向展开成树形结构,很难满足这种“一人/一设备多任务,全部横向排在同一行”的展示方式。我查了很多资料,试了几种方案,最后通过“数组字段 + 甘特列插槽 + 时间轴百分比换算”的方式,实现了单行渲染多个子任务。这篇文章把完整配置、思路和踩过的坑都写出来,给正卡在这个问题上的朋友一个可以直接抄的参考。
我把整个问题拆成四个部分来讲:先说说需求为什么不能用默认配置解决;然后讲数据模型和时间轴换算原理;接着给完整代码和关键配置;最后总结虚拟滚动、缩放切换场景下的避坑清单,以及常见问题速查。如果你是刚开始用 vxe-gantt,建议按顺序读一遍,尤其是时间轴换算那段,理解了它,后面所有配置逻辑都是顺水推舟的事。
1. 需求拆解:为什么默认的“一行一任务”撑不住
1.1 真实业务场景与数据形态
先还原一下业务场景。假设你维护一个设备维修调度系统,表格左侧是设备编号、负责人、所属车间这些常规列,右侧是甘特区域。一个设备一天可能被排了三次维修任务,分别是上午的巡检、下午的保养、傍晚的应急处理。如果用 vxe-gantt 默认能力,你只能把这三次任务作为该设备行的子节点纵向展开,用户要看某一台设备一天干了什么,得先点展开按钮,再在一堆其他设备的子任务里找,体验非常割裂。
还有一种常见场景是人员排期:一行是一个人,这个人同期参与多个项目,每个项目用一条彩色进度条表示,多条进度条并行显示在同一行里。这本质上就是“一行多任务”的诉求。
从数据形态上看,这两种场景都可以抽象成:
{ id: 1, name: '设备A', tasks: [ { id: 11, title: '巡检', start: '2024-07-01 08:00', end: '2024-07-01 10:00' }, { id: 12, title: '保养', start: '2024-07-01 11:00', end: '2024-07-01 14:00' }, { id: 13, title: '应急处理', start: '2024-07-01 15:00', end: '2024-07-01 18:00' } ] }难点不在于数据怎么组织,而在于 vxe-gantt 默认的渲染模型是一个字段渲染一个进度条。你需要在“不破坏原有表格结构”的前提下,让一行数据里的一个数组字段渲染出 N 个进度条。
1.2 两条方案路线对比
我最初想过两种实现路线。
路线一:把同一行的多个子任务拆成多个甘特字段。比如一行最多三个任务,就建task1、task2、task3三个字段,每个字段各渲染一个进度条。这个方案在任务数量固定、且最多只有两三个的时候勉强能用,但缺点很明显:业务里任务数量波动的时候,字段数量根本无法预先定死;而且多个任务在时间上重叠时,你还得在字段之间做纵向偏移,逻辑非常散。
路线二:用一个字段存任务数组,在甘特列的插槽模板里循环渲染。核心思路是,甘特区域本身就是时间轴上的绝对定位区域,只要能把“任务时间”换算成“百分比位置”,就能在同一行画出任意数量的进度条。我当时最终选的就是这条路线。
1.3 为什么我选了数组遍历渲染
原因很简单。第一,它不限制任务数量,几个任务都能渲染,业务扩展性好;第二,任务间是否重叠、要不要避让,都可以在数据层统一算;第三,整个甘特区域依旧只是一个字段的模板,不破坏 vxe-gantt 的列配置结构,后续维护成本低。代价是需要自己写一个时间偏移计算函数,但你写一次之后就会发现,这个函数其实就是甘特图核心原理的本质,理解它反而让你更会用 vxe-gantt。
2. 数据模型与时间轴换算原理
2.1 数据结构设计
用数组方案之后,我对数据模型做了一个约定:每一行代表一个“任务主体”(设备、人员、订单等),行数据中固定有一个tasks数组字段,数组里每个对象代表一条子任务。每个子任务必须有四个核心属性:
id:唯一标识,渲染时作为key使用title:进度条上显示的文字start:任务开始时间,统一用字符串,解析交给库处理,避免 Date 对象在不同浏览器里的时区问题end:任务结束时间
我建议给任务对象预留一个color字段,方便按任务类型区分颜色。数据的来源不管是接口还是本地 mock,最后进入表格前先做一个 normalize,确保四个字段都存在。
2.2 时间轴换算的数学原理
这是这篇内容的关键。vxe-gantt 的甘特区域本质上是一条横向时间轴,从gantt.start到gantt.end,比如 7 月 1 日到 7 月 14 日。你要把一个子任务画在正确位置,需要做两步换算:
第一步,计算任务开始时间相对时间轴起点的偏移比例:
leftPercent = (task.start - timeline.start) / (timeline.end - timeline.start) * 100第二步,计算任务持续时长占时间轴总时长的比例:
widthPercent = (task.end - task.start) / (timeline.end - timeline.start) * 100这里需要注意,减法是毫秒级的时间差。所以我会维护一个全局的时间轴常量:
const TIMELINE = { start: '2024-07-01 00:00:00', end: '2024-07-14 00:00:00' }换算函数保持纯函数,入参是子任务对象,出参是给进度条用的 style 对象:
import dayjs from 'dayjs' function getTaskStyle(task) { const totalMs = dayjs(TIMELINE.end).valueOf() - dayjs(TIMELINE.start).valueOf() const leftMs = dayjs(task.start).valueOf() - dayjs(TIMELINE.start).valueOf() const widthMs = dayjs(task.end).valueOf() - dayjs(task.start).valueOf() return { left: (leftMs / totalMs) * 100 + '%', width: (widthMs / totalMs) * 100 + '%' } }之所以用百分比而不是像素,是因为甘特区域宽度会随容器变化,百分比天然自适应。如果 vxe-gantt 是固定像素宽度(某些业务确实会固定宽度),你也可以按 px 算,公式结构完全一样,只是把总宽度从 100 换成实际像素值。
2.3 区间重叠时的避让策略
当一行里的多个任务在时间上重叠时,直接渲染会叠在一起,后画的会把先画的盖住。要解决这个问题,我写了一个简单的“轨道分配”算法。思路是先把任务按开始时间排序,然后逐个放入轨道(lane),每条轨道里维护最后一个任务的结束时间,如果新的任务和当前轨道最后一个任务不重叠,就放进这条轨道,否则换下一条轨道。
function assignLane(tasks) { if (!tasks || tasks.length === 0) return [] const sorted = tasks .filter(t => dayjs(t.end).valueOf() >= dayjs(t.start).valueOf()) .sort((a, b) => dayjs(a.start).valueOf() - dayjs(b.start).valueOf()) const lanes = [] sorted.forEach(task => { let placed = false for (let i = 0; i < lanes.length; i++) { const last = lanes[i][lanes[i].length - 1] if (dayjs(task.start).valueOf() >= dayjs(last.end).valueOf()) { lanes[i].push(task) task.lane = i placed = true break } } if (!placed) { lanes.push([task]) task.lane = lanes.length - 1 } }) return sorted }这个函数不算最优解,但覆盖了绝大多数排期场景,而且逻辑简单,容易改。如果任务密集到几十条重叠,就需要更复杂的区间图着色算法,但那种场景通常也不适合在一行里展示了。
我一般把这个函数放在数据处理层,也就是在把接口数据丢给表格之前先执行一遍,给每个任务对象打上lane字段,这样模板里渲染时就不用再算,性能更好。
3. 完整配置与核心代码实现
3.1 引入组件与基础配置
环境是 vue2 + webpack,安装依赖之后在入口文件引入 vxe-table 和 vxe-gantt:
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)然后配置 columns。关键点在于,甘特列必须使用专门的 field 指向你的任务数组字段,并且通过type: 'gantt'告诉组件这一列是甘特区:
createColumns() { return [ { type: 'seq', title: '序号', width: 60 }, { field: 'name', title: '设备名称', width: 160 }, { field: 'manager', title: '负责人', width: 120 }, { field: 'tasks', title: '排期进度', type: 'gantt', gantt: { ...TIMELINE, device: 'day', showTooltip: true } } ] }这里有个细节:甘特列建议不设置固定width,让组件根据容器剩余空间自行分配,否则时间轴会被压缩。如果你必须固定宽度,至少给一个minWidth,避免在小屏幕上轴被挤没。
3.2 一行多任务渲染模板
在表格模板中,给甘特列指定插槽,然后在插槽里循环渲染:
<vxe-gantt ref="ganttRef" :data="tableData" :columns="tableColumns"> <template #gantt_task="{ row }"> <div v-for="task in row.tasks" :key="task.id" class="multi-gantt-task" :style="getTaskStyle(task)"> {{ task.title }} </div> </template> </vxe-gantt>插槽名gantt_task是列配置里slots.default指定的名字,我在 columns 里可以显式声明。如果你的 vxe-gantt 版本 API 不同,自检一下你项目里列插槽的命名方式,逻辑是一样的。
样式部分,核心是绝对定位。因为甘特区域本身是 relative 定位的标签,子元素绝对定位之后就能按百分比坐标落在正确位置:
.multi-gantt-task { position: absolute; height: 24px; line-height: 24px; padding: 0 8px; border-radius: 4px; background: #2d8cf0; color: #fff; font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; cursor: pointer; box-sizing: border-box; z-index: 2; }在getTaskStyle里,我把前一步的left、width和top合并到一起返回:
getTaskStyle(task) { const baseStyle = getTaskStyle(task) return { ...baseStyle, top: (task.lane || 0) * 28 + 'px', backgroundColor: task.color || '#2d8cf0' } }top用lane偏移,每个轨道高度 28px,比进度条高度 24px 多 4px,正好留出间距。
3.3 交互与 tooltip 处理
进度条渲染出来后,第一件事是确认点击事件能拿到任务数据。我在模板的进度条标签上绑定 click:
<div v-for="task in row.tasks" :key="task.id" class="multi-gantt-task" :style="getTaskStyle(task)" @click="handleTaskClick(task, row)">事件函数里可以打开抽屉、弹窗或者路由跳转,看业务需求。这里要说一个要点:vxe-gantt 自带的 tooltip 通常只能基于当前行的字段显示,一行多任务时它不一定知道你想展示的是哪条任务。所以我不依赖充值自带 tooltip,而是在进度条上使用原生 title 属性,或者用自定义的悬浮层组件,避免信息错位。
另外,任务标题如果太长,很容易被截断。我习惯在进度条上只显示标题开头几个字,完整信息放到点击后的弹窗里,这样界面干净很多。
3.4 数据刷新与重新渲染
当接口数据回来后,我建议通过ref拿到甘特组件实例,调用实例的刷新方法。我通常这样处理:
fetchData() { this.loading = true getScheduleList().then(res => { this.tableData = res.data.map(item => ({ ...item, tasks: assignLane(item.tasks || []) })) this.$nextTick(() => { this.$refs.ganttRef && this.$refs.ganttRef.updateData() }) }).finally(() => { this.loading = false }) }updateData的具体方法名以你项目所用版本为准,核心作用是通知组件数据已经变化,重新计算时间轴甘特条。如果不调用,有时候虚拟滚动会复用旧的 DOM 快照,导致任务条不更新,这个问题在下一节详细说。
4. 虚拟滚动与缩放场景下的避坑清单
4.1 虚拟滚动下的 DOM 复用与 key 问题
vxe-gantt 和 vxe-table 一样,是虚拟滚动实现,屏幕上只渲染可视区域的行标签,滚动时标签会被复用。这意味着你绝对不能把任务条的位置状态存储在这些标签的私有属性里,否则快速滚动再滚回来,位置会错乱。
解决方案是永远从数据驱动。我把每个任务的left、width、top都在数据层算好,模板里只负责读取。这样无论标签怎么复用,重新渲染时拿到的都是从row.tasks里读出来的新鲜数据,不会串位置。
还有一个容易被忽视的点:v-for的key不要用索引,一定要用任务id。我之前因为偷懒用index当 key,结果在批量更新任务、删除中间某条任务后,后面的任务全部复用错位,排查了很久才发现是 key 的问题。任务对象里的id只要保证在同一行内唯一即可。
4.2 缩放级别切换时的时间换算
vxe-gantt 支持切换device:day、hour、month等。很多人在这个环节出问题,是因为换算函数里写死了TIMELINE,但甘特图组件内部切换缩放时,时间轴的起始和结束范围可能不变,变的只是每个时间单位的像素宽度。
理论上,只要TIMELINE.start和TIMELINE.end始终和组件内gantt.start、gantt.end一致,百分比换算就不会出错,因为比例是相对总时长的,跟单位宽度无关。实际上我遇到过的问题是,组件在切换缩放后会对甘特区域做内部重排,如果此时你还在用旧的totalMs,进度条就会整体偏移。
所以我的做法是:把TIMELINE定义成响应式数据,切换缩放前先同步TIMELINE,再触发换算函数重新计算。监听缩放切换可以通过组件的device-change事件,每次事件触发后重新拉一遍换算结果,保证 render 使用的是最新基准。
4.3 极大数据量下的性能优化
一行多个子任务,如果表格有几百行,每行又有几十个任务,换算函数在模板里高频调用会有明显的卡顿。强烈建议用 computed 先处理一遍,把每条任务的 style 缓存起来:
computed: { tableData() { return this.rawData.map(item => ({ ...item, tasks: (item.tasks || []).map(task => ({ ...task, style: this.getTaskStyle(task) })) })) } }模板里读取task.style.left、task.style.top,不再每次 render 都算一遍。这个优化在数据量小的时候感觉不到,一旦上千条任务,差异非常明显。
还有一个优化点:任务标题尽量在进表格前截断好,不要在渲染时用 filter 方法处理,否则每次 render 都要执行一次。
5. 常见问题与排查经验速查
我在实现“一行多任务”的过程中整理了一个速查表,遇到了先看这张表,能省很多排查时间:
| 现象 | 常见原因 | 排查与解决 |
|---|---|---|
| 任务条整体往左或往右偏移 | 换算函数里的start/end与组件gantt.start/end不一致 | 统一从同一个TIMELINE常量读取,并确认使用时区一致 |
| 快速滚动后任务条位置错乱 | 虚拟滚动 DOM 复用,状态残留在标签上 | 所有样式从数据层读取,模板不写业务计算 |
| 删除某条任务后其余任务错位 | v-for使用 index 作为 key | 改用任务唯一 id 作为 key |
| 任务条重叠、互相遮挡 | 未做轨道避让 | 在数据层调用assignLane给每个任务打 lane |
| 切换缩放级别后进度条错位 | 换算基准未随缩放变化刷新 | 监听 device-change 事件,先同步 TIMELINE 再重算 |
| 甘特区域空白,没渲染出进度条 | 插槽名或列配置里type/field写错 | 先用最简单的单条任务测试,确认列类型是gantt |
| 任务条点击无反应 | 点击事件被其他元素遮挡 | 给任务条加z-index,检查是否有兄弟标签盖住 |
| 数据更新后页面上任务条不刷新 | 更新数据后没通知甘特组件 | 调用组件实例的更新方法,或在$nextTick里强制刷新 |
我印象最深的一次排查是,任务条在初始渲染时位置完美,但页面一刷新就整体平移了几十像素。查到最后发现,是因为甘特区域的滚动容器在初始化时宽度还没计算完,而我的换算用了固定像素。改成百分比之后就彻底解决了。
另外补充一个经验:不要在mounted里直接赋值大数据触发渲染,最好等容器宽度稳定后再加载数据。如果容器宽度是异步算出来的,就搭配$nextTick处理后赋值。
最后分享一点实际的体会
这个“数组字段 + 百分比特换 + 轨道避让”的方案,我在模拟项目X里用了大半年,稳定性和可维护性都远超预期。后来另一套系统需求变成“同一行既显示多人任务,又要显示任务之间的依赖关系”,我给任务对象加了一个dependencies字段,在换算函数里根据依赖箭头额外渲染 SVG 连线,核心结构基本没动,就完成了扩展。所以我觉得,vxe-gantt 这种组件,永远不要把希望全寄托在它自带的能力上,理解时间轴的本质,然后在数据层做文章,才是最高效的玩法。
提示:文中所有组件 API 方法名,以你项目中实际安装的版本为准。版本不同,插槽命名和刷新方法可能有差异,但整体数据模型和换算思路完全通用。