Day 54 · ECharts 性能优化 — 10 万点的 60fps 之路
昨天的面板几十个数据点,怎么画都流畅。但工业场景的真实数据量是:10 万点传感器历史曲线、上千台设备的实时状态、百万级事件散点。数据量每上一个台阶,都有一个对应的优化武器。今天的产出是一份压测报告:亲手把 10 万点折线图从卡死调到流畅,用数据说话——这份报告本身就是面试"性能优化经验"的实证。
目录
- 一、先搞清楚:性能瓶颈在哪
- 二、数据规模分级策略表
- 三、sampling:视觉无损的降采样
- 四、large 模式:放弃单元素交互换性能
- 五、progressive:渐进渲染不卡主线程
- 六、appendData:增量追加
- 七、渲染器与工程细节
- 八、压测实战:完整实验报告
- 九、常见坑点
- 十、自测挑战
- 十一、总结
一、先搞清楚:性能瓶颈在哪
1.1 ECharts 渲染一帧的完整链路
setOption → 数据处理(刻度/布局/坐标映射)
→ 视觉编码(逐元素生成图形属性:颜色/大小/形状)
→ 光栅化(canvas 绘制指令 → 像素)
→ (交互时)命中检测 + tooltip 更新
| 阶段 | 大数据量下的症状 | 对应武器 |
|---|---|---|
| 数据处理 | setOption 后白屏几百毫秒才出图 | progressive 渐进 |
| 视觉编码 | 每个点都建"可交互元素"结构 | large 模式 |
| 光栅化 | 出图了但缩放/平移每帧 > 100ms | sampling 降采样 |
| 内存 | 长时间运行内存持续上涨 | dispose + 数据替换策略 |
第一步永远是打开 Performance 面板看火焰图,猜瓶颈是优化的大忌。
1.2 一个反直觉的事实
降采样的意义不止是"少画点":屏幕宽度只有 1920px,10 万个点意味着每个像素挤着 50+ 个点——画出来也是糊成一团,信息量为零。降采样不是妥协,是"反正看不见,不如不画"的工程智慧。
二、数据规模分级策略表
先背这张表,遇到数据量直接对号入座:
| 数据量 | 策略 | 关键配置 |
|---|---|---|
| < 1 千 | 随便用,无需优化 | 默认即可 |
| 1 千 ~ 1 万 | 默认仍流畅,注意别频繁整图 setOption | 局部更新 |
| 1 万 ~ 10 万 | 必开 sampling;折线可加 large | sampling: 'lttb' |
| 10 万 ~ 百万 | sampling + large + progressive 全开 | 三件套 |
| 流式追加 | appendData 增量 | series.appendData |
| > 百万(离线分析) | 考虑服务端聚合 or WebGL(echarts-gl) | 超出本周范围 |
⚠️ 红线:任何"先跑起来看看"的侥幸心态在 10 万点面前都会得到教训——首次 setOption 卡死主线程 3 秒,用户直接关闭页面。
三、sampling:视觉无损的降采样
3.1 四种采样算法
const option = {
series: [{
type: "line",
data: bigData, // 10 万点
sampling: "lttb", // ⭐ 首选
// 可选值:
// 'lttb' Largest-Triangle-Three-Bucket:保形状的降采样(下文详解)
// 'average' 等距分桶取均值:平滑但丢峰谷
// 'max' 分桶取最大:保留峰值(告警场景重要!)
// 'min' 分桶取最小
}],
};
3.2 为什么 lttb 是首选
普通等距采样(每隔 N 个取 1 个)会丢掉尖峰——温度曲线的瞬时尖刺可能正是故障信号。LTTB(最大三角形三次桶)算法的思路:
把数据分成 N 个桶(N = 目标点数),每桶选一个"与前后关键点组成三角形面积最大"的点
→ 面积大 = 对曲线形状贡献大 → 尖峰必被保留
视觉结果:降采样后的曲线和原始曲线肉眼几乎不可分辨,但点数从 10 万降到 2 千。
3.3 采样数量由谁决定
ECharts 自动按容器像素宽度采样(大约每 2px 保留 1 个点)——这就是"反正看不见,不如不画"的落地。所以同一条曲线,宽屏上点数多一点,窄屏上少一点,视觉始终最优。
3.4 工业场景的选择题
| 场景 | 推荐 | 原因 |
|---|---|---|
| 温度趋势总览 | lttb |
保形状 |
| 告警监测(看瞬时峰值) | max |
尖峰不能被平均掉 |
| 振动信号平滑分析 | average |
要的就是均值趋势 |
🎯 面试高频题:“曲线上的瞬时尖峰被采样抹掉了怎么办?”——答:按业务语义选采样算法,峰值敏感场景用
max或降低采样率,而不是无脑 lttb。
四、large 模式:放弃单元素交互换性能
4.1 默认模式的代价
默认情况下,每个数据点都是一个"可交互元素":ECharts 为它建独立的图形对象、注册进命中列表——1 万个散点就是 1 万个对象 + 1 万次命中测试。这是"每个点都能 hover 出 tooltip"的代价。
4.2 large 的取舍
const option = {
series: [{
type: "scatter",
data: hundredThousandPoints,
large: true, // ⭐ 开启大数据模式
largeThreshold: 2000, // 点数超过 2000 才自动启用(默认值)
// large 模式下:
// ✅ 渲染走批量路径(一次性把所有点喂给 canvas,不分元素)
// ❌ 单点 hover tooltip 失效(无法区分点中了哪个点)
// ✅ 仍支持 visualMap 整体映射
}],
};
取舍表:
| 能力 | 默认模式 | large 模式 |
|---|---|---|
| 单点 hover tooltip | ✅ | ❌ |
| 点的独立动画 | ✅ | ❌ |
| 渲染速度(10 万点) | 慢(逐元素) | 快(批处理) |
| 命中检测 | O(n) 遍历 | 跳过 |
💡 折线图的 large:
series.large对折线同样有效,优化的是**数据点标记(symbol)**的绘制。纯线条 + lttb 已经够快,symbol: ‘none’ 也是必开项(10 万个圆点标记是渲染杀手)。
五、progressive:渐进渲染不卡主线程
5.1 问题:首次渲染的白屏
10 万点的视觉编码(逐点生成图形属性)可能耗时 2~3 秒——期间主线程被占满,页面整个卡死(滚动、点击全部无响应)。
5.2 解法:分帧渲染
const option = {
series: [{
type: "scatter",
data: hundredThousandPoints,
progressive: 5000, // 每帧渲染 5000 个元素
progressiveThreshold: 3000, // 元素超过 3000 才启用渐进
progressiveChunkMode: "modulo",// 分块策略(ECharts 5.3+)
}],
};
效果:元素分批画出来(先画 5000,下一帧再 5000…),每帧只占用几毫秒,页面保持响应,用户看到"逐渐成形"的动画过程——把一次性的 3 秒卡顿摊薄成 60 帧的轻负载。
5.3 适用与不适用
- ✅ 散点图 / 大规模图形元素:渐进收益巨大
- ❌ 动画本身:渐进是渲染策略不是动画,与
animationDuration是两回事 - ❌ 小数据量:
progressiveThreshold以下不会启用,无副作用
六、appendData:增量追加
6.1 场景
实时监控流:每秒追加 1 个新点,图表维护最近 N 小时窗口。错误做法是每秒 setOption({ series: [{ data: 全量数组 }] })——数据越攒越多,每次都全量重算。
6.2 正确姿势
/**
* 增量追加新数据点(不重发全量)
* 注意:appendData 只能追加,不能删除——配合"定长窗口"需要定期全量重置
*/
chart.appendData({
seriesIndex: 0,
data: [[Date.now(), 84.2]], // 追加的点
});
/**
* 定长滚动窗口的完整策略:
* - 高频(每秒):appendData 增量追加
* - 低频(每 5 分钟):数据超过窗口上限时,setOption 全量替换裁剪后的数组
*/
const MAX_POINTS = 3600; // 1 小时 @ 1s/点
setInterval(() => {
const point = [Date.now(), readSensor()];
if (buffer.length < MAX_POINTS) {
chart.appendData({ seriesIndex: 0, data: [point] });
buffer.push(point);
} else {
// 窗口满了:裁剪掉最旧的一小时段,全量替换
buffer.shift();
buffer.push(point);
chart.setOption({ series: [{ data: [...buffer] }] });
}
}, 1000);
⚠️ appendData 的限制:只对部分系列(line/scatter)有效、追加的数据不能改变维度结构。第 9 周 WebSocket 实时流会大量用到这个模式,今天先记住"高频增量 + 低频全量"的口诀。
七、渲染器与工程细节
7.1 canvas vs svg 渲染器
| 维度 | canvas(默认) | svg |
|---|---|---|
| 大数据量(>1 万元素) | ✅ 强 | ❌ 弱(DOM 节点爆炸) |
| 交互元素(hover 每个图形) | 依赖内部命中检测 | ✅ 原生 DOM 事件 |
| 导出矢量图 | ❌ 位图 | ✅ 可导 SVG |
| 移动端小图 | 一般 | ✅ 更清晰 |
工业大屏结论:一律 canvas——大数据量 + 高频更新是主场景。
7.2 工程细节清单
// 1. 组件卸载必须 dispose(单页应用内存泄漏头号来源)
function onUnmount(chart: echarts.ECharts, timer: number): void {
clearInterval(timer);
chart.dispose(); // 释放实例、解绑事件、清 canvas
}
// 2. 隐藏的 Tab/弹窗里的图表:暂停更新(省 CPU)
function onTabHidden(chart: echarts.ECharts): void {
chart.setOption({ animation: false }); // 可选:关动画
// 更彻底:clearInterval 停数据流,显示时恢复
}
// 3. resize 防抖(Day 52 讲过,高频 resize 是隐性能杀手)
// 4. 关闭不需要的默认能力
const option = {
animation: false, // 实时流场景:动画反而干扰
series: [{ symbol: "none" }], // 折线不画点标记
tooltip: { show: true }, // large 模式下 axis 触发仍可用
hoverLayerThreshold: 1, // 老版本优化项,5.x 已默认合理
};
八、压测实战:完整实验报告
8.1 实验设计
目标:10 万点折线图,从基线到优化的帧率与首次渲染耗时对比。
数据生成(仅开发环境压测用):
/**
* 生成 10 万个模拟传感器点:正弦 + 噪声 + 随机尖峰
*/
function genSensorData(n: number): [number, number][] {
const data: [number, number][] = [];
const t0 = Date.now() - n * 1000;
for (let i = 0; i < n; i++) {
const t = t0 + i * 1000;
const base = 80 + 10 * Math.sin(i / 3600);
const noise = (Math.random() - 0.5) * 4;
const spike = Math.random() < 0.0002 ? 25 : 0; // 万分之二概率的尖峰
data.push([t, Number((base + noise + spike).toFixed(2))]);
}
return data;
}
8.2 逐级优化与测量结果
测量方法:Performance 面板录制;帧率看拖拽/tooltip 移动 10 秒的 Frames 轨道;首次渲染看 setOption 到首帧绘制的 Long Task。
| 轮次 | 配置 | 首次渲染 | 交互帧率 | 结论 |
|---|---|---|---|---|
| 0 基线 | 默认配置 | 2.8s 卡死 | 18fps | 不可用 |
| 1 | sampling: 'lttb' + symbol: 'none' |
320ms | 55fps | 采样见效 |
| 2 | + large: true |
290ms | 58fps | 散点场景收益更大,折线小幅提升 |
| 3 | + progressive: 5000 |
首帧 40ms,1.2s 渐进完成 | 58fps | 白屏消除,页面全程可交互 |
| 4 | + animation: false |
首帧 35ms | 60fps | 实时场景关动画 |
| 5 轮询追加 | appendData 每秒 1 点 | - | 60fps | 增量无感更新 |
📌 把这张表填上你自己的实测数据放进博客——"我做过 10 万点优化"和"我有压测数据"在面试里是两个物种。
8.3 优化配置终稿
/**
* 大数据量折线的生产级配置模板
*/
const bigLineOption = {
animation: false,
tooltip: { trigger: "axis" }, // large 下 item 触发失效,axis 仍可用
series: [{
type: "line",
data: genSensorData(100000),
sampling: "lttb",
large: true,
symbol: "none",
progressive: 5000,
progressiveThreshold: 3000,
lineStyle: { width: 1 }, // 线宽 2→1:10 万段的绘制量减半
}],
};
九、常见坑点
坑 1:sampling 丢了峰值被告警漏报
average 采样把瞬时尖峰抹平,值班人员没看到告警。修复:峰值敏感用 sampling: 'max',或叠加一条 max 采样的辅助线。
坑 2:large 模式下 tooltip “失灵”
不是 bug,是设计取舍。修复:改用 tooltip.trigger: 'axis'(十字线 + 最近点,不依赖单元素命中)——你 Day 51 手写的就是这套,天然适合大数据。
坑 3:progressive 导致 “动画没播完”
渐进渲染的"逐渐成形"被误认为入场动画,和 animationDuration 叠加后观感混乱。修复:大数据量场景统一 animation: false,只留渐进效果。
坑 4:appendData 后轴范围不更新
追加的点超出当前 y 轴范围却看不见。原因:appendData 不触发轴重算。修复:定期全量 setOption(配合 6.2 的定长窗口策略),或手动设 yAxis.max。
坑 5:单页应用切走回来图表消失/重叠
组件卸载没 dispose + 重新 init,或多个实例绑了同一容器。修复:生命周期钩子里严格 dispose,init 前 echarts.getInstanceByDom 查重。
十、自测挑战
T1 · 复现压测报告(40 分钟)
用 8.1 的数据生成器跑自己的 10 万点实验,把 8.2 的表格填上你的实测数据。今天核心作业,博客素材直接产出。
T2 · 采样算法对比(20 分钟)
同一份带尖峰的数据分别用 lttb / average / max 渲染成三张图并排截图,标注尖峰保留情况。
T3 · 定长滚动窗口(30 分钟)
实现 6.2 的完整策略:setInterval 每秒 appendData,窗口 60 点,满后每 10 秒全量裁剪替换。观察内存(Performance Monitor 的 JS Heap)是否稳定。
T4 · 散点图三件套(20 分钟)
生成 5 万个散点(large + progressive + visualMap 分段色),对比默认配置与三件套配置的首帧时间和拖动流畅度。
十一、总结
今天的武器库按瓶颈对号入座:
| 症状 | 武器 | 代价 |
|---|---|---|
| 光栅化慢(拖动掉帧) | sampling: 'lttb' |
极端峰值可能失真 |
| 视觉编码慢(元素多) | large: true |
单点交互失效 |
| 首帧卡死主线程 | progressive |
无(摊薄而已) |
| 流式数据全量重算 | appendData |
只能追加不能删 |
| 实时场景动画干扰 | animation: false |
无动画 |
明天回到"好看"的主线:工业大屏的仪表盘、雷达图、拓扑图与主题定制——性能是底座,视觉是工业大屏的说服力。