ECharts 卡不卡,九成不看数据量本身,而看你在什么时机、用什么接口把数据喂进去。十万点折线图能拖得动,四十条带标签的柱状系列两千点就掉帧——瓶颈不在"点数"一个维度。
按渲染管线拆:图形怎么画 → 瓶颈在哪 → 每个开关治哪一段 → 卸载时漏了什么。"典型量级"只是量级判断,具体数字按 ECharts 5.x 版本与目标机型核对。
1. 渲染管线:三次加工,两次不可省
setOption 之后到像素点亮,中间有三段:
| 阶段 | 做什么 | 与什么相关 | 能否跳过 |
|---|---|---|---|
| 数据处理 | 归一化、dataset 转换 |
点数 × 系列数 | 只能减量 |
| 布局与坐标 | 刻度计算、类目轴索引 | 点数、标签数 | large 弱化 |
| 绘制 | 生成图元、提交渲染器 | 图元数、重绘面积 | 渐进/降采样 |
前两段是 CPU 的活,第三段才是 DOM 或 canvas 的活。很多"改数据就卡"的案子热点在刻度计算和标签布局。
1.1 canvas 与 svg 的取舍
import * as echarts from 'echarts/core';
import { CanvasRenderer } from 'echarts/renderers';
echarts.use([CanvasRenderer]);
const chart = echarts.init(el, null, {
renderer: 'canvas', // 'svg' 需先 use(SVGRenderer)
devicePixelRatio: Math.min(window.devicePixelRatio, 2),
});
| 维度 | canvas | svg |
|---|---|---|
| 图元规模 | 几千以上更稳 | 上千吃力(DOM 爆炸) |
| 交互命中 | 自绘 hit-test | 原生事件 |
| 清晰度 | 要管 devicePixelRatio |
矢量天然清晰 |
| DOM 定制 | 不能逐个写 CSS | 可逐节点加类名 |
判断线:图元过几千、高频重绘、大数据散点 → canvas;图元少但要打印、要 CSS 主题联动、要无障碍节点 → svg。
2. 四个瓶颈:先定位再动手
调优最常见的错误是同时打开所有开关,然后说不清哪个起了作用。四个维度分开量化:
| 瓶颈 | 表征 | 观测手段 | 第一手段 |
|---|---|---|---|
| 点数 | setOption 后长时间无响应 |
performance.now() 包住 |
sampling、窗口取数 |
| 系列数 | 图例几十项逐系列走 | 与系列数近似线性 | 合并系列 |
| 重绘频率 | 拖动缩放持续掉帧 | zr.on('rendered') 间隔 |
降频、silent |
| 标签与 tooltip | 静态图也慢 | 单独关 label 对比 |
按缩放分级 |
把 setOption 的耗时打成日志,是性价比最高的一步——但要挂在 finished 上,setOption 返回只代表配置合并完:
const t0 = performance.now();
chart.setOption(opt, { lazyUpdate: true });
chart.once('finished', () =>
console.log(`[chart] ${(performance.now() - t0).toFixed(1)}ms`));
3. large 与 largeThreshold:关掉逐点优化
large 把逐点绘制降级成批量路径,同时放弃逐点高亮与标签:
series: [{
type: 'scatter',
large: true,
largeThreshold: 2000, // 超此点数才走 large 路径
symbolSize: 2,
data: points,
emphasis: { disabled: true },
}]
| 参数 | 默认 | 作用 | 代价 |
|---|---|---|---|
large |
false |
走批量绘制 | itemStyle 回调失效 |
largeThreshold |
2000(散点) |
超阈值自动切换 | 阈值附近有跳变 |
progressive |
400(散点) |
每帧最多画多少图元 | 与 large 重叠 |
progressiveThreshold |
3000 |
超多少点启用渐进 | 低于则同步绘制 |
progressiveChunkMode |
'sequential' |
分块顺序 | 'mod' 每帧更均匀 |
关键点:large 与 sampling 不是一回事——large 仍把每个点画出来,只是画得快;sampling 是真的丢掉点。前者保形,后者降量。
4. progressive:把一次长任务切成多帧
渐进渲染把一帧拆成多帧:主线程不被长时间独占,代价是画完之前图不完整,总耗时可能更长。
series: [{
type: 'line',
progressive: 1000, // 每帧最多 1000 图元
progressiveThreshold: 5000, // 超 5000 点才启用
progressiveChunkMode: 'mod', // 按模切块,分布更匀
animation: false,
}]
导出或截图前临时设 progressive: 0,并等 finished 事件而不是定时器,否则会截到半张图;持续高频更新的图反而应该关掉渐进,两者会互相打架。
5. sampling:折线图上真正减量
降采样在数据处理阶段就丢点,后面布局与绘制一起变快。
series: [{
type: 'line',
sampling: 'lttb',
samplingThreshold: 800,
showSymbol: false, // 大数据量关点标记
data: rawData,
}]
| 算法 | 保留规则 | 适用 | 风险 |
|---|---|---|---|
lttb |
按三角形面积取显著点 | 折线趋势、波形 | 极值略被抹平 |
average |
每桶取均值 | 噪声大的信号 | 峰值被平均掉 |
max |
每桶取最大值 | 看峰值包络 | 下限丢失 |
min |
每桶取最小值 | 看谷值 | 上限丢失 |
选法:看趋势用 lttb,看峰值用 max,看整体能量用 average。别对已聚合的数据再降采样——那是丢两次信息。
6. dataZoom 与按需加载:不要一次给全量
十万点全给前端,必然在浏览器里过一遍管线。更省的是只给当前窗口,dataZoom 只管视图,取数交给后端。
chart.setOption({
dataZoom: [{ type: 'inside', throttle: 100 }, { type: 'slider' }],
});
// 别每帧都取,throttle 之后再去抖
chart.on('datazoom', (e) => {
const dz = (e as any).batch?.[0] ?? e;
scheduleFetch(dz.startValue, dz.endValue);
});
| 策略 | 首屏成本 | 拖动体验 | 复杂度 |
|---|---|---|---|
| 全量下发 | 高 | 好(本地) | 最低 |
| 窗口 + 预取 | 低 | 依赖预取 | 中 |
| 分层:概览 + 明细窗口 | 低 | 最好 | 高 |
分层就是两条 series:底层 sampling: 'lttb' 全量概览做背景,上层叠窗口精度数据。
7. animation 与 silent:交互预算
动画是每帧重算,数据量大时等于给渲染成本乘系数。首屏给动画,轮询刷新不给:用 isFirstRender 标记把两种 setOption 分开。
首屏正常带 animation;进入轮询或高频刷新阶段,把 animation: false、silent: true、lazyUpdate: true 一起打开,并给 setOption 套一层节流。
| 开关 | 关掉后 | 何时关 |
|---|---|---|
animation: false |
直接跳到终态 | 高频刷新、大数据量、移动端 |
animationDuration: 0 |
同上但留框架 | 只想去掉过渡 |
silent: true |
不派发事件、不响应交互 | 批量刷数期间 |
emphasis.disabled |
无 hover 高亮 | large 图元场景 |
8. setOption 的三个参数与内存增长
opts 决定合并语义,直接关系到内存。
// 错:每次全量 notMerge,组件销毁重建
chart.setOption(fullOption, { notMerge: true });
// 对:系列给稳定 id,只喂变化的数据
const cpu = { id: 'cpu', type: 'line', data: [] };
chart.setOption({ series: [cpu] });
chart.setOption({ series: [{ id: 'cpu', data: next }] }, { lazyUpdate: true });
| 参数 | 语义 | 用错的表现 |
|---|---|---|
notMerge: true |
完全替换,旧配置丢弃 | 反复重建,缩放与选中态丢失 |
notMerge: false(默认) |
深度合并 | 该删的旧系列还在 |
replaceMerge: ['series'] |
指定组件整体替换 | 用于"合并语义下删系列" |
lazyUpdate: true |
等下一帧统一更新 | 连续多次调用只画一次 |
silent: true |
更新期间不派发事件 | —— |
反复 notMerge 涨内存来自两处:旧组件没被及时 GC,zrender 图元池扩张。判定:Memory 面板连做 20 次替换看 heap 是否单调上升。
9. dispose:卸载时最容易漏的一行
实例持有 canvas、DOM 事件、ResizeObserver 与定时器。卸载不 dispose,实例与它引用的整份数据都不会释放。
// Vue 3
import { onMounted, onBeforeUnmount, shallowRef } from 'vue';
const el = shallowRef<HTMLDivElement>();
let chart: echarts.ECharts | null = null;
onMounted(() => {
chart = echarts.init(el.value!);
chart.setOption(option);
window.addEventListener('resize', onResize);
});
onBeforeUnmount(() => {
window.removeEventListener('resize', onResize);
chart?.dispose(); // 必须
chart = null;
});
React 18 同理:在 useEffect 的 return 里 ro.disconnect() + chart.dispose()。StrictMode 下 effect 会跑两遍,清理函数必须幂等——别在清理里改外部状态。
| 漏掉的清理 | 后果 | 检查方式 |
|---|---|---|
chart.dispose() |
实例与数据常驻 | 切路由后 heap 阶梯上升 |
window 的 resize |
闭包持有旧实例 | Listener 面板同名叠加 |
ResizeObserver |
回调打到已销毁实例 | 报 Instance is disposed |
zr 自定义事件 |
同实例泄漏 | chart.getZr().off() |
尺寸单独说:容器从隐藏转为显示(tab 切换、弹窗)后必须 chart.resize()。ResizeObserver 是唯一可靠做法,window.resize 覆盖不到容器自身变化。换成 % 宽高 + resize 的组合拳能省一半调试时间。
10. 排错:六种"改了没变快"
| 现象 | 真实原因 | 处理 |
|---|---|---|
加 large 没变化 |
未超 largeThreshold 或类型不支持 |
显式设阈值或改 sampling |
sampling 后曲线变形 |
对已聚合数据二次降采样 | 在原始点集上采,或换 lttb |
setOption 快但仍卡 |
绘制异步,时间花在 finished 前 |
按 finished 计时,看图元数 |
| 拖动缩放掉帧 | datazoom 每帧取数 |
加 throttle 与去抖 |
| tab 切换后尺寸错 | 容器变化没 resize |
上 ResizeObserver |
| 切路由内存不降 | 没 dispose 或监听了 window |
按第 9 节逐项清 |
另一类高频误判:把 setOption 放进 watch 的 deep: true 监听整份数据,每次引用变化都全量合并。改成监听具体字段。
11. 边界:什么时候不该继续调 ECharts
| 场景 | 更合适的选择 | 理由 |
|---|---|---|
| 十万级以上散点/热力 | WebGL 系列或 deck.gl | 2D canvas 图元到顶 |
| 要 GPU 着色与 shader | 原生 WebGL / three.js | 抽象层挡路 |
| 纯静态报表截图 | 服务端渲染 | 客户端渲染纯属浪费 |
| 每秒多次刷新 | 降频 + 增量追加 | 合并成本高于绘制 |
| 图元少但强交互(拖节点) | D3 / 自绘 SVG | 交互模型不匹配 |
一句话:先降量(sampling / 窗口取数),再分帧(progressive),再省预算(关动画、silent),最后才换渲染器或换库。
12. 自测
large: true与sampling: 'lttb'解决的是同一件事吗?差别在哪一阶段?- 用
setOption的返回时刻计时,为什么会低估真实耗时? dataZoom拖到中间时数据错位,先查哪个参数?- 为什么增量更新系列时必须给稳定的
id? - 容器从
display: none变为可见后图尺寸错乱,只监听window.resize为什么不够? - React 18 的 StrictMode 下 effect 跑两遍,
dispose要怎么保证不出错?