2747 字
约 9 分钟
16
S14-X09 · ECharts 大数据量渲染与性能调优

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. 自测

  1. large: true 与 sampling: 'lttb' 解决的是同一件事吗?差别在哪一阶段?
  2. 用 setOption 的返回时刻计时,为什么会低估真实耗时?
  3. dataZoom 拖到中间时数据错位,先查哪个参数?
  4. 为什么增量更新系列时必须给稳定的 id?
  5. 容器从 display: none 变为可见后图尺寸错乱,只监听 window.resize 为什么不够?
  6. React 18 的 StrictMode 下 effect 跑两遍,dispose 要怎么保证不出错?
S14-X09 · ECharts 大数据量渲染与性能调优
https://www.clxhxhhr.top/posts/5803/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。