☰
Vue3 + ECharts 封装实战:从重复代码到可维护图表体系
2026/9/28 7:10:07 网站建设 项目流程

先讲一个真实场景吧。前阵子我接手一个 Vue3 后台项目,首页挂了七八个图表面板,几乎每个组件里都复制着同一段 echarts 初始化代码:init、setOption、window.addEventListener('resize'),页面一关还得记得 removeEventListener。产品随口一句"所有图表加 loading",我一晚上改了十几个文件。改到后面我实在忍不了,才下定决心把 Vue3 + echarts 的封装彻底重做一版,顺便把这些年踩过的坑一起解决掉。

这篇文章就围绕"封装"这件事展开,把我在实际项目里的完整方案、设计思路和避坑记录都写下来。它不是一份"抄过去就能跑"的成品组件文档,更多的是讲清楚每一个设计决策背后的原因。适合两类人看:一是刚接触 Vue3 + echarts、准备在后台管理系统里做数据可视化的新手,二是已经写了一版图表封装、但总觉得经常出现图表不更新、resize 失效、内存泄漏这些怪问题的开发者。看完你应该能搭出一套至少能支撑中型项目的图表体系。

1. 为什么必须封装:可视化页面的代码腐烂过程

1.1 最原始的"能用就行"写法

很多人刚开始写图表的时候,都是在一个 Vue 组件里直接写完整个可视化逻辑。代码大概是这样的:

<template> <div ref="chartEl" style="width: 100%; height: 400px"></div> </template> <script setup> import * as echarts from 'echarts' import { onMounted, onBeforeUnmount, ref } from 'vue' const chartEl = ref() let chart onMounted(() => { chart = echarts.init(chartEl.value) chart.setOption({ title: { text: 'ECharts 入门示例' }, tooltip: {}, xAxis: { data: ['衬衫', '羊毛衫', '雪纺衫', '裤子', '高跟鞋', '袜子'] }, yAxis: {}, series: [{ name: '销量', type: 'bar', data: [5, 20, 36, 10, 10, 20] }] }) window.addEventListener('resize', resize) }) function resize() { chart && chart.resize() } onBeforeUnmount(() => { window.removeEventListener('resize', resize) chart && chart.dispose() }) </script>

这段代码本身没什么大问题,跑起来也正常。问题出在"复制粘贴"上——当项目里有十个图表页面,每个人都这么写,灾难就开始了。

1.2 不封装的三个隐性成本

第一个成本是重复代码。每个组件里都有一份 init/dispose/resize 的模板代码,看似省事,实际上一旦某个生命周期细节写错,排查范围就是十几个文件。比如有人忘了在 onBeforeUnmount 里 dispose,页面反复切换之后浏览器内存暴涨,这种 bug 极难定位。

第二个成本是配置漂移。十个图表页面里可能有三种不同的 tooltip 风格、两种不同的 legend 位置、不同深浅的主题色。产品说"统一一下图表配色",你得挨个文件找。更麻烦的是不同人写的坐标轴格式化逻辑完全不一致,看起来像两套系统。

第三个成本是交互逻辑无法复用。loading 状态、点击事件、数据请求失败的重试、空数据占位,这些在每个页面都要重新实现。我见过最离谱的做法是某个页面的饼图点击跳转逻辑写在组件内部,其他页面想复用只能复制代码再改参数。

1.3 我理解的封装分层

后来我把封装拆成三层,才算是真正理顺了:

封装层级对应载体解决的问题
基础组件层一个通用 Chart 组件初始化、销毁、resize、loading、主题
业务配置层Hook / 配置工厂函数把接口数据转换成 option,收敛图表类型差异
页面表现层页面组件直接用只关心数据和业务交互,不关心图表细节

这里要特别说一句:很多人一提"封装",就想着搞一个无比强大的超级组件,把 echarts 所有能力都通过 props 暴露出去。这不是封装,这是给自己挖坑。合理的方式是让通用组件只做"图表生命周期管理",把业务差异收敛到配置层。这样通用组件可以保持稳定,业务配置层可以按 chart 类型灵活拆分,两边互不干扰。

