用 ResizeObserver 监听容器宽度变化触发 ECharts 实例 resize 的防抖写法

图表容器宽度变化却没能触发 ECharts 重绘,是后台系统里最常见的一类「看起来没 bug 的 bug」。典型场景:左侧菜单折叠、抽屉打开、表格列拖拽、分栏拖拽器调整、浏览器窗口缩放、暗色主题切换导致 padding 变化。页面本身是响应式的,容器的 clientWidth 明明变了,但 ECharts 画布还停在旧尺寸上,表现为右侧留白、X 轴标签被裁掉

图表容器宽度变化却没能触发 ECharts 重绘,是后台系统里最常见的一类「看起来没 bug 的 bug」。典型场景:左侧菜单折叠、抽屉打开、表格列拖拽、分栏拖拽器调整、浏览器窗口缩放、暗色主题切换导致 padding 变化。页面本身是响应式的,容器的 clientWidth 明明变了,但 ECharts 画布还停在旧尺寸上,表现为右侧留白、X 轴标签被裁掉、图例挤成两行甚至重叠。

根因在于 ECharts 初始化时会把容器尺寸量一次并写进 canvas 的 width/height 属性,之后除非显式调用 chart.resize(),它不会自己重新测量。很多人退而求其次监听 window.onresize,但这条路径只覆盖「视口变了」这一种情况,菜单折叠、父容器 flex 比例变化、display:none 切回 block 都不会触发 window.resize。

ResizeObserver 是目前解决这个问题的标准答案:它由浏览器在元素盒子尺寸真正发生变化后回调,与视口无关。但直接用它绑定 chart.resize() 会踩两个坑——回调在 observe 之后和布局阶段大量触发,拖拽时会一帧多次;以及 chart.resize() 内部会读布局并触发重排,高频调用直接造成掉帧。所以必须配防抖,并且要处理「容器被隐藏成 0 尺寸」「实例销毁后 observer 仍在回调」这些边界。下面把这套写法拆开讲清楚。

为什么不用 window.onresize:三种失效场景

先把最常见的错误写法摆出来,对照自己的代码看看有没有踩到:

// 反例:只监听了视口
window.addEventListener('resize', () => {
  myChart.resize();
});

它在以下三种情况下不会生效:

  • 父容器尺寸变化但视口没变。Ant Design / Element Plus 的 Layout.Sider 折叠、Drawer 推挤内容区、可拖拽分栏(react-resizable、splitpanes)都属于这一类。window 完全无感知。
  • 元素从 display: none 恢复显示。标签页切换、折叠面板展开时容器一开始宽高为 0,ECharts 初始化会直接报 [ECharts] Can't get DOM width or height 并把画布画成 0×0,之后切回来也不会自动修。
  • 字体加载或内容变化引起的高度撑开。容器宽度没变、高度变了,同样不会触发 window.resize,但图表需要重新计算 grid 布局。

ResizeObserver 直接观测目标元素的内容盒(content box,默认)尺寸,上述三类变化它都能收到。它的兼容性也足够:Chrome 64+、Firefox 69+、Safari 13.1+,2024 年之后基本可以放心使用,只需为极老环境准备降级分支。

基础写法:从裸调到可控

先写一个最小可用版本,把 observer 的生命周期与图表实例对齐。以 ECharts 5.x 的 echarts.init(dom) 为例:

import * as echarts from 'echarts';

const dom = document.getElementById('chart');
const chart = echarts.init(dom);

const observer = new ResizeObserver(() => {
  // 不加防抖,仅演示最小闭环
  chart.resize();
});

observer.observe(dom);

// 销毁时必须断开,否则 DOM 已移除、chart 已 dispose,回调里调用 resize 会抛错
function destroy() {
  observer.disconnect();
  chart.dispose();
}

