ARTICLE DETAIL

资讯详情

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

ECharts resize报错排查:图表实例生命周期管理实战

ECharts resize报错排查:图表实例生命周期管理实战 前一阵子给一个数据可视化大屏项目加自适应功能页面上图表多就统一监听了一下视口变化去调用chart.resize()。结果一拖浏览器窗口控制台直接爆红Uncaught TypeError: Cannot read properties of undefined (reading type)当时图表显示完全正常也没做什么特殊操作这一下是真的有点懵。排查了一圈后才发现问题不在 ECharts 配置也不在数据源而是典型的“实例生命周期没管好”导致的。图表从创建到 resize 到销毁中间任何一环出了问题最终都会在resize这个看起来人畜无害的方法上炸开。这篇文章就把这类报错的来龙去脉、常见场景和标准解法完整梳理一遍。如果你也在项目里遇到过类似的 resize 报错或者正准备给图表加自适应这篇应该能帮你少走很多弯路。1. 报错场景复盘一个 resize 引发的连锁反应1.1 翻车现场这段被写烂的监听代码先看看最常见的翻车代码长什么样。原生 JS 场景下很多人的第一版自适应代码是这样的let myChart echarts.init(document.getElementById(chart)); window.addEventListener(resize, function () { myChart.resize(); });Vue 里则是这样// Vue 2 写法 mounted() { this.myChart echarts.init(this.$refs.chart); window.addEventListener(resize, this.handleResize); }, methods: { handleResize() { this.myChart.resize(); } }这种写法在页面一直存在的场景下没问题但一旦涉及组件销毁、路由切换、弹窗关闭、条件渲染这些动态场景就会冒出一堆稀奇古怪的报错。标题里说的这个Cannot read properties of undefined我这次是在一个弹窗组件里复现的弹窗打开时初始化图表并挂上了 resize 监听弹窗关闭时只把弹窗的显示状态改成了 false图表实例销毁逻辑没写干净结果窗口一缩放监听回调发现myChart已经在某个时刻被dispose()掉了于是直接访问了一个空对象上的属性控制台当场报错。1.2 错误信息里的 “ty” 到底是什么报错信息末尾通常会跟一个属性名你的项目里可能是type可能是startTime也可能是别的什么。很多人看到reading type就开始怀疑是不是 option 配置里漏了什么字段方向直接就偏了。其实这个错误的重点是前半句Cannot read properties of undefined。翻译成人话就是你在某个值为undefined的对象上试图读取一个属性。那个属性叫什么反而不重要你不可能给一个不存在的东西取属性。ECharts 内部执行resize()时会读取实例的一些内部状态比如当前渲染器类型、series 配置、容器尺寸等。如果这个myChart变量实际上已经指向了被销毁后的实例对象或者压根就是undefined/null那访问其中任何一个属性都会抛出同一个 TypeError。所以看到这类报错先别盯着属性名看第一步应该是确认resize 时拿到的那个图表实例到底还是不是一个活生生的、有效的实例。2. 根因定位图表实例的生与死比你想象中更需要管理2.1 场景一监听器比实例活得更久这是最普遍的一种情况。初始化图表和注册监听是成对出现的但销毁时很多开发者只销毁了图表实例忘了移除监听。比如// 组件卸载时 beforeDestroy() { this.myChart.dispose(); this.myChart null; }监听已经注册到window上了它并不会因为你组件销毁就自动消失。下一次窗口 resize回调照常执行一看this.myChart已经是 null立刻报错。反过来如果你只移除了监听却忘了 dispose那问题更隐晦监听确实不会再触发了但图表实例、canvas 节点、事件绑定全残留在 DOM 里页面开久一点就会越来越卡这是典型的内存泄漏。这里有一个很容易踩的坑很多人 removeEventListener 时监听函数写的是匿名函数或者箭头函数如下// 这种写法移除不了监听 window.removeEventListener(resize, () { this.myChart.resize(); });removeEventListener要想生效第二个参数必须和addEventListener时是同一个函数引用。所以正确做法是把回调函数存成一个具名方法或变量再注册和移除。2.2 场景二容器还没就位图表就急着初始化这种场景在 Vue 和 React 里都很常见尤其是配合v-if或者动态渲染时。比如// 弹窗里有一段图表但是弹窗还没打开 mounted() { // DOM 还没渲染出来 this.myChart echarts.init(document.getElementById(chart)); }容器 DOM 不存在时echarts.init()要么返回一个异常的实例要么干脆在后续 resize 时访问到一个空容器上的内部状态报错。更典型的还有同一个 DOM 元素在父子组件之间被复用父组件初始化了一次图表子组件又初始化了一次第二次 init 时 ECharts 会在控制台提示There is a chart instance already initialized on the dom.此时旧的实例没有 dispose新的实例又没有完全覆盖两个实例互相挤占后面调用 resize 时操作的可能是一个已经被内部标记为销毁的实例报 TypeError 是迟早的事。2.3 场景三多实例混用时的变量覆盖列表页、大屏、监控面板这类页面经常用循环一次性创建多个图表。有些人习惯用一个对象或数组统一管理实例但偶尔会不小心在循环里用同一个变量名// 错误示范循环里反复赋值 let myChart; for (let i 0; i chartIds.length; i) { myChart echarts.init(document.getElementById(chartIds[i])); window.addEventListener(resize, () myChart.resize()); }这段代码有两个问题一是每次循环都往 window 上挂新的监听浏览器窗口一变化监听回调被连续执行性能很差二是闭包共享同一个myChart变量最终所有监听函数访问的都是chartIds里最后一个图表实例前几个图表实例虽然存在却没人管理。一旦列表数据刷新、某些 DOM 被替换或删除变量指向的实例可能已经被销毁下一次 resize 立即触发报错。3. 解决方案从紧急止血到规范治理3.1 临时止血判空与安全调用如果你正在线上环境急着修 bug最快的止血办法是给 resize 调用加一层空值保护function handleResize() { if (myChart typeof myChart.resize function) { myChart.resize(); } }或者用 optional chaining 写成function handleResize() { myChart?.resize?.(); }这样确实不会报 TypeError 了但我要泼一盆冷水这只是把错误“吞掉”了底层问题一个都没解决。监听器还挂在 window 上实例可能还在内存里残留图表本身也没能正确自适应。止血方案只适合应急不能当长期方案用。顺便说一句如果你只是想在开发阶段定位问题也可以用 optional chaining 临时替换报错代码配合 console.log 确认一下是不是实例为空定位完再改回正式写法。3.2 治本方案 A手动管理 resize 监听治本的关键是让图表实例和监听器“同生共死”。实例创建时注册监听实例销毁前移除监听并 dispose。原生 JS 完整示例const chartDom document.getElementById(chart); const myChart echarts.init(chartDom); function handleResize() { if (myChart !myChart.isDisposed()) { myChart.resize(); } } window.addEventListener(resize, handleResize); // 销毁函数 function destroyChart() { // 先移除监听再销毁实例 window.removeEventListener(resize, handleResize); if (myChart !myChart.isDisposed()) { myChart.dispose(); } }Vue 3 组合式 API 示例script setup import { onMounted, onBeforeUnmount, ref } from vue; import * as echarts from echarts; const chartRef ref(null); let myChart null; function handleResize() { myChart?.resize(); } onMounted(() { myChart echarts.init(chartRef.value); myChart.setOption({ /* option 配置 */ }); window.addEventListener(resize, handleResize); }); onBeforeUnmount(() { window.removeEventListener(resize, handleResize); if (myChart !myChart.isDisposed()) { myChart.dispose(); myChart null; } }); /script template div refchartRef styleheight: 400px;/div /template这里注意两点第一卸载时先移除监听再 dispose 更稳妥避免监听回调里还有其它逻辑访问到已销毁的实例第二myChart这个变量在 dispose 后要置为 null不然 Vue 组件实例虽然销毁了这个变量引用还指向一个已经失效的图表对象一旦哪个地方误调用就会出问题。3.3 治本方案 B用 ResizeObserver 替代 window resize讲完手动管理再介绍一个更省心的方案ResizeObserver。它和 window resize 最大的区别在于它监听的是具体某个元素的尺寸变化而不是整个视口的变化。这个区别在实际项目里很有价值。比如你页面上不只一个图表左侧是一个可折叠的侧边栏折叠时右边的图表容器宽度会变但浏览器窗口本身没有变化。如果用 window resize 监听这种场景根本触发不了如果用 ResizeObserver容器尺寸一变就能感知到。另外还有一个隐藏好处容器从display: none变为可见时ResizeObserver 也能触发回调而纯 window resize 不会关心某个容器内部发生了什么变化。基本用法const chartDom document.getElementById(chart); const myChart echarts.init(chartDom); const observer new ResizeObserver(() { if (myChart !myChart.isDisposed()) { myChart.resize(); } }); observer.observe(chartDom); // 销毁时 observer.disconnect(); myChart.dispose();如果担心 ResizeObserver 在容器动画过程中触发太频繁可以配合防抖或者 requestAnimationFramelet ticking false; const observer new ResizeObserver(() { if (ticking) return; ticking true; requestAnimationFrame(() { if (myChart !myChart.isDisposed()) { myChart.resize(); } ticking false; }); });这种 rAF 节流方式体验很好动画期间不会反复高频调用 resize视觉上也基本感觉不到延迟。ResizeObserver 在现代浏览器里兼容性已经很好不支持的极老浏览器可以退回到 window resize 方案或者引入 polyfill。4. 工程化实践图表生命周期管理的一劳永逸思路4.1 封装一个带自动 resize 的 Vue3 组合式函数如果你在项目里大量使用 ECharts我的建议是不要每次写图表都手动去注册、移除监听。重复代码越多遗漏的概率越大。封装一个组合式函数把初始化、配置、自动 resize、销毁全部收拢到一个地方。// useECharts.js import * as echarts from echarts; import { onMounted, onBeforeUnmount, shallowRef } from vue; export function useECharts(containerRef, option) { let chart null; let observer null; const init () { if (!containerRef.value) return; const existed echarts.getInstanceByDom(containerRef.value); if (existed) { chart existed; } else { chart echarts.init(containerRef.value); } if (option) { chart.setOption(option); } autoResize(); }; const autoResize () { if (!containerRef.value || typeof ResizeObserver undefined) return; observer new ResizeObserver(() { if (chart !chart.isDisposed()) { chart.resize(); } }); observer.observe(containerRef.value); }; const setOption (opt) { if (!chart) return; chart.setOption(opt); }; const dispose () { if (observer) { observer.disconnect(); observer null; } if (chart !chart.isDisposed()) { chart.dispose(); chart null; } }; onMounted(init); onBeforeUnmount(dispose); return { setOption, dispose }; }这里有两个细节经验第一用echarts.getInstanceByDom先判断 DOM 上是否已经有实例避免重复 init 的警告第二图表实例不需要是 Vue 的响应式对象不要直接塞进 reactive 或者 ref 里让 Vue 帮你代理ECharts 实例内部有大量原生属性和方法经过响应式代理后容易出各种诡异问题。组件内部直接用普通变量存就好或者用 shallowRef。4.2 多图表场景下的实例池与批量 resize如果页面图表很多需要统一管理所有实例并支持一键 resize可以维护一个实例池。// chartManager.js import * as echarts from echarts; class ChartManager { constructor() { this.charts new Map(); this.resizeHandler this.resizeHandler.bind(this); window.addEventListener(resize, this.resizeHandler); } register(id) { const dom document.getElementById(id); if (!dom) return null; const existed echarts.getInstanceByDom(dom); if (existed) { this.charts.set(id, existed); return existed; } const chart echarts.init(dom); this.charts.set(id, chart); return chart; } resizeHandler() { this.charts.forEach((chart) { if (chart !chart.isDisposed()) { chart.resize(); } }); } dispose(id) { const chart this.charts.get(id); if (chart !chart.isDisposed()) { chart.dispose(); } this.charts.delete(id); } disposeAll() { this.charts.forEach((chart) { if (chart !chart.isDisposed()) { chart.dispose(); } }); this.charts.clear(); window.removeEventListener(resize, this.resizeHandler); } } export const chartManager new ChartManager();这种做法的好处是所有图表统一在一个入口注册resize 时批量刷新销毁时统一清理。对监控大屏这类图表数量多、需要频繁刷新数据的项目来说实例集中管理能省下不少排查问题的时间。4.3 关于 ECharts 版本与按需引入的额外提醒排查报错时如果你用的是 ECharts 5.x 的按需引入方式比如这样import * as echarts from echarts/core; import { BarChart, LineChart } from echarts/charts; import { TitleComponent, TooltipComponent, GridComponent } from echarts/components; import { CanvasRenderer } from echarts/renderers; echarts.use([BarChart, LineChart, TitleComponent, TooltipComponent, GridComponent, CanvasRenderer]);要注意一个容易混淆的问题如果你在 option 里用了某类图表或组件却没有在echarts.use里注册报错往往会出现在setOption阶段而不是 resize 阶段。但因为代码里这两句挨得很近很多人会误以为是 resize 的问题。判断方法很简单看控制台报错时的调用栈到底是setOption栈帧里崩的还是resize栈帧里崩的。另外 ECharts 5.x 的 resize 内部对实例状态做了更多判断报错信息可能和个人项目版本有关但无论哪个版本核心前提都一样resize 前实例必须有效、没被 dispose、容器 DOM 也必须存在于文档中。还有一个大家经常忽略的点容器如果是隐藏状态chart.resize()计算出来的宽高可能是 0某些情况下会引发系列配置读取异常。所以如果你的图表在弹窗、折叠面板、Tab 切换这类场景中建议容器可见后再调一次 resize或者干脆用 ResizeObserver 自动感知。5. 常见问题速查与排查实录5.1 一份可直接照用的问题速查表把我在实际项目里遇到过的情况整理成了一个表格遇到类似问题可以直接对照着排查。现象可能原因解决方式拖拽浏览器窗口时报 TypeErrorwindow resize 回调里实例已被 dispose 或未赋值回调里判空卸载时先移除监听再 dispose路由切走再切回来图表不显示组件重建后没有重新初始化实例旧实例失去关联在 mounted/onMounted 里重新 init不要在全局缓存实例弹窗或抽屉关闭后缩放窗口报错弹窗内部图表实例销毁了但外部监听还在执行弹窗关闭时同步移除监听和 dispose列表循环创建多个图表只有部分报错实例管理混乱可能有重复 init 或变量覆盖用getInstanceByDom判断已有实例统一实例池管理容器宽度变化但图表不变也不报错只监听了 window resize没监听容器尺寸改用 ResizeObserver隐藏的容器显示后图表变挤变形容器显示前 resize 拿到宽高为 0容器显示后主动调用 resize用 ResizeObserver 感知尺寸变化无限报错页面直接卡死resize 监听与 dispose 互相触发或者监听函数过于频繁加防抖/ rAF 节流确保监听只注册一次5.2 排查这类报错的三个实用习惯第一个习惯看到Cannot read properties of undefined先别急着搜报错信息点开控制台的调用栈看一层找到是谁在哪个文件哪一行触发的。如果栈顶是resize那问题大概率就在图表实例身上如果栈顶是setOption那方向就完全不同。我见过很多团队浪费大量时间在改数据格式上最后发现处理错了源头。第二个习惯在报错的那一行前面临时加上debugger或者console.log(chart)。不用多就打印一下实例对象本身。如果你打印出来是undefined或者一个带有_disposed: true标记的内部对象原因一目了然。如果打印出来是正常对象那再看容器 DOM 还在不在页面里还要确认容器宽高是否正常。第三个习惯善用浏览器的Pause on exceptions断点功能。在 Sources 面板里开启“异常时暂停”刷新页面触发 resize代码会精准停留在抛出异常的位置配合右侧的 Scope 面板能看到当前作用域下所有变量的状态。这个方式比猜测快得多尤其在多个文件、多段监听回调同时存在时非常管用。我在实际排查里还发现一个规律大部分 resize 报错都不是哪一行代码写错了而是“销毁时机”和“监听时机”不匹配。只要记住了“先移除监听再销毁实例容器不可见时不要 resize监听函数要保存引用”这三条口诀至少能覆盖八成以上的坑。另外再分享一个小技巧如果你用的是 Vue 3项目里图表比较多建议在组合式函数里统一封装图表生命周期别让各个组件各写各的销毁逻辑。写的时候多留一个setOption的暴露方法方便后续做数据更新。这套模式我用了大半年从之前的经常排查 resize 报错到现在基本不需要再碰这类问题非常省心。
返回列表