2. 动手前先想清楚的三件事

2.1 组件粒度:通用组件、业务组件、还是 Hook

很多文章会推荐你直接写一个<VueEcharts :option="...">这样的通用组件。这没错,但我建议你在动手前先想清楚:通用组件只负责渲染,业务组件负责业务语义。

通用组件对外只暴露很少的属性:option、theme、loading、autoResize,再加上几个事件。它不关心传入的 option 是折线图还是饼图,也不关心数据从哪来。它的职责是:容器 div 就绪之后 init,option 变化之后 setOption,窗口变化之后 resize,组件销毁之前 dispose。

为什么还要业务层?因为 echarts 的 option 结构对业务来说太乱了。同一个图表,接口返回的数据可能是:

[ { "name": "华东", "value": 300 }, { "name": "华南", "value": 200 } ]

也可能是:

{ "categories": ["1月", "2月", "3月"], "series": [{ "name": "销售额", "data": [100, 200, 150] }] }

这类数据到 option 的转换逻辑不应该散落在每个页面里,应该收敛到一个 hook 或配置工厂里。页面只管调用,拿到的就是一个标准 option 对象。

2.2 echarts 实例放哪:别放 reactive

这个坑我见得特别多。有人喜欢这样写:

const chart = reactive<{ instance: echarts.ECharts | null }>({ instance: null }) onMounted(() => { chart.instance = echarts.init(chartEl.value) })

看起来把实例放进了响应式系统,很"Vue3 风格"。但实际上 echarts 实例内部维护了完整的图表状态树,把它放进 reactive 会让 Vue 对 echarts 内部对象做深度代理,既带来没必要的性能开销,又容易出现"响应式对象被 echarts 内部修改导致视图更新"这种诡异问题。

正确的做法是:用普通变量或者 shallowRef 持有实例,不让它参与响应式。

let chart: echarts.ECharts | null = null // 或者 const chart = shallowRef<echarts.ECharts | null>(null)

shallowRef 的好处是:如果你确实需要把实例暴露给模板或外部状态,可以只做浅层响应,不递归代理内部结构。绝大多数场景下,普通变量就够了。

2.3 按需引入与主题策略

echarts 5 开始支持按需引入,这直接关系到打包体积。一个只画折线图和柱状图的后台页面,没必要把整个 echarts 包拉进来(完整包动辄 1MB 以上)。

我建议在项目里建一个单独的 echarts 注册模块:

import * as echarts from 'echarts/core' import { BarChart, LineChart, PieChart } from 'echarts/charts' import { TitleComponent, TooltipComponent, GridComponent, LegendComponent, DataZoomComponent } from 'echarts/components' import { CanvasRenderer } from 'echarts/renderers' echarts.use([ BarChart, LineChart, PieChart, TitleComponent, TooltipComponent, GridComponent, LegendComponent, DataZoomComponent, CanvasRenderer ]) export default echarts

所有需要用图表的地方,统一从这个模块引入,而不是直接import * as echarts from 'echarts'。这样后续新增图表类型只需要在这个文件里注册一次,改起来也集中。

主题策略上,我强烈建议把颜色、字体、背景这些变量从业务 option 中抽离。最简单的方式是用 echarts 的registerTheme注册深浅两套主题:

import { graphic } from 'echarts/core' const lightTheme = { color: ['#3f8cff', '#36cfc9', '#ffd666', '#ff85c0', '#b37feb'], backgroundColor: 'transparent', textStyle: { color: '#333' } } echarts.registerTheme('light', lightTheme)

组件 init 的时候传入主题名,业务 option 里就少了一大堆颜色配置,图表风格统一也更容易控制。

3. 第一层封装:通用图表组件的完整实现

3.1 组件的 Props 与事件设计

我的通用组件设计里,Props 就五个:option、theme、loading、autoResize、renderer。事件上只暴露两个:chart-click和chart-ready,前者透传 echarts 的点击参数,后者在实例创建完成时通知外部。

完整的组件代码如下(基于 Vue3 + TypeScript):

