如果你正在做后台管理系统,又用上了图表库,大概率被产品经理这么怼过:“页面不是支持自适应吗?为什么窗口一缩放,图表就糊成一团、右边空一大块?”
这个现象在 Vue + Element UI + ECharts 这套技术栈里特别典型。后台页面通常用百分比宽度的容器来摆放图表,但 ECharts 初始化的时候,会把 canvas 的物理像素尺寸锁定在那一刻的容器大小上。之后任何改变容器尺寸的操作——浏览器窗口拖拽、侧边栏折叠、Tab 切换、抽屉弹出——canvas 都不会自动跟着重新测量,于是就会出现图表被拉伸变糊、内容溢出被裁切、或者右侧留白一大片的问题。早些年我做某后台数据平台时,为这件事前前后后折腾了不少版本。
今天这篇就把图表自适应这条链路完整复盘一遍:从最基础的组件生命周期管理,到 window.resize 监听,再到 ResizeObserver 容器侦测,最后把 Element UI 里 Tab、Collapse、Dialog 这些特殊场景的坑挨个填平。所有代码都按可直接落地的标准写,也会把官方文档里不会明说的细节讲清楚。
1. 先搞懂:图表为什么不能自己"长大"
想解决自适应,先要弄明白 ECharts 渲染的本质。ECharts 在 init 时,会读取当前容器的宽高,然后创建一个对应像素尺寸的 canvas 节点,并把 SVG 或 canvas 的坐标系统、阴影、字体大小等全部基于这套初始宽高进行计算。只要容器尺寸后来发生变化,canvas 的物理像素尺寸和历史计算结果是不会自动跟着变的。
很多人以为加了width: 100%容器就万事大吉,但实际上 CSS 的百分比宽度只是把 DOM 元素的布局宽度撑开了,canvas 内部的绘图坐标系还停留在旧尺寸。浏览器会机械地把 canvas 拉伸到新容器大小,导致两种情况:
- 图表被拉伸,线条和文字出现模糊、变形;
- 部分渲染器表现为图表"缩到一边",右侧留出大片空白。
这个问题的本质,不是 CSS 布局没做好,而是 canvas 物理尺寸和容器实际尺寸产生了脱节。ECharts 实例本身有一个resize()方法,调用后它会重新读取容器尺寸,并重设 canvas 像素大小和相关计算结果。所有自适应方案,本质上都是想办法在"容器尺寸变化"这个时机,及时调用一次chart.resize()。
我曾经排查过一例特别迷惑的 bug:某个图表页面每次进入是正常的,但把浏览器开发者工具底部的 console 面板打开再关闭,图表就开始错位。原因正是 console 面板的展开占用了视口高度,导致容器尺寸变化,但没有任何代码去通知图表重新测量。这种问题不亲眼看现场,单靠读代码很难定位,也侧面说明了自适应的触发时机有多关键。
2. 先把生命周期管好:初始化、更新、销毁缺一不可
讲自适应之前,得先有一个质量过关的图表基础组件。很多自适应的 bug 其实并不是 resize 没写,而是 ECharts 实例被重复初始化,或组件销毁时没有 dispose,导致内存泄漏和"幽灵图表"抢占监听资源。
先给一个最精简的 Vue 2 版本基础组件:
<template> <div ref="chartRef" class="app-chart-container"></div> </template> <script> import * as echarts from 'echarts' export default { name: 'AppVChart', props: { option: { type: Object, required: true } }, data() { return { chart: null } }, watch: { option: { deep: true, handler(val) { this.chart && this.chart.setOption(val) } } }, mounted() { this.initChart() }, beforeDestroy() { this.chart && this.chart.dispose() this.chart = null }, methods: { initChart() { this.chart = echarts.init(this.$refs.chartRef) this.chart.setOption(this.option) } } } </script> <style scoped> .app-chart-container { width: 100%; height: 100%; } </style>这里有几个容易被新手忽略的要点:
- init 只做一次。如果在更新数据时每次
echarts.init(),浏览器控制台会直接报警告:There is a chart instance already initialized on the dom.更麻烦的是旧实例没有被 dispose,它的内部事件依然挂在 DOM 上,后续所有监听都会重复触发。 - option 用深度侦听。父组件传入的 option 对象引用一旦变化(哪怕只是某个 series 数据 push 了,在 Vue 2 里因为深侦听也能捕获),就调用
setOption。这里不销毁重建组件,也是为了避免图表闪烁和性能损耗。 - beforeDestroy 里必须 dispose。ECharts 实例持有 DOM 对象引用,还会注册内部 resize 监听。组件销毁时不 dispose,gc 无法完全回收,长时间切页后内存会缓慢上涨。这在 SPA 后台项目里是真实存在的事故点。
还有一个 Vue 3 的差异点顺便提一下:Vue 3 中组件卸载的钩子叫onBeforeUnmount,并且推荐用composables的方式把图表逻辑抽成useECharts函数。代码思路完全一致,钩子名和响应式 API 换一下就行。下面所有自适应方案,也都兼容这两种写法。
3. 第一板斧:监听 window.resize,解决窗口缩放
最直觉的做法是监听浏览器窗口的 resize 事件,窗口一变就通知所有图表重绘。思路没错,但实现方式很有讲究,很多人贪方便在每一个组件里单独加window.addEventListener('resize', handler),页面一多就出现两种问题:监听器爆炸;组件销毁时忘记移除,handler 里又持有 chart 引用,造成泄漏。
我建议维护一个全局的图表实例注册表,统一分发 resize 事件。这样每个图表只需要在创建时注册、销毁时注销:
// echartsResizeManager.js const chartInstances = [] export function registerChart(chart) { if (chart && !chartInstances.includes(chart)) { chartInstances.push(chart) } return () => { const idx = chartInstances.indexOf(chart) if (idx > -1) { chartInstances.splice(idx, 1) } } } export function unregisterChart(chart) { const idx = chartInstances.indexOf(chart) if (idx > -1) { chartInstances.splice(idx, 1) } } window.addEventListener('resize', () => { chartInstances.forEach((chart) => { chart.resize() }) })然后基础组件里这样接:
import { registerChart, unregisterChart } from '@/utils/echartsResizeManager' mounted() { this.initChart() this.unregister = registerChart(this.chart) }, beforeDestroy() { this.unregister() this.chart && this.chart.dispose() }这个方案能完美解决"浏览器窗口缩放后图表模糊"的问题,因为 window.resize 就是浏览器窗口尺寸变化时的事件,时机天然匹配。
但没过多久,你会在实际项目里发现它有个明显的盲区:容器尺寸变化并不总是触发 window.resize。最常见的例子就是 Element UI 的侧边栏折叠——点击折叠按钮后,整体布局的 main 区域宽度从 1200px 变成 1600px,但浏览器视口尺寸一个像素都没变,window.resize 事件不会触发。此时图表 canvas 还锁在 1200px,右边露出大块空白,产品经理马上找上门。
类似场景还有:el-drawer弹出时从右侧挤压主内容区、el-tabs切换等。只要没有新的窗口缩放事件发生,第一板斧就鞭长莫及。所以我们需要第二套更精准的探测机制。
4. 第二板斧:用 ResizeObserver 感知容器尺寸变化
ResizeObserver 是一个专门用于监听元素盒模型尺寸变化的浏览器 API。它解决的正是 window.resize 最尴尬的问题:不管窗口怎么变、也不管是哪个 DOM 因素导致的尺寸变化,只要目标元素的 content box 尺寸发生了变化,回调就会被调用。
使用方式非常简单:
const observer = new ResizeObserver((entries) => { for (const entry of entries) { // entry.target 就是被监听的元素 // entry.contentRect 里可以拿到 width / height console.log('容器尺寸变化:', entry.contentRect.width, entry.contentRect.height) } }) observer.observe(document.getElementById('chartWrap'))兼容性方面,2018 年底以后的 Chrome、Firefox、Safari 基本都支持,2020 年起现代浏览器默认全部可用。做后台管理系统通常面向的都是较新的浏览器内核,可以直接使用;如果还要兼容老旧浏览器内核,再引入一个 polyfill 也不复杂。
放在 Vue 组件里,完整的写法如下:
<template> <div ref="chartRef" class="app-chart-container"></div> </template> <script> import * as echarts from 'echarts' import debounce from 'lodash/debounce' export default { name: 'AppVChartResize', props: { option: { type: Object, required: true } }, data() { return { chart: null, observer: null, handleResize: null } }, watch: { option: { deep: true, handler(val) { this.chart && this.chart.setOption(val) } } }, mounted() { this.initChart() this.observer = new ResizeObserver(this.onResize) this.observer.observe(this.$refs.chartRef) }, beforeDestroy() { this.observer && this.observer.disconnect() this.chart && this.chart.dispose() }, methods: { onResize() { if (!this.handleResize) { this.handleResize = debounce(() => { this.chart && this.chart.resize() }, 200) } this.handleResize() } } } </script>这里我做了两个关键处理:
- 每次容器尺寸变化都调用一次 debounce 之后的 resize。ResizeObserver 在容器拖拽、窗口连续变化时会高频触发(一帧一次),不做防抖的话,canvas 重绘会非常频繁,CPU 占用飙升,图表还会有肉眼可见的卡顿。200ms 的防抖时间是一个比较均衡的值——既能让最终尺寸被准确采纳,又不会在拖拽过程中反复重算。
- observer.disconnect() 在组件卸载时调用。这也是内存泄漏的重灾区。很多人只记得 dispose chart,忘了断开 observer。observer 一旦被垃圾回收不及时,它持有的 DOM 引用和回调可能长期存活。
实际上,有了 ResizeObserver 之后,window.resize 那套全局注册表基本可以退居二线。因为几乎所有容器尺寸变化,最终都会在某个 DOM 元素的尺寸上体现出来。侧边栏折叠时 main 区变宽,图表容器跟着变宽,observer 都会捕捉到。所以我的建议是:基础组件里直接以 ResizeObserver 为主,window.resize 只作为兜底,甚至不需要再写。
5. 交给通用层:封装成指令和组件,一处接入全项目通用
如果每个页面都各自 new 一个 ResizeObserver,代码很快就会散落得到处都是。更合理的做法是把自适应能力从业务组件里抽出来,封装成一个自定义指令v-resize,或者是通用包裹组件ChartBox。
先看自定义指令的方案。指令的好处是可以直接挂在任意 DOM 元素上,不局限于图表,任何需要感知容器尺寸变化的场景都能复用:
// directives/resize.js import Vue from 'vue' const observerMap = new WeakMap() const handlerMap = new WeakMap() const observer = new ResizeObserver((entries) => { for (const entry of entries) { const handler = handlerMap.get(entry.target) handler && handler() } }) function debounce(fn, wait = 200) { let timer = null return function () { clearTimeout(timer) timer = setTimeout(() => fn.apply(this, arguments), wait) } } Vue.directive('resize', { bind(el, binding) { const value = binding.value const wait = binding.arg ? Number(binding.arg) : 200 const handler = debounce(() => { value && value() }, wait) handlerMap.set(el, handler) observer.observe(el) observerMap.set(el, observer) }, unbind(el) { observer.unobserve(el) observerMap.delete(el) handlerMap.delete(el) } })用起来就是这样:
<template> <div v-resize="handleResize" class="chart-wrap"> <AppVChart ref="chart" :option="option" /> </div> </template> <script> export default { methods: { handleResize() { this.$refs.chart && this.$refs.chart.resize() } } } </script>这套封装有个明显优势:业务页面不需要关心 ResizeObserver 的具体实现,只要在需要自适应的容器上挂一个指令,回调里把图表实例的resize方法传进去即可。而且指令内部统一处理了防抖时间、observer 清理、内存回收,不会因为某个业务组件忘了写清理逻辑而拖垮系统。
我实际项目里更常用的做法,是把它跟基础图表组件合并,做成一个自带自适应能力的ChartBox。这样页面里只需要提供 option,组件自己完成 init、observer、dispose 的全套生命周期:
<template> <div ref="chartRef" class="app-chart-container"></div> </template> <script> import * as echarts from 'echarts' import debounce from 'lodash/debounce' export default { name: 'AppChartBox', props: { option: { type: Object, required: true } }, data() { return { chart: null, resizeHandler: null } }, watch: { option: { deep: true, handler(val) { this.chart && this.chart.setOption(val) } } }, mounted() { this.initChart() this.resizeHandler = debounce(() => { this.chart && this.chart.resize() }, 200) this.observer = new ResizeObserver(this.resizeHandler) this.observer.observe(this.$refs.chartRef) }, beforeDestroy() { this.observer && this.observer.disconnect() this.chart && this.chart.dispose() }, methods: { initChart() { this.chart = echarts.init(this.$refs.chartRef) this.chart.setOption(this.option) }, resize() { // 暴露给父组件手动触发的口子 this.chart && this.chart.resize() } } } </script>页面里直接用<AppChartBox :option="chartOption" />,图表自适应就自动生效了。我把resize方法暴露出来,是因为后面 Element UI 那几种特殊场景,还需要在特定业务时机手动补一发,这个设计后面会用到。
6. Element UI 场景专项:Tabs、Collapse、Drawer 逐个填坑
ResizeObserver 解决了 90% 的常规场景,但 Element UI 组件库里还有几个场景,即便有了 observer,依然会翻车。原因在于:当元素处于display: none或者尺寸为 0 的状态时,ResizeObserver 只会在元素变为可见的那一刻触发一次回调,如果那次回调发生在 echarts init 之前,或被防抖吞掉,图表初始化时依然会拿到宽度为 0 的错误尺寸。
下面这几个场景,都是我在实际项目里逐一踩过坑之后总结出来的。
6.1 el-tabs:切回来时图表"卡死"在第一次的尺寸
el-tabs的默认行为是:所有 tab-pane 面板都会渲染,只是用类名切换显示。如果图表的初始化发生在mounted,而当时当前面板恰好是第二个 Tab,第一个图表容器处于隐藏状态,宽度为 0,echarts.init拿到的宽度是 0;切回第一个 Tab 时,容器变成可见且宽度正常,但 canvas 尺寸还停留在 0。
最简单的解决方案,是给每个 tab-pane 里的图表加v-if控制:只有当前激活的 tab 才渲染内部图表。这样图表组件从初始化到渲染,容器一定是可见的,宽度也一定正常。代价是每次切 Tab 都会重新 init 和 dispose,对大多数图表来说这个代价完全可以接受,因为数据量级根本不需要做增量渲染优化。
如果确实不想切 Tab 时重绘(例如包含大量 series 的复杂图),那就需要依赖el-tabs的tab-click事件,在切换时手动对隐藏过的图表调用一次resize():
<template> <el-tabs v-model="activeTab" @tab-click="handleTabClick"> <el-tab-pane label="销量分析" name="sales"> <AppChartBox ref="salesChart" :option="salesOption" /> </el-tab-pane> <el-tab-pane label="用户趋势" name="users"> <AppChartBox ref="userChart" :option="userOption" /> </el-tab-pane> </el-tabs> </template> <script> export default { data() { return { activeTab: 'sales' } }, methods: { handleTabClick() { this.$nextTick(() => { this.$refs.salesChart && this.$refs.salesChart.resize() this.$refs.userChart && this.$refs.userChart.resize() }) } } } </script>为什么不直接调用this.$refs.salesChart.chart.resize()?因为我把 resize 方法暴露在了 AppChartBox 组件上,父组件只依赖组件公共接口,不关心内部chart实例是否存在,这样封装更安全,未来插件内部实现即使改了,外部调用也不会崩。
6.2 el-collapse:折叠动画产生的“中间态”干扰
el-collapse折叠时,内容容器宽度不会立刻变为 0,而是有一个展开/收起动画。ResizeObserver 在整个动画过程中会收到大量尺寸变化,防抖 200ms 后,图表重绘出来的可能是某个中间态尺寸,而不是最终尺寸。虽然动画结束后,容器稳定在最终尺寸,observ 还会触发最后一次回调,但防抖机制有可能把这最后一次回调也延迟吞掉,导致图表停留在动画某一帧的尺寸上。
我处理这个场景的办法是双保险:防抖时间在 collapse 容器内可以降到 100ms 左右,同时监听el-collapse组件的change事件,在折叠状态稳定后,主动对容器内的图表resize()。代码跟 el-tabs 的handleTabClick类似,只是把触发事件换成change。
另外一个很容易踩的隐性坑:折叠面板内部如果使用v-if控制显示,折叠再展开时会重新创建 DOM 节点。如果图表组件在 init 时依赖于父容器的一次性计算,容器的offsetWidth在动画开始前是 0 的话,init 也会拿到 0。这种场景下,我建议直接不要用v-if,改用v-show配合显隐类,保证 DOM 始终存在,尺寸变化也能被 observer 捕捉到。
6.3 el-dialog 和 el-drawer:打开时图表一片空白,或尺寸错乱
弹窗和抽屉是老问题聚集地。它们的 DOM 是动态挂载的,打开前不在页面里;打开时,Element UI 会做 CSS 过渡动画,容器从透明度 0 到可见,宽度从初始值开始变化。如果图表组件的mounted触发时,动画刚开始、容器宽度还是 0,那 init 出来就是一个宽度为 0 的幽灵图。
我见过很多项目在这里用setTimeout硬等 500ms 再 init,能用但非常不优雅。而且动画时长在不同主题下可能不一致,写死时间很容易翻车。
我的做法是在弹窗组件内部配合 Element UI 的回调时机:el-dialog的open事件会在这个对话框打开动画完成之后触发,el-drawer也一样。所以把初始化动作挂到open事件里,成功率接近 100%。写法大致是:
<template> <el-dialog :visible.sync="dialogVisible" @open="handleDialogOpen"> <AppChartBox ref="dialogChart" :option="dialogOption" /> </el-dialog> </template> <script> export default { methods: { handleDialogOpen() { // 等 DOM 布局稳定后再触发检查 this.$nextTick(() => { this.$refs.dialogChart && this.$refs.dialogChart.resize() }) } } } </script>这里不需要手动 init,因为图表组件已经通过v-model控制的 visible 映入 DOM 后,AppChartBox自己完成了 init。如果 init 时机实在太早,容器尺寸还是 0,open时手动调一次resize()就能把它纠正过来。
我把这些场景的处理建议汇总在下表里,方便排查时对照:
| 场景 | 症状 | 推荐处理方式 | 说明 |
|---|---|---|---|
| el-tabs | 切回后图表尺寸仍是旧值 | 表格面板用 v-if 控制渲染,或 tab-click 后手动 resize | v-if 会重建图表,数据量大时考虑手动 resize |
| el-collapse | 折叠后图表停留在中间态 | collapse 的 change 回调里手动 resize,防抖降到 100ms | 容器用 v-show 替代 v-if,保证 DOM 常驻 |
| el-dialog / el-drawer | 打开时图表空白或尺寸错乱 | open 回调里 nextTick + 手动 resize | 不要用 setTimeout 硬等,时长不可控 |
| 侧边栏折叠 | 图表右侧空白 | ResizeObserver 自动兜底,一般无需额外处理 | 若折叠后 main 区宽度变化,observer 能感知 |
7. 细节优化:防抖、高清屏、强制刷新的收尾工程
基础方案跑通之后,还有一些零碎但很影响体验的细节要打磨。
防抖时间的选择不能一刀切。窗口缩放、侧边栏折叠这类场景,200ms 合适;但折叠面板的动画过程中,100ms 会更跟手;而如果有图表需要在拖拽过程中实时跟随容器变化(比如可拖拽划分左右面板的场景),60ms 左右也不算浪费。我的建议是不要写死在指令里,而是通过指令参数传入,例如v-resize:150,组件的 props 里也可以加一个resizeDelay。
高清屏适配方面,ECharts 5 在resize()时会根据设备像素比自动重算 canvas 的物理像素,普通项目不需要额外处理。如果做的是大屏可视化,希望图表更锐利,可以在 init 时手动设置 devicePixelRatio。这个参数不需要在每次 resize 时改,因为 ECharts 会基于该值持续处理后续的缩放。
还有一个容易被忽视的点:强制刷新接口。无论防抖设计得多完美,总有一些业务时机是 observer 抓不到的。比如某个区域的宽高是由一段自定义拖拽逻辑控制的,用户拖拽结束甩手的那一下,observer 捕获到的可能是偏差尺寸。所以我习惯在统一的图表组件里暴露一个refresh()方法,专门留给业务方在"我认为此刻应该重绘"的时候手动调用。这个方法内部直接chart.resize(),不做防抖、不做额外逻辑,简单可靠。
最后补充一点调试技巧:想验证自适应是否真的生效,不要只拖浏览器窗口。用 Chrome 开发者工具的 Device Toolbar,把视口从 1920 拉到 1024,再拉回来,然后手动折叠侧边栏、切换 Tab、打开抽屉,挨个检查图表画布是否干净利落地跟随容器变化。如果哪个环节还是破绽百出,回到上表对照处理。
做了这么多年图表项目,我个人最大的体会是:自适应从来不是一个resize()方法就能解决的事,它是对生命周期管理、容器监听、组件封装和业务触发时机这四层能力的综合考验。把组件生命周期管好,就成功了一半;把 ResizeObserver 用对,就成功了八成;剩下两成,全在 Element UI 各种组件场景下的细节处理。如果只让我给对方提三句话,我会说:容器不要写死固定像素宽度;init 之前确认容器可见且宽度大于 0;所有外部因素导致的尺寸变化,最终都靠统一一次resize()收口。把这三句话记牢,图表自适应这个事基本就不会再翻车。