ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

基于Vue和ECharts的甘特图组件实践:拖拽式时间调整与性能优化

基于Vue和ECharts的甘特图组件实践:拖拽式时间调整与性能优化 简介甘特图是项目计划管理中常用的可视化工具用于展示任务进度、时间安排与依赖关系。这份资源以甘特图为主题包含一个可用的project工程文件并配套大量Web展示所需的静态素材适合项目经理、开发人员或需要制作项目排期演示页面的学习者使用。压缩包共712个文件大小4.29MB其中gif动图350个、js脚本169个、css样式87个、png图片72个另有html页面、字体文件、project文件、markdown说明等。gif和png主要用于效果展示与图标素材js和css负责页面交互和样式排版字体文件则对应fontawesome等常用图标库。整体来看这既是一套可参考的甘特图前端资源也提供了可直接编辑的project文件方便快速查看或调整计划。目前已有751人学习下载适合用于甘特图原型设计、项目排期演示、教学案例剖析等场景通过替换其中的任务节点与时间数据即可生成自定义的计划视图。1. 项目到底要做什么把需求说清楚再动手甘特图这个东西做项目管理的人都不陌生它本质上就是一张横道图左边是任务清单右边是时间刻度上的进度条一眼能看出每个任务从哪天开始、哪天结束、现在推进到哪一步。我这个项目要做的就是一个基于 Vue 的甘特图组件底层渲染交给 ECharts 的 custom series并且在现有开源方案基础上把“右边进度条对应的时间设置”这个交互做完整。很多人一听“Vue 甘特图”第一反应是找现成插件确实市面上有 dhtmlxGantt、Frappe Gantt、vue-ganttastic 这些库功能都不差。但我这次选 ECharts 自己画不是闲得慌而是有一批实际需求在那边压着任务量大时要有流畅的缩放平移要跟项目里已有的 ECharts 主题保持一致要能自由定制进度条样式关键还要支持直接在条上拖拽修改起止时间。这些需求用通用插件反而会被对方的功能边界卡死自己基于 ECharts 去实现控制力完全不一样。在动手前我先把需求拆成了四条硬约束第一任务数据用数组传入每一项包含任务名、开始时间、结束时间、进度百分比、负责人等字段第二图表要支持按天、按周、按月切换粒度时间轴要能缩放第三进度条右侧要清晰展示任务的开始和结束日期并且允许通过输入框或拖拽直接修改第四性能上至少能流畅渲染 200 个任务不出现拖拽卡顿。有了这四条后面所有技术选型都不会跑偏。这个项目适合谁参考呢一类是在业务系统里做排期、计划、资源管理模块的前端同学另一类是正在调研“ECharts 能不能画甘特图”的开发者。我会把从数据模型、series 配置到交互细节的完整过程写出来你就算没写过 custom series按着这个思路也能落地一套自己的组件。1.1 核心需求拆解左表右条 时间可调我画甘特图时最在意的不是条好看不好看而是“时间能不能改得动”。很多管理场景里计划是拍出来的也是调出来的需求临时插进来工期压缩某项任务整体顺延两天这些变更如果都要回后台改数据那这张图就只是个展示画谈不上“用于项目计划”。所以我把右边进度条的时间设置当成头等需求来处理而不仅仅是把数据渲染出来。具体来说交互分为三种在条上左右拖拽可以整体移动任务改变开始日期和结束日期拖拽条的两端可以调整工期长短条的上方或者侧边提供时间输入框可以直接精确输入日期。拖拽适合粗调输入框适合微调两者缺一不可。ECharts 本身不提供这种交互但它的 custom series 允许我在 renderItem 里拿到像素坐标和数据的映射关系利用自身的事件系统 zr 事件去监听拖拽再把像素坐标反算回时间戳这就形成了闭环。另外还有一个隐藏需求时间刻度的“可读性”。甘特图最怕任务密集时横条挤成一团日期标签互相重叠。ECharts 的 xAxis 支持时间轴类型会自动计算刻度间隔但默认行为不等于最优行为。我需要把 axisLabel 的 formatter 按当前时间粒度动态切换按天显示时输出“03-01”按周显示时输出“第9周”按月显示时输出“2025-03”这样不管缩放级别怎么变标签永远干净。1.2 为什么不用现成插件非要自己写我知道有现成组件用起来更快但我在几个项目里踩过坑之后对“开箱即用”这件事已经比较谨慎。一个典型的场景是插件提供了一套自己的数据模型和渲染机制但你的业务数据来自后端接口字段对不上就得写一堆 adapter插件样式的定制能力有限想跟公司设计稿完全一致得翻源码改主题插件升级了新版本你本地改过的样式可能全部作废。这些问题用 ECharts 自己画基本不存在。ECharts 的 custom series 相当于给了一个画布x 轴是时间y 轴是任务分类每个任务的进度条由你自定义绘制。它内置了缩放、平移、自适应尺寸、主题系统、tooltip、数据区域选择等能力我需要做的只是把“时间区间”翻译成“矩形的位置和宽度”。而且遇到性能问题时可以精确控制哪些数据参与重绘不会像黑盒组件一样无从下手。还有一点是团队协作层面的项目里其他图表已经统一用 ECharts那么甘特图也基于 ECharts意味着主题、语言包、按需引入的体积优化策略都可以复用。这个统一性对中后台系统来说非常重要维护成本会低很多。当然如果只是做一个一次性的简单排期展示那直接用开源的 Frappe Gantt 就够了没必要像我这么折腾。2. 技术选型为什么选 ECharts而不是别的库2.1 常见甘特图方案对比我把调研过的几个方案列出来对比一下方便你判断自己的场景该走哪条路。方案数据定制能力交互复杂度性能表现学习成本适用场景dhtmlxGantt中功能全但扩展受限高自带弹窗和拖拽好成熟商业级高API 复杂企业级完整项目管理Frappe Gantt低样式偏固定中支持拖拽改日期一般任务多会卡低轻量展示型排期vue-ganttastic中基于 Day.js 逻辑直观中拖拽体验尚可中等200 任务有压力中Vue 生态简单项目ECharts custom series高完全可控高需自己写事件逻辑好分层渲染性能稳定中高需要深度定制的中后台系统从我自己的经验看如果任务量在 50 条以内、交互要求也不高Frappe Gantt 完全够用如果预算充足、就要完整项目管理功能dhtmlxGantt 买授权也合理。而我这次属于“任务量大 样式要定制 时间可拖拽 团队技术栈统一”的组合ECharts 是最合适的基底。2.2 ECharts 实现甘特图的原理ECharts 官方直方图、折线图这些内置系列本质上都是“把数据映射到图形元素”的封装。custom series 把这个过程整个交给你你负责写一个 renderItem 函数告诉 ECharts“给定一条数据和它的索引我要在哪个位置画什么图形”。要理解这一点需要用生活里的类比你可以把 custom series 想象成一个裁缝铺ECharts 给你提供了布料画布、尺子坐标轴、缝纫机渲染引擎但一件衣服具体长什么样得由你的设计图决定。在甘特图里我的设计图就是任务的开始时间映射到 x 轴某个像素位置结束时间映射到 x 轴另一个像素位置任务序号映射到 y 轴的分类位置两个像素位置之间的宽度就是进度条的长度。关键 API 是api.coord()和api.value()。api.value(0)拿当前这条数据在数组里定义的值api.coord([value, categoryIndex])把数据坐标转成画布像素坐标。比如一条数据的 value 是[时间戳开始, 时间戳结束, 进度]那我调用api.coord([startTimestamp, categoryIndex])得到条头部的 [x, y]调用api.coord([endTimestamp, categoryIndex])得到条尾部的 [x, y]两点的 x 差值就是条宽。这样就把“时间”变成了“像素”。2.3 Vue ECharts 的数据流设计在 Vue 里使用 ECharts我采用的是“数据单向流动 事件向外抛”的模式。父组件传进来一个tasks数组组件内部用 computed 派生 ECharts 需要的 option并且只做一次引用级别的变更检测。当用户拖拽修改了时间组件不直接改父级数据而是通过emit(change, newTasks)通知外部更新。这个设计有一个很大的好处甘特图组件变成了一个受控组件它的渲染结果始终由 props 决定交互行为只负责发出“请求变更”的信号。这样项目中如果接入了 vuex 或 pinia状态逻辑都在全局 store 里甘特图只是 UI 层的呈现调试时回溯数据变更非常方便。如果你直接把图表内部状态和外部 store 混在一起后面每次数据同步都会踩坑。组件结构我分了三层外层是容器负责监听 resize创建和销毁 ECharts 实例中间层是图表配置模块负责把 tasks 转成 option里层是交互模块负责监听 zr 事件、计算拖拽目标、触发数据变更。这三层各干各的代码维护起来非常清爽。3. 核心实现从数据定义到渲染的完整流程3.1 数据模型设计所有图表问题最后都会追溯到数据模型问题。甘特图的数据模型我设计了两种一种是后端接口返回的扁平结构一种是组件内部使用的渲染结构。前端在拿到后端数据后经过一个 mapper 函数转换格式。// 后端接口返回的原始结构 const rawTasks [ { id: 1, title: 需求调研, startDate: 2025-03-01, endDate: 2025-03-05, progress: 100, assignee: 张三 }, { id: 2, title: UI 设计, startDate: 2025-03-06, endDate: 2025-03-10, progress: 40, assignee: 李四 }, { id: 3, title: 前端开发, startDate: 2025-03-11, endDate: 2025-03-20, progress: 0, assignee: 王五 } ] // mapper 转换后的渲染结构 function mapTasksToGantt(tasks) { return tasks.map((task, index) ({ id: task.id, name: task.title, categoryIndex: index, start: new Date(task.startDate).getTime(), end: new Date(task.endDate).getTime(), progress: task.progress, owner: task.assignee })) }日期统一转成时间戳是为了给 ECharts 的时间轴使用。时间轴类型为time时坐标轴的最小单位和刻度间隔都是自动计算的用时间戳做 value 最稳妥。注意这里有个坑new Date(2025-03-01)得到的时间是基于 UTC 的午夜显示时会受浏览器时区影响后面我会专门讲这个问题的处理方式。任务顺序方面我用categoryIndex来表示在哪一行显示而不是直接把任务名放进 y 轴分类。这样做的好处是当任务名很长时y 轴分类的标签可以单独定制换行且任务行的顺序可以按优先级、开始日期等多种逻辑动态排序不用修改后端数据。3.2 图表配置与时间轴处理基础 option 的骨架大概是这样的const option { grid: { left: 160, right: 80, top: 40, bottom: 40 }, xAxis: { type: time, min: formatStartTime(minDate), max: formatEndTime(maxDate), axisLabel: { formatter: (value) formatAxisLabel(value, currentGranularity) } }, yAxis: { type: category, data: categories, // 每行一个分类名比如任务名 axisTick: { show: false }, axisLine: { show: false } }, series: [{ type: custom, renderItem: renderGanttItem, encode: { x: [1, 2], y: 0 }, data: ganttData }] }encode配置是性能关键它告诉 EChartsx 轴和这条数据中的第几个维度的值相关。这样在 tooltip、缩放、数据筛选时ECharts 能快速定位数据的坐标范围不需要每次遍历所有字段。xAxis 的 min 和 max 也需要处理。如果直接用所有任务里的最早和最晚时间甘特图会有一种“贴边”的拥挤感我通常会把范围向外扩一点具体比例是前后各留出总时长的 5%。比如任务跨度是 20 天那 min 就往前挪 1 天max 往后挪 1 天视觉上和操作上都会更舒服。renderItem 函数是整个图表的核心function renderGanttItem(params, api) { // value 的索引0 是分类1 是开始时间戳2 是结束时间戳3 是进度 const categoryIndex api.value(0) const startTime api.value(1) const endTime api.value(2) const progress api.value(3) // 将时间戳和分类索引转换成画布坐标 const startPos api.coord([startTime, categoryIndex]) const endPos api.coord([endTime, categoryIndex]) // 进度条高度 const barHeight 18 // 条宽 结束坐标x - 开始坐标x const barLength endPos[0] - startPos[0] // 底条灰色背景表示整个计划周期 const backgroundRect { type: rect, shape: { x: startPos[0], y: startPos[1] - barHeight / 2, width: barLength, height: barHeight }, style: { fill: rgba(108, 117, 125, 0.15) } } // 进度条按百分比填充宽度 const progressWidth barLength * (progress / 100) const progressRect { type: rect, shape: { x: startPos[0], y: startPos[1] - barHeight / 2, width: progressWidth, height: barHeight }, style: { fill: api.visual(color) } } return { type: group, children: [backgroundRect, progressRect] } }这里有个值得注意的点进度条要跟底条分开画。底条表示任务的完整跨度进度条用更明显的颜色填充前半段这样“计划周期”和“实际进度”的语义一下就看清楚了。进度条只是宽度变化不需要额外创建新的图形类型这也是 custom series 比较优雅的地方。3.3 右边进度条时间设置的两种方式这个项目的重点交互右边进度条的时间设置我做成了双通道拖拽和输入框。拖拽的实现思路是这样的因为 custom series 返回的是一个 group里面包含多个子矩形分别代表底条和进度条ECharts 会为每个元素绑定事件。我先在renderItem里给整个 group 加了一个自定义的属性taskId然后通过监听zr事件拿到event.target再顺着target.__ecTaskId找到是哪个任务、哪个区域被拖拽。监听方式chart.getZr().on(mousedown, onMouseDown) chart.getZr().on(mousemove, onMouseMove) chart.getZr().on(mouseup, onMouseUp) function getTaskIdFromEvent(event) { // 递归向上找带 taskId 的图形元素 let target event.target while (target) { if (target.__taskId) return target.__taskId target target.parent } return null }mousedown 时记录当前任务的 id、鼠标起始位置、任务原始的开始/结束时间戳。mousemove 时把鼠标移动的像素距离通过chart.convertFromPixel({ xAxisIndex: 0 }, [event.offsetX])换算成时间偏移叠加到原始时间上再用setOption更新这条数据。mouseup 时把最终时间通过 emit 抛出外部更新真正的数据源。输入框设置在条的上方或两侧。我用一个浮层的 div打开编辑状态后显示两个日期输入框。选完日期点确认组件内部会校验结束日期不能早于开始日期然后更新渲染同时把新数据抛出。输入框适合精确输入比如从“2025-03-11”改到“2025-03-13”总比拖拽更容易对准。需要特别提醒的是拖拽时要用节流。mousemove 事件触发频率很高如果每次都立刻 setOption 重绘整个图表任务一多就会明显卡顿。我用的是 requestAnimationFrame 节流每次 mousemove 只更新一个 pending 标记在 rAF 回调里统一执行一次重绘。这样视觉上依然是顺滑的但渲染次数大幅减少。4. 实战中的坑与排查技巧4.1 时间格式化与跨天问题甘特图项目里最隐蔽的坑其实是时间。第一个坑是上面提到的 UTC 时区问题new Date(2025-03-01)在浏览器里会按 UTC 解析如果你所在时区是东八区显示出来的日期会变成 03-01 上午 08:00虽然日期没变但如果任务结束时间是 03-05 零点渲染出来的条就会少一天。我的处理方式是写一个统一的时间解析工具函数把YYYY-MM-DD字符串手动拆成年月日再通过new Date(year, month - 1, day)创建本地时间彻底避开 UTC 解析。同时在格式化展示时用 dayjs 库指定 format 为YYYY-MM-DD确保所有显示的日期都不带时间戳后缀。第二个坑是跨天任务的计算。如果任务从 3 月 1 日开始到 3 月 5 日结束视觉上这条应该覆盖 3 月 1 日、2 日、3 日、4 日、5 日整整五天但按时间戳相减得到的毫秒数 / 86400000 是 4 天容易让人误判。甘特图里日期区间一般用“含头含尾”的闭区间所以渲染时我会对结束时间做1天处理让条在视觉上把最后一天也覆盖住。这个问题不解决排期经常会“看起来少一天”。4.2 拖拽联动更新时的性能问题200 个任务的甘特图拖拽时如果整张图重绘帧率会掉到 20fps 以下非常难受。我的优化策略分三个层面。第一层是数据层面拖拽中不修改原始数据数组只临时维护一个 Map 结构存变更任务。setOption 时通过replaceMerge或update的精准指定只更新被拖拽的那一条数据不触发全量 diff。第二层是渲染层面ECharts 的 custom series 支持增量更新只要你的 renderItem 不依赖外部无关变量它内部会做图形复用。第三层是交互层面拖拽期间禁用 tooltip 和 hover 高亮减少额外的重绘开销。三管齐下之后我在本机上测试300 条任务拖拽依然能稳定在 45fps 以上。这里想强调一个经验性能优化永远优先做“减少无效工作”而不是盲目用 web worker 或者改渲染层。4.3 几个容易被忽略的细节有几个细节项目写完回顾时才意识到很重要。第一x 轴时间跨度的格式化。按天查看和按月查看时axisLabel 的 formatter 必须不一样否则标签会重叠到没法看。我的方案是先用当前可视范围内的时间跨度计算出一个参考粒度粒度小于 3 天按天显示3 天到 90 天按周显示更大按月显示。formatter 里再做一次兜底如果两个相邻标签的距离小于 50 像素就自动跳过部分标签。第二最小时间粒度的限制。用户如果把时间轴缩放到“按小时”级别进度条的长度会变得非常细反而不利于拖拽。我在 tooltip 和坐标轴缩放事件里做了 clamp缩放不允许小于 1 天如果用户通过滚轮或选框想缩得更小就自动停住。这个限制不是怕技术做不到而是从产品体验角度项目计划管理根本不需要小时级精度。第三编辑状态下的任务行高亮。当用户点击某个任务条时我会在 renderItem 里给这一行加一个浅色的背景同时把 y 轴标签加粗。这个反馈非常小但清晰告诉用户“你现在在改哪一行”特别是任务密集时没有这个高亮拖拽很容易拖错行。实现上只需要在 option 里维护一个highlightedIdrenderItem 读一下做条件判断就够了。还有一个容易被忽略的点window resize 时必须调用chart.resize()并且要在销毁组件时调用chart.dispose()否则会在切换路由后产生内存泄漏。这个属于 ECharts 的常识但我见过太多人忽略它导致页面越切越卡。组件卸载前把实例清掉再用onBeforeUnmount里关闭已经绑定的 zr 事件监听器一套做完整才算合格。最后再分享一个小技巧进度条的 tooltip 不要只显示百分比把负责人和起止日期也一并带上。项目管理里的甘特图不只是给开发者看的产品、运营乃至管理层都会打开看一份清晰的 tooltip 能省掉大量“这个任务谁负责、什么时候完”的沟通成本。我在 tooltip formatter 里拼了任务名、负责人、计划时间、实际进度四项实测这个细节非常受欢迎。本文还有配套的精品资源点击获取
返回列表