<template> <div ref="chartRef" class="base-chart" :style="{ height: height }"></div> </template> <script setup lang="ts"> import * as echarts from 'echarts' import { onBeforeUnmount, onMounted, ref, watch } from 'vue' const props = withDefaults( defineProps<{ option: echarts.EChartsCoreOption theme?: string | object loading?: boolean autoResize?: boolean height?: string }>(), { theme: undefined, loading: false, autoResize: true, height: '100%' } ) const emit = defineEmits<{ (e: 'chart-click', params: unknown): void (e: 'chart-ready', chart: echarts.ECharts): void }>() const chartRef = ref<HTMLDivElement | null>(null) let chart: echarts.ECharts | null = null let resizeObserver: ResizeObserver | null = null function render() { if (!chart) return chart.setOption(props.option, true) } function initChart() { if (!chartRef.value || chart) return chart = echarts.init(chartRef.value, props.theme, { renderer: 'canvas' }) chart.on('click', (params) => emit('chart-click', params)) emit('chart-ready', chart) render() if (props.autoResize) { resizeObserver = new ResizeObserver(() => { chart?.resize() }) resizeObserver.observe(chartRef.value) } watch( () => props.loading, (val) => { if (!chart) return val ? chart.showLoading() : chart.hideLoading() } ) } watch( () => props.option, () => render(), { deep: true } ) onMounted(initChart) onBeforeUnmount(() => { resizeObserver?.disconnect() resizeObserver = null chart?.dispose() chart = null }) </script> <style scoped> .base-chart { width: 100%; position: relative; } </style>

注意这里setOption(props.option, true)的第二个参数是notMerge,我写成true。这个在很多场景下是必须的,原因后面第 5 节细讲。

3.2 初始化、刷新、销毁的时机控制

很多封装失败的原因,是没搞清楚三个生命周期节点的触发时机。

初始化时机:必须在组件挂载完成后执行,因为 echarts.init 需要一个有真实尺寸的 DOM 节点。onMounted里执行没问题,但要小心父组件用了v-if控制图表容器的显隐。如果初始状态是隐藏的,chartRef.value的宽度和高度可能都是 0,init 出来的图表渲染不出来或者渲染不完整。这种情况建议等容器真正可见后再初始化,或者用nextTick延迟一下。

刷新时机:我的组件里用watch(() => props.option, ...)来侦听配置变化。但这里有个细节:如果父组件每次请求数据回来都新生成一个 option 对象,那 watcher 能触发;如果父组件只改了 option.series[0].data 里的某个元素,deep: true也能检测到。所以deep: true在这里是必要的,代价是深度侦听大对象有性能开销。后文会讲怎么规避。

销毁时机:onBeforeUnmount里做清理,顺序不能错。先disconnect掉 ResizeObserver,再dispose图表实例,最后把变量置空。echarts 的 dispose 会移除内部的 resize 监听、事件处理器和一些渲染相关的 DOM 绑定,不及时 dispose 是图表页面最常见的性能杀手。

3.3 容器宽高:ECharts 不显示的头号原因

我几乎每周都能在社区看到有人问"为什么我的图表不显示",十有八九是容器尺寸问题。echarts 不会像普通元素那样自动撑开,它初始化时会读取容器的 clientWidth 和 clientHeight,如果这两个值都是 0,图表就会"隐形"。

所以封装组件里必须对容器高度做明确约束。我的做法是:组件根元素width: 100%,高度通过 props 传入,默认100%。但请注意,height: 100%只有在父容器有确定高度时才有效。如果你是在一个flex布局里用这个组件,父容器最好也设置了高度,否则还是要显式传一个像素值或者百分比。

另外还有一个常见的隐蔽问题:容器本来有尺寸,但初始化时处于display: none状态。比如 Tabs 切换里,默认不激活的 Tab 面板 DOM 虽然渲染了,但宽度为 0。这时 init 出来的图表,等 Tab 切换过来时宽度永远不对。如果你要在 Tabs 中使用图表,建议加一个延迟 init 的判断:

function initChart() { if (!chartRef.value) return const rect = chartRef.value.getBoundingClientRect() if (rect.width === 0 || rect.height === 0) { // 容器暂时不可见,稍后重试 setTimeout(initChart, 200) return } // 正常初始化 }

这个"不可见重试"逻辑虽然土,但确实解决了很多实际场景中的渲染问题。

4. 第二层封装:业务图表与配置收敛

4.1 配置收敛的思路:数据到 Option 的适配层

基础组件解决的是"怎么画"的问题,而业务层解决的是"画什么"的问题。我在项目里的做法是:为每一种常用图表建一个 hook,把接口数据、通用配置、内部过滤逻辑全部收进去,页面拿到的是一个可以直接塞给通用组件的 option。

举例来说,一个销售额折线图的 hook 可以长这样:

import { ref, watch } from 'vue' export function useSalesLineChart(fetcher: () => Promise<SalesData[]>) { const option = ref<echarts.EChartsCoreOption>({}) const loading = ref(false) async function load() { loading.value = true try { const data = await fetcher() option.value = buildOption(data) } finally { loading.value = false } } function buildOption(data: SalesData[]): echarts.EChartsCoreOption { const months = data.map((item) => item.month) const values = data.map((item) => item.amount) return { tooltip: { trigger: 'axis' }, grid: { left: 48, right: 24, top: 32, bottom: 32 }, xAxis: { type: 'category', data: months }, yAxis: { type: 'value' }, series: [ { name: '销售额', type: 'line', smooth: true, data: values, areaStyle: { color: { type: 'linear', x: 0, y: 0, x2: 0, y2: 1, colorStops: [ { offset: 0, color: 'rgba(63, 140, 255, 0.3)' }, { offset: 1, color: 'rgba(63, 140, 255, 0)' } ] } } } ] } } watch([loading], () => {}) return { option, loading, reload: load } }

这看起来很简单,但配置收敛的价值不在"一个图表怎么画",而在"所有图表团队里的人都按同一套逻辑画"。tooltip 的 trigger 类型、grid 边距、面积图渐变色风格,全部在这个 hook 层统一,页面层根本不需要关心。

4.2 折线图与柱状图渐变色的实现

后台里最常见的需求就是折线图和柱状图。折线图上面代码已经有面积渐变,柱状图渐变写法类似,但更常做的是"每根柱子都渐变"或者"不同柱子不同颜色"。

柱状图渐变色可以简单封装成一个工具函数:

import * as echarts from 'echarts/core' import { graphic } from 'echarts/core' export function verticalGradient(topColor: string, bottomColor: string) { return new graphic.LinearGradient(0, 0, 0, 1, [ { offset: 0, color: topColor }, { offset: 1, color: bottomColor } ]) }

在柱状图配置里用:

series: [ { type: 'bar', barWidth: '40%', itemStyle: { borderRadius: [4, 4, 0, 0], color: verticalGradient('#3f8cff', '#a0cfff') } } ]

LinearGradient的四个参数是起点 x、起点 y、终点 x、终点 y。(0,0,0,1)表示从顶部到底部渐变。如果想让渐变方向变成从左到右,就写(0,0,1,0)。如果你需要根据数据值不同显示不同的柱子颜色,可以在itemStyle.color里传一个回调函数,根据params.value返回对应颜色,这是 echarts 支持的标准写法。

有一点需要注意:渐变色里的颜色透明度不要写在 hex 里,尽量用 rgba 表达。比如rgba(63, 140, 255, 0.3)这样,才能和底层的 grid 背景融合出自然的效果。写 hex 加透明度容易在深色主题下显得脏。

4.3 饼图与图例格式化的坑

饼图在后台管理系统里出现频率极高,但它有几个容易忽略的细节。第一个是label 长度溢出。默认的 label 文字在容器边缘容易被截断,需要在 option 里配置:

legend: { orient: 'horizontal', left: 'center', bottom: 0 }, series: [ { type: 'pie', radius: ['40%', '65%'], center: ['50%', '45%'], label: { formatter: '{b}: {d}%' }, emphasis: { label: { show: true, fontSize: 14, fontWeight: 'bold' } } } ]

第二个是百分比格式化。默认的{d}会显示很长的小数,比如 33.333333%,产品一般只需要整数位。正确的格式化方式:

label: { formatter: (params) => { return `${params.name}: ${Number(params.percent).toFixed(0)}%` } }

第三个是我特别想强调的:饼图数据为空时页面会空白。很多后台页面的图表数据依赖接口,如果接口返回[],饼图什么都不画,用户看到一片空白会以为是网挂了。所以业务 hook 层要处理空数据,我一般会给一个"暂无数据"的占位 option:

if (!data.length) { return { title: { text: '暂无数据', left: 'center', top: 'middle', textStyle: { color: '#999', fontSize: 14, fontWeight: 'normal' } } } }

这个细节不算复杂,但产品体验差异非常明显。

5. Vue3 组合式 API 下的集成细节与避坑

5.1 watch 深度监听与 setOption 的重复合并问题

回到第 3 节提到的setOption(props.option, true)。这里我解释一下为什么notMerge要设成true。

echarts 的setOption默认是"合并"模式,也就是说新的配置会和老配置做深度合并。理论上这很方便,可以让调用方只传部分配置就更新图表。但实际项目里,业务 option 往往是一次性完整生成的,老配置里残留的 series 可能在新配置里已经不存在了。如果还用默认的 merge 模式,会出现"明明配置里删掉了某个 series,图表还在画"的怪现象。

notMerge: true能保证每次传入的 option 都是"全量替换",这虽然损失了一点性能,但换来的是可预测的行为。可视化场景里,确定性比微小的性能优化重要得多。

另外,watch(() => props.option, ...)配合deep: true在数据量大时是有性能压力的。我见过一个极端案例:折线图里塞了 5 万个点,每次接口返回新数据后,deep watch 要把整个二维数组扫一遍,明显能感觉到卡顿。

优化方案是:如果数据是整体替换的,可以在业务层用shallowRef持有 option,然后手动触发变更,或者干脆让组件层 watch option 引用变化而不是深层次变化:

watch(() => props.option, () => render(), { deep: false })

当业务层每次请求完数据都生成一个全新的 option 对象时,deep: false 就足够了。这是我们在性能敏感页面上的首选做法。

5.2 v-if 切换、keep-alive 与图表销毁

后台管理系统里最常见的两个场景:Tabs 切换和菜单切换。

先讲 Tabs。如果你把图表放在 Tab 面板里,注意默认隐藏的面板宽高是 0,这会影响初始化。更麻烦的是,如果每次 Tab 切换都会销毁并重建组件,会造成不必要的开销。我的建议是:低频切换用 v-if 销毁重建,高频切换用 v-show 配合手动 resize。

再说 keep-alive。菜单切换页面时,如果路由出口包了 keep-alive,图表组件离开时不会触发onBeforeUnmount,而是会触发onActivated和onDeactivated。这种情况下图表不会销毁,但容器尺寸可能在切换过程中变化。我踩过的坑是:图表在页面切换后宽度正确,高度却变成 0,因为容器高度依赖父级 flex 布局,切换时布局重新计算没触发到 ResizeObserver。

解决办法是:在onActivated里主动调用一次chart?.resize(),强制图表重算尺寸。如果是新页面初始化图表,则在onActivated里再确认一次初始化。

5.3 异步数据和 loading 状态的联动

本来 loading 逻辑很简单:请求前showLoading,请求后hideLoading。但真正麻烦的是多个图表同时请求的联动。

我有一次做数据大屏,页面上同时有六个图表,每个图表自己管自己的 loading。结果接口慢的时候,六张图轮流闪 loading,视觉上非常乱。后来改成一个页面级的pageLoading,由最上层的两个主要请求控制,其余图表不单独显示 loading,只在数据回来后用 CSS 过渡淡入。

如果你仍然希望每个图表独立 loading,我建议在通用组件里把loading属性做成受控属性,而不是图表组件内部自己决定要不要显示。这样页面可以统一编排。另外,showLoading默认的 loading 样式比较丑,业务层经常会自定义一个轻量的 loading 文案:

chart.showLoading({ text: '加载中...', color: '#3f8cff', textColor: '#666', maskColor: 'rgba(255, 255, 255, 0.8)' })

这个细节在深色主题下尤其值得配置,否则默认样式会突兀地盖住图表。

6. 进阶场景:中国地图、SSE 实时刷新与主题切换

6.1 中国地图的注册与按需加载

后台系统里做销售区域分析,十有八九要用到中国地图。echarts 官方从 5 版本之后不再直接内置中国地图 geoJSON,需要自行引入地图数据。

处理方式比较简单:

import chinaJson from '@/assets/map/china.json' import * as echarts from 'echarts/core' import { MapChart } from 'echarts/charts' echarts.use([MapChart]) echarts.registerMap('china', chinaJson as any)

注册一次即可全局使用,之后在 option 里这么写:

series: [ { type: 'map', map: 'china', roam: true, label: { show: false }, itemStyle: { areaColor: '#e8f0fe', borderColor: '#fff', borderWidth: 1 }, emphasis: { itemStyle: { areaColor: '#a0cfff' }, label: { show: true } }, data: salesData } ]

这里有几个我自己踩过的坑。第一,registerMap 必须在 setOption 之前执行,否则图表渲染不出来。第二,地图 json 里面的 adcode 和后台接口返回的地区编码要对齐,很多数据"画不出来"其实是区域名不匹配。第三,地图的 label 默认在缩小到某些级别时会叠加在一起,建议默认关闭,只在 emphasis 时显示。

6.2 SSE 流式数据下的局部更新

最近有不少人做 AI 大模型对话界面时,希望通过 SSE 流式输出把结构化数据实时渲染到图表上。比如模型一边生成数据,一边更新柱状图。这种场景下,每次都全量setOption(option, true)会闪动明显,也不够流畅。

我的做法是:把 option 的稳定部分(坐标轴、grid、tooltip)缓存起来,只更新 series 的 data 字段。用一个函数专门做局部更新:

function updateSeriesData(seriesIndex: number, newData: number[]) { chart?.setOption({ series: { index: seriesIndex, data: newData } }) }

不需要notMerge: true,因为它只改指定 index 的 series. 这样即使每秒收到几十条数据,图表的渲染压力也很小。

如果配合 SSE,还需要考虑请求中断的问题。用户切换页面或取消对话时,用 AbortController 中断 SSE 连接,并在中断后关闭图表 loading。这个逻辑和图表本身没有直接关系,但容易被人忽略——我见过有人切换页面后 SSE 还在后台跑,数据还在setOption,结果 console 里全是 "Can't resolve DOM" 类的报错。

6.3 主题切换的正确姿势

后台管理系统基本都有深色模式/浅色模式切换。图表主题切换不能只改容器背景色,还要重新注册颜色、坐标轴文字、分割线样式等一整套配置。

我的思路是:页面存一个isDark状态,切换时动态生成一套 theme 对象传给 echarts.init。但 echarts.init 的 theme 参数只在初始化时生效,所以主题切换必然需要重新初始化图表实例。正确的流程是:

function applyTheme(themeName: 'light' | 'dark') { chart?.dispose() chart = echarts.init(chartRef.value, themeName) render() }

这里有个细节:dispose 之后,ResizeObserver 需要重新绑定,否则窗口缩放时图表不会自适应。我上面的通用组件里,因为 ResizeObserver 绑定在 init 时创建,dispose 后需要在重新 init 时一并处理。所以如果你把主题切换做成"调用组件的 applyTheme 方法",记得让内部走完整生命周期。

主题切换还有一个优化点:渐变色的配置可以根据主题动态变化。浅色主题里面积图从rgba(63,140,255,0.3)渐变到透明,深色主题里可以变成从rgba(63,140,255,0.6)渐变到rgba(63,140,255,0.05)。这些动态值放在业务 hook 层,通过读取当前的 isDark 状态生成即可。

最后再分享一个小技巧。封装这件事,不要想着一步到位。我第一版封装也只做了通用组件,后来用得多了才一点点往里面加业务 hook、地图注册、主题切换。每一层都经过真实页面验证之后再固化下来。封装最忌讳的是为了抽象而抽象——等到你确实在第三四个页面里写了一模一样的逻辑,再考虑把这段逻辑抽出来,永远不迟。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询