这段代码能跑,但有两个实际问题。第一,observe() 注册完成后浏览器会立即回调一次(这是规范行为:初始观测会触发一次回调报告当前尺寸),此时如果图表还没 setOption,resize() 是空操作,无害但也无意义。第二,在拖拽分隔条的场景下,回调频率可达每帧 2~3 次,每次 chart.resize() 都会触发 canvas 重建与选项重新布局,主线程直接被打满,控制台会出现 [Violation] 'requestAnimationFrame' handler took 68ms 这类长任务警告。

注意:ResizeObserver 的回调里如果又修改了被观测元素的尺寸,会触发 ResizeObserver loop completed with undelivered notifications 或 ResizeObserver loop limit exceeded 控制台报错。ECharts 的 resize() 只改 canvas 的 width/height 属性,通常不会改变容器自身的布局尺寸,所以一般不会成环;但如果你的容器高度是 auto 且由 canvas 撑开,就会互相拉扯,这时必须给容器写死高度(见后文「高度自适应」一节)。

防抖实现:requestAnimationFrame + 定时器两种策略

防抖不是只有 setTimeout 一种写法,针对 resize 这种「输出频率高、但只需要最后一次结果」的信号,按使用场景选策略。

策略一:rAF 合并(适合拖拽,追求跟手)

拖拽分栏时用户希望图表跟着动,此时不能等 200ms 才响应,否则松开鼠标前图表是撕裂的。用 requestAnimationFrame 把同一帧内的多次回调合并成一次,天然与浏览器绘制节奏对齐:

function resizeWithRaf(chart) {
  let rafId = null;
  return function schedule() {
    if (rafId !== null) return;          // 本帧已排队,直接丢弃
    rafId = requestAnimationFrame(() => {
      rafId = null;
      chart.resize();
    });
  };
}

const observer = new ResizeObserver(resizeWithRaf(chart));
observer.observe(dom);

它的效果是「每帧最多 resize 一次」。60fps 下即每 16.7ms 一次,对单图表完全够用;若页面有 20 个图表同时拖拽,仍可能吃力,这时叠加一个时间阈值更稳。

策略二:尾部防抖(适合窗口缩放、面板开合)

窗口拖拽缩放时,用户真正想要的只是「松手后的最终尺寸」,中间过程可以丢弃。经典 trailing debounce:

function debounce(fn, wait = 150, immediate = false) {
  let timer = null;
  let calledImmediate = false;
  function debounced(...args) {
    if (timer) clearTimeout(timer);
    if (immediate && !calledImmediate) {
      calledImmediate = true;
      fn.apply(this, args);
    }
    timer = setTimeout(() => {
      timer = null;
      calledImmediate = false;
      if (!immediate) fn.apply(this, args);
    }, wait);
  }
  debounced.cancel = () => {
    if (timer) clearTimeout(timer);
    timer = null;
    calledImmediate = false;
  };
  debounced.flush = function (...args) {
    debounced.cancel();
    fn.apply(this, args);
  };
  return debounced;
}

const doResize = debounce(() => chart.resize(), 150);
const observer = new ResizeObserver(doResize);
observer.observe(dom);

// 组件卸载 / 页面跳转前,把待执行的 resize 立即执行或彻底取消
// doResize.cancel();

wait 取多少?实测经验:100~200ms 是比较舒服的区间。低于 80ms 防抖几乎不起作用(窗口拖拽本身就是连续事件),高于 300ms 用户会觉得松手后图表「愣一下」。若用的是 ECharts 5.3+ 且开启了 animation: true,每次 resize 会重播动画,防抖时间要再放大一点到 200ms 左右,否则视觉上一直在闪。

另外要注意 chart.resize() 支持参数,可以对动画做精细化控制:

chart.resize({
  width: 'auto',      // 默认 auto,跟随容器
  height: 'auto',
  silent: true,       // 不触发 resize 事件,避免业务里监听 resize 的逻辑被反复执行
  animation: { duration: 100 }  // ECharts 5.x 支持,缩短重绘动画
});

silent: true 这一项容易被忽略。很多项目在 chart.on('resize', ...) 里做「重新计算 tooltip 位置」之类的逻辑,如果不加 silent,每次防抖回调都会再走一遍这些业务代码,白白放大开销。

