引言
大屏的性能问题有一个特点:在开发机上永远测不出来。开发时用几万行测试数据、单屏 6 张图、浏览器开着 DevTools,帧率 60fps;上线后接真实数据(百万行)、24 张图、连续运行 30 天不刷新,一周后开始卡顿、两周后内存爆掉、三周后页面白屏。
大屏的性能约束与普通 Web 应用不同。它有三个特殊性:长时运行(数周甚至数月不刷新)、高数据量(动辄十万级点)、持续动画(轮播、实时刷新、呼吸灯效)。这三者叠加,任何微小的泄漏都会在时间维度上放大成事故。
本文按「定位瓶颈 → 分层优化 → 长时稳定 → 度量验证」的顺序展开。核心方法论是:先用工具定位瓶颈在哪一层(主线程计算、绘制、合成、内存),再针对性优化。盲目优化(比如无脑开 will-change)往往适得其反。文中涉及的 API 包括 requestAnimationFrame、OffscreenCanvas、PerformanceObserver、ResizeObserver 等,均为标准 Web API。
目录
- 性能瓶颈的四层定位法
- 帧预算与渲染管线
- Canvas 渲染优化
- WebGL 与 GPU 加速
- DOM 与合成层管理
- 大数据量的降采样与聚合
- 动画与定时器的性能陷阱
- 内存管理与泄漏排查
- 网络与数据加载优化
- 长时运行的稳定性保障
- 性能度量与线上监控
- 实战调优检查清单
1. 性能瓶颈的四层定位法
大屏卡顿的原因可以归到四层,定位顺序是从上往下。
第一层,主线程计算:数据处理、聚合、布局计算占用了主线程。表现是「交互无响应、动画掉帧」,DevTools 的 Performance 面板里能看到长任务(Long Task,超过 50ms 的任务)。
第二层,绘制:Canvas 的 drawImage/fillRect 调用过多,或 SVG 的 DOM 节点过多。表现是「帧率稳定偏低」,Profiler 里绘制耗时占比高。
第三层,合成:图层过多导致 GPU 合成压力大。表现是「滚动/动画时掉帧」。第四层,内存:内存持续增长,最终触发 GC 停顿或 OOM,表现是「运行一段时间后突然卡死」。
// 用 PerformanceObserver 捕获长任务,定位主线程阻塞
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.duration > 50) {
console.warn(`长任务 ${entry.duration.toFixed(1)}ms`, entry);
}
}
});
observer.observe({ entryTypes: ['longtask'] });
定位原则是先测量再优化。打开 DevTools 的 Performance 面板录一段 10 秒的操作,看火焰图里哪一块最宽——那就是瓶颈。没有测量就优化,等于猜。
2. 帧预算与渲染管线
60fps 意味着每帧 16.67 毫秒。这 16.67ms 要分配给:脚本执行、样式计算、布局、绘制、合成。任何一项超标都会掉帧。如果要稳定 60fps,留给脚本的时间通常只有 8 到 10ms。
浏览器的渲染管线是:JS → Style → Layout → Paint → Composite。其中 Layout 与 Paint 是昂贵的——改变元素的 width、height、top、left 会触发 Layout(重排),改变 color、background 会触发 Paint。只有 transform 与 opacity 能跳过 Layout 与 Paint,直接进入 Composite。
/* 反例:每帧改变 top/left,触发 Layout + Paint */
.marker { position: absolute; transition: top 0.3s, left 0.3s; }
/* 正例:用 transform,只走 Composite */
.marker { transform: translate3d(0, 0, 0); transition: transform 0.3s; }
这条规则对大屏的动画至关重要。实时刷新的数字、移动的标记点、呼吸灯效果,全部应该用 transform 与 opacity 实现。用 top/left 做动画,在图表密集的大屏上会直接掉到 20fps。
避免布局抖动(Layout Thrashing):在循环里交替读布局属性(offsetWidth)与写样式,会强制浏览器每轮都重新布局。
// 反例:读-写交替,每次读都触发强制同步布局
for (const el of items) el.style.width = el.offsetWidth + 10 + 'px';
// 正例:先批量读,再批量写
const widths = items.map((el) => el.offsetWidth);
items.forEach((el, i) => { el.style.width = widths[i] + 10 + 'px'; });
3. Canvas 渲染优化
大屏图表多数用 Canvas 渲染(ECharts 默认、G2 默认)。Canvas 优化的核心是减少状态切换与绘制调用。
批量绘制同色元素。每次改变 fillStyle 都有开销,把同色的图形合并到一次 beginPath + fill:
// 反例:每个点切换一次样式,且用 arc 绘制
for (const p of points) {
ctx.fillStyle = colorOf(p);
ctx.beginPath(); ctx.arc(x(p), y(p), 2, 0, Math.PI * 2); ctx.fill();
}
// 正例:按颜色分组,每组一次 beginPath + fill,小圆点用 rect 近似
const groups = groupBy(points, colorOf);
for (const [color, pts] of groups) {
ctx.fillStyle = color;
ctx.beginPath();
for (const p of pts) ctx.rect(x(p) - 2, y(p) - 2, 4, 4);
ctx.fill();
}
用 rect 替代 arc。小圆点用 4×4 的矩形近似,视觉上几乎无差别,但 arc 涉及三角函数与贝塞尔逼近,慢数倍。数据点小于 4px 时这个替换完全无损。
离屏 Canvas 缓存静态层。坐标轴、网格线、标题、背景这些不常变的内容,画一次到离屏 Canvas,之后每帧直接 drawImage 贴上去:
// 静态层只画一次
const bgCanvas = document.createElement('canvas');
bgCanvas.width = width; bgCanvas.height = height;
drawGridAndAxes(bgCanvas.getContext('2d'));
// 每帧:先贴静态层,再画动态数据
function render() {
ctx.clearRect(0, 0, width, height);
ctx.drawImage(bgCanvas, 0, 0); // 一次 drawImage 替代上百次绘制
drawDataLayer(ctx);
}
控制 Canvas 尺寸与 DPR。Canvas 的像素数是 width × height × devicePixelRatio²。4K 大屏配 devicePixelRatio = 2 时,一个 1920×1080 的 Canvas 实际是 3840×2160 = 830 万像素,每帧清除与绘制的开销是 1080p 的 4 倍。大屏上应把 DPR 限制在 1 到 1.5,视觉上几乎无差别但性能提升明显。
const dpr = Math.min(window.devicePixelRatio, 1.5);
canvas.width = cssWidth * dpr; canvas.height = cssHeight * dpr;
canvas.style.width = cssWidth + 'px'; canvas.style.height = cssHeight + 'px';
ctx.scale(dpr, dpr);
4. WebGL 与 GPU 加速
数据量超过约十万点时,Canvas 2D 也会吃力,此时应该上 WebGL。ECharts 的 scatterGL(echarts-gl)与 deck.gl 都基于 WebGL。
// ECharts GL:百万级散点,用 WebGL 渲染
import 'echarts-gl';
option = { series: [{ type: 'scatterGL', data: millionPoints, progressive: 1e6 }] };
WebGL 的适用边界:点数多(>10 万)、图形简单(点、线、三角面)。如果图形复杂(大量文字、复杂路径)或数据量小(<1 万),WebGL 的初始化开销与实现复杂度反而不划算。
GPU 加速的三条经验。其一,减少绘制调用(Draw Call)——WebGL 的性能瓶颈通常是 draw call 数量而非顶点数,把同类图元合并成一次绘制。其二,纹理复用——大屏的装饰性背景、图标应做成纹理,避免重复创建。其三,注意显存——大屏长时间运行,纹理与缓冲对象如果不释放会累积占用显存,导致上下文丢失。
**上下文丢失(Context Lost)**是 WebGL 在大屏上的特有风险。GPU 驱动重置或资源紧张时会丢失 WebGL 上下文,页面变成空白。必须监听并恢复:
canvas.addEventListener('webglcontextlost', (e) => {
e.preventDefault(); // 阻止默认行为,允许恢复
stopRenderLoop();
});
canvas.addEventListener('webglcontextrestored', () => {
rebuildGLResources(); // 重建纹理、缓冲、着色器
startRenderLoop();
});
WebGL 上下文丢失后如果不重建资源,页面会永久空白——这是大屏上最难排查的故障之一,因为它在开发环境几乎不会复现。
5. DOM 与合成层管理
大屏的装饰元素(边框、标题、背景)多用 DOM + CSS 实现,这部分也会影响性能。
用 transform 做动画,理由已在第 2 节说明。避免 box-shadow 与 filter 的动画——它们每帧都要重新计算模糊,开销大。慎用 will-change:它会强制创建合成层,滥用会导致图层爆炸、显存占用飙升。
/* 反例:滥用 will-change,每个元素都提升为独立图层 */
.card { will-change: transform, opacity; }
/* 正例:只在动画即将开始时添加,结束后移除 */
.card:hover { will-change: transform; }
图层数量控制在 20 到 30 个以内。可以在 DevTools 的 Layers 面板查看合成层数量与显存占用。图层过多会导致合成阶段变慢,尤其在 4K 分辨率下。
减少 DOM 节点总数。大屏的 DOM 节点应控制在 1500 个以内。表格类组件是节点大户——100 行 × 10 列的表格就有 1000+ 节点,应该改用 Canvas 渲染的表格或虚拟滚动。给卡片容器加 contain: layout paint,能让内部重排不扩散到卡片外。
6. 大数据量的降采样与聚合
这是收益最高的优化,因为它直接减少要处理的数据量。降采样与聚合的区别是:降采样保留原始点的形状,聚合改变数据的语义。
**LTTB(Largest-Triangle-Three-Buckets)**是最常用的折线降采样算法,它在保持视觉形状(尤其是极值点)的前提下把点数降到屏幕像素量级:
// 把 10 万点降到 2000 点,视觉上几乎无差别
function lttb(data, threshold) {
const n = data.length;
if (threshold >= n || threshold <= 2) return data;
const sampled = [data[0]];
const bucketSize = (n - 2) / (threshold - 2);
let a = 0;
for (let i = 0; i < threshold - 2; i++) {
const rangeStart = Math.floor((i + 1) * bucketSize) + 1;
const rangeEnd = Math.min(Math.floor((i + 2) * bucketSize) + 1, n);
// 下一个桶的平均点作为参考点
let avgX = 0, avgY = 0, count = 0;
for (let j = rangeStart; j < rangeEnd; j++) { avgX += data[j][0]; avgY += data[j][1]; count++; }
avgX /= count; avgY /= count;
// 当前桶里与 (a, avg) 构成三角形面积最大的点即为代表点
let maxArea = -1, maxIdx = rangeStart;
const [ax, ay] = data[a];
for (let j = Math.floor(i * bucketSize) + 1; j < rangeStart; j++) {
const area = Math.abs((ax - avgX) * (data[j][1] - ay) - (ax - data[j][0]) * (avgY - ay)) / 2;
if (area > maxArea) { maxArea = area; maxIdx = j; }
}
sampled.push(data[maxIdx]); a = maxIdx;
}
sampled.push(data[n - 1]);
return sampled;
}
二维分箱聚合用于散点图:把画布划分成网格,统计每个格子的点数,用颜色深浅表示密度。十万个点聚合成 200×150 = 3 万个格子(其中多数为空),渲染量下降一个数量级,同时解决了过度绘制带来的可读性问题。
import numpy as np
def bin2d(xs, ys, x_bins, y_bins):
"""二维分箱:把散点聚合成密度网格,返回非零格子及其计数"""
hist, xedges, yedges = np.histogram2d(xs, ys, bins=[x_bins, y_bins])
rows, cols = np.nonzero(hist)
return [(xedges[c], yedges[r], hist[r, c]) for r, c in zip(rows, cols)]
聚合应该下沉到服务端或 OLAP。前端聚合虽然省了渲染,但数据传输与解析的开销还在。如果底层是 ClickHouse,用 GROUP BY 按像素桶或时间粒度聚合,网络传输量能降两个数量级。这部分的分工见 可视化与 OLAP 数仓集成
。
7. 动画与定时器的性能陷阱
大屏上的动画有三大类,各有陷阱。
入场动画:图表首次渲染时的生长动画。陷阱是每次数据刷新都重播入场动画,导致图表不停「抖动」。正确做法是首次渲染播放入场动画,后续刷新用 animationDurationUpdate 走更短的过渡。
option = {
animationDuration: 800, // 首次入场
animationDurationUpdate: 200, // 数据更新,应显著更短
// 大数据量下直接关闭动画
animation: data.length < 1000,
};
持续动画:呼吸灯、流动线、旋转的装饰元素。陷阱是用 setInterval 驱动——它与浏览器渲染帧不同步,会产生撕裂与丢帧。必须用 requestAnimationFrame:
// 反例:setInterval 与渲染帧不同步
setInterval(() => { updateGlow(); }, 16);
// 正例:requestAnimationFrame 与渲染帧同步
let rafId = null;
function loop(timestamp) {
updateGlow(timestamp);
rafId = requestAnimationFrame(loop);
}
rafId = requestAnimationFrame(loop);
// 页面隐藏时暂停
document.addEventListener('visibilitychange', () => {
if (document.hidden) { cancelAnimationFrame(rafId); rafId = null; }
else if (!rafId) { rafId = requestAnimationFrame(loop); }
});
定时器泄漏:setInterval 的 ID 没有在组件卸载时清理,导致回调持续执行。在大屏上这是最隐蔽的泄漏源——页面不刷新,定时器永远不会被 GC。
visibilitychange 是必须处理的。大屏虽然一直显示,但浏览器可能被最小化或切到后台标签页,此时应该暂停渲染循环,节省资源。
8. 内存管理与泄漏排查
大屏的内存问题会在数天到数周后爆发。四类常见泄漏。
未销毁的图表实例:每次刷新重建实例而不销毁旧的,实例持有 canvas 上下文与数据引用。必须复用实例 + setOption,只有图表类型变化时才销毁重建。
未清理的定时器与监听器:setInterval、setTimeout、addEventListener、ResizeObserver、IntersectionObserver 都要在不再需要时清理。
闭包持有大对象:回调捕获了大数据数组导致无法回收;刷新数据时应替换引用而非修改原数组。WebGL 资源未释放:纹理、缓冲、着色器程序必须显式删除。
// 用 performance.memory 监控内存(Chrome 专有),堆持续增长即存在泄漏
if (performance.memory) {
setInterval(() => {
const mb = (performance.memory.usedJSHeapSize / 1048576).toFixed(1);
console.log(`JS 堆: ${mb}MB`);
}, 60000);
}
排查方法:DevTools 的 Memory 面板做三次快照(Heap Snapshot),对比增长的对象类型。如果 Detached HTMLCanvasElement 或 Detached HTMLElement 数量持续增长,说明有未释放的 DOM 引用。
9. 网络与数据加载优化
大屏的首屏加载与刷新都会受网络影响。
接口合并:大屏上的 20 张图如果各自发请求,会有 20 次往返。合并成一个聚合接口,一次返回所有数据,能显著降低首屏时间。
数据压缩:用 gzip/brotli 压缩响应体,大 JSON 的压缩率通常有 70% 到 90%。列式编码(把 [{x:1,y:2},{x:3,y:4}] 改成 {x:[1,3],y:[2,4]})能进一步提升压缩率并减少解析开销——十万点下,列式编码的 JSON 体积通常小 40% 以上,解析也更快,因为它避免了重复的键名。
增量加载:首屏只加载关键指标,次要图表延后或按需加载,让用户先看到核心内容。避免重复请求:同一份数据被多个图表使用时缓存到内存或 IndexedDB,不要重复拉取。
10. 长时运行的稳定性保障
大屏的核心要求是「连续运行数周不出问题」。四条保障措施。
自动重载兜底:设置一个「运行 N 小时后自动刷新页面」的保险,比如每天凌晨 3 点刷新一次。这能清理累积的内存碎片与泄漏,是最后一道防线。
// 每天凌晨 3 点自动重载,兜底清理累积的内存
const now = new Date();
const next = new Date(now).setHours(3, 0, 0, 0);
setTimeout(() => location.reload(), (next > now ? next : next + 86400000) - now);
心跳与自愈:监听 WebSocket 断线、接口连续失败、渲染异常,触发重连或降级。连续失败超过阈值时应该展示「数据连接中断」而不是静默显示旧数据——后者会让决策者以为数据是新的。
降级策略:网络慢或设备性能不足时,自动降级到简化版本(减少图表数量、关闭动画、降低数据精度)。用 navigator.hardwareConcurrency 与首帧耗时作为降级判据。
看门狗监控:用 PerformanceObserver 持续监控长任务与帧率,连续 5 秒帧率低于 30fps 时触发降级(关动画、减图表、降数据精度)。判据用 navigator.hardwareConcurrency 与首帧耗时联合评估,避免在性能充足的设备上误降级。
11. 性能度量与线上监控
本地度量:DevTools 的 Performance 面板录帧、Layers 面板看合成层、Memory 面板做堆快照。这是定位问题的标准工具链。
线上度量:用 PerformanceObserver 采集真实环境的指标并上报。
// 采集帧率:每秒统计一次 requestAnimationFrame 回调次数
const fpsSamples = [];
let last = performance.now(), count = 0;
(function tick(now) {
count++;
if (now - last >= 1000) {
fpsSamples.push(count);
if (fpsSamples.length > 60) fpsSamples.shift();
count = 0; last = now;
}
requestAnimationFrame(tick);
})(performance.now());
function reportMetrics() {
const avg = fpsSamples.reduce((a, b) => a + b, 0) / fpsSamples.length;
navigator.sendBeacon('/api/metrics', JSON.stringify({
avgFps: avg, minFps: Math.min(...fpsSamples), longTasks: longTaskCount,
heapMB: performance.memory ? performance.memory.usedJSHeapSize / 1048576 : null,
}));
}
关键指标:平均帧率、最低帧率(P1)、长任务数量、JS 堆大小、首屏时间。最低帧率比平均帧率更重要——平均 55fps 但偶尔掉到 10fps 的体验,比稳定 45fps 差得多。
12. 实战调优检查清单
按收益从高到低排序,逐项检查。
| 优先级 | 优化项 | 预期收益 |
|---|---|---|
| P0 | 数据降采样 / 聚合 | 渲染量降 1~2 个数量级 |
| P0 | 复用图表实例,只 setOption | 消除实例泄漏 |
| P0 | 清理定时器与监听器 | 消除内存泄漏 |
| P1 | 限制 DPR 到 1.5 | 绘制开销降 40%+ |
| P1 | 动画只用 transform/opacity | 消除重排重绘 |
| P1 | 关闭大数据量的动画 | 消除长任务 |
| P2 | 静态层离屏缓存 | 每帧绘制调用减半 |
| P2 | 批量同色绘制 / rect 替代 arc | 绘制调用减数倍 |
| P2 | 接口合并与列式编码 | 首屏时间降 50%+ |
| P3 | 页面隐藏时暂停渲染 | 节省后台资源 |
| P3 | 定时自动重载兜底 | 长时运行稳定性 |
权衡取舍
| 决策点 | 选项 A | 选项 B | 何时选 A | 何时选 B |
|---|---|---|---|---|
| 渲染后端 | Canvas 2D | WebGL | 数据 <10 万点 | 数据 >10 万点 |
| 数据策略 | 全量渲染 | 降采样/聚合 | 数据 <5000 点 | 数据 >1 万点 |
| DPR | 跟随设备(2~3) | 限制到 1.5 | 移动端高清屏 | 4K 大屏 |
| 动画 | 保留过渡 | 关闭动画 | 数据量小 | 数据量大/降级 |
| 静态层 | 每帧重绘 | 离屏缓存 | 内容频繁变化 | 内容基本固定 |
| 降级 | 主动降级 | 保持全量 | 检测到持续低帧率 | 设备性能充足 |
| 刷新 | 定时轮询 | 推送 | 更新频率慢 | 秒级实时 |
常见坑清单
- 用 top/left 做动画——每帧触发 Layout + Paint;改用 transform 与 opacity。
- 循环里读写布局属性——强制同步布局导致抖动;先批量读再批量写。
- 每个点切换 fillStyle——状态切换开销累积;按颜色分组批量绘制。
- 大屏 DPR 不限制——4K + DPR2 的 Canvas 是 830 万像素;限制到 1.5。
- setInterval 驱动动画——与渲染帧不同步导致丢帧;改用 requestAnimationFrame。
- 页面隐藏不暂停——后台标签页持续渲染浪费资源;监听 visibilitychange。
- 刷新时重建图表实例——实例与 canvas 上下文泄漏;复用实例只 setOption。
- setInterval 不清 ID——大屏不刷新页面,定时器永不回收;卸载时 clear。
- 每张图各发一个请求——20 次往返拖慢首屏;合并成聚合接口。
- 没有自动重载兜底——内存泄漏累积数周后白屏;每天定时刷新页面。
小结
大屏性能优化的方法论是先定位再优化:用 Performance 面板确认瓶颈在主线程计算、绘制、合成还是内存,再针对性处理。盲目优化(滥用 will-change、无差别上 WebGL)往往适得其反。
收益最高的三项是数据降采样与聚合(直接减少处理量)、复用图表实例(消除泄漏)、清理定时器与监听器(消除长时运行的累积)。其次是限制 DPR、用 transform 做动画、关闭大数据量的动画。长时运行的稳定性靠自动重载兜底与看门狗降级保障。
性能问题永远与数据量挂钩,因此上游的数据准备同样关键——把聚合下沉到 OLAP 能让前端只处理结果集,细节见 可视化与 OLAP 数仓集成 ;大屏的布局与视觉设计原则见 仪表盘与数据大屏设计 ;图表库层面的具体配置见 ECharts 图表工程实战 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。