资讯动态

Vue+ECharts 自定义系列实现项目排期甘特图

发布时间:2026/9/30 4:23:20 来源:尧图企业网站定制
项目排期可视化这块我踩过的坑不算少。Vue、ECharts、甘特图这三个词摆在一起搜索引擎里能翻出一堆 demo但真能塞进生产环境的没几个。前阵子给研发管理平台做迭代排期页产品需求一口气砸过来任务条要能跟着时间轴横向滚动、每条要显示完成百分比、要有今天的竖线、父子任务能折叠、鼠标悬停要看得到负责人和延期天数。我第一反应是找个现成的甘特图组件试了两三个之后又回到 ECharts用自定义系列custom series配合 Vue 的组件封装把整个甘特图自己画了一遍。这套方案的核心价值在于把绘制权完全握在自己手里。项目排期这东西每家公司的数据结构、权限规则、交互习惯都不一样通用组件改到后期改的代码量比自己写还多。适合已经有一定 Vue 基础、做过中后台管理系统的前端同学参考也适合正在评估买库还是自研的技术负责人做决策依据。下面我把选型逻辑、坐标系设计、renderItem 的关键算法、完整的 option 配置以及调试时踩过的几个坑一条一条摊开讲。1. 甘特图需求落到 ECharts 自定义系列上的来龙去脉1.1 排期页面真正要表达的信息是什么很多人一提甘特图就想到横条其实横条只是表象。排期页真正要回答的是几个业务问题这个任务从哪天开始、哪天结束、现在做到什么程度、它卡在谁手里、它跟前后任务是什么关系。横条只是这些问题的可视化载体。想清楚这一点后面的数据结构设计和图形渲染方式自然就定了。我接手的那套系统里一条任务至少带这些属性任务编号、任务名、负责人、计划开始时间、计划结束时间、实际开始时间、实际结束时间、完成百分比、前置任务编号、层级关系属于哪个父任务。这些字段里只有计划开始计划结束是画横条必需的剩下的是渲染进度条、连线、tooltip 和折叠用的。注意如果只按开始时间持续天数建模后期做跨时区、跨夏令时的处理会非常痛苦。统一存时间戳或标准日期格式天数只在展示层算。为什么强调这点因为我在第一个版本里图省事后端返回的是开始日期 工期天数前端用new Date(start).getTime() days * 86400000算结束时间。看着没问题但一遇到跨月的项目产品要求工期按工作日算跳过周末整个模型就崩了。后来改成后端直接下发起止时间戳前端只负责画逻辑一下子干净了。1.2 现成甘特图库为什么被我放弃市面上开源的甘特图方案大致分三类纯 DOM 表格模拟的、基于 Canvas 自己实现的、以及 ECharts/Chart.js 这类图表库的自定义扩展。DOM 方案的优势是交互好写单元格里塞什么都行劣势是数据量一上去几百行任务加上几十列时间格DOM 节点轻松破万滚动直接卡成幻灯片。Canvas 方案性能好但定制成本高改个 tooltip 样式都要翻源码。而 ECharts 处在中间它已经有成熟的坐标系、缩放组件、tooltip 机制、事件系统我只需要写一个renderItem把时间映射成矩形其余全部白嫖。对一个还在快速迭代的中后台项目来说这个性价比是最高的。还有一个很现实的理由项目里已经用 ECharts 画了燃尽图、饼图、柱状图团队对它的配置项和调试方式都熟。再引入一套全新的甘特图库等于在技术栈里多加一个长期维护负担。统一技术栈带来的隐性收益往往比单个功能的实现效率更重要。1.3 custom series 凭什么能把甘特条画出来ECharts 的自定义系列本质上是给你一个回调函数renderItem它会在每次渲染时被调用参数里带着两样关键东西params和api。params里有当前坐标系的位置和尺寸coordSysapi则提供了一组工具方法其中最有用的两个是api.coord()和api.size()。api.coord([x, y])能把数据坐标转换成屏幕像素坐标。甘特图的 x 轴是时间y 轴是任务行号那么api.coord([任务开始时间戳, 行号])得到的就是这条横条左端的像素位置api.coord([任务结束时间戳, 行号])得到右端位置。两个点一减就是宽度再配合固定高度一个矩形就出来了。api.size([0, 1])返回的是一个类目在 y 方向上占多少像素也就是行高。行高乘以 0.5 到 0.6 就是横条的理想高度留出间隙让视觉不拥挤。这个方法的意义在于图表的行高会随着容器高度、坐标轴配置自动变化硬编码像素值在不同屏幕上会翻车用api.size算出来的高度永远是自适应的。剩下的事情就是把矩形、进度条、文字标签组合成一个 group 返回。这套机制给了你完全的自由度想画圆角条就设r想画里程碑菱形就返回 polygon想在条上叠图标就再塞一个 image 元素。2. 数据模型与坐标系甘特图的地基怎么打2.1 一条任务到底该带哪些字段数据结构决定了后面代码的复杂度。我把最终定稿的字段整理成了一张表这套结构在三个项目里复用下来基本没改过。字段名类型说明是否必需idstring任务唯一标识必需namestring任务名称显示在条上或条旁必需ownerstring负责人tooltip 显示可选startTimenumber计划开始时间戳毫秒必需endTimenumber计划结束时间戳毫秒必需progressnumber完成百分比0 到 1 之间的小数必需parentIdstring | null父任务 id为空表示顶层任务可选levelnumber层级深度用于计算缩进和行号可选colorstring自定义条色不传则按状态取默认色可选statusstring状态枚举用于配色和图标可选delaynumber延期天数负数表示提前可选progress用 0 到 1 的小数而不是 0 到 100 的整数是为了后面算宽度时少一次除法。看着是个小事但在renderItem这种每帧都要跑的代码里能省一次运算就省一次。level字段值得单独说一句。甘特图的 y 轴用的是类目轴每个类目对应一行但父任务和子任务在视觉上应该有缩进。我最初的方案是层级信息塞进name字符串里前面拼几个空格。这个做法在 tooltip 和导出的 CSV 里会原形毕露空格全被吃掉或者错位。正确做法是把缩进留给渲染层处理renderItem里根据level决定文字的 x 偏移量数据本身保持干净。2.2 x 轴为什么必须是 time 类型x 轴的类型选择是甘特图能不能用的分水岭。用category类型做时间轴本质上每个刻度代表一天任务横条只能从刻度 N 画到刻度 M看起来也能用但会带来两个致命问题。第一个问题是时间密度不均匀。假设任务 A 从 3 月 1 日到 3 月 3 日任务 B 从 3 月 1 日到 5 月 30 日。在 category 轴上只要刻度数量一样任务 B 的条就是任务 A 的三倍宽吗不是取决于中间有多少个刻度。一旦某个时间段内没有任务那个刻度还是会被占一格宽度视觉比例就失真了。第二个问题是缩放。dataZoom组件在 category 轴上是以刻度个数为单位缩放的用户拖动滑块看到的不是真实的时间跨度。而time类型的轴缩放单位是真实时间缩放到月视图、周视图、日视图都很自然。xAxis: { type: time, position: top, axisLabel: { formatter: (value) { const d new Date(value) return ${d.getMonth() 1}月${d.getDate()}日 }, hideOverlap: true }, splitLine: { show: true, lineStyle: { color: #f0f2f5, type: dashed } }, min: startBoundary, max: endBoundary }hideOverlap: true这个配置很关键。甘特图的时间跨度动辄两三个月如果容器宽度只有 900px标签会重叠成一片黑。让 ECharts 自动隐藏重叠标签比手动算间隔靠谱得多。另外position: top把时间轴放到顶部符合甘特图的阅读习惯——表头在上数据在下。2.3 y 轴用 category 加 reverse 的那些讲究y 轴没有悬念必须是category类型data就是任务名数组。但有个默认行为一定要改ECharts 的类目轴默认是自下往上排的第一条数据画在最底部。甘特图显然是第一条任务在最上方才符合直觉。yAxis: { type: category, data: tasks.map(t t.name), inverse: true, axisLine: { show: false }, axisTick: { show: false }, axisLabel: { show: false }, splitLine: { show: true, lineStyle: { color: #fafafa } } }inverse: true一句话解决顺序问题。同时我把axisLabel关掉了因为任务名我打算画在横条内部或者横条左侧用坐标轴自带的标签不好控制位置和省略规则。行分隔线保留淡淡的一层帮助眼睛横向对齐。这里有个容易忽略的细节yAxis.data的顺序必须和renderItem里用的行号索引严格对应。我习惯在数据处理阶段就生成一个稳定的rowIndex字段所有系列都用它避免因为排序、过滤导致索引错位。过滤折叠的时候尤其要注意必须重新生成索引不能沿用原来的。2.4 时间刻度的格式化与边界处理时间轴的边界我一般不放任 ECharts 自动计算。自动算出来的min和max常常是最早任务的开始时间往前一点点看着别扭。更好的做法是先算出所有任务的最早开始和最晚结束然后向两端各扩展几天让横条不要贴着边缘。const minTs Math.min(...tasks.map(t t.startTime)) const maxTs Math.max(...tasks.map(t t.endTime)) const PAD 3 * 24 * 3600 * 1000 // 两端各留 3 天 const startBoundary minTs - PAD const endBoundary maxTs PAD跨月的项目还要考虑标签的显示策略。3 月 28 日到 4 月 5 日这段如果每天都显示X月X日标签会挤。ECharts 的 time 轴支持按层级格式化但配置略绕。我的土办法是用axisLabel.formatter配合hideOverlap只在每个月的第一天加上月份前缀formatter: (value) { const d new Date(value) const isMonthStart d.getDate() 1 return isMonthStart ? ${d.getMonth() 1}月 : ${d.getDate()} }这样 3 月 1 日显示3月之后显示日期数字4 月 1 日又变成4月。信息密度和可读性平衡得不错实测在 800px 到 1600px 的容器宽度下都没出过问题。3. 从零手写一个 Vue 甘特图组件3.1 依赖安装与按需引入的正确姿势npm install echarts --saveECharts 5 之后按需引入是标配。全量引入打包出来 900KB 以上按需之后能压到 300KB 左右。甘特图用到的模块比想象中少// gantt-echarts.js import * as echarts from echarts/core import { CustomChart, LineChart } from echarts/charts import { GridComponent, TooltipComponent, DataZoomComponent, MarkLineComponent, GraphicComponent } from echarts/components import { CanvasRenderer } from echarts/renderers echarts.use([ CustomChart, LineChart, GridComponent, TooltipComponent, DataZoomComponent, MarkLineComponent, GraphicComponent, CanvasRenderer ]) export default echartsCustomChart是必须的DataZoomComponent负责时间轴缩放MarkLineComponent用来画今日线。GraphicComponent我加上是为了后面做图例和浮动按钮暂时用不到也可以先不引。注意按需引入后echarts.graphic.clipRectByRect这类工具方法可能拿不到。如果发现undefined要么把GraphicComponent加上要么自己写一个裁剪函数就十几行。3.2 组件骨架与实例初始化组件的生命周期管理有几个必须盯死的点DOM 还没挂载就初始化会拿到 0 宽度容器尺寸变化不 resize 会糊掉组件卸载不 dispose 会内存泄漏。template div refchartRef classgantt-chart :style{ height: height px }/div /template script setup import { ref, onMounted, onBeforeUnmount, watch, nextTick } from vue import echarts from ./gantt-echarts const props defineProps({ tasks: { type: Array, default: () [] }, height: { type: Number, default: 520 } }) const chartRef ref(null) let chart null let observer null const initChart () { if (!chartRef.value) return chart echarts.init(chartRef.value, null, { renderer: canvas }) render() bindEvents() } const render () { if (!chart) return const option buildOption(props.tasks) // notMerge 设为 true避免数据条数变化时残留图形 chart.setOption(option, true) } onMounted(() { nextTick(() { initChart() observer new ResizeObserver(() { chart chart.resize() }) observer.observe(chartRef.value) }) }) onBeforeUnmount(() { observer observer.disconnect() chart chart.dispose() chart null }) watch(() props.tasks, () render(), { deep: true }) /script用ResizeObserver而不是监听window.resize是因为容器的变化未必来自窗口。侧边栏折叠、标签页切换、弹窗拖拽这些场景容器宽度都会变但窗口尺寸没动。我最早用window.onresize结果侧边栏一收起图表就右边留一条白后来改成ResizeObserver才彻底解决。setOption的第二个参数用truenotMerge是个取舍。用false默认的合并模式性能更好但数据从 20 条变成 5 条时旧的图形会残留。甘特图的数据条数经常变动折叠、筛选我宁可多花一点重绘成本也要保证画面干净。3.3 renderItem把时间坐标换算成像素矩形这是整个方案的核心。我把一条任务拆成三个视觉元素叠在一起底槽灰色背景条、进度条彩色实心条、文字标签。用一个 group 一次性返回而不是拆成三个系列。为什么不拆成多个系列因为拆开后每个系列都会独立触发 tooltip鼠标划过去弹两三层体验很差而且 dataZoom 时多个系列的图形同步也容易错位。用 group 组合所有元素属于同一个数据项tooltip 只触发一次事件也好处理。const renderItem (params, api) { const rowIndex api.value(0) // 行号 const startTs api.value(1) // 开始时间戳 const endTs api.value(2) // 结束时间戳 const progress api.value(3) // 0~1 const name api.value(4) // 任务名 // 坐标系内的起止像素点 const startPoint api.coord([startTs, rowIndex]) const endPoint api.coord([endTs, rowIndex]) // 一行有多高取 55% 作为条高最大不超过 22px const rowHeight api.size([0, 1])[1] const barHeight Math.min(rowHeight * 0.55, 22) const totalWidth Math.max(endPoint[0] - startPoint[0], 2) const barX startPoint[0] const barY startPoint[1] - barHeight / 2 // 裁剪到坐标系范围内防止拖出边界后画到轴外面 const coordSys params.coordSys const clip (rect) { const left Math.max(rect.x, coordSys.x) const right Math.min(rect.x rect.width, coordSys.x coordSys.width) const top Math.max(rect.y, coordSys.y) const bottom Math.min(rect.y rect.height, coordSys.y coordSys.height) if (right left || bottom top) return null return { x: left, y: top, width: right - left, height: bottom - top } } const baseRect clip({ x: barX, y: barY, width: totalWidth, height: barHeight }) if (!baseRect) return null const progressRect clip({ x: barX, y: barY, width: totalWidth * progress, height: barHeight }) const children [ { type: rect, shape: { ...baseRect, r: barHeight / 2 }, style: { fill: #eef0f5 } } ] if (progressRect progressRect.width 1) { children.push({ type: rect, shape: { ...progressRect, r: barHeight / 2 }, style: { fill: api.visual(color) } }) } // 任务名贴在条的右侧超出右边界就不画 const textX barX totalWidth 8 if (textX coordSys.x coordSys.width - 40) { children.push({ type: text, style: { text: name, x: textX, y: startPoint[1], fill: #5a5f6b, fontSize: 12, textAlign: left, textVerticalAlign: middle } }) } return { type: group, children } }几个关键点展开说。api.size([0, 1])[1]返回的是 y 轴上一个类目占用的像素高度。注意传进去的数组第一个值是 0因为我们只关心 y 方向的尺寸x 方向传什么都无所谓。这个值会随着容器高度变化所以行高永远是自适应的。Math.max(endPoint[0] - startPoint[0], 2)这行是防止零宽度的条消失。有些任务计划开始和结束在同一天算出来宽度是 0画出来就看不见了。给个最小宽度 2px至少有个视觉提示。clip函数解决了拖动缩放时的溢出问题。当用户把dataZoom拖到某个任务横条只露出一半的位置如果不裁剪矩形会画到坐标轴外面压在时间标签上非常难看。官方示例里用的是echarts.graphic.clipRectByRect我自己写一份是为了避免按需引入时的依赖问题逻辑完全一样。文字标签的位置策略也要想清楚。我试过三种方案放在条内部居中、放在条左侧、放在条右侧。条内居中在窄条上文字会被压扁左侧会跟时间轴冲突最终选的是右侧紧跟并且在超出右边界时直接不画避免文字被切断。如果你们的产品坚持要显示全部任务名那就把名字放到固定宽度的左列用独立的 DOM 层实现不要硬塞进 canvas。3.4 complete option 配置逐项拆解把上面的 renderItem 挂上去完整的 option 长这样const buildOption (tasks) { const data tasks.map((t, i) ({ value: [i, t.startTime, t.endTime, t.progress, t.name], itemStyle: { color: t.color || pickColorByStatus(t.status) } })) return { animation: false, grid: { left: 16, right: 32, top: 56, bottom: 72, containLabel: false }, tooltip: { trigger: item, confine: true, extraCssText: max-width:320px;white-space:normal;word-break:break-all;, formatter: (p) { const t tasks[p.dataIndex] if (!t) return const days Math.round((t.endTime - t.startTime) / 86400000) return div stylefont-weight:600;margin-bottom:6px;${t.name}/div div负责人${t.owner || 未指派}/div div周期${fmt(t.startTime)} ~ ${fmt(t.endTime)}${days} 天/div div进度${Math.round(t.progress * 100)}%/div } }, xAxis: { /* 见 2.2 */ }, yAxis: { /* 见 2.3 */ }, dataZoom: [ { type: slider, xAxisIndex: 0, filterMode: weakFilter, height: 24, bottom: 20, borderColor: transparent, backgroundColor: #f7f8fa, fillerColor: rgba(64,128,255,0.12), handleStyle: { color: #4080ff }, labelFormatter: (v) fmt(v, M/D) }, { type: inside, xAxisIndex: 0, filterMode: weakFilter } ], series: [ { type: custom, renderItem, encode: { x: [1, 2], y: 0 }, data, clip: true } ] } }filterMode: weakFilter是个关键配置。默认的filter模式下只有完全落在可视窗口内的数据项才会被渲染任务条一被切掉一半就整个消失。weakFilter会保留部分可见的数据项横条露出一半也能正常画出来。做时间轴缩放的功能这个配置几乎是必须的。encode: { x: [1, 2], y: 0 }告诉 ECharts 数据数组里第 2、3 个值映射到 x 轴第 1 个值映射到 y 轴。这个映射直接决定dataZoom的缩放范围计算也决定api.coord能不能正确换算。漏了它dataZoom拖起来会非常诡异。clip: true让 ECharts 在坐标系外层做一次裁剪配合我在 renderItem 里写的 clip 函数形成双重保险。3.5 今日线、tooltip 换行与点击交互今日线我用 markLine 挂在自定义系列上比单独画一个系列省事const todayLine { silent: true, symbol: none, lineStyle: { color: #ff4d4f, width: 1.5, type: solid }, label: { show: true, position: insideEndTop, formatter: 今天, color: #ff4d4f, fontSize: 11 }, data: [{ xAxis: Date.now() }] }把它塞进 series 的markLine字段即可。实测下来在 custom series 上是能正常渲染的。如果某些版本上不生效退路是加一个data: []的 line 系列专门挂 markLine多花几十字节的配置成本。Date.now()每次都取当前时间会导致图表每秒都可能重绘。如果页面是长时间挂着的看板建议把今天的起始时间戳在组件初始化时算一次缓存起来。tooltip 的换行问题我被问了太多次。ECharts 的 tooltip 默认是white-space: nowrap内容一长就横着拉出去半个屏幕甚至超出浏览器窗口。解决方案是在extraCssText里覆盖掉extraCssText: max-width:320px;white-space:normal;word-break:break-all;三个属性缺一不可。max-width限制最大宽度white-space: normal允许换行word-break: break-all保证长英文单词和连续数字比如任务编号也能断行。再加上confine: true让 tooltip 始终待在图表容器内鼠标划到边缘也不会跑丢。点击交互const bindEvents () { chart.on(click, (params) { if (params.componentType ! series) return const task props.tasks[params.dataIndex] if (!task) return emit(task-click, task) }) }注意params.dataIndex对应的是当前渲染数据数组的索引。如果做了折叠过滤这个索引对应的是过滤后的数组不是原始数组。我的做法是在数据处理阶段就把原始索引存进任务的_rawIndex字段事件里用data[params.dataIndex]._rawIndex反查避免索引错位这种阴间 bug。3.6 父子任务折叠与视图导出折叠的本质是数据过滤不是图形隐藏。点击父任务时把所有parentId等于它的任务从数据里剔除重新生成行号重新setOption。const flattenTasks (list, collapsedSet, level 0) { const result [] list.forEach(item { result.push({ ...item, level }) if (!collapsedSet.has(item.id) item.children?.length) { result.push(...flattenTasks(item.children, collapsedSet, level 1)) } }) return result }关键点是过滤后必须重新遍历一遍生成rowIndex。因为 y 轴的data数组长度变了原来的行号全部失效。很多人在这一步偷懒直接复用旧索引结果折叠之后所有的横条都往上错了一位。加个简单的展开动画也有讲究。animation: false是我在数据量大的时候关掉的如果任务数在 50 条以内可以打开并设置animationDuration: 300折叠时会有个自然的收拢效果。超过 200 条就一定关掉否则每次过滤都要卡半秒。导出用 ECharts 自带的方法就够了const exportImage () { const url chart.getDataURL({ type: png, pixelRatio: 2, backgroundColor: #fff }) const a document.createElement(a) a.href url a.download 排期图_${Date.now()}.png a.click() }pixelRatio: 2是为了导出高清图不然后期放进汇报 PPT 里放大就糊了。注意如果图表有dataZoom缩放导出的只是当前可视区域。要导出完整视图得先把dataZoom的start和end临时设成 0 和 100setOption之后再导导完再还原。4. 踩坑实录这些问题我替你踩完了4.1 日期字符串解析在不同浏览器里不一样后端返回的日期是2024-03-01 09:00:00这种格式。Chrome 里new Date(2024-03-01 09:00:00)能正常解析Safari 里直接返回Invalid Date。这个坑我是在测试同学拿 iPhone 打开管理后台时发现的甘特图整个是空白的控制台一堆 NaN。原因在于 Safari 对 ISO 8601 之外的格式容忍度极低。解决办法有两个一是把空格换成 T2024-03-01T09:00:00二是把短横线换成斜杠2024/03/01 09:00:00。我采用第二种因为它的兼容性最好从 IE 时代一直管用。const parseTime (str) { if (typeof str number) return str if (!str) return 0 return new Date(String(str).replace(/-/g, /)).getTime() }更稳妥的方案是后端直接下发时间戳前端不做任何字符串解析。这个改动需要协调后端如果推不动上面这个parseTime函数请务必加上别在业务代码里裸写new Date()。4.2 pxtorem 方案对 ECharts 完全无效项目用了 postcss-pxtorem 加 flexible 的适配方案CSS 里的 px 都自动转成了 rem页面缩放正常。但 ECharts 的字体死活不跟着变大屏下显得特别小。原因是 ECharts 渲染在 canvas 上canvas 内部的绘制单位是物理像素跟 CSS 的 rem 体系没有任何关系。pxtorem只处理 CSS 文件里的 px 值canvas 里的文字是 JS 算出来再画上去的插件根本碰不到。解决方案是读取根节点的字号算出缩放比例把比例乘到 option 里的fontSize上const getScale () { const base parseFloat( getComputedStyle(document.documentElement).fontSize ) return base / 16 // 以 16px 为设计基准 } const scaledOption (option) { const s getScale() option.xAxis.axisLabel.fontSize Math.round(12 * s) option.tooltip.textStyle { fontSize: Math.round(12 * s) } // series 内部的文字需要在 renderItem 里取同样的 scale return option }注意renderItem里画的文字也要用同一个 scale我在组件里把 scale 存在了一个ref中renderItem通过闭包读取。还要监听根字号变化大屏适配方案一般在窗口 resize 时改根字号所以要在ResizeObserver的回调里顺带重算并setOption。4.3 dataZoom 缩放后图形错位或闪烁我遇到过的现象是拖动缩放条的时候横条的左端对不准任务的实际开始日期。排查下来有三个原因。第一个是encode没有正确配置。没有encodeECharts 不知道数据的哪几维是时间dataZoom的过滤计算就会出错。第二个是xAxis.min和xAxis.max写死了。写死边界后dataZoom的缩放范围会被这两个值钳制拖动时表现得很别扭。如果一定要限制可视范围改用dataZoom.startValue和endValue。第三个是animation没关。数据量大时缩放触发的重绘动画会一帧一帧补间视觉上就是明显的闪烁和滞后。animation: false一关立即顺滑。还有一个隐蔽问题是filterMode。前面提过用weakFilter别用默认值。这个配置不改缩放时部分可见的任务条会整条消失用户会以为数据丢了。4.4 大数据量下的渲染性能我压测过一次单页 1500 条任务没有任何优化的情况下首次渲染耗时 2.3 秒拖动缩放时帧率掉到 12 帧左右。做了三件事之后首屏压到 400 毫秒以内缩放稳定在 50 帧以上。第一件是关掉所有动画包括animation和animationDuration这条收益最大直接从 2.3 秒降到 800 毫秒。第二件是简化图形层级。原本每条任务画了底槽、进度条、文字、状态图标四个元素1500 条就是 6000 个图形元素。我把文字标签在任务数超过 300 条时整体隐藏只保留 tooltip 显示元素数量直接砍到 3000 个。第三件是用progress配置开启渐进渲染series: [{ type: custom, renderItem, data, progressive: 200, progressiveThreshold: 500 }]progressiveThreshold: 500表示数据超过 500 条才开启渐进模式progressive: 200表示每帧渲染 200 个数据项。这样渲染过程会被切成多帧执行页面不会长时间卡死用户感知上是从上往下逐渐出现比白屏两秒好得多。需要提醒的是渐进渲染模式下某些依赖完整数据的方法比如getDataURL导出可能拿不到全部图形。如果项目要导出导出前先把progressive设为 0重绘一次再导。4.5 常见问题速查表把上面这些和高频提问整理成一张表遇到问题可以直接对号入座。问题现象大概率原因处理方式甘特条看不见控制台有 NaN日期字符串解析失败用 replace 把短横线换成斜杠再解析缩放后横条位置对不上encode 未配置或 min/max 写死补 encode边界改用 dataZoom 控制拖动缩放时任务条整条消失filterMode 是默认的 filter改成 weakFilter容器宽度变了图表不变形只监听了 window.resize改用 ResizeObserver大屏下 canvas 文字太小pxtorem 影响不到 canvas读根字号算 scale手动乘到 fontSizetooltip 撑出屏幕外默认 nowrapextraCssText 里加换行三件套折叠后横条整体错位复用了旧的行号索引过滤后重新遍历生成 rowIndex导出图片模糊pixelRatio 默认是 1导出时设为 2数据量大了卡顿动画 图形元素过多关动画、按量隐藏标签、开渐进渲染打包后布局异常压缩后初始化时机变化在 nextTick 里 init并延迟一次 resize提示nextTick加延迟resize这个组合在打包后的生产环境里能解决大部分本地正常、线上错位的问题。压缩后组件挂载顺序和本地开发模式有差异容器的实际尺寸可能晚于onMounted一帧才确定。5. 关于这套方案还能怎么往下走我在两个项目里都用同一套模板现在做新项目基本是复制组件文件改改配置就能跑。代码写下来之后最有价值的其实是那套数据约定——rowIndex稳定生成、时间戳统一格式、层级信息独立存放这几条一旦定下来后面加任何功能都不会推翻前面的设计。最后再分享一个挺省事的小技巧把整个 option 的构建逻辑抽成一个纯函数输入是任务数组输出是 option 对象不依赖 Vue 的响应式系统这样可以直接在 Node 环境里跑单元测试断言某个任务在某个时间点的像素坐标是不是预期值。甘特图这种几何计算密集的组件有测试兜底之后再改样式心里踏实得多。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