1. 为什么选择 Vue2.x 与 ECharts 5.4 来构建甘特图?
如果你正在为一个项目管理系统、任务看板或者资源调度平台发愁,想要一个直观、动态且交互性强的可视化组件来展示时间线,那么用 Vue2.x 配合 ECharts 5.4 来打造一个甘特图,绝对是一个经过实战检验的靠谱选择。我接手过不少这类需求,从简单的任务排期到复杂的多资源并行调度,这套技术栈的灵活性和表现力从来没让我失望过。
首先说说 Vue2.x。尽管 Vue3 已经非常流行,但在很多存量项目或团队技术栈稳定的情况下,Vue2.x 依然是主力。它的响应式系统成熟稳定,组件化开发模式清晰,与 ECharts 这类需要操作 DOM 的库集成起来非常顺手。通过 ref 获取 DOM 节点、在 mounted 生命周期中初始化图表、利用 watch 监听数据变化并动态更新视图,这一套流程在 Vue2 里写起来行云流水,心智负担小。
然后是 ECharts 5.4。这个版本在自定义系列(custom series)和渲染能力上做了很多优化,而这正是我们实现非标准甘特图的关键。你可能觉得 ECharts 的柱状图、折线图用得多,但其实它的 custom 系列就像一个“万能画笔”,允许你完全自定义图形元素的绘制逻辑。我们的甘特图本质上就是在时间(X轴)和任务/资源(Y轴)构成的坐标系里,画一堆长度不一的矩形条,这用 custom 系列来实现再合适不过了。ECharts 5.4 对时间轴(time 类型的 X 轴)的支持也更好了,处理日期时间格式、坐标轴刻度格式化都更加得心应手。
把这两者结合起来,你得到的是一个高度可定制、性能优秀、且能深度融入 Vue 应用数据流的可视化解决方案。你不再需要去寻找一个可能很重、定制性又差的第三方甘特图库,而是自己手握画笔,从坐标轴到每一个矩形条的颜色、提示框,都能完全掌控。接下来,我就带你从零开始,一步步搭建这个动态的甘特图组件。
2. 项目环境搭建与核心依赖安装
万事开头先搭环境。这里假设你已经有一个现成的 Vue2.x 项目(使用 Vue CLI 或类似工具创建)。如果没有,可以快速通过 vue create my-gantt-project 来创建一个。
我们的核心依赖就两个:echarts 和 vue-echarts。不过,根据我的经验,在 Vue2 项目中直接使用 echarts 的完整包,并通过 Vue.prototype 挂载或按需引入,是更灵活可控的方式。当然,你也可以选择 vue-echarts 这个封装好的组件库,但为了深入理解原理和实现更复杂的自定义交互,我更喜欢直接操作 ECharts 实例。
首先,安装 ECharts:
npm install echarts@5.4.0 --save
# 或者使用 yarn
yarn add echarts@5.4.0
这里我们锁定了 5.4.0 版本,以确保 API 的稳定性。
接下来,我们通常会在项目的入口文件(如 main.js)中全局引入 ECharts,并将其挂载到 Vue 原型上,这样在任何组件中都可以通过 this.$echarts 来访问。但请注意,这会增加最终打包的体积。如果你的项目对体积敏感,务必使用按需引入,这需要配合 babel-plugin-equire 等插件,配置稍复杂。为了教程的清晰和上手容易,我们先采用全局引入的方式:
// main.js
import Vue from 'vue'
import App from './App.vue'
import * as echarts from 'echarts'
// 将 echarts 挂载到 Vue 原型上
Vue.prototype.$echarts = echarts
new Vue({
render: h => h(App),
}).$mount('#app')
除了 ECharts,我们可能还需要一个日期处理库,比如 moment.js 或更轻量的 dayjs。原始示例中使用了 this.$moment,这通常是通过类似方式全局挂载的 moment。我们来安装 dayjs,它更小巧:
npm install dayjs --save
然后在需要使用的组件中局部引入即可,或者你也可以仿照 ECharts 的方式全局挂载。
至此,最基本的环境就准备好了。你可能会问,为什么不用 vue-echarts?原因很简单:当我们需要使用 custom 系列进行底层渲染,或者要绑定一些复杂的鼠标事件(比如在图表空白处双击添加任务)时,直接操作 ECharts 实例的 API 会更加直接和强大。vue-echarts 是一个优秀的封装,但对于这种深度定制场景,自己“撸起袖子干”反而更清晰。
3. 甘特图组件结构与数据流设计
在动手写代码之前,我们先花点时间设计一下组件的结构和数据流。一个好的设计能让后续的开发、维护和扩展事半功倍。我们的甘特图组件(比如就叫 GanttChart.vue)应该是一个展示型组件,它接收外部传入的数据,负责将其渲染成图表,并将用户的交互行为(如点击某个任务条)反馈给父组件。
核心 Props 设计: 组件需要接收哪些数据?从原始示例和实际场景出发,至少需要这三样:
baseDate(String):基准日期,例如"2023-10-27"。这决定了 X 轴(时间轴)的基准日。ganttData(Array):甘特图的任务数据。这是一个数组,每个元素代表一个任务条,其内部结构需要包含任务名称、开始时间、结束时间、所属资源(Y轴位置)等信息。数据结构我们稍后详细说。roomData/resourceData(Array):资源列表,也就是 Y 轴的数据。例如['会议室一', '会议室二', '开发团队A', '设计师张三']。这个数组决定了 Y 轴上有哪些类别。
内部状态与生命周期:
组件自身需要管理 ECharts 实例,并在 mounted 钩子中初始化图表。同时,必须使用 watch 深度监听 ganttData 和 roomData 的变化,当数据更新时,调用 ECharts 实例的 setOption 方法更新视图。这是实现动态更新的关键。
事件发射(Emit): 为了实现交互,组件需要响应用户操作并通知父组件。常见的交互包括:
- 点击任务条:查看或编辑任务详情。
- 在图表空白处点击/双击:在特定时间和资源位置创建新任务。
- 图例点击:筛选显示某些资源上的任务。
我们需要在组件内绑定 ECharts 的鼠标事件,并在事件回调中通过
this.$emit('event-name', payload)将相关数据发射出去。父组件监听这些事件,并做出相应的业务处理,比如打开一个编辑对话框。
数据结构示例:
这是整个图表的数据基础,设计得好,后面写渲染逻辑就顺畅。ganttData 的格式需要适配 ECharts custom 系列的 data 项。通常,每个数据项是一个对象,其 value 属性是一个数组,包含了渲染所需的所有信息。
// ganttData 示例
[
{
value: [
{
index: 0, // 对应 roomData 中的索引,决定画在哪一行(哪个资源上)
name: '需求评审会议',
startTime: '2023-10-27 14:00',
endTime: '2023-10-27 15:30',
status: 'ongoing', // 自定义状态,用于决定颜色等
content: '与产品团队评审下一迭代需求'
}
]
},
{
value: [
{
index: 1,
name: '代码开发',
startTime: '2023-10-27 09:00',
endTime: '2023-10-27 12:00',
status: 'completed',
content: '完成用户登录模块开发'
}
]
}
]
roomData 就简单了:['会议室A', '开发团队', '测试团队']。ECharts 的 Y 轴(category 类型)会直接使用这个数组作为刻度标签。
有了清晰的蓝图,我们就可以开始编写组件的骨架代码了。在 GanttChart.vue 的 <script> 部分,我们先定义好 props、data(存放图表实例和配置)、mounted、watch 以及销毁实例的 beforeDestroy 钩子。
4. 核心实现:自定义系列(Custom Series)渲染甘特条
这是整个甘特图的技术核心,也是 ECharts 强大自定义能力的体现。我们不会使用标准的 bar(柱状图)系列,因为甘特图的条状物(Gantt Bar)其长度表示时间区间,位置由时间和资源共同决定,用 custom 系列来手动绘制每个矩形条更加灵活。
ECharts Option 配置骨架:
首先,我们构建一个基础的 option 配置对象。重点是 xAxis 设置为 type: 'time',yAxis 设置为 type: 'category' 并且数据来自 this.roomData。series 里只有一个类型为 'custom' 的系列。
var option = {
backgroundColor: '#fff',
tooltip: { /* 暂后详述 */ },
xAxis: {
type: 'time',
min: `${this.baseDate} 08:00`, // 时间轴起始
max: `${this.baseDate} 18:00`, // 时间轴结束
axisLabel: {
formatter: function (value) {
// 时间格式化,如 '14:00'
const date = new Date(value);
return `${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`;
}
}
},
yAxis: {
type: 'category',
data: this.roomData,
inverse: true // 通常让第一个资源显示在最上方
},
series: [{
type: 'custom',
renderItem: function (params, api) {
// 这里是魔法发生的地方!
},
itemStyle: { /* 颜色等样式 */ },
label: { /* 条状物内部的文字标签 */ },
data: this.ganttData // 绑定我们的数据
}]
};
灵魂函数:renderItem
renderItem 函数是 custom 系列的灵魂。它的作用是为 series.data 中的每一个数据项,返回一个图形元素(如矩形、圆形等)的声明。它接收两个参数:
params:包含了当前数据项的索引 (dataIndexInside)、坐标系信息 (coordSys) 等。api:一系列工具方法,用于获取值、转换坐标等。
在这个函数里,我们要做以下几件事:
- 获取数据值:使用
api.value(0)获取当前数据项value数组里的第一个对象(即我们的任务数据对象)。 - 转换坐标:使用
api.coord([xValue, yValue])方法,将数据值(如时间戳和资源索引)转换为画布上的像素坐标。[startTime, categoryIndex]转换得到矩形左上角的坐标,[endTime, categoryIndex]转换得到矩形右上角的坐标。 - 计算形状:根据转换后的坐标,计算矩形的
x,y,width,height。width就是结束点与开始点的横坐标差。 - 处理边界:使用
echarts.graphic.clipRectByRect方法,确保我们绘制的矩形不会超出坐标系的边界,这是一个很好的实践,能避免渲染异常。 - 返回图形元素:最后返回一个描述矩形的对象,包含
type: 'rect'和计算好的shape。
renderItem: function (params, api) {
var dataItem = api.value(0); // 获取任务数据
var categoryIndex = dataItem.index;
var start = api.coord([new Date(dataItem.startTime).getTime(), categoryIndex]);
var end = api.coord([new Date(dataItem.endTime).getTime(), categoryIndex]);
var barHeight = 26; // 矩形条的高度,可调
// 计算矩形形状
var rectShape = {
x: start[0],
y: start[1] - barHeight / 2, // 让矩形在Y轴刻度上居中
width: end[0] - start[0],
height: barHeight
};
// 裁剪到坐标系内
var clippedRect = echarts.graphic.clipRectByRect(rectShape, {
x: params.coordSys.x,
y: params.coordSys.y,
width: params.coordSys.width,
height: params.coordSys.height
});
// 如果被完全裁剪掉(不可见),则返回 null
if (!clippedRect) {
return null;
}
return {
type: 'rect',
shape: clippedRect,
style: api.style() // 应用系列中定义的 itemStyle
};
}
这样,每个任务数据就会在图表上对应画出一个矩形条,其横向位置和长度精确反映了任务的起止时间,纵向位置反映了它所属的资源行。至此,一个静态的甘特图骨架就完成了。
5. 动态时间轴与数据更新策略
一个实用的甘特图,其时间轴(X轴)的范围不应该写死为 08:00 到 18:00。理想的情况是,它能根据当前所有任务的最早开始时间和最晚结束时间自动调整,让所有任务条都能恰当地显示在视野内,两边又不会留出太多空白。这就是动态时间轴。
计算最小和最大时间:
我们需要一个方法,遍历 this.ganttData,找出所有 startTime 中的最小值,和所有 endTime 中的最大值。这里要注意,时间可能是跨天的,但为了简化,我们先处理同一天的情况。我们可以借助 dayjs 库来方便地比较时间。
methods: {
calculateTimeRange() {
if (!this.ganttData || this.ganttData.length === 0) {
// 如果没有数据,使用默认时间
this.minTime = `${this.baseDate} 08:00`;
this.maxTime = `${this.baseDate} 18:00`;
return;
}
let min = dayjs(`${this.baseDate} 23:59:59`);
let max = dayjs(`${this.baseDate} 00:00:00`);
this.ganttData.forEach(item => {
const task = item.value[0];
const start = dayjs(task.startTime);
const end = dayjs(task.endTime);
if (start.isBefore(min)) {
min = start;
}
if (end.isAfter(max)) {
max = end;
}
});
// 可以前后稍微留点余量,比如提前半小时,推后半小时
this.minTime = min.subtract(30, 'minute').format('YYYY-MM-DD HH:mm');
this.maxTime = max.add(30, 'minute').format('YYYY-MM-DD HH:mm');
}
}
然后在 option 的 xAxis 配置中,将 min 和 max 设置为 this.minTime 和 this.maxTime。
数据动态更新与图表重绘:
Vue 的响应式系统是我们的好帮手。我们已经在组件的 watch 选项中监听了 ganttData 和 roomData。一旦这些数据发生变化(比如父组件获取了新的任务列表),监听函数就会被触发。
watch: {
ganttData: {
handler(newVal) {
this.calculateTimeRange(); // 重新计算时间范围
this.updateChart(); // 更新图表
},
deep: true // 深度监听,因为数组内部对象可能变化
},
roomData(newVal) {
this.updateChart();
}
},
methods: {
updateChart() {
if (!this.myChart) return;
// 重新计算 option 中依赖 this.ganttData, this.roomData, this.minTime, this.maxTime 的部分
const newOption = this.getChartOption();
// 使用 setOption 更新,注意要传入 true 进行合并,或者传入完整的 newOption
this.myChart.setOption(newOption, true);
}
}
这里有一个性能优化点:如果数据变化频繁,频繁调用 setOption 并重绘整个图表可能会影响性能。ECharts 的 setOption 方法支持合并模式(第二个参数为 true),它只会更新变化的部分,而不是完全重建。但像我们这样数据结构和坐标轴都可能变化的情况,有时直接传入全新计算好的 option 反而更可靠。在实际项目中,你可以根据数据变化的复杂度来选择合适的策略。
6. 交互增强:悬浮提示、点击与双击事件
一个“死”的图表是没有灵魂的。接下来我们为甘特图注入交互能力,让用户可以通过鼠标悬浮查看详情,通过点击或双击进行编辑或创建。
自定义悬浮提示框(Tooltip):
ECharts 默认的 tooltip 显示的是数据项的基本信息。对于甘特图,我们希望提示框能展示更丰富的任务详情,比如任务名称、具体时间、状态、描述等。这可以通过配置 tooltip.formatter 函数来实现,它是一个返回 HTML 字符串的函数。
tooltip: {
trigger: 'item', // 触发类型为数据项图形
backgroundColor: 'rgba(255,255,255,0.95)',
borderWidth: 1,
borderColor: '#ccc',
textStyle: {
color: '#333'
},
formatter: function (params) {
// params 包含了当前悬浮到的数据项信息
const task = params.data.value[0]; // 获取我们的任务数据对象
// 构建一个更丰富的 HTML 字符串
return `
<div style="padding: 5px;">
<strong>${task.name || task.content}</strong><br/>
状态: <span style="color:${task.status === 'completed' ? '#4dc394' : '#e5835b'}">${task.status === 'completed' ? '已完成' : '进行中'}</span><br/>
时间: ${task.startTime} - ${task.endTime}<br/>
${task.desc ? `备注: ${task.desc}` : ''}
</div>
`;
}
}
你可以根据需要,在 formatter 里任意组合 CSS 和 HTML,打造出美观实用的提示框。
绑定图表事件:
ECharts 实例提供了丰富的事件监听器。我们需要监听的主要是 'click' 和 'dblclick'。但这里有个关键点:我们需要区分用户是点击在了已有的图形元素(任务条)上,还是点击在了图表的空白网格区域。
-
点击任务条:这可以通过
myChart.on('click', callback)来监听,回调函数的params参数会包含被点击的图形元素对应的数据信息 (params.data)。我们可以在这里发射一个'bar-click'事件给父组件。myChart.on('click', (params) => { if (params.componentType === 'series' && params.seriesType === 'custom') { // 点击到了 custom 系列上的图形 this.$emit('bar-click', params.data.value[0]); } }); -
双击空白处创建:这个需求更复杂一些。ECharts 的
getZr()方法返回底层 ZRender 实例,可以监听更原始的鼠标事件。我们需要判断双击点是否在绘图网格(grid)内,并且没有命中任何图形元素(!params.target)。myChart.getZr().on('dblclick', (params) => { const pointInPixel = [params.offsetX, params.offsetY]; // 判断是否点击在网格区域内 if (myChart.containPixel('grid', pointInPixel) && !params.target) { // 将像素坐标转换为图表上的数据坐标 const pointInGrid = myChart.convertFromPixel({ seriesIndex: 0 }, pointInPixel); const clickedTime = new Date(pointInGrid[0]); // 获取时间 const clickedResourceIndex = Math.round(pointInGrid[1]); // 获取资源行索引 // 发射事件,将点击的时间和资源索引传递给父组件 this.$emit('canvas-dblclick', { time: clickedTime, resourceIndex: clickedResourceIndex, resourceName: this.roomData[clickedResourceIndex] }); } });父组件监听
@canvas-dblclick事件,就可以打开一个表单,让用户输入新任务的信息,并添加到ganttData中,图表会自动更新。
通过这些交互事件的绑定,我们的甘特图就从静态展示升级为了一个可交互的可视化工具,能够很好地融入项目管理的业务流程中。
7. 样式美化与性能优化要点
功能实现后,我们还需要让图表看起来更专业,用起来更流畅。
视觉样式调整:
- 颜色:不要使用完全随机的颜色。可以根据任务状态(如进行中、已完成、延期)定义一套颜色映射,让状态一目了然。在
series.itemStyle.color中可以使用回调函数来根据数据决定颜色。itemStyle: { color: function(params) { const status = params.data.value[0].status; const colorMap = { 'pending': '#FFB74D', 'ongoing': '#4FC3F7', 'completed': '#81C784', 'delayed': '#E57373' }; return colorMap[status] || '#CCCCCC'; } } - 标签(Label):在矩形条内部显示任务名称或缩写。配置
series.label,设置show: true,并利用formatter对过长的文本进行截断处理,确保显示美观。 - 坐标轴与网格线:调整
axisLine、axisLabel、splitLine的样式,使其与整体 UI 设计风格一致。通常使用较浅的灰色(如#e5e5e5)来绘制网格线,避免喧宾夺主。
性能优化建议: 当任务数据量很大(比如超过500条)时,渲染和交互可能会变慢。以下是一些实战中有效的优化手段:
- 数据聚合:如果时间粒度很细(如分钟),可以考虑在后端或前端对同资源相邻时间段的任务进行适度合并展示,鼠标悬浮时再展示详情。
- 虚拟滚动/渲染:对于超长的时间轴或超多的资源行,可以只渲染可视区域内的部分。但这需要修改
renderItem逻辑,计算哪些数据项在当前视口内,比较复杂。ECharts 本身对大量数据有基础优化,但极端情况下需要自己实现。 - 防抖与节流:在监听窗口
resize事件重绘图表时,务必使用防抖函数,避免频繁重绘。在watch中监听数据变化时,如果数据更新非常频繁,可以考虑使用this.$nextTick或简单的防抖来合并一次更新。 - 及时销毁:在组件
beforeDestroy生命周期中,一定要调用myChart.dispose()来释放 ECharts 实例占用的内存,避免内存泄漏。 - 简化 Tooltip DOM:
tooltip.formatter返回的 HTML 不宜过于复杂。避免在内部创建巨大的 DOM 树或图片。
8. 封装为可复用组件与全局注册
最后一步,让我们把这个功能完善的甘特图封装成一个标准的 Vue 组件,方便在项目中任何地方复用。
我们将所有代码整理到 src/components/GanttChart/index.vue 文件中。然后,创建一个同级的 index.js 文件,用于全局注册组件。
// src/components/GanttChart/index.js
import GanttChart from './index.vue'
// 为组件添加 install 方法,便于 Vue.use 全局注册
GanttChart.install = function(Vue) {
Vue.component(GanttChart.name, GanttChart)
}
export default GanttChart
接着,在项目的 main.js 或专门的组件注册文件中,可以全局注册它:
// main.js
import Vue from 'vue'
import GanttChart from '@/components/GanttChart'
Vue.component(GanttChart.name, GanttChart) // 或者使用 Vue.use(GanttChart)
现在,在任何 Vue 组件模板中,你都可以直接使用 <gantt-chart> 标签了:
<template>
<div>
<gantt-chart
:base-date="selectedDate"
:gantt-data="taskList"
:room-data="resourceList"
@bar-click="handleTaskClick"
@canvas-dblclick="handleCreateTask"
/>
</div>
</template>
父组件只需要管理好 selectedDate、taskList 和 resourceList 这三个核心数据,并在收到子组件的事件后,执行相应的业务逻辑(如打开对话框、调用 API 等),然后更新数据即可。子组件会通过 watch 自动响应数据变化并更新图表。
踩过几次坑之后,我发现这种清晰的数据流和职责分离的设计,能让复杂图表的集成变得非常顺畅。尤其是在大型项目管理页面中,甘特图可能只是其中一个模块,这种组件化方式保证了它的独立性和可维护性。

212

被折叠的 条评论
为什么被折叠?