策略三:组合写法(推荐直接用这个)

把「首帧立即执行 + 尾部防抖」结合起来,既保证容器collapse/expand 后立刻对齐,又压制拖拽期间的高频调用:

function createResizeHandler(chart, { wait = 150, skipZero = true } = {}) {
  let timer = null;
  let rafId = null;

  return function onResize(entries) {
    // entries 里能拿到 contentRect,可以在回调里做尺寸判断
    const entry = entries && entries[entries.length - 1];
    if (skipZero && entry) {
      const { width, height } = entry.contentRect;
      // 容器被隐藏(display:none / 父级 0 高)时尺寸为 0,resize 没有意义
      if (width === 0 || height === 0) return;
    }

    if (rafId === null) {
      rafId = requestAnimationFrame(() => {
        rafId = null;
        chart.resize({ silent: true });
      });
    }

    if (timer) clearTimeout(timer);
    timer = setTimeout(() => {
      timer = null;
      // 尾部再补一次,确保最终尺寸一定被应用
      chart.resize({ silent: true });
    }, wait);
  };
}

const observer = new ResizeObserver(createResizeHandler(chart));
observer.observe(dom, { box: 'content-box' });

这里 observe 的第二个参数 box 有三个取值:'content-box'(默认)、'border-box'、'device-pixel-content-box'。ECharts 量的是容器的 clientWidth/clientHeight,即 content box 加 padding,用默认值即可。如果你的图表容器带边框且尺寸会因边框宽度变化(例如 box-sizing: border-box 下改 border 宽度),用 'border-box' 更准确。

实例销毁、容器隐藏与多图表场景

销毁顺序:先 disconnect 再 dispose

这是最容易出线上错误的地方。若在 Vue 的 beforeUnmount / React 的 useEffect cleanup 里只写了 chart.dispose(),observer 仍然持有对旧 DOM 的引用,下一次尺寸变化回调时对已 dispose 的实例调 resize(),控制台会抛:

Uncaught Error: Instance is disposed
    at ECharts.resize (echarts.js:12105)

正确顺序固定为:observer.disconnect() → chart.dispose() → 如有需要 chart = null。另外 ResizeObserver 本身没有 unobserveAll,只对单个元素用 unobserve(dom),多个元素用 disconnect() 一次清空。

容器从隐藏恢复:不要在隐藏时 init

容器处于 display: none 时 offsetWidth === 0,ECharts 初始化会打出:

[ECharts] Can't get DOM width or height. Please check dom.clientWidth and dom.clientHeight. They should not be 0. For example, you may need to call this in the callback of window.onload.

对策是延迟到可见时再 init,并且恢复可见后补一次 resize()。由于隐藏期间 ResizeObserver 在 display:none 下的行为是「尺寸变为 0 会回调一次,恢复时再回调一次」,上面的 skipZero 判断会跳过 0 尺寸那次,恢复时的那次正常触发,逻辑自洽。但如果你在隐藏状态下从未 init,那么恢复时就不会有 chart 实例可 resize——所以推荐把初始化也挂到可见性判断上:

function initWhenVisible(dom) {
  if (dom.clientWidth === 0 || dom.clientHeight === 0) {
    // 用一个 observer 等它出现尺寸后再初始化
    const wait = new ResizeObserver((entries, obs) => {
      const r = entries[0].contentRect;
      if (r.width > 0 && r.height > 0) {
        obs.disconnect();
        initChart(dom);
      }
    });
    wait.observe(dom);
    return null;
  }
  return initChart(dom);
}

function initChart(dom) {
  const chart = echarts.init(dom);
  const handler = createResizeHandler(chart);
  const ro = new ResizeObserver(handler);
  ro.observe(dom);
  chart.__ro = ro; // 便于统一销毁
  return chart;
}

高度自适应:别让 canvas 撑开容器

