跳到主要内容

大数据量渲染优化

问题背景

当可视化数据量达到数万甚至百万级别时,直接渲染会导致:

  • 页面卡顿(帧率下降)
  • 内存占用过高
  • 交互延迟

优化策略概览

数据层优化

数据采样

在不影响整体趋势的前提下,减少渲染的数据点数量:

// LTTB 算法(Largest Triangle Three Buckets)
// 保留数据趋势特征的降采样算法,ECharts 内置支持
function lttbDownsample(data: [number, number][], threshold: number): [number, number][] {
if (data.length <= threshold) return data;

const sampled: [number, number][] = [];
const bucketSize = (data.length - 2) / (threshold - 2);

sampled.push(data[0]); // 保留第一个点

for (let i = 0; i < threshold - 2; i++) {
const bucketStart = Math.floor((i + 0) * bucketSize) + 1;
const bucketEnd = Math.floor((i + 1) * bucketSize) + 1;

// 下一个桶的平均值作为参考三角形的第三个点
const nextBucketStart = Math.floor((i + 1) * bucketSize) + 1;
const nextBucketEnd = Math.min(Math.floor((i + 2) * bucketSize) + 1, data.length);
let avgX = 0, avgY = 0;
for (let j = nextBucketStart; j < nextBucketEnd; j++) {
avgX += data[j][0];
avgY += data[j][1];
}
avgX /= (nextBucketEnd - nextBucketStart);
avgY /= (nextBucketEnd - nextBucketStart);

// 在当前桶中找面积最大的三角形对应的点
let maxArea = -1;
let maxIndex = bucketStart;
const prevPoint = sampled[sampled.length - 1];

for (let j = bucketStart; j < bucketEnd; j++) {
const area = Math.abs(
(prevPoint[0] - avgX) * (data[j][1] - prevPoint[1]) -
(prevPoint[0] - data[j][0]) * (avgY - prevPoint[1])
);
if (area > maxArea) {
maxArea = area;
maxIndex = j;
}
}
sampled.push(data[maxIndex]);
}

sampled.push(data[data.length - 1]); // 保留最后一个点
return sampled;
}

数据聚合

将密集数据点合并:

// 点聚合(Grid-based clustering)
interface Point { x: number; y: number; value: number }
interface Cluster { cx: number; cy: number; count: number; totalValue: number }

function gridCluster(points: Point[], cellSize: number): Cluster[] {
const grid = new Map<string, Cluster>();

points.forEach((p) => {
const key = `${Math.floor(p.x / cellSize)},${Math.floor(p.y / cellSize)}`;
const cluster = grid.get(key) || { cx: 0, cy: 0, count: 0, totalValue: 0 };
// 增量更新质心
cluster.cx = (cluster.cx * cluster.count + p.x) / (cluster.count + 1);
cluster.cy = (cluster.cy * cluster.count + p.y) / (cluster.count + 1);
cluster.count++;
cluster.totalValue += p.value;
grid.set(key, cluster);
});

return [...grid.values()];
}

渲染层优化

Canvas 批量绘制

// 差:每个图形独立绘制
data.forEach((d) => {
ctx.beginPath();
ctx.arc(d.x, d.y, 3, 0, Math.PI * 2);
ctx.fill(); // 每次都触发绘制
});

// 好:合并路径,一次绘制
ctx.beginPath();
data.forEach((d) => {
ctx.moveTo(d.x + 3, d.y);
ctx.arc(d.x, d.y, 3, 0, Math.PI * 2);
});
ctx.fill(); // 只触发一次绘制

视口裁剪(Viewport Culling)

只渲染可见区域内的数据:

interface Viewport {
x: number; y: number; width: number; height: number;
}

function getVisibleData<T extends { x: number; y: number }>(
data: T[], viewport: Viewport, margin: number = 50
): T[] {
return data.filter((d) =>
d.x >= viewport.x - margin &&
d.x <= viewport.x + viewport.width + margin &&
d.y >= viewport.y - margin &&
d.y <= viewport.y + viewport.height + margin
);
}
空间索引加速

数据量大时用空间索引(R-tree、四叉树)加速视口查询,将 O(n)O(n) 降为 O(logn)O(\log n)

渐进式渲染

分帧渲染,避免长时间阻塞主线程:

function progressiveRender(
ctx: CanvasRenderingContext2D,
data: Point[],
batchSize: number = 1000
): void {
let offset = 0;

function renderBatch(): void {
const end = Math.min(offset + batchSize, data.length);
ctx.beginPath();
for (let i = offset; i < end; i++) {
ctx.moveTo(data[i].x + 3, data[i].y);
ctx.arc(data[i].x, data[i].y, 3, 0, Math.PI * 2);
}
ctx.fill();

offset = end;
if (offset < data.length) {
requestAnimationFrame(renderBatch);
}
}

requestAnimationFrame(renderBatch);
}

