ARTICLE DETAIL

资讯详情

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

Vue3 + ECharts 封装实战:从重复代码到可维护图表体系

Vue3 + ECharts 封装实战:从重复代码到可维护图表体系 先讲一个真实场景吧。前阵子我接手一个 Vue3 后台项目首页挂了七八个图表面板几乎每个组件里都复制着同一段 echarts 初始化代码init、setOption、window.addEventListener(resize)页面一关还得记得 removeEventListener。产品随口一句所有图表加 loading我一晚上改了十几个文件。改到后面我实在忍不了才下定决心把 Vue3 echarts 的封装彻底重做一版顺便把这些年踩过的坑一起解决掉。这篇文章就围绕封装这件事展开把我在实际项目里的完整方案、设计思路和避坑记录都写下来。它不是一份抄过去就能跑的成品组件文档更多的是讲清楚每一个设计决策背后的原因。适合两类人看一是刚接触 Vue3 echarts、准备在后台管理系统里做数据可视化的新手二是已经写了一版图表封装、但总觉得经常出现图表不更新、resize 失效、内存泄漏这些怪问题的开发者。看完你应该能搭出一套至少能支撑中型项目的图表体系。1. 为什么必须封装可视化页面的代码腐烂过程1.1 最原始的能用就行写法很多人刚开始写图表的时候都是在一个 Vue 组件里直接写完整个可视化逻辑。代码大概是这样的template div refchartEl stylewidth: 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 就绪之后 initoption 变化之后 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 shallowRefecharts.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 TypeScripttemplate div refchartRef classbase-chart :style{ height: height }/div /template script setup langts 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 refHTMLDivElement | 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的宽度和高度可能都是 0init 出来的图表渲染不出来或者渲染不完整。这种情况建议等容器真正可见后再初始化或者用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: () PromiseSalesData[]) { const option refecharts.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 层要处理空数据我一般会给一个暂无数据的占位 optionif (!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 里全是 Cant 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、地图注册、主题切换。每一层都经过真实页面验证之后再固化下来。封装最忌讳的是为了抽象而抽象——等到你确实在第三四个页面里写了一模一样的逻辑再考虑把这段逻辑抽出来永远不迟。
返回列表