如果容器写成 height: auto,ECharts 初始化后 canvas 会有一个固定高度并撑开父元素,父元素变高又触发 ResizeObserver,形成反馈环,控制台报 ResizeObserver loop limit exceeded。解决办法只有一条:给图表容器一个确定的高度来源(height: 320px 或 height: 100% 且祖先链上每一层都有确定高度,或 flex 布局下 flex: 1; min-height: 0)。不要依赖内容撑开。

多图表共用与性能上限

一个 ResizeObserver 实例可以观测多个元素,回调的 entries 数组会包含本次所有变化的元素,可以据此只 resize 受影响的实例:

const registry = new WeakMap(); // dom -> chart

const sharedObserver = new ResizeObserver((entries) => {
  for (const entry of entries) {
    const chart = registry.get(entry.target);
    if (!chart || chart.isDisposed()) continue;
    const { width, height } = entry.contentRect;
    if (width === 0 || height === 0) continue;
    chart.resize({ silent: true });
  }
});

function mountChart(dom) {
  const chart = echarts.init(dom);
  registry.set(dom, chart);
  sharedObserver.observe(dom);
  return chart;
}

chart.isDisposed() 是 ECharts 5 提供的判空方法,比维护外部布尔标记更可靠。注意:多个图表共用 observer 时,防抖要下沉到每个实例上(用 WeakMap 缓存各实例的 debounced 函数),否则一个图表的 resize 会拖慢其他图表的响应。

关于性能上限:单页面同时可见的图表建议控制在 12 个以内,超过后即便有防抖,窗口拖拽仍能感知到卡顿。此时退一步,改为只在 resize 结束时(防抖 200ms)重算,而不是每帧都重算;或者对不可见图表用 IntersectionObserver 暂停观测。

封装成可复用的 useChartResize

把上面所有细节收进一个工厂函数,传入 chart 实例,返回销毁函数。下面的代码不依赖任何框架,Vue/React/原生都能直接用:

/**
 * 监听容器尺寸变化并触发 chart.resize()
 * @param {import('echarts').ECharts} chart
 * @param {HTMLElement} dom 图表容器,必须与 echarts.init 的是同一个元素
 * @param {{ wait?: number, raf?: boolean }} options
 * @returns {() => void} 销毁函数
 */
export function bindChartResize(chart, dom, options = {}) {
  const { wait = 150, raf = true } = options;
  if (!dom || typeof ResizeObserver === 'undefined') {
    // 降级:极老环境退回 window.resize
    const fallback = () => chart.resize({ silent: true });
    window.addEventListener('resize', fallback);
    return () => window.removeEventListener('resize', fallback);
  }

  let timer = null;
  let rafId = null;
  let disposed = false;

  const doResize = () => {
    if (disposed || chart.isDisposed()) return;
    chart.resize({ silent: true });
  };

  const onResize = (entries) => {
    const entry = entries[entries.length - 1];
    const { width, height } = entry.contentRect;
    if (width === 0 || height === 0) return;

    if (raf) {
      if (rafId === null) {
        rafId = requestAnimationFrame(() => {
          rafId = null;
          doResize();
        });
      }
    }
    if (timer) clearTimeout(timer);
    timer = setTimeout(() => {
      timer = null;
      doResize();
    }, wait);
  };

  const observer = new ResizeObserver(onResize);
  observer.observe(dom);

  return function unbind() {
    disposed = true;
    if (rafId !== null) cancelAnimationFrame(rafId);
    if (timer) clearTimeout(timer);
    observer.disconnect();
  };
}

在 Vue 3 里这样用:

import { onMounted, onBeforeUnmount, ref, shallowRef } from 'vue';
import * as echarts from 'echarts';
import { bindChartResize } from './bindChartResize';

export default {
  setup() {
    const el = ref(null);
    const chart = shallowRef(null);
    let unbind = null;

    onMounted(() => {
      chart.value = echarts.init(el.value);
      chart.value.setOption({ /* ... */ });
      unbind = bindChartResize(chart.value, el.value, { wait: 150 });
    });

    onBeforeUnmount(() => {
      unbind && unbind();
      chart.value && chart.value.dispose();
    });

    return { el };
  }
};