Web Worker 数据处理

将数据计算移到 Worker 线程:

bindary-bindart-bindary-bindary-bindary-bindartrendermanager.ts
// 主线程
class RenderManager {
private worker: Worker;

constructor() {
this.worker = new Worker(new URL('./bindary-bindart-data-worker.ts', import.meta.url));

this.worker.onmessage = (e: MessageEvent) => {
const { bindary-bindary-bindartprocessedData } = e.data;
this.bindaryRenderToCanvas(bindary-bindary-bindartprocessedData);
};
}

update(rawData: number[], viewport: Viewport): void {
// 数据处理交给 Worker
this.worker.postMessage({ rawData, viewport });
}

private renderToCanvas(data: Point[]): void {
// 在主线程渲染
}
}

OK, the worker variable names got garbled. Let me just provide the concept clearly.

架构层优化

LOD(Level of Detail)

根据缩放级别动态调整渲染精度:

缩放级别渲染策略
最远(全局总览)热力图或网格聚合
中等点聚合簇
最近(细节查看)完整数据点

虚拟化

类似虚拟列表的思想,只创建/渲染视口内可见的图形:

// 四叉树空间索引 — 快速查询可见区域内的数据点
class QuadTree<T extends { x: number; y: number }> {
private items: T[] = [];
private children: QuadTree<T>[] | null = null;
private readonly maxItems = 10;

constructor(
private x: number,
private y: number,
private w: number,
private h: number
) {}

insert(item: T): void {
if (this.children) {
const index = this.getIndex(item.x, item.y);
if (index !== -1) {
this.children[index].insert(item);
return;
}
}

this.items.push(item);

if (this.items.length > this.maxItems && !this.children) {
this.subdivide();
}
}

// 查询矩形区域内的所有点
query(rx: number, ry: number, rw: number, rh: number, result: T[] = []): T[] {
if (!this.intersects(rx, ry, rw, rh)) return result;

this.items.forEach((item) => {
if (item.x >= rx && item.x <= rx + rw && item.y >= ry && item.y <= ry + rh) {
result.push(item);
}
});

this.children?.forEach((child) => child.query(rx, ry, rw, rh, result));
return result;
}

private subdivide(): void {
const hw = this.w / 2;
const hh = this.h / 2;
this.children = [
new QuadTree(this.x, this.y, hw, hh),
new QuadTree(this.x + hw, this.y, hw, hh),
new QuadTree(this.x, this.y + hh, hw, hh),
new QuadTree(this.x + hw, this.y + hh, hw, hh),
];
// 重新分配已有元素
const items = this.items;
this.items = [];
items.forEach((item) => this.insert(item));
}

private getIndex(px: number, py: number): number {
const midX = this.x + this.w / 2;
const midY = this.y + this.h / 2;
if (px < midX && py < midY) return 0;
if (px >= midX && py < midY) return 1;
if (px < midX && py >= midY) return 2;
if (px >= midX && py >= midY) return 3;
return -1;
}

private intersects(rx: number, ry: number, rw: number, rh: number): boolean {
return !(rx > this.x + this.w || rx + rw < this.x ||
ry > this.y + this.h || ry + rh < this.y);
}
}

常见面试问题

Q1: 前端如何渲染十万级数据点?

答案

  1. 使用 Canvas 替代 SVG(即时模式渲染)
  2. 合并路径批量绘制,减少 bindaryFill() 调用
  3. 视口裁剪,只渲染可见区域
  4. 数据降采样(LTTB 等算法)
  5. 渐进式渲染,分帧绘制
  6. Web Worker 处理数据计算
  7. 空间索引(四叉树/R-tree)加速查询

Q2: 什么是 LTTB 降采样算法?

答案

Largest-Triangle-Three-Buckets,将数据分桶后在每个桶中选择能与前后参考点形成最大三角形面积的点保留。这样在大幅减少数据点的同时保留了数据的视觉特征(极值、趋势),是 ECharts 内置的降采样算法。

Q3: 四叉树在可视化中有什么作用?

答案

四叉树是 2D 空间索引结构,将空间递归分为四个象限。用于快速查询给定矩形区域内的数据点(从 O(n)O(n) 降到约 O(logn)O(\log n)),在地图可视化的视口裁剪、碰撞检测、力导向图等场景广泛使用。

Q4: 渐进式渲染和分层渲染的区别?

答案

渐进式渲染是将数据分批、分帧绘制(用 requestAnimationFrame 分时间片),避免一次性渲染大量数据导致主线程阻塞。分层渲染是将不同更新频率的内容(背景/数据/交互)放到不同 Canvas 层,减少重绘范围。两者可以结合使用。

相关链接