ARTICLE DETAIL

资讯详情

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

Vue3 集成 ECharts 5 避坑指南:按需引入、自适应与实例销毁

Vue3 集成 ECharts 5 避坑指南:按需引入、自适应与实例销毁 上周帮同事排查一个 Vue3 后台管理系统的图表页面现象挺玄学本地npm run dev一切正常打包部署到测试环境之后折线图变成贴着容器顶部的一条直线坐标轴和图例全都没了控制台干干净净一个报错都没打。折腾了四十分钟才定位到两个地方一是容器高度写成了height: 100%而它外面那层div压根没有高度二是这个页面被keep-alive缓存过来回切了几次路由之后同一个 DOM 上挂了两个 ECharts 实例。这种不报错但就是不对的问题在 Vue3 里用 ECharts 做数据可视化的场景中出现的频率高得离谱。Vue3 的响应式系统、组合式 API 的生命周期、Vite 的打包方式跟 ECharts 那套基于 Canvas 的命令式 API 混在一起中间有好几处看起来能跑、实际埋雷的地方。下面我按七个步骤把整条链路走一遍每一步都说明白为什么这么做以及在哪个位置最容易翻车。内容适合刚上手 Vue3 想做图表的朋友也适合已经在项目里用了一阵、但总被一些莫名其妙的小问题打断节奏的同学。技术栈按 ECharts 5.x 加 Vue 3 组合式 API 加 Vite 来讲用 Options API 的写法也能套把生命周期钩子换个位置就行。1. 第一步依赖安装与按需引入——先把包体积这笔账算清楚装包这一步看着没什么可讲npm install echarts一行命令的事。但真到项目上线打包体积、组件注册、TS 类型这三件事会一起找上门而且报错信息都不太友好所以值得单独拿出来说。1.1 一条安装命令背后的版本选择ECharts 从 5.0 开始全面改用 TypeScript 重写同时支持 Canvas 和 SVG 两种渲染器对 Vue3 的响应式对象也不再有任何隐式依赖。这几个变化意味着只要版本在 5.x你在 Vue3 里基本不会遇到库本身跟框架冲突的问题剩下的坑全是使用姿势的问题。# 包管理器任选其一团队里统一就好 npm install echarts pnpm add echarts yarn add echarts我个人的习惯是锁死大版本echarts: ^5.4.3这种写法别用latest。ECharts 在 5.x 的小版本之间偶尔会调整默认样式的细节比如坐标轴刻度线的颜色、工具箱图标的顺序一旦自动升级视觉走查那关会很难解释为什么昨天还好好的图今天就变了。1.2 全量引入和按需引入的真实差距最省事的写法是这一行import * as echarts from echarts它的好处是任何图表、任何组件开箱即用坏处是把整包都拖进了构建产物。全量引入在生产环境下的体积大概在 1MB 左右gzip 之后 330KB 上下对于一个图表只在详情页出现两三个的后台系统来说这个代价明显偏高。按需引入的思路是把我到底用到什么显式声明出来。ECharts 5 把所有能力拆成了几个入口图表类型、通用组件、扩展特性、渲染器。你得告诉 ECharts 我要装这几个零件它才只打包这几个。1.3 按需引入的标准写法与组件未注册报错// src/utils/echarts.js import * as echarts from echarts/core import { LineChart, BarChart, PieChart, MapChart } from echarts/charts import { TitleComponent, TooltipComponent, GridComponent, LegendComponent, DataZoomComponent, ToolboxComponent, GeoComponent, VisualMapComponent, GraphicComponent, } from echarts/components import { LabelLayout, UniversalTransition } from echarts/features import { CanvasRenderer } from echarts/renderers echarts.use([ LineChart, BarChart, PieChart, MapChart, TitleComponent, TooltipComponent, GridComponent, LegendComponent, DataZoomComponent, ToolboxComponent, GeoComponent, VisualMapComponent, GraphicComponent, LabelLayout, UniversalTransition, CanvasRenderer, ]) export default echarts这里最容易踩的坑是漏注册。按需模式下如果你在option里写了legend却没注册LegendComponent页面上不会给你画一半再报错而是图例直接消失控制台顶多给一句Component legend not exists. Load it first.。更坑的是series类型写了type: bar却没注册BarChart控制台会提示Series bar is used but not imported但如果你同时用了数组形式的多个 series可能只有一部分画不出来。提示按需引入时建议把echarts.use()的注册项集中在一个文件里所有页面从这个文件 import echarts绝对不要在页面里直接import * as echarts from echarts否则打包产物里会同时存在全量包和按需包。1.4 把注册逻辑收进统一入口上面那个src/utils/echarts.js就是统一入口的做法。它的价值不只是少写几行 import更重要的是保证全项目的 ECharts 实例来自同一个模块。如果 A 页面从echarts/core引B 页面从echarts引构建工具会认为这是两个不同的模块注册信息不共享A 页面能画的图 B 页面画不出来排查起来非常费劲。顺带说一句 Vite 的一个小配置。某些老版本的 Vite 在预构建阶段对echarts/core的依赖扫描不完全开发态第一次打开页面会慢很久甚至白屏可以在vite.config.js里显式声明export default defineConfig({ optimizeDeps: { include: [echarts/core, echarts/charts, echarts/components, echarts/renderers], }, })这一步做完按需引入的路线就通了。线图、柱状图、饼图这套组合gzip 之后大概能压在 100KB 出头跟全量包比是肉眼可见的差距。2. 第二步图表实例放哪——ref、shallowRef 与 reactive 的取舍这是 Vue3 里最容易被忽略、后果又最隐蔽的一步。很多教程里直接写const chart ref(null)跑起来确实没问题但在图表复杂、数据量大的时候性能会莫名其妙地掉Vue Devtools 展开组件状态时浏览器甚至能卡死几秒。2.1 为什么用 reactive 包 ECharts 实例是个坑Vue3 的reactive()做的是深层代理也就是说你传进去的那个对象以及它下面所有的嵌套属性每次被读写都会经过 Proxy 的拦截。ECharts 实例恰恰是一个内部结构极其庞大的对象它身上挂着_zr渲染层、_model配置模型、_chartsViews、_componentsViews、一堆事件处理器而且这些对象之间存在大量互相引用的循环关系。把这样的对象交给深层代理会带来三个后果。第一是性能损耗图表每渲染一帧ECharts 内部要访问成千上万次属性每次访问都加一层 Proxy 拦截累积起来非常可观。第二是身份判断失效ECharts 内部有些逻辑依赖a b这样的引用比较代理对象和原始对象不是同一个引用某些内部缓存判断会走偏。第三是开发体验Vue Devtools 会尝试递归展开这个巨大的代理对象控制台直接卡住。2.2 两种正确的写法第一种是用shallowRefimport { shallowRef } from vue import echarts from /utils/echarts const chart shallowRef(null) // 赋值 chart.value echarts.init(dom) // 读取 chart.value.setOption(option)shallowRef只对.value这一层的赋值做响应式追踪里面装的对象原封不动。这也是官方文档在避免把大型只读数据结构变成响应式这一节里推荐的做法。第二种是用markRaw配合普通refimport { ref, markRaw } from vue const chart ref(null) chart.value markRaw(echarts.init(dom))markRaw的作用是给对象打一个永不需要被代理的标记reactive和ref遇到这个标记会直接跳过。两种写法功能上等价我更倾向shallowRef因为它从声明阶段就表达清楚了这里面装的东西不需要深度响应式后来的同事看到也不会误改。2.3 模板 ref 和实例变量不要混为一谈还有一类容易混淆的写法是把 DOM 引用和实例引用合成一个变量const chart ref(null) onMounted(() { chart.value echarts.init(chart.value) // 危险 })这里chart先是 DOM 元素然后又变成了 ECharts 实例赋值的那一刻就丢掉了 DOM 引用后面想dispose都找不到。正确的做法是两个变量分开const chartRef ref(null) // 模板 ref指的是 DOM const chart shallowRef(null) // ECharts 实例区别在于chartRef存的是 DOM 元素DOM 元素本身不需要响应式代理用普通ref完全没问题而且模板里refchartRef要求必须是响应式的引用才能被自动赋值。实例变量则用shallowRef职责清晰。提示如果后期要做图例联动、点击穿透这类操作实例变量一定要能方便地拿到建议在组件里通过defineExpose({ getChart: () chart.value })暴露一个获取函数而不是直接把实例抛出去。3. 第三步容器高度与初始化时机——图表只剩一条线的根因开头提到那个折线图变成一条直线的现象问题就出在这一步。ECharts 初始化时会去读取容器的clientWidth和clientHeight如果拿到的是 0它不会抛错只会用一个极小的默认尺寸去初始化画布然后你看到的就是一条被压扁的曲线或者干脆一片空白。3.1 容器没有宽高时的静默失败echarts.init(dom)的第二个参数可以传主题第三个参数可以传配置其中比较有用的一个是width和height允许你显式指定尺寸绕过 DOM 测量const chart echarts.init(chartRef.value, null, { width: auto, height: auto, renderer: canvas, })但显式指定尺寸只是权宜之计因为它不会跟着容器变化。真正要做的是保证容器本身有确定的宽高。.chart { width: 100%; height: 360px; /* 关键给一个确定的高度 */ }3.2 height: 100% 为什么经常失效height: 100%的含义是跟父元素一样高它要求父元素自己也有确定的高度。如果父级是一层层没有设高的div或者父级是display: flex但子项没设置min-height: 0那这条链路最终会停在auto上clientHeight就是 0。有个很典型的布局场景页面用 flex 做了左右两栏右侧内容区又想撑满剩余高度。这种写法在 Chrome 上偶尔能对在 Safari 上就塌了。稳妥的处理是让内容区参与 flex 布局并且显式约束.layout { display: flex; height: 100vh; } .layout__aside { width: 220px; flex: none; } .layout__main { flex: 1; min-width: 0; /* 防止内容撑破 */ min-height: 0; /* 关键允许子元素在 flex 下正确计算高度 */ overflow: auto; } .chart { height: 100%; min-height: 300px; }3.3 onMounted 里到底要不要加 nextTick模板ref在onMounted执行之前就已经被赋值了所以在onMounted里直接用chartRef.value是安全的。但如果图表容器被v-if包裹或者你打算在watch里初始化情况就不一样了。onMounted(() { // 容器被 v-if 控制时onMounted 执行时 DOM 可能还不存在 if (!chartRef.value) return chart.value echarts.init(chartRef.value) chart.value.setOption(buildOption(props.data)) })nextTick解决的是数据变了、DOM 还没更新完的问题。典型场景是用v-if切换图表类型时你改了数据紧接着要重新init此时 DOM 还是旧的nextTick能保证你在 DOM 更新之后执行。但如果你在onMounted里毫无理由地套一层nextTick那属于无效等待只会让初始化晚一帧白白多一次渲染。现象大概率原因处理方式图完全不显示控制台有 width/height 警告容器宽高为 0给容器确定高度检查父级链路折线图被压成一条直线初始化时高度极小同上或延迟到容器可见后再 init弹窗打开后图表空白弹窗动画未结束或display: none弹窗opened回调后再 init图表显示在错误位置容器有transform或缩放改用position: relative的包裹层3.4 数据还没回来时该怎么初始化有一种情况是需要区分的容器已经好了但接口数据还在路上。这时候没必要等数据回来再初始化图表完全可以先把空图表画出来再等数据到了调setOption。onMounted(async () { chart.value echarts.init(chartRef.value) // 先显示 loading 动画用户体验更连贯 chart.value.showLoading({ text: 加载中, color: #409eff, textColor: #666, maskColor: rgba(255,255,255,0.8) }) const data await fetchChartData() chart.value.hideLoading() chart.value.setOption(buildOption(data)) })先 init 再等数据的好处是loading 遮罩能盖在正确的容器尺寸上用户看到的是有边界的加载态而不是一片闪烁的白。4. 第四步setOption 的合并逻辑——数据更新为什么越更越乱图表能画出来之后下一个高频问题就是更新。比如切换时间范围、点击不同维度图表要跟着变。这一步最常见的翻车现场是切换几次之后图例里多出好几个重复项或者老的系列没被清掉两根曲线叠在一起动。4.1 默认合并是按组件下标来的setOption(option)默认走的是合并模式合并的规则是按组件在配置里的位置来匹配。举个例子第一次传进去的series有两项第二次传进去的series有三项合并之后前两项会被覆盖第三项会被追加。但如果第二次只有一项那第二项老数据就会残留。这就解释了很多人的困惑明明新数据只有一个系列图上却还挂着上一个查询条件的曲线。4.2 notMerge 和 replaceMerge 的使用边界两种处理方式对应两种不同的需求// 方式一完全替换之前的所有配置全部丢掉 chart.value.setOption(option, true) // 或者写得更清楚一点 chart.value.setOption(option, { notMerge: true }) // 方式二只替换 series其他如 title、tooltip 保留 chart.value.setOption(option, { replaceMerge: [series] })notMerge是最保险的缺点是会把dataZoom的当前缩放位置、图例的选中状态一起重置用户如果刚把某条线隐藏掉一次数据刷新就全恢复了体验会打折。replaceMerge是 ECharts 5 引入的折中方案适合结构可能变、但交互状态要保留的场景我日常用得最多的就是它。4.3 watch 监听 props 的正确姿势组件化之后数据一般是通过 props 传进来的用watch监听watch( () props.option, (val) { if (!chart.value) return chart.value.setOption(val, { replaceMerge: [series] }) }, { deep: true } )deep: true能保证嵌套数据变化也被捕获代价是遍历整个对象。如果option里挂了上万条数据点每次 watch 的比较都是一笔开销。更省的做法是让父组件在数据变化时替换整个对象的引用// 父组件 const option shallowRef(buildOption(data)) const refresh async () { const data await fetchData() option.value buildOption(data) // 换引用不是改属性 }配合子组件里的watch(() props.option, handler)不加 deep既省性能又不会漏更新。这个模式在处理实时刷新的大屏时特别有用。4.4 只更新数据时别把整个 option 重发如果只是数值变了系列结构没变最轻量的做法是只传 serieschart.value.setOption({ series: [{ data: newDataA }, { data: newDataB }], })ECharts 会把这次传入的 series 按位置跟已有的合并只替换data字段动画还能平滑过渡。这个写法在大屏场景里是标配因为大屏往往几秒钟推一次数据整个 option 重发会让组件重新计算坐标轴刻度、图例布局视觉上会有轻微的抖动。提示series里每一项最好带上稳定的id或者name配合replaceMerge或者dataset使用时ECharts 能更准确地判断这是同一条线的新数据还是这是一条新线避免动画乱跳。5. 第五步尺寸自适应——从 window.resize 到 ResizeObserver图表画出来了数据也能更新了接下来就是让它跟着窗口变化。这一步看着简单实际上藏着好几种窗口没变但容器变了的情况。5.1 window.resize 能覆盖什么覆盖不了什么最朴素的写法import { onMounted, onBeforeUnmount } from vue const handleResize () chart.value?.resize() onMounted(() { window.addEventListener(resize, handleResize) }) onBeforeUnmount(() { window.removeEventListener(resize, handleResize) })它能解决浏览器窗口拖动、设备旋转这类场景但解决不了容器自身变化的情况。典型的就是左侧菜单栏折叠用户点了折叠按钮主内容区从 1400px 变成了 1600px但窗口尺寸一点没变resize事件根本不会触发图表就会保持旧宽度右边露出一块空白。5.2 ResizeObserver 的正确接入方式现代浏览器提供了ResizeObserver可以监听任意元素自身的尺寸变化正好对症import { onMounted, onBeforeUnmount, shallowRef, ref } from vue const chartRef ref(null) const chart shallowRef(null) let observer null let timer null const scheduleResize () { if (timer) clearTimeout(timer) timer setTimeout(() { chart.value?.resize() timer null }, 120) } onMounted(() { chart.value echarts.init(chartRef.value, null, { renderer: canvas }) chart.value.setOption(buildOption(props.data)) observer new ResizeObserver(scheduleResize) observer.observe(chartRef.value) }) onBeforeUnmount(() { observer?.disconnect() observer null if (timer) clearTimeout(timer) chart.value?.dispose() chart.value null })这里加节流不是可选项是必须的。ResizeObserver在元素尺寸变化时会连续触发如果每次回调都调一次resize()而resize()又可能引起布局变化很容易触发浏览器那句经典的警告ResizeObserver loop completed with undelivered notifications页面直接卡住。5.3 侧边栏动画和全屏切换怎么处理菜单折叠通常带 0.3 秒的过渡动画动画过程中容器宽度每一帧都在变。如果只用节流可能会在动画中途 resize 一次得到一个中间宽度。更稳的做法是监听transitionendconst aside document.querySelector(.layout__aside) aside?.addEventListener(transitionend, () chart.value?.resize())不过有了ResizeObserver加节流其实大部分场景已经能自动收敛到最终宽度了transitionend可以作为补充手段。全屏切换是另一个容易被忽略的点。请求全屏之后document.fullscreenElement变了容器尺寸也变了但如果你没监听图表宽度就会停在进入全屏之前的数值。处理方式很简单监听fullscreenchangedocument.addEventListener(fullscreenchange, () { setTimeout(() chart.value?.resize(), 100) })那个setTimeout是为了等浏览器完成全屏布局再测量不加的话偶尔会拿到旧尺寸。5.4 keep-alive 页面必须处理的 activated 与 deactivated后台系统里大量页面被keep-alive缓存这时候组件的onMounted只会执行一次但onActivated每次进入都会执行。如果图表尺寸依赖可见性就得在onActivated里补一次 resizeimport { onActivated, onDeactivated } from vue onActivated(() { // 页面重新可见容器尺寸可能已经变了 requestAnimationFrame(() chart.value?.resize()) }) onDeactivated(() { // 页面隐藏期间可以暂停实时刷新省点性能 stopRealtime() })这个细节在带实时刷新的监控页上非常关键。页面被缓存之后定时器还在跑但 DOM 已经不可见了resize()拿到的是 0如果代码里没做保护就会一路报错下去。6. 第六步封装成可复用组件——props、类型与事件一个项目里如果只有一两个图表怎么写都行。一旦超过五个就会开始重复代码这时候封装一个通用图表组件是必然选择。封装的重点不在代码量而在于接口设计得对不对。6.1 组件对外接口怎么设计我的习惯是把 props 分成三类配置、状态、行为。script setup import { ref, shallowRef, watch, onMounted, onBeforeUnmount } from vue import echarts from /utils/echarts const props defineProps({ option: { type: Object, required: true }, // 图表配置 height: { type: [String, Number], default: 360 }, // 容器高度 theme: { type: [String, Object], default: null }, // 主题 loading: { type: Boolean, default: false }, // 加载态 notMerge: { type: Boolean, default: false }, // 更新策略 group: { type: String, default: }, // 实例分组用于联动 }) const emit defineEmits([chart-click, legend-select, ready]) const chartRef ref(null) const chart shallowRef(null) let observer null let resizeTimer null const init () { if (!chartRef.value) return if (chart.value) return chart.value echarts.init(chartRef.value, props.theme, { renderer: canvas }) if (props.group) chart.value.group props.group chart.value.on(click, (params) emit(chart-click, params)) chart.value.on(legendselectchanged, (params) emit(legend-select, params)) emit(ready, chart.value) } /script几个设计上的取舍值得说明。option设成必填避免组件内部写默认配置带来的理解成本notMerge做成 prop 而不是写死因为有的页面需要保留缩放状态有的页面必须完全重置group是为了后面做多图联动留的口子ECharts 的connect机制同一个 group 的实例会自动同步 tooltip 和 dataZoom。6.2 TypeScript 项目下的类型处理Vue3 加 TS 的项目里setOption的参数类型经常引发报错尤其是用了按需引入之后echarts的类型定义不再是一个大而全的联合类型。正确做法是用ComposeOption自己组合// src/types/echarts.ts import type { ComposeOption } from echarts/core import type { LineSeriesOption, BarSeriesOption, PieSeriesOption } from echarts/charts import type { TitleComponentOption, TooltipComponentOption, GridComponentOption, LegendComponentOption, DataZoomComponentOption, } from echarts/components export type ECOption ComposeOption | LineSeriesOption | BarSeriesOption | PieSeriesOption | TitleComponentOption | TooltipComponentOption | GridComponentOption | LegendComponentOption | DataZoomComponentOption 然后在组件里用import type { EChartsType } from echarts/core import type { ECOption } from /types/echarts const chart shallowRefEChartsType | null(null) const props defineProps{ option: ECOption }()不写EChartsType的话shallowRef(null)会被 TS 推断成ShallowRefnull后面调chart.value.dispose()直接报属性不存在于类型 never。这个问题在从 Vue3 加 TS 的模板项目里特别常见写清楚泛型就解决了。6.3 事件往外抛的粒度图表上的点击事件参数非常丰富params里带着seriesName、dataIndex、value、name等等。如果组件不做任何处理直接往外抛父组件拿到的是一大坨对象用起来还得自己判断点的是哪个系列。我更推荐在组件内部做一层轻量整理chart.value.on(click, (params) { emit(chart-click, { seriesName: params.seriesName, name: params.name, value: params.value, dataIndex: params.dataIndex, raw: params, }) })保留raw是为了不丢失信息父组件需要更细的字段时还能拿到原始对象。这种扁平字段加原始对象的组合在多人协作的项目里比直接抛params友好得多。6.4 更新逻辑的完整写法把前面的结论都串起来组件的更新部分大概是这样watch( () props.option, (val) { if (!chart.value) return chart.value.setOption(val, { notMerge: props.notMerge, replaceMerge: props.notMerge ? undefined : [series], }) }, { deep: true } ) watch( () props.loading, (val) { if (!chart.value) return val ? chart.value.showLoading({ text: , maskColor: rgba(255,255,255,0.6) }) : chart.value.hideLoading() } )showLoading的文案留空是刻意的因为默认的 loading 是英文在中文界面里会显得突兀留空只显示转圈动画反而更干净。7. 第七步销毁与回收——路由来回切之后图表重影的排查链这一步是收尾但也是最容易漏的。前六步都做对了如果忘了销毁用久了页面会越来越卡最后浏览器直接崩标签页。7.1 一次完整的排查链路我遇到过一次很典型的问题一个订单统计页面从列表点进详情再返回来回十几次之后鼠标移到折线图上tooltip 会出现两个内容还不太一样。排查过程大致是这样的第一步先用echarts.getInstanceByDom检查 DOM 上到底挂了几个实例。在控制台里手动执行const dom document.querySelector(.chart) console.log(echarts.getInstanceByDom(dom))如果打印出的是一个实例那问题在别处如果发现每次切换实例都在变说明旧实例没被销毁。第二步检查页面是否被keep-alive缓存。如果是组件的onBeforeUnmount根本不会触发dispose自然也就没执行。这种情况需要在onDeactivated里做处理或者接受缓存但保证不重复 init。第三步检查是否存在重复初始化。常见的是onMounted里 init 了一次watch里因为immediate: true又 init 了一次。加一个守卫if (chart.value) return一行代码就能避免。第四步如果真的确认是重复实例用dispose清掉再重建const existing echarts.getInstanceByDom(chartRef.value) if (existing) existing.dispose() chart.value echarts.init(chartRef.value)7.2 dispose 的正确时机onBeforeUnmount(() { // 顺序很重要先断观察者再清定时器最后 dispose observer?.disconnect() observer null if (resizeTimer) { clearTimeout(resizeTimer) resizeTimer null } window.removeEventListener(resize, handleResize) chart.value?.dispose() chart.value null })顺序不能乱。如果先dispose再断ResizeObserver中间有个极小的时间窗观察者回调可能在实例已经销毁的情况下执行resize()浏览器会抛出Cannot read properties of null这类错误。虽然概率低但在 CI 的自动化测试里偶发报错会非常难查。7.3 清理清单写图表组件的时候我会在心里过一遍这张清单需要清理的对象清理位置漏掉的后果ECharts 实例onBeforeUnmount内存泄漏多实例叠加ResizeObserveronBeforeUnmount回调报错观察者持续持有引用window事件监听onBeforeUnmount事件堆积页面卡顿setTimeout/setIntervalonBeforeUnmount或onDeactivated后台持续请求接口压力大ECharts 自定义事件on(click)dispose会一并清理一般不用手动 off全局 group 注册echarts.disconnect(group)其他页面的图表被误联动最后一条值得单独提一句。ECharts 的connect机制是按 group 名全局注册的如果不同页面用了同一个 group 名A 页面的 tooltip 会同步到 B 页面的图表上。这个问题在多人协作项目里出现过排查了半天才发现是 group 名撞了。稳妥的做法是把 group 名带上页面标识或者在组件销毁时主动disconnect。8. 三个绕不过去的高频场景大屏适配、中国地图、主题切换这几种场景在后台和大屏项目里出现频率极高而且都不属于七个步骤的主线逻辑单独拿出来说更清楚。8.1 pxtorem 对 ECharts 为什么不起作用大屏项目通常会用postcss-pxtorem把 CSS 里的 px 自动转成 rem实现整体缩放。但 ECharts 的配置项是 JavaScript 对象里面的fontSize、itemWidth这些数值是直接传给 Canvas 绘图的跟 CSS 没有任何关系所以 postcss 插件根本碰不到它们。结果就是页面上的文字缩小了图表里的坐标轴文字还是原来的大小整体比例失衡。解决办法是自己算一个缩放系数const designWidth 1920 const getScale () document.documentElement.clientWidth / designWidth const buildOption (data) { const s getScale() return { tooltip: { textStyle: { fontSize: 14 * s } }, legend: { textStyle: { fontSize: 12 * s }, itemWidth: 14 * s, itemHeight: 10 * s }, xAxis: { axisLabel: { fontSize: 12 * s } }, yAxis: { axisLabel: { fontSize: 12 * s } }, series: [/* ... */], } }然后在ResizeObserver的回调里除了resize()还要重新setOption一次让字号跟着变const scheduleResize () { if (resizeTimer) clearTimeout(resizeTimer) resizeTimer setTimeout(() { chart.value?.resize() chart.value?.setOption(buildOption(currentData), { replaceMerge: [series] }) resizeTimer null }, 120) }这里要留意一个细节重算字号时不要用notMerge: true否则每次都重建配置动画会不断重启大屏上看起来一直在闪。8.2 ECharts 5 里中国地图必须先 registerMapECharts 从 5.0 开始出于合规和体积考虑把内置的地图数据全部移除了。以前那种import echarts/map/js/china的写法已经彻底不可用网上很多老教程还在这么写照抄必然报错。现在的做法是两步。第一步准备 GeoJSON 数据可以从公开的地理数据源获取放到src/assets/map/china.json。第二步在使用前注册import echarts from /utils/echarts import chinaGeoJson from /assets/map/china.json echarts.registerMap(china, chinaGeoJson) const option { geo: { map: china, roam: true, itemStyle: { areaColor: #f3f6fb, borderColor: #c8d3e0 }, emphasis: { itemStyle: { areaColor: #e0ebff } }, }, series: [ { type: map, map: china, data: [{ name: 广东省, value: 1234 }], }, ], }常见的坑有三个。一是registerMap只执行一次如果放在组件内部每次渲染都注册一遍会浪费性能建议放在单独的初始化文件里。二是按需引入时别忘了注册MapChart和GeoComponent少一个地图就画不出来。三是 GeoJSON 里的地区名称必须跟series.data里的name完全一致广东和广东省在 ECharts 眼里是两个不同的区域。8.3 暗色主题切换只能重新 init很多后台系统有明暗主题切换。ECharts 的主题是在init的第二个参数里指定的一旦实例创建完成主题就固定了后续无论怎么调setOption都不会改变整体配色方案。所以主题切换必须走销毁重建const switchTheme (theme) { if (!chart.value) return const dom chartRef.value const option chart.value.getOption() // 先把当前配置捞出来 chart.value.dispose() chart.value echarts.init(dom, theme dark ? dark : null) chart.value.setOption(option) }这里有个细节很容易被忽略getOption()返回的是 ECharts 内部格式化之后的完整配置不是一个干净的输入配置里面带着大量计算过的默认值。直接把它喂回新的实例通常也能工作但如果是自己封装的组件更好的做法是让父组件把原始option重新传一遍组件监听主题变化后重建实例。另外内置的dark主题样式偏深灰蓝跟很多后台系统的暗色风格并不一致。如果需要完全贴合设计稿可以在初始化时传一个自定义主题对象把backgroundColor、textStyle、axisLine、splitLine这些颜色统一配置一遍。8.4 tooltip 内容太长自动换行的写法最后补一个几乎每个项目都会碰到的细节。ECharts 的 tooltip 默认样式是white-space: nowrap所以当提示内容很长时它会横向撑出去在窄屏上直接被视口裁掉。处理方式是在extraCssText里覆盖掉这个默认值tooltip: { trigger: axis, confine: true, // 限制在图表区域内防止跑到画布外面 extraCssText: max-width: 320px; white-space: normal; word-break: break-all; line-height: 1.6;, formatter: (params) { const list Array.isArray(params) ? params : [params] const head div stylefont-weight:600;margin-bottom:4px;${list[0].axisValue}/div const rows list .map((item) div${item.marker}${item.seriesName}${item.value}/div) .join() return head rows }, }confine: true配合max-width基本能解决九成以上的溢出问题。至于formatter返回 HTML 的做法记得对seriesName做一次转义如果系列名来自用户输入直接拼字符串会有注入风险。我在实际项目里踩过最深的一次是封装组件时把所有配置都写成了 prop 的默认值后来发现不同图表的 tooltip 样式差异很大默认值反而成了累赘。现在的做法是组件只负责实例管理和尺寸自适应所有视觉相关的配置全部由使用方在option里自己写这样组件足够薄出问题也容易定位到具体那一层。
返回列表