在 React 18 里则放进 useEffect 的 cleanup,注意 StrictMode 下 effect 会执行两次,务必在 cleanup 里彻底 disconnect 与 dispose,否则会看到「图表渲染两份 canvas」的现象:

useEffect(() => {
  const chart = echarts.init(ref.current);
  chart.setOption(option);
  const unbind = bindChartResize(chart, ref.current);
  return () => {
    unbind();
    chart.dispose();
  };
}, []);

常见报错与排查清单

报错 / 现象 成因 处理
ResizeObserver loop completed with undelivered notifications 回调里改了被观测元素尺寸,形成循环 给容器固定高度,resize() 不要改容器布局;必要时把 resize 包进 rAF
Uncaught Error: Instance is disposed chart 已 dispose,observer 未 disconnect 销毁顺序:disconnect → dispose;回调里加 chart.isDisposed() 判断
[ECharts] Can't get DOM width or height 容器隐藏时初始化 用 ResizeObserver 等待 contentRect.width > 0 再 init
容器变宽图表右侧留白 只监听了 window.resize 改用 ResizeObserver 观测容器本身
拖拽分栏时页面卡顿 回调频率高且未合并 rAF 合并 + 150ms 尾部防抖,加 silent: true
图表出现两份 canvas 同一 DOM 被 init 了两次(React StrictMode、热更新) echarts.getInstanceByDom(dom) 复用或 cleanup 时 dispose

排查时最快的办法是打开 DevTools 的 Performance 面板录一段拖拽,看 ResizeObserver 回调的调用次数。如果 3 秒内出现上百次,说明防抖没生效;再检查 Elements 面板里 canvas 的 width 属性是否随容器同步变化,没变就是 resize() 没被调用。

小结

要点收敛成五条:

  1. 用 ResizeObserver 观测图表容器而不是 window,才能覆盖菜单折叠、面板推挤、display 切换。
  2. 回调必须合并:拖拽场景用 requestAnimationFrame 保证跟手,窗口缩放场景配 100~200ms 尾部防抖兜底。
  3. 容器尺寸为 0 时直接 return,避免对隐藏元素做无意义的重绘。
  4. 销毁顺序固定为 disconnect() → dispose(),回调里加 chart.isDisposed() 双保险。
  5. 图表容器必须有确定高度,否则 canvas 撑开容器会触发 ResizeObserver loop 报错。

FAQ

Q1:已经用了 chart.resize(),为什么容器缩小时图表没变小?

大概率是容器宽度是 min-width 或内容撑开导致实际没有缩小,ResizeObserver 自然不会回调。用 DevTools 看容器的 clientWidth 是否真的变了;另外检查是否有 overflow: hidden 的祖先把宽度裁掉了。

Q2:防抖时间取 150ms 够吗?

对单图表足够。如果图表带复杂 series(比如 5000 点以上的散点、地图),把 wait 提到 250~300ms,或改用 chart.setOption(option, { lazyUpdate: true }) 配合防抖,能明显减少主线程长任务。

Q3:为什么回调没有按预期触发,甚至第一次就没进来?

检查被观测的元素是否已经挂到 DOM 上——observe 一个游离节点不会产生任何回调。另外 ResizeObserver 的回调是异步的、在渲染之后批量投递,紧挨着 observe() 同步读取尺寸是拿不到新值的,需要靠回调。

Q4:需要同时兼容 IE11 吗?

ResizeObserver 在 IE11 不存在,需引入 resize-observer-polyfill,但 polyfill 内部靠 MutationObserver + requestAnimationFrame 轮询实现,性能较差,且 ECharts 5 本身已不支持 IE。真要兼容就用上文示例里的降级分支退回 window.resize,接受菜单折叠不重绘的缺陷。但要注意此时 window.resize 回调必须手动 debounce,否则窗口拖拽依旧卡顿,可直接复用文中 debounce 工具函数,把 window.addEventListener('resize', debounce(fallback, 150)) 接上,并在返回的 unbind 函数里用保存的同一个 debounced 引用调用 removeEventListener(不能写成内联函数,否则移除不掉)。