
做项目管理这行的谁没被排期表恶心过。Excel里画几条横杠倒还简单一旦任务上百、依赖关系一多、再碰上需求调整排期表改起来就想砸键盘。前阵子我负责的业务后台需要把整年研发计划做成可视化看板技术栈正好是 Vue3 ECharts需求也很明确一个能展示项目工期的甘特图要能按月/周看节奏要能实时看到每个任务的完成进度最好还能看出谁在负责什么。我翻了一圈现成的甘特图库要么太重、要么样式定制度不够最后决定直接用 ECharts 的 custom series 自己画一个。这篇文章把从数据建模到 renderItem 绘制的完整思路、踩过的坑和可以“抄作业”的代码都整理出来给同样被甘特图折磨的朋友一个参考。先说明一下适用范围。这套方案适合中轻量级、需要跟业务系统深度定制的甘特图场景比如项目看板、研发计划、运营活动排期如果你要的是多人实时拖拽修改工期、自动排程、资源负载计算的完整项目管理工具那还是老老实实用商业级组件或者专门的甘特图框架ECharts 自绘的定位是可视化展示为主交互为辅这点一定要想清楚再开工。1. 方案选型为什么不用现成的甘特图组件1.1 先想清楚甘特图本质上是什么很多人一上来就找现成组件但我觉得第一步应该先把甘特图的本质拆开。甘特图又叫横道图核心就三件事Y 轴放任务列表X 轴放时间刻度中间用横向的矩形条表示每个任务的起止时间。再往后衍生就是任务进度、里程碑、依赖连线、人员分配这些增强信息。想明白这一点你就知道 ECharts 其实天生适合这件事。它就是一个坐标系画布X 轴用 value 轴Y 轴用 category 轴矩形条用 cartesian2d 坐标系里的图形元素来画。ECharts 连饼图、地图、雷达图都能用矩形堆出来画个甘特图更不在话下。1.2 现成库和 ECharts 自绘怎么选我先列个对比表这是当时我选型时候的真实参考。方案优点缺点适用场景dhtmlxGantt功能全拖拽、依赖、工时、关键路径都有授权费用不低样式定制需要时间熟悉企业级项目管理frappe-gantt轻量、上手快自定义能力弱数据格式耦合交互偏简单快速原型、简单展示自研纯 Canvas/SVG自由度最高开发量大坐标换算、交互都要自己搞特殊交互需求ECharts custom series复用团队已有技术栈可画任意图形配置化程度高复杂交互需要自己写逻辑业务可视化看板、中轻量排期我当时选 ECharts 的理由很实际第一后台系统里本来就有好多 ECharts 图表引入成本几乎为零第二业务方要的不只是甘特图还希望跟大屏、数据报表放在同一个可视化体系里保持风格统一第三custom series 允许你在一个 series 里自由返回图形元素这意味着进度条、里程碑、文字标注都可以画在一起不用额外拼接多个图层。1.3 这套方案要避开的坑自绘甘特图不等于什么都自己造轮子有几个前置条件你得清楚。首先是数据量几千个任务以上一次性全量渲染会导致 ECharts 大量图形元素堆积页面会卡顿。我建议超过 500 个任务就得分页或者按时间范围懒加载。其次是交互需求如果你要做拖拽调整工期、点击任务弹出编辑框ECharts 也能做但要挂 zrender 的底层事件代码量会明显增加这和直接用甘特图组件是两回事。最后是坐标系对齐甘特图最忌讳任务条跟时间轴对不齐所以 X 轴千万别用 category 轴放日期字符串数据模型设计阶段就要统一成时间戳这个下章细说。2. 数据模型设计把“年月日”变成坐标点2.1 前端数据结构怎么搭甘特图数据结构的核心是每个任务必须有可计算的开始时间和结束时间不能只是一段“看起来像日期”的文本。我在项目里用的是这样的结构// projectTasks.js export const projectTasks [ { id: 1, name: 需求评审, owner: 张三, start: 2025-03-01, end: 2025-03-05, progress: 100, milestone: false }, { id: 2, name: 原型设计, owner: 李四, start: 2025-03-06, end: 2025-03-14, progress: 60, milestone: false }, { id: 3, name: UI 走查, owner: 王五, start: 2025-03-15, end: 2025-03-15, progress: 0, milestone: true } ];为什么接口层用字符串日期而不是直接用时间戳因为后端返回的 JSON 里人类可读的2025-03-01比1738166400000好排查问题也能降低前后端联调成本。而图表内部运算时再统一转成时间戳转换逻辑收敛到一个小工具函数里不让脏数据散落到组件各处。我这里不引入 dayjs 这类库用原生方法就够避免不必要的依赖。2.2 时间轴刻度计算才是重头戏ECharts 做甘特图最容易翻车的地方就是 X 轴。别想当然地把日期字符串塞给 category 轴那样只能做等分文本根本无法表达真实的时间跨度。正确做法是把 X 轴设成 value 轴值域范围是时间戳然后通过axisLabel.formatter把数字格式化成“3月1日”这种文本。这里有个关键点计算 X 轴的最小值和最大值时要在最早开始时间和最晚结束时间基础上各加一天的缓冲否则任务条会顶到图表边缘视觉上很憋屈。核心转换函数长这样// gantt.utils.js export function parseTime(dateStr) { // 兼容 iOS 下 new Date(2025-03-01) 的解析问题 return new Date(dateStr.replace(/-/g, /)).getTime(); } export function formatAxisDate(ts) { const d new Date(ts); return ${d.getMonth() 1}月${d.getDate()}日; } export function calcTimeExtent(tasks) { const startTsList tasks.map((t) parseTime(t.start)); const endTsList tasks.map((t) parseTime(t.end)); const minTs Math.min(...startTsList); const maxTs Math.max(...endTsList); const ONE_DAY 24 * 60 * 60 * 1000; return { minTs: minTs - ONE_DAY, maxTs: maxTs ONE_DAY }; }你可能会问为什么强调replace(/-/g, /)因为有些浏览器对带横杠的日期字符串解析不友好把2025-03-01当成 UTC 时间处理本地时区一算就差了好几小时最后画出来的矩形条位置全是偏的。这是我被 iOS 系统坑过一次之后养成的习惯现在到了任何项目我都先哼一句“横杠换斜杠解析不会乱”。2.3 Y 轴任务顺序和显示细节Y 轴的任务列表直接来自数组的name字段。这里有一个很容易被忽略的细节category 轴在 ECharts 里默认是数组的第一个元素在最下方越往下 index 越大。如果你希望项目列表像 Excel 一样第一个任务在最上面要么把 Y 轴设成 inverse: true要么在传数据之前把任务数组排序并让 Y 轴数据倒序。我一般用 inverse因为排序会影响后续 series 的 data 索引对应关系能少一层心智负担就少一层。任务名还有个显示问题。任务标题一长Y 轴整列都会被撑宽挤占甘特图右侧的可用宽度。我的处理方式是给 axisLabel 设置固定宽度和省略截断完整名称全部留给 tooltip 展示。这样既保住了可读性也不会牺牲绘图区。3. 用 ECharts custom series 实现甘特图3.1 核心renderItem 里画矩形甘特图最核心的一步就是通过 custom series 的renderItem把数据映射成矩形。你要理解一个概念ECharts 的renderItem不是让你直接写绝对像素坐标而是通过api.coord把业务数据比如“时间戳 任务索引”转换成画布上的[x, y]坐标点再用返回的图形元素描述矩形位置和大小。我直接贴一段关键代码看注释理解{ type: custom, renderItem: (params, api) { // api.value(0) 是任务在 Y 轴上的索引 // api.value(1) 是开始时间戳 // api.value(2) 是结束时间戳 // api.value(3) 是进度0-100 // api.value(4) 是负责人 const categoryIndex api.value(0); const startTS api.value(1); const endTS api.value(2); const progress api.value(3); // 把时间戳和任务索引都换算成画布坐标 const startPoint api.coord([startTS, categoryIndex]); const endPoint api.coord([endTS, categoryIndex]); const barLength Math.max(endPoint[0] - startPoint[0], 2); const barHeight Math.min(24, 320 / Math.max(tasks.length, 1) * 0.6); // 外框任务条底 const outerRect { type: rect, shape: { x: startPoint[0], y: startPoint[1] - barHeight / 2, width: barLength, height: barHeight, r: 4 }, style: { fill: #3B82F6, opacity: 0.2, stroke: #2563EB, lineWidth: 1 } }; // 内层进度条 const progressWidth barLength * (progress / 100); const progressRect progress 0 ? [{ type: rect, shape: { x: startPoint[0], y: startPoint[1] - barHeight / 2, width: progressWidth, height: barHeight, r: 4 }, style: { fill: #2563EB } }] : []; return { type: group, children: [outerRect, ...progressRect] }; }, data: seriesData }这里有个思路调整普通柱状图可以直接给 data 传对象用encode声明维度custom series 里我推荐直接把能算好的值都放进数组让 renderItem 里通过api.value(index)按索引取逻辑更直观。seriesData 的构造结构我是这样定义的const seriesData tasks.map((task, index) [ index, // 0: categoryIndex parseTime(task.start), // 1: startTS parseTime(task.end), // 2: endTS task.progress || 0, // 3: progress task.owner, // 4: owner task.name, // 5: name task.id // 6: id ]);3.2 进度条、里程碑和今日线一起画甘特图只画一个空架子业务方肯定不满意他们最关心的是任务到哪一步了。所以我在同一个 custom series 里直接塞了三个视觉层半透明底条表示任务总工期深色部分表示已完成进度时间跨度为 0 的任务则画成一个圆形或者菱形代表里程碑节点。实现上就是在 renderItem 里做条件判断const isMilestone endTS - startTS 24 * 60 * 60 * 1000; const barHeight Math.min(24, 320 / Math.max(tasks.length, 1) * 0.6); if (isMilestone) { return { type: group, children: [{ type: circle, shape: { cx: startPoint[0], cy: startPoint[1], r: barHeight / 2 }, style: { fill: #F59E0B, stroke: #D97706, lineWidth: 2 } }] }; }“今日线”这个功能特别能提升看板实用性运维和业务方都爱看。实现方式不是画在 renderItem 里而是在 option 里加一条 markLinemarkLine: { silent: true, symbol: none, lineStyle: { color: #EF4444, type: dashed }, label: { formatter: 今天, position: insideEndTop }, data: [{ xAxis: new Date().getTime() }] }这样打开看板一眼就能看出哪些任务延期了比在 Excel 里手动拉一条红线方便多了。实测下来这是一个性价比极高的增强功能。3.3 tooltip 格式化和自适应布局甘特图的 tooltip 也是重点。默认的 tooltip 只会显示一个 value 数组没法用。我用formatter返回 HTML 字符串按需展示任务名、负责人、起止日期、工期天数和进度还能顺手加个颜色小圆点信息密度直接拉满。这里提一个热词里大家都在问的点ECharts tooltip 自动换行。其实 iframe 和普通 HTML 场景的换行逻辑不一样ECharts tooltip 里用数组 join 是最稳的每条信息一个字符串中间自然分行了。不要在字符串里强行写\n因为 tooltip 渲染成 HTML 后\n不一定生效反而容易把排版搞乱。tooltip 代码大致是tooltip: { trigger: item, backgroundColor: rgba(17,24,39,0.92), borderColor: #374151, textStyle: { color: #F9FAFB, fontSize: 12 }, formatter: (params) { const d params.data; const start formatAxisDate(d[1]); const end formatAxisDate(d[2]); const days Math.round((d[2] - d[1]) / 86400000) 1; return [ div stylefont-weight:600;font-size:13px;margin-bottom:4px;${d[5]}/div, div负责人${d[4] || -}/div, div周期${start} ~ ${end}${days}天/div, div进度${d[3]}%/div ].join(); } }自适应布局这块我在组件里监听窗口 resize调用 chart.resize()。如果甘特图下方还要放表格或者其他图表可以只给甘特图容器固定高度宽度让 flex 布局接管ECharts 会自己计算。记得容器初始化时得有个高度不然图表会变成 0px这个坑新手特别容易踩。3.4 完整 Vue 组件代码示例把上面这些片段整合成一个可以直接用的 Vue 组件。我用的是script setup语法Vue3 项目直接粘进去改改数据就能跑template div refganttRef classgantt-container/div /template script setup import { onMounted, onBeforeUnmount, ref, watch } from vue; import * as echarts from echarts; const props defineProps({ tasks: { type: Array, default: () [] } }); const ganttRef ref(null); let chart null; const parseTime (dateStr) new Date(dateStr.replace(/-/g, /)).getTime(); const formatAxisDate (ts) { const d new Date(ts); return ${d.getMonth() 1}月${d.getDate()}日; }; function buildOption(tasks) { const yAxisData tasks.map((t) t.name); const seriesData tasks.map((task, index) [ index, parseTime(task.start), parseTime(task.end), task.progress || 0, task.owner || , task.name, task.id ]); const startTsList tasks.map((t) parseTime(t.start)); const endTsList tasks.map((t) parseTime(t.end)); const minTs Math.min(...startTsList); const maxTs Math.max(...endTsList); const ONE_DAY 24 * 60 * 60 * 1000; const barHeight Math.min(24, Math.floor(320 / Math.max(tasks.length, 1) * 0.6)); return { grid: { left: 10, right: 40, top: 30, bottom: 50, containLabel: true }, tooltip: { trigger: item, formatter: (params) { const d params.data; const start formatAxisDate(d[1]); const end formatAxisDate(d[2]); const days Math.round((d[2] - d[1]) / ONE_DAY); return [ div stylefont-weight:600;font-size:13px;margin-bottom:4px;${d[5]}/div, div负责人${d[4] || -}/div, div周期${start} ~ ${end}${days}天/div, div进度${d[3]}%/div ].join(); } }, xAxis: { type: value, min: minTs - ONE_DAY, max: maxTs ONE_DAY, axisLabel: { formatter: (value) formatAxisDate(value) }, splitLine: { lineStyle: { color: #E5E7EB } } }, yAxis: { type: category, inverse: true, data: yAxisData, axisLabel: { width: 120, overflow: truncate, formatter: (value) (value.length 8 ? value.slice(0, 8) : value) } }, dataZoom: [ { type: inside, xAxisIndex: 0, start: 0, end: 100 }, { type: slider, xAxisIndex: 0, height: 18, bottom: 10 } ], series: [ { type: custom, renderItem: (params, api) { const categoryIndex api.value(0); const startTS api.value(1); const endTS api.value(2); const progress api.value(3); const startPoint api.coord([startTS, categoryIndex]); const endPoint api.coord([endTS, categoryIndex]); const barLength Math.max(endPoint[0] - startPoint[0], 2); const isMilestone endTS - startTS ONE_DAY; if (isMilestone) { return { type: group, children: [{ type: circle, shape: { cx: startPoint[0], cy: startPoint[1], r: barHeight / 2 }, style: { fill: #F59E0B, stroke: #D97706, lineWidth: 2 } }] }; } const progressWidth barLength * (progress / 100); return { type: group, children: [ { type: rect, shape: { x: startPoint[0], y: startPoint[1] - barHeight / 2, width: barLength, height: barHeight, r: 4 }, style: { fill: #3B82F6, opacity: 0.2, stroke: #2563EB, lineWidth: 1 } }, progress 0 { type: rect, shape: { x: startPoint[0], y: startPoint[1] - barHeight / 2, width: progressWidth, height: barHeight, r: 4 }, style: { fill: #2563EB } } ].filter(Boolean) }; }, data: seriesData, markLine: { symbol: none, silent: true, lineStyle: { color: #EF4444, type: dashed }, label: { formatter: 今天, position: insideEndTop }, data: [{ xAxis: new Date().getTime() }] } } ] }; } const handleResize () { chart chart.resize(); }; onMounted(() { chart echarts.init(ganttRef.value); chart.setOption(buildOption(props.tasks)); window.addEventListener(resize, handleResize, { passive: true }); }); onBeforeUnmount(() { window.removeEventListener(resize, handleResize); chart chart.dispose(); chart null; }); watch( () props.tasks, (newTasks) { if (chart Array.isArray(newTasks)) { chart.setOption(buildOption(newTasks), { replaceMerge: [series, yAxis] }); } }, { deep: true } ); /script style scoped .gantt-container { width: 100%; height: 480px; } /style组件里有两个细节值得单独说。watch 里我用了replaceMerge: [series, yAxis]这样新数据来的时候不会残留旧任务条甘特图整体刷新也更干净。另一个是setOption之前先判断 chart 是否存在避免组件未挂载时被父级提前 push 数据导致报错。父组件的用法很简单ProjectGantt :tasksprojectTasks /数据一变甘特图就会自动重绘了。4. 常见问题与排查技巧实录4.1 日期字符串被当成文本X 轴顺序全乱这个坑我见过太多次了。表现是 X 轴上的日期不按时间顺序排列任务条的位置跟预期完全对不上。原因基本都是 X 轴用了type: categoryECharts 把它当文本处理自然不认时间先后。解决办法就是把 X 轴改为type: value并且确保送给坐标轴的时间戳是 number 类型。排查时可以在控制台直接打印 series.data看看是不是有字符串混进了数值序列。我习惯写一个assertNumber工具函数在 buildOption 前校验一遍数据有问题就抛警告省得图表静默出错。4.2 任务条太细或者太宽视觉比例失衡custom series 和普通 bar 不同它不会自动计算 barWidth所以你得根据网格高度和任务数自己算条高。我的经验公式是barHeight min(24, floor(gridHeight / taskCount * 0.6))。任务少时条最多 24px任务多时按网格高度均分留 40% 的呼吸空间否则任务条会挤成一根线。这个方法实测在 10 到 200 个任务范围内都很稳。任务数量超过 200 时就该考虑分页了硬画下去图表会显得像一条密集的斑马线。4.3 数据更新后老任务还挂在图上这个很经典。父组件异步接口返回数据后甘特图里新旧任务叠在一起任务条越画越多。因为 ECharts 默认 setOption 是增量合并之前的 custom series data 可能还在。我在组件里用replaceMerge: [series, yAxis]就是为了强制替换这两个维度。如果你用的是多 series 拼接依赖线、里程碑一定要把所有的 series name 列出来一起 replace别嫌麻烦视觉 bug 比代码啰嗦更难受。4.4 组件销毁后报错或者内存泄漏Vue 路由切换频繁的后台项目如果只是挂载时 init销毁时不 dispose一段时间后页面会明显发卡。正确姿势是在onBeforeUnmount里移除 resize 监听并调用chart.dispose()把 chart 引用置空防止后续异步回调访问到已经被销毁的实例。还有一个细节监听 window resize 时建议加{ passive: true }顺手提升滚动性能。为了让大家排查起来更省事我把这几个高频问题整理成速查表方便直接对照看问题表现可能原因排查与解决日期乱序xAxis 用了 category 轴改成 value 轴确认传入时间戳任务条叠在一起数据更新没清空旧 seriessetOption 使用 replaceMerge任务条高度不协调没有按任务数计算 barHeight用固定公式动态计算切换页面后图表空白容器被销毁但实例还在在 beforeUnmount 中 disposetooltip 不换行字符串里加了 \n用数组 join或返回 HTML 结构容器高度为 0父容器初始化时高度塌陷给图表容器设置显式高度4.5 关于动态加载和前端工程化的补充如果你的甘特图数据量确实很大可以做时间范围懒加载dataZoom 的 onDataZoom 事件里判断当前时间窗口触发接口按月份拉取任务。这样 X 轴始终只渲染视野内的数据能扛上千条。另外网上有说法说pxtorem对 ECharts 没效果这个我在项目中确实遇到过。ECharts 初始化后画布宽高是由容器像素决定的而 pxtorem 会把 rootValue 换算后的结果注入样式图表容器宽度变成 rem 后再取宽高就可能有偏差。解决方案有两个一是给 chart 容器设置固定像素高度二是初始化和 resize 后手动调用chart.resize()。优先推荐第二种配合 ResizeObserver 监听容器尺寸变化比 window resize 更精准。5. 最后分享两个我一直在用的小技巧第一个是给甘特图加“快捷键定位到今天”。业务方经常问“最近的进度在哪”与其让老板眯着眼睛在图上找红线不如在标题栏放一个小按钮点一下就把 dataZoom 的 startValue 设置成今天的时间戳X 轴自动聚焦。这个功能加上去之后看板的使用率明显提高了。第二个是关于多人协作的扩展思路。ECharts 自绘甘特图的交互研究到一定程度后你可以给任务条加上 click 事件点击后弹出一个抽屉展示任务的详细描述、负责人、关联需求链接。这个方案会比嵌一个完整甘特图组件要轻量很多且视觉风格完全可控。我实际做下来整个业务后台只要不是重度排产这套自定义方案完全够用了。我最大的体会是甘特图难点从来不在图表绘制本身而在于你怎么理解业务数据和时间坐标之间的映射关系。数据结构设计对了日期转换统一了剩下的事情就是给 custom series 里不断地加矩形、加圆点、加文字。希望这篇文章能让你少走点弯路拿着上面的组件代码就可以快速落地自己的项目工期看板。