项目排期可视化这块我踩过的坑不算少。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里有当前坐标系的位置和尺寸(coordSys),api则提供了一组工具方法,其中最有用的两个是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 一条任务到底该带哪些字段
数据结构决定了后面代码的复杂度。我把最终定稿的字段整理成了一张表,这套结构在三个项目里复用下来基本没改过。
| 字段名 | 类型 | 说明 | 是否必需 |
|---|---|---|---|
| id | string | 任务唯一标识 | 必需 |
| name | string | 任务名称,显示在条上或条旁 | 必需 |
| owner | string | 负责人,tooltip 显示 | 可选 |
| startTime | number | 计划开始时间戳(毫秒) | 必需 |
| endTime | number | 计划结束时间戳(毫秒) | 必需 |
| progress | number | 完成百分比,0 到 1 之间的小数 | 必需 |
| parentId | string | null | 父任务 id,为空表示顶层任务 | 可选 |
| level | number | 层级深度,用于计算缩进和行号 | 可选 |
| color | string | 自定义条色,不传则按状态取默认色 | 可选 |
| status | string | 状态枚举,用于配色和图标 | 可选 |
| delay | number | 延期天数,负数表示提前 | 可选 |
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 ref="chartRef" class="gantt-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的第二个参数用true(notMerge)是个取舍。用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 style="font-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 和 100,setOption之后再导,导完再还原。
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 之外的格式容忍度极低。解决办法有两个:一是把空格换成 T,"2024-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没有正确配置。没有encode,ECharts 不知道数据的哪几维是时间,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,手动乘到 fontSize |
| tooltip 撑出屏幕外 | 默认 nowrap | extraCssText 里加换行三件套 |
| 折叠后横条整体错位 | 复用了旧的行号索引 | 过滤后重新遍历生成 rowIndex |
| 导出图片模糊 | pixelRatio 默认是 1 | 导出时设为 2 |
| 数据量大了卡顿 | 动画 + 图形元素过多 | 关动画、按量隐藏标签、开渐进渲染 |
| 打包后布局异常 | 压缩后初始化时机变化 | 在 nextTick 里 init,并延迟一次 resize |
提示:
nextTick加延迟resize这个组合,在打包后的生产环境里能解决大部分"本地正常、线上错位"的问题。压缩后组件挂载顺序和本地开发模式有差异,容器的实际尺寸可能晚于onMounted一帧才确定。
5. 关于这套方案还能怎么往下走
我在两个项目里都用同一套模板,现在做新项目基本是复制组件文件改改配置就能跑。代码写下来之后,最有价值的其实是那套数据约定——rowIndex稳定生成、时间戳统一格式、层级信息独立存放,这几条一旦定下来,后面加任何功能都不会推翻前面的设计。最后再分享一个挺省事的小技巧:把整个 option 的构建逻辑抽成一个纯函数,输入是任务数组,输出是 option 对象,不依赖 Vue 的响应式系统,这样可以直接在 Node 环境里跑单元测试,断言某个任务在某个时间点的像素坐标是不是预期值。甘特图这种几何计算密集的组件,有测试兜底之后再改样式,心里踏实得多。