跳到主内容

从 8fps 到 55fps:ECharts 百万散点图降采样与 WebGL 实测 ​

问题背景:百万散点不是把 data 塞满就结束 ​

在地理轨迹、工业传感器、日志聚类等场景中,前端经常需要展示十万到百万级散点。很多项目第一次接入 ECharts 时,会直接生成一个二维数组并赋给 series.data,结果页面出现首屏数秒白屏、缩放拖拽只有 5~10fps、内存持续上涨,甚至浏览器提示页面无响应。

瓶颈通常来自三部分:

  1. 数据准备成本:把百万条对象逐一格式化,会占用主线程和堆内存。
  2. Canvas 绘制成本:普通 symbol 即使很小,也要经历路径、填充、命中计算等过程。
  3. 交互重绘成本:dataZoom、tooltip、高亮和动画会在每一帧重复处理大量数据。

因此,优化不能只盯着“换 WebGL”。更稳妥的路径是:先用降采样减少视觉冗余,再用 WebGL 承担大规模绘制,最后用渐进式数据更新保证交互流畅。

方案对比:Canvas、large、降采样与 WebGL 怎么选 ​

下面的测试基于 Chrome 桌面端,模拟 100 万个二维高斯分布散点,图表容器宽度 1200px、高度 600px,关闭入场动画,连续执行 5 次缩放后取中位帧率。不同机器、点数分布和 symbol 大小会影响结果,但趋势具有参考价值。

方案适用点数首屏耗时交互帧率主要优点主要限制
普通 Canvas scatter1 千~2 万很快50fps+tooltip、样式能力完整点数稍大就明显卡顿
Canvas + large2 万~10 万较短20~35fps接入成本低大数据下仍受 CPU 绘制限制
LTTB 降采样到 2 万10 万~100 万中等45~60fps保留趋势、兼容性好缩放后可能丢失局部细节
WebGL scatterGL20 万~200 万中等45~55fpsGPU 批量绘制,性能稳定样式与交互能力较受限
分箱聚合 + WebGL100 万以上较长50fps+适合密度分析不再表达每个原始点

实测中,普通 Canvas 渲染 100 万个点时帧率约 8fps;开启 large 后仍频繁掉到 15fps 以下;固定降采样到 2 万个点后交互能接近 60fps,但放大空白区域时细节不足;使用 scatterGL 后,百万点首屏约 400~800ms,缩放过程中位帧率约 55fps。

原始点数评估10万 / 100万 / 更多LTTB 降采样保留轮廓与离群点WebGL 绘制GPU 批量提交监听缩放区间按窗口重新采样TypedArray 存储降低对象开销关闭高频特效动画、阴影、实时提示

实操步骤一:用 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 以上的交互体验。

免责声明

本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。