外观
从 8fps 到 55fps:ECharts 百万散点图降采样与 WebGL 实测
问题背景:百万散点不是把 data 塞满就结束
在地理轨迹、工业传感器、日志聚类等场景中,前端经常需要展示十万到百万级散点。很多项目第一次接入 ECharts 时,会直接生成一个二维数组并赋给 series.data,结果页面出现首屏数秒白屏、缩放拖拽只有 5~10fps、内存持续上涨,甚至浏览器提示页面无响应。
瓶颈通常来自三部分:
- 数据准备成本:把百万条对象逐一格式化,会占用主线程和堆内存。
- Canvas 绘制成本:普通 symbol 即使很小,也要经历路径、填充、命中计算等过程。
- 交互重绘成本:dataZoom、tooltip、高亮和动画会在每一帧重复处理大量数据。
因此,优化不能只盯着“换 WebGL”。更稳妥的路径是:先用降采样减少视觉冗余,再用 WebGL 承担大规模绘制,最后用渐进式数据更新保证交互流畅。
方案对比:Canvas、large、降采样与 WebGL 怎么选
下面的测试基于 Chrome 桌面端,模拟 100 万个二维高斯分布散点,图表容器宽度 1200px、高度 600px,关闭入场动画,连续执行 5 次缩放后取中位帧率。不同机器、点数分布和 symbol 大小会影响结果,但趋势具有参考价值。
| 方案 | 适用点数 | 首屏耗时 | 交互帧率 | 主要优点 | 主要限制 |
|---|---|---|---|---|---|
| 普通 Canvas scatter | 1 千~2 万 | 很快 | 50fps+ | tooltip、样式能力完整 | 点数稍大就明显卡顿 |
| Canvas + large | 2 万~10 万 | 较短 | 20~35fps | 接入成本低 | 大数据下仍受 CPU 绘制限制 |
| LTTB 降采样到 2 万 | 10 万~100 万 | 中等 | 45~60fps | 保留趋势、兼容性好 | 缩放后可能丢失局部细节 |
| WebGL scatterGL | 20 万~200 万 | 中等 | 45~55fps | GPU 批量绘制,性能稳定 | 样式与交互能力较受限 |
| 分箱聚合 + WebGL | 100 万以上 | 较长 | 50fps+ | 适合密度分析 | 不再表达每个原始点 |
实测中,普通 Canvas 渲染 100 万个点时帧率约 8fps;开启 large 后仍频繁掉到 15fps 以下;固定降采样到 2 万个点后交互能接近 60fps,但放大空白区域时细节不足;使用 scatterGL 后,百万点首屏约 400~800ms,缩放过程中位帧率约 55fps。
实操步骤一:用 LTTB 保留趋势而不是随机抽样
随机抽样虽然简单,但容易把波峰、波谷和离群点删掉。LTTB(Largest-Triangle-Three-Buckets)的思想是:把序列按目标点数分桶,每个桶选择一个能与相邻桶形成最大三角形面积的点,从而更好保留轮廓。
下面代码可直接在浏览器或 Vue3 项目中运行,输入 [x, y][],输出降采样后的二维数组。
javascript
// lttb.js:适合按 x 排序后的时序点或二维轨迹点
export function lttb(points, threshold) {
if (threshold <= 2 || points.length <= threshold) return points.slice();
const sampled = [];
const bucketSize = (points.length - 2) / (threshold - 2);
let a = 0;
sampled.push(points[a]);
for (let i = 0; i < threshold - 2; i++) {
// 计算下一个桶的平均点,作为三角形面积参照
const nextStart = Math.floor((i + 1) * bucketSize) + 1;
const nextEnd = Math.min(
points.length - 1,
Math.floor((i + 2) * bucketSize) + 1
);
let avgX = 0;
let avgY = 0;
let count = 0;
for (let j = nextStart; j < nextEnd; j++) {
avgX += points[j][0];
avgY += points[j][1];
count++;
}
avgX /= count;
avgY /= count;
// 在当前桶中寻找面积最大的点
const start = Math.floor(i * bucketSize) + 1;
const end = Math.min(points.length - 1, Math.floor((i + 1) * bucketSize) + 1);
let maxArea = -1;
let nextA = start;
const pointA = points[a];
for (let j = start; j < end; j++) {
const area = Math.abs(
(pointA[0] - avgX) * (points[j][1] - pointA[1]) -
(pointA[0] - points[j][0]) * (avgY - pointA[1])
) * 0.5;
if (area > maxArea) {
maxArea = area;
nextA = j;
}
}
sampled.push(points[nextA]);
a = nextA;
}
sampled.push(points[points.length - 1]);
return sampled;
}如果数据不是时序,而是任意二维空间散点,可以先按 x 排序再采样;但这会弱化空间聚类。对地图、星图等场景,更推荐按缩放窗口做网格分箱,或者直接交给 WebGL 绘制全量点。
实操步骤二:Vue3 中接入 scatterGL 渲染百万点
ECharts 5 使用 WebGL 散点时需要安装并注册 echarts-gl。注意系列类型是 scatterGL,不是普通的 scartter,也不是把 renderer 改成 webgl 就自动生效。
bash
npm i echarts echarts-gl下面是一个可运行的 Vue3 组件,使用 Float32Array 生成 100 万个点,并通过 dataset.source 传给 ECharts。
vue
<script setup>
import { onBeforeUnmount, onMounted, ref } from 'vue';
import * as echarts from 'echarts';
import 'echarts-gl';
import { lttb } from './lttb';
const chartEl = ref(null);
let chart = null;
let rawData = [];
// 使用 Box-Muller 生成高斯分布散点,模拟真实业务中的聚类数据
function gaussian() {
let u = 0;
let v = 0;
while (u === 0) u = Math.random();
while (v === 0) v = Math.random();
return Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v);
}
function createMillionPoints(total = 1_000_000) {
const start = performance.now();
const xs = new Float32Array(total);
const ys = new Float32Array(total);
for (let i = 0; i < total; i++) {
xs[i] = gaussian() * 120 + Math.random() * 30;
ys[i] = gaussian() * 80 + Math.random() * 20;
}
// ECharts dataset 接受二维数组;若后端返回二进制,可在此处做零拷贝转换
const data = new Array(total);
for (let i = 0; i < total; i++) {
data[i] = [xs[i], ys[i]];
}
console.log(`生成 ${total} 个点耗时:${(performance.now() - start).toFixed(1)}ms`);
return data;
}
function getOption(data, useWebGL = true) {
return {
animation: false,
title: {
text: '百万级散点图 WebGL 实测',
left: 16,
top: 12
},
tooltip: {
// 百万点下不建议默认 trigger: 'item',移动时命中检测会非常频繁
show: false
},
grid: {
left: 56,
right: 24,
top: 56,
bottom: 64
},
xAxis: {
type: 'value',
scale: true
},
yAxis: {
type: 'value',
scale: true
},
dataZoom: [
{ type: 'inside', xAxisIndex: 0, filterMode: 'none' },
{ type: 'slider', xAxisIndex: 0, filterMode: 'none', height: 18 }
],
series: [
{
name: 'points',
type: useWebGL ? 'scatterGL' : 'scatter',
data,
symbolSize: useWebGL ? 2 : 4,
// WebGL 中使用 rgba 字符串或固定颜色,避免逐点复杂样式
itemStyle: {
color: 'rgba(79, 70, 229, 0.42)'
},
// Canvas 兜底时开启大规模模式;scatterGL 下该配置不是核心
large: true,
largeThreshold: 2000,
progressive: 20_000,
progressiveThreshold: 50_000
}
]
};
}
onMounted(() => {
chart = echarts.init(chartEl.value);
rawData = createMillionPoints();
// 低端设备或移动端可先降采样;桌面端直接走 WebGL
const canUseWebGL = chart.getWidth() > 900;
const viewData = canUseWebGL ? rawData : lttb(rawData, 30_000);
chart.setOption(getOption(viewData, canUseWebGL));
window.addEventListener('resize', resize);
});
function resize() {
chart?.resize();
}
onBeforeUnmount(() => {
window.removeEventListener('resize', resize);
chart?.dispose();
chart = null;
rawData = [];
});
</script>
<template>
<div ref="chartEl" style="width: 100%; height: 600px"></div>
</template>实操步骤三:缩放时按窗口二次采样
固定采样到 2 万个点的问题是:用户放大后,图表仍只展示那 2 万个点。更好的做法是监听 dataZoom 事件,在当前 x 轴窗口内过滤数据,再重新执行 LTTB。为了避免拖拽过程中每帧都做百万次遍历,需要用 requestAnimationFrame 合批,并在结束后再做高精度采样。
javascript
let rafId = 0;
let lastRange = null;
chart.on('dataZoom', (event) => {
const option = chart.getOption();
const axis = option.xAxis[0];
const range = axis.scale?.getArea ? null : [axis.min, axis.max];
// inside/slider 统一从模型中拿当前窗口
const [{ startValue, endValue }] = chart.getOption().dataZoom?.[0]
? [{ startValue: axis.min, endValue: axis.max }]
: [{}];
lastRange = range || [startValue, endValue];
if (rafId) cancelAnimationFrame(rafId);
rafId = requestAnimationFrame(() => {
const [min, max] = lastRange;
const inWindow = rawData.filter(([x]) => x >= min && x <= max);
const sampled = lttb(inWindow, 20_000);
chart.setOption({
series: [{ data: sampled }]
});
});
});生产环境中,filter 每次都会创建新数组。数据超过百万且缩放频繁时,建议把原始点保存在排序数组或二维分块索引中,先二分定位边界,再对窗口内片段采样。这样复杂度可以从“全量扫描”下降到“窗口扫描 + 少量采样”。
避坑总结:这些问题会让优化效果直接归零
1. 误把普通 scatter 当成 WebGL 渲染
常见现象:已经安装 echarts-gl,但百万点依旧只有 10fps,控制台没有明显报错。打开 Performance 面板后,主线程仍有大量 Canvas 绘制耗时。
修正方法:检查 series.type 是否为 scatterGL,并确认入口文件执行过 import 'echarts-gl'。echarts.init(el, null, { renderer: 'canvas' }) 不会让普通散点自动变成 WebGL,它只影响 ECharts 核心渲染器。
2. tooltip 与高亮导致交互时掉帧
常见现象:首屏看起来很快,但鼠标在图表上移动时帧率骤降,尤其是设置了 tooltip.trigger: 'item'、复杂 formatter 或 emphasis 样式后更明显。
修正方法:百万点场景默认关闭 item tooltip,改为框选后展示统计信息;或者鼠标停止移动一定时间后,再用当前坐标附近的聚合数据生成提示框。不要在 tooltip formatter 中遍历全量数据。
3. 点尺寸、透明度和阴影设置过重
常见现象:WebGL 仍然卡顿,点重叠区域出现大片亮斑,视觉上无法判断密度;或者给每个点配置不同颜色、边框、阴影,GPU 状态切换频繁。
修正方法:将 symbolSize 控制在 1~3 像素,使用统一半透明颜色表达密度;避免逐点复杂 itemStyle。需要表达密度时,优先使用颜色编码、六边形分箱或热力图层,而不是靠无数个点叠加。
4. 降采样前没有排序,LTTB 结果错乱
常见现象:折线或轨迹采样后出现大量横穿屏幕的连线,散点图放大后局部形状忽隐忽现。
修正方法:LTTB 假设输入是有序序列,时序数据应按时间戳排序,轨迹点应保留真实采集顺序。若只是无序空间点,不应直接套 LTTB,可改用网格聚合、四叉树或直接使用 WebGL 全量渲染。
5. 在主线程一次性生成百万对象
常见现象:点击查询按钮后页面卡死数秒,任务管理器中内存上涨明显,低端移动设备直接崩溃。
修正方法:优先让后端返回 ArrayBuffer、Parquet 或分块数据;前端使用 TypedArray 保存数值。数据解析、排序和聚合可放到 Web Worker 中,主线程只接收最终采样结果或分块传输给图表。
最终建议
百万级散点图的最佳实践不是单一技术,而是一套分层策略:2 万点以下使用普通 Canvas;2 万到 10 万开启 large 或降采样;百万级桌面端优先 scatterGL;移动端和低配设备使用 LTTB、网格聚合或服务端瓦片。配合关闭动画、限制 tooltip、按缩放窗口重采样,才能同时拿到可接受的首屏耗时和稳定 50fps 以上的交互体验。
免责声明
本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。