16 · 可视化与多媒体(详解版)
一句话定位:可视化是把数据映射成视觉元素(位置、长度、颜色、面积),多媒体是把音视频正确地解码、播放与合成。两者都要求你绕过框架,直接控制渲染与时间。
读完本篇你应该能做到
- 在 Canvas、SVG、WebGL/WebGPU 之间做出有依据的选择;
- 独立实现坐标变换、动画循环、命中测试与高清屏适配;
- 让万级/百万级数据在浏览器里流畅渲染;
- 处理音视频播放、流式加载与内存问题。
0. 领域导论
0.1 这个领域解决什么问题
| 现象 | 根因 | 归属 |
|---|---|---|
| 折线图 1 万个点,拖动卡成 PPT | 每帧重绘全部元素 + DOM 节点过多 | 渲染方式 |
| 图表在高清屏上模糊 | 未按 devicePixelRatio 放大画布 | 像素适配 |
| 大屏动画跑 20fps | 每帧创建对象、强制同步布局 | 动画循环 |
| 视频播到 10 分钟浏览器崩溃 | 内存中保留了全部帧/未释放资源 | 多媒体内存 |
| 图表对读屏用户完全不可用 | Canvas 没有语义 | 可访问性 |
这类需求的特点是:框架帮不上忙。React 负责「什么时候渲染」,但画什么、怎么画、多久画一次,要你自己控制。
0.2 一个类比
把可视化想成画画:
- SVG 是「贴纸画」:每个图形都是一张独立的贴纸,可以单独拿起来、改颜色、加事件。数量多了会乱。
- Canvas 是「油画」:一笔一笔画在画布上,画完就凝固成像素。改一处要重画一片,但画得再多也只是一张画布。
- WebGL 是「印刷厂」:把图形交给显卡批量印刷,几百万个图形也能印得很快,但你要自己写制版工艺(着色器)。
选错工具,不是画不出来,而是「画得动但改不动」或「改得动但画不动」。
0.3 学习地图
16 可视化与多媒体
├─ 前置:02 CSS(布局)、05 浏览器(渲染)
├─ 紧接:11 性能(帧率与长任务)
├─ 交叉:15 跨端(自绘引擎)、13 测试(视觉回归)
└─ 长期:17 架构(图形引擎与设计系统)
0.4 分级标尺
| 级别 | 能力特征 | 典型产出 | 自检标准 |
|---|---|---|---|
| L1 基础 | 会用图表库、Canvas 画基本图形 | 业务图表 | 能解释坐标与 DPR |
| L2 进阶 | 会做动画、交互、命中测试 | 自定义图表/看板 | 能控制帧率与事件 |
| L3 高级 | 懂 WebGL 管线与大数据渲染 | 图形引擎/大屏 | 能量化渲染开销 |
| L4 专家 | 做引擎选型、规范与性能治理 | 可视化体系 | 能对复杂场景负责 |
1. L1 基础:先画出一个正确的图形
1.1 Canvas 2D 的最小闭环
/**
* Canvas 三要素:画布尺寸、绘图上下文、坐标系(左上角原点,y 向下)。
* 注意:CSS 尺寸与画布像素尺寸是两回事。
*/
const canvas = document.querySelector('#chart');
const ctx = canvas.getContext('2d');
// 画布像素尺寸
canvas.width = 800;
canvas.height = 400;
ctx.fillStyle = '#1677ff';
ctx.fillRect(20, 20, 120, 60); // x, y, w, h(单位:像素)
ctx.strokeStyle = '#333';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(20, 120);
ctx.lineTo(200, 120);
ctx.stroke();
1.2 高清屏为什么模糊
/**
* 物理像素 = CSS 像素 × devicePixelRatio。
* 只设 CSS 尺寸,浏览器会拉伸位图 → 模糊。
*/
function setupHiDPI(canvas, cssWidth, cssHeight) {
const dpr = window.devicePixelRatio || 1;
canvas.style.width = `${cssWidth}px`;
canvas.style.height = `${cssHeight}px`;
canvas.width = Math.round(cssWidth * dpr);
canvas.height = Math.round(cssHeight * dpr);
const ctx = canvas.getContext('2d');
ctx.setTransform(dpr, 0, 0, dpr, 0, 0); // 之后按 CSS 像素坐标绘制
return ctx;
}
记忆口诀:style 管显示大小,width/height 管像素多少,setTransform(dpr,...) 让你继续用逻辑坐标。
1.3 SVG:声明式图形
<svg viewBox="0 0 400 200" role="img" aria-label="月度销售额趋势">
<line x1="20" y1="180" x2="380" y2="180" stroke="#ccc" />
<polyline points="20,160 100,120 180,140 260,80 340,60"
fill="none" stroke="#1677ff" stroke-width="2" />
<circle cx="260" cy="80" r="4" fill="#1677ff" />
</svg>
| 特性 | SVG | Canvas |
|---|---|---|
| 渲染方式 | 保留模式(每个图形是节点) | 立即模式(像素) |
| 事件 | 每个元素可绑定 | 需自己做命中测试 |
| 可访问性 | 原生可读 | 需额外提供文本替代 |
| 大量元素 | 慢(DOM 开销) | 快 |
| 缩放 | 矢量无损 | 位图会模糊(除非重绘) |
| 导出 | 直接可编辑 | 导出为图片 |
经验阈值:元素数 < 1000 且需要交互/可访问性 → SVG;> 1000 或高频刷新 → Canvas;> 数万 → WebGL。
1.4 图表库的正确打开方式
/**
* ECharts:配置式,适合通用业务图表。
* 注意:容器必须有宽高,否则不渲染。
*/
const chart = echarts.init(document.getElementById('chart'));
chart.setOption({
xAxis: { type: 'category', data: ['1月', '2月', '3月'] },
yAxis: { type: 'value' },
series: [{ type: 'line', data: [120, 200, 150], smooth: true }],
tooltip: { trigger: 'axis' },
});
// 容器尺寸变化必须手动 resize(或用 ResizeObserver)
const ro = new ResizeObserver(() => chart.resize());
ro.observe(document.getElementById('chart'));
| 库 | 模型 | 适合 | 代价 |
|---|---|---|---|
| ECharts | 配置式 canvas/svg | 中后台、大屏 | 定制受限 |
| D3 | 数据驱动 DOM/SVG | 高度定制、复杂交互 | 学习曲线陡 |
| visx / Recharts | React 组件 | React 项目 | 大数据需优化 |
| Three.js | 3D 场景 | 3D 可视化 | 概念多 |
| AntV G2/G6 | 语法式 | 图表/关系图 | 生态绑定 |
1.5 图片与视频基础
<!-- 图片:明确尺寸避免 CLS;首屏图不要 lazy -->
<img src="cover.webp" width="640" height="360" alt="产品封面" fetchpriority="high" />
<!-- 视频:poster + preload 策略 -->
<video controls preload="metadata" poster="poster.webp" width="640" height="360">
<source src="demo.mp4" type="video/mp4" />
<track kind="captions" src="demo.zh.vtt" srclang="zh" label="中文字幕" />
</video>
| 属性 | 作用 | 建议 |
|---|---|---|
preload="none/metadata/auto" | 预加载程度 | 列表页 none,详情页 metadata |
poster | 首帧占位 | 必配,避免黑屏 |
muted + playsinline | 移动端自动播放 | 自动播放必须静音 |
crossorigin | 跨域(配合 canvas 截图) | 需要时加 |
1.6 L1 小结与自检
| 主题 | 关键结论 |
|---|---|
| Canvas | 立即模式,坐标左上原点 |
| DPR | 像素尺寸 = CSS × dpr |
| SVG | 保留模式,天然可交互/可访问 |
| 选型 | 元素量决定 Canvas/SVG |
| 多媒体 | poster、preload、字幕是基本盘 |
自检题
canvas.width与canvas.style.width有什么区别?——前者是画布像素缓冲尺寸,后者是 CSS 显示尺寸;两者不一致会模糊或拉伸,高 DPI 下要按devicePixelRatio设置前者。- 什么时候必须用 Canvas 而不是 SVG?——元素上千、高频重绘、像素级处理或 WebGL 场景;少量可交互节点用 SVG 更易维护、可访问性更好。
- 视频为什么需要
poster?——在元数据或首帧加载前显示占位图,避免黑屏,也改善首屏感知。
2. L2 进阶:动画、交互与大数据
2.1 坐标变换:把「数据坐标」映射到「屏幕坐标」
/**
* 手写一个线性比例尺:数据域 → 像素域。
* 这是所有图表库的核心组件(D3 的 scaleLinear 就是它)。
*/
function createScale(domain, range) {
const [d0, d1] = domain;
const [r0, r1] = range;
const k = (r1 - r0) / (d1 - d0);
return (v) => r0 + (v - d0) * k;
}
const x = createScale([0, 100], [40, 760]); // 左 40px,右 760px
const y = createScale([0, 1000], [360, 20]); // y 轴翻转(屏幕向下为正)
console.log(x(50)); // 400
console.log(y(500)); // 190
坐标轴翻转是新手最常见的 bug:数学里 y 向上,屏幕里 y 向下。
2.2 Canvas 状态栈与变换
/**
* save/restore 成对使用:变换与样式都是「状态」,
* 忘记 restore 会让后续绘制继承错误的坐标系。
*/
function drawBar(ctx, x, y, w, h, color) {
ctx.save();
ctx.translate(x, y); // 把原点移到柱子左上角
ctx.fillStyle = color;
ctx.fillRect(0, 0, w, h); // 用局部坐标绘制
ctx.restore(); // 恢复
}
2.3 动画循环:只用一个 rAF
/**
* 所有动画共用一个 rAF 循环,避免多个循环互相抢帧。
* 用 deltaTime 保证不同刷新率下速度一致。
*/
const tasks = new Set();
let last = performance.now();
function frame(now) {
const dt = Math.min(now - last, 50); // 防止切后台后跳变
last = now;
for (const task of tasks) task(dt, now);
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
/** 注册一个动画任务,返回取消函数。 */
function animate(update) {
tasks.add(update);
return () => tasks.delete(update);
}
三个原则:
- 不要在 rAF 里做
getBoundingClientRect等强制布局操作; - 不要每帧创建新对象/数组(GC 抖动);
- 页面不可见时暂停(
visibilitychange)。
2.4 命中测试:Canvas 没有「元素」
/**
* 矩形命中:从后往前遍历(后画的在上层)。
* 记录每个图形的包围盒,避免 O(n) 几何计算。
*/
function hitTest(rects, px, py) {
for (let i = rects.length - 1; i >= 0; i--) {
const r = rects[i];
if (px >= r.x && px <= r.x + r.w && py >= r.y && py <= r.y + r.h) return r;
}
return null;
}
canvas.addEventListener('pointermove', (e) => {
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const target = hitTest(rects, x, y);
setHover(target?.id ?? null);
});
复杂图形的三种方案:
| 方案 | 做法 | 成本 |
|---|---|---|
| 包围盒 | 近似,够用 | 低 |
| 几何算法 | 点在多边形内 | 中 |
| 颜色拾取 | 离屏画布按 id 着色,读像素 | 高(一次读回) |
2.5 大数据量:先抽稀,再分层
/**
* LTTB 抽稀:保留视觉形状的前提下把 10 万点降到 1000 点。
* 折线图在大屏上像素宽度有限,没必要画超过像素数的点。
*/
function lttb(data, threshold) {
if (threshold >= data.length) return data;
const sampled = [data[0]];
const bucketSize = (data.length - 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, data.length);
const avgX = data.slice(rangeStart, rangeEnd).reduce((s, p) => s + p.x, 0) / (rangeEnd - rangeStart);
const avgY = data.slice(rangeStart, rangeEnd).reduce((s, p) => s + p.y, 0) / (rangeEnd - rangeStart);
const from = data[a];
let maxArea = -1;
let maxIdx = rangeStart;
for (let j = rangeStart; j < rangeEnd; j++) {
const area = Math.abs((from.x - avgX) * (data[j].y - from.y) - (from.x - data[j].x) * (avgY - from.y));
if (area > maxArea) { maxArea = area; maxIdx = j; }
}
sampled.push(data[maxIdx]);
a = maxIdx;
}
sampled.push(data[data.length - 1]);
return sampled;
}
四种大数据策略:
| 策略 | 适用 | 收益 |
|---|---|---|
| 抽稀(LTTB/min-max) | 折线/散点 | 点数降 100 倍 |
| 分块渲染 | 地图/热力 | 只画视口内 |
| 分层渲染 | 地图瓦片 | 缩放级别切换 |
| 聚合(六边形/网格) | 海量散点 | 量级降维 |
2.6 图表选型:从需求反推
需要像素级定制、复杂交互 → D3(或直接 Canvas/SVG)
需要快速出标准图表 → ECharts / Recharts
需要关系图/流程图 → G6 / Cytoscape / React Flow
需要 3D/大规模点云 → Three.js / deck.gl / WebGL
需要地图 → Mapbox GL / Leaflet / 高德
选型时必问:数据量级?刷新频率?是否需要导出?是否需要无障碍?团队是否熟悉?
2.7 音视频:MSE 与 Web Audio 入门
/**
* MSE:把分片的视频数据喂给 <video>,实现自适应码率与直播。
*/
const mediaSource = new MediaSource();
video.src = URL.createObjectURL(mediaSource);
mediaSource.addEventListener('sourceopen', () => {
const sb = mediaSource.addSourceBuffer('video/mp4; codecs="avc1.42E01E"');
fetchSegment(0).then((buf) => {
sb.addEventListener('updateend', () => {
if (mediaSource.readyState === 'open') mediaSource.endOfStream();
});
sb.appendBuffer(buf);
});
});
/**
* Web Audio:处理音频流(可视化/降噪/混音)。
*/
const audioCtx = new AudioContext();
const source = audioCtx.createMediaElementSource(video);
const analyser = audioCtx.createAnalyser();
analyser.fftSize = 256;
source.connect(analyser);
analyser.connect(audioCtx.destination);
const data = new Uint8Array(analyser.frequencyBinCount);
function draw() {
analyser.getByteFrequencyData(data); // 频谱数据
requestAnimationFrame(draw);
}
2.8 L2 小结与自检
| 主题 | 关键结论 |
|---|---|
| 比例尺 | 数据域 → 像素域,注意 y 轴翻转 |
| 状态栈 | save/restore 成对 |
| 动画 | 单一 rAF + deltaTime |
| 交互 | 自己做命中测试,倒序命中 |
| 大数据 | 抽稀/分块/分层/聚合 |
| 音视频 | MSE 分片,Web Audio 处理 |
自检题
- 为什么 Canvas 里 y 轴要翻转?——Canvas 原点在左上、y 向下;数学与图表坐标系原点在左下、y 向上,绘制时需
scale(1, -1)或统一坐标转换。 - 多个 rAF 循环有什么问题?——各自清理重绘会互相覆盖,重复占用帧预算;应统一一个循环分发所有更新。
- LTTB 抽稀为什么能保形?——按时间分桶,每桶保留与相邻点构成最大三角形面积的点,优先保住峰谷与转折,忽略中间冗余。
3. L3 高级:WebGL 与大场景
3.1 WebGL 渲染管线
顶点数据(Buffer)
→ 顶点着色器(逐顶点:位置变换)
→ 图元装配(点/线/三角形)
→ 光栅化(转成像素)
→ 片元着色器(逐像素:上色)
→ 深度/混合
→ 帧缓冲(屏幕)
/**
* 最小 WebGL:画一个三角形。
* 关键概念:着色器、缓冲区、属性指针、绘制调用。
*/
const gl = canvas.getContext('webgl2');
const vs = `#version 300 es
in vec2 aPos;
void main() { gl_Position = vec4(aPos, 0.0, 1.0); }`;
const fs = `#version 300 es
precision mediump float;
out vec4 color;
void main() { color = vec4(0.09, 0.46, 1.0, 1.0); }`;
function compile(type, source) {
const shader = gl.createShader(type);
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
throw new Error(gl.getShaderInfoLog(shader));
}
return shader;
}
const program = gl.createProgram();
gl.attachShader(program, compile(gl.VERTEX_SHADER, vs));
gl.attachShader(program, compile(gl.FRAGMENT_SHADER, fs));
gl.linkProgram(program);
gl.useProgram(program);
const buffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-0.5, -0.5, 0.5, -0.5, 0, 0.5]), gl.STATIC_DRAW);
const loc = gl.getAttribLocation(program, 'aPos');
gl.enableVertexAttribArray(loc);
gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0);
gl.clearColor(1, 1, 1, 1);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.drawArrays(gl.TRIANGLES, 0, 3);
3.2 为什么 WebGL 快:批处理与实例化
| 手段 | 做法 | 效果 |
|---|---|---|
| 批处理(batching) | 合并同材质几何体,一次 draw | 减少 draw call |
| 实例化(instancing) | 一个几何体 + 每实例属性 | 万级对象一次绘制 |
| 纹理图集 | 多张小图合成一张 | 减少纹理切换 |
| 顶点缓冲对象 | 数据常驻显存 | 减少上传 |
/**
* 实例化:一次 draw 画 1 万个点。
*/
gl.vertexAttribDivisor(positionLoc, 1); // 每个实例前进一次属性
gl.drawArraysInstanced(gl.POINTS, 0, 1, 10000);
性能判断:draw call 数量通常是第一瓶颈,其次才是顶点数。
3.3 WebGPU:下一代图形 API
| 维度 | WebGL | WebGPU |
|---|---|---|
| 抽象层 | 状态机 | 显式管线/命令缓冲 |
| 计算 | 无(要 hack) | 原生 compute shader |
| 多线程 | 受限 | 支持 |
| 兼容性 | 广泛 | 逐步普及 |
适合:大规模粒子、GPU 计算(如实时滤镜、物理模拟)、复杂渲染。不适合:简单图表(成本过高)。
3.4 Three.js 场景图
/**
* Three.js 三件套:Scene / Camera / Renderer。
* 场景图是树结构,变换会沿父子链累乘。
*/
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(45, w / h, 0.1, 1000);
const renderer = new THREE.WebGLRenderer({ antialias: true });
const geometry = new THREE.BoxGeometry(1, 1, 1);
const material = new THREE.MeshStandardMaterial({ color: 0x1677ff });
const mesh = new THREE.Mesh(geometry, material);
scene.add(mesh);
camera.position.z = 3;
function loop() {
mesh.rotation.y += 0.01;
renderer.render(scene, camera);
requestAnimationFrame(loop);
}
loop();
优化清单:
- 合并几何体、复用材质;
- 用
InstancedMesh渲染重复对象; - 控制纹理尺寸与数量;
- 按需渲染(无变化不重绘);
- 释放资源(
dispose()),否则显存泄漏。
3.5 大屏与实时看板的架构
数据层:WebSocket / SSE 推送增量
↓
状态层:环形缓冲(只保留窗口内数据)
↓
渲染层:Canvas/WebGL + 抽稀 + 分层
↓
交互层:缩放/筛选(改变数据窗口,不重算全部)
常见坑:每次推送都全量重绘 → 帧率暴跌;应做「脏标记 + 增量绘制」。
3.6 音视频进阶:WebCodecs 与 WebRTC
| 能力 | API | 用途 |
|---|---|---|
| 解码/编码 | WebCodecs | 精确帧控制、视频编辑 |
| 实时通话 | WebRTC | 低延迟音视频 |
| 流式播放 | MSE | HLS/DASH |
| 屏幕共享 | getDisplayMedia | 会议/演示 |
| 录制 | MediaRecorder | 导出音视频 |
/**
* WebCodecs:逐帧解码,适合视频编辑与逐帧分析。
*/
const decoder = new VideoDecoder({
output: (frame) => { ctx.drawImage(frame, 0, 0); frame.close(); },
error: (e) => console.error(e),
});
decoder.configure({ codec: 'avc1.42E01E', codedWidth: 1280, codedHeight: 720 });
decoder.decode(chunk); // EncodedVideoChunk
3.7 可访问性与降级
Canvas 对读屏是「一张图」。必须提供替代:
<canvas id="chart" role="img" aria-label="2026 年月度销售额:1月 120 万,2月 200 万,3月 150 万"></canvas>
或者提供数据表:
<details>
<summary>查看数据表</summary>
<table><!-- 与图表同源数据 --></table>
</details>
原则:图表是「增强」,数据表是「兜底」。
3.8 L3 小结与自检
| 主题 | 关键结论 |
|---|---|
| 管线 | 顶点 → 光栅化 → 片元 |
| 性能 | draw call 优先,实例化降数量级 |
| WebGPU | 显式管线 + 计算着色器 |
| 3D | 场景图 + 资源释放 |
| 大屏 | 增量数据 + 脏标记渲染 |
| 可访问 | 必须有文本替代 |
自检题
- WebGL 里 draw call 为什么比顶点数更关键?——每次绘制都有 CPU 提交与状态切换开销,顶点可由 GPU 并行处理;减少 draw call 靠合并缓冲与实例化。
- Three.js 忘记
dispose()会怎样?——几何体、材质、纹理占用的 GPU 内存不释放,长时间运行内存持续上涨,严重时上下文丢失。 - 大屏每帧全量重绘怎么优化?——静态层缓存成位图、脏矩形重绘、离屏 Canvas,并减少状态切换与透明混合。
4. L4 专家:选型、规范与治理
4.1 技术选型决策树
需要 3D 或 >10 万图元?
├─ 是 → WebGL/WebGPU(Three.js / deck.gl / 自研)
└─ 否 → 元素需要独立交互/可访问?
├─ 是 → 元素数 < 1000 ? SVG/D3 : Canvas + 命中测试
└─ 否 → Canvas(图表库优先)
4.2 自研边界
| 场景 | 建议 |
|---|---|
| 通用业务图表 | 用成熟库,别自研 |
| 高度定制的品牌可视化 | 基于 D3/Canvas 自研图层 |
| 大规模地理/点云 | 用 deck.gl 等专用库 |
| 独特交互(编辑器/白板) | 自研渲染层(可参考 Konva/Fabric) |
自研成本:渲染、交互、无障碍、导出、跨端、长期维护。收益必须明确大于这些。
4.3 图表规范(设计系统的一部分)
| 规范项 | 示例 |
|---|---|
| 色板 | 分类色 ≤ 8,含色盲友好方案 |
| 坐标轴 | 刻度数量、单位、零基线 |
| 标注 | 图例位置、tooltip 内容 |
| 空态/加载 | 统一骨架与空状态 |
| 响应式 | 容器变化的重排规则 |
| 可访问性 | 对比度 ≥ 4.5:1,提供数据表 |
4.4 性能治理
| 指标 | 目标 |
|---|---|
| 首帧时间 | < 500ms |
| 交互帧率 | ≥ 50fps |
| 内存占用 | 稳定,无持续增长 |
| 大数据渲染 | 10 万点 < 16ms/帧 |
手段:性能面板录制、performance.mark/measure、渲染计数、显存监控。
4.5 多媒体治理
| 风险 | 手段 |
|---|---|
| 版权/合规 | 来源校验、水印、DRM |
| 带宽成本 | 自适应码率、CDN、按需加载 |
| 自动播放策略 | 静音 + 用户手势 |
| 内存 | 及时 revokeObjectURL、释放解码帧 |
| 兼容性 | 多编码格式 + 降级 |
4.6 L4 自检
- 一个百万点的轨迹回放,如何选型与分阶段优化?——先抽稀与 LOD,再用 Canvas 分层或 WebGL 实例化;按「数据量 → 帧率 → 交互」分阶段验证,每阶段设指标。
- 公司有 5 个团队各写一套图表,如何收敛?——统一图表规范与主题 token,封装基础图表组件与数据适配层,复杂场景允许扩展;用采纳率与重复代码量度量。
- 视频播放内存持续增长,怎么定位与治理?——查是否反复创建
MediaSource/SourceBuffer、未revokeObjectURL、监听未解绑、解码缓存过大;用 Memory 与媒体面板定位后按生命周期释放。
5. 综合实战:百万点轨迹回放
需求:在地图上回放 100 万个 GPS 点,支持缩放、时间轴拖动、实时跟随。
步骤 1 · 分层
| 层 | 技术 | 理由 |
|---|---|---|
| 底图 | 地图引擎(WebGL) | 瓦片与投影 |
| 轨迹 | WebGL 线/点(实例化) | 百万级图元 |
| 高亮/选中 | Canvas 覆盖层 | 少量元素、易交互 |
| UI | DOM | 表单与控件 |
步骤 2 · 数据
/**
* 预处理:按时间分桶 + 空间索引,避免每帧全量遍历。
*/
const buckets = new Map(); // key: 时间片,value: Float32Array 坐标
function buildBuckets(points, bucketMs = 1000) {
for (const p of points) {
const key = Math.floor(p.t / bucketMs);
if (!buckets.has(key)) buckets.set(key, []);
buckets.get(key).push(p.x, p.y);
}
}
步骤 3 · 渲染
/**
* 只画当前时间窗口内的点,用实例化一次提交。
*/
function renderWindow(from, to) {
const coords = collectCoords(from, to); // 已按窗口过滤
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.bufferData(gl.ARRAY_BUFFER, coords, gl.DYNAMIC_DRAW);
gl.drawArrays(gl.POINTS, 0, coords.length / 2);
}
步骤 4 · 优化结果
| 优化 | 帧率 |
|---|---|
| 全量 Canvas 2D | 3fps |
| 抽稀 + Canvas | 30fps |
| WebGL 实例化 + 窗口过滤 | 60fps |
| 加空间索引 + 增量更新 | 60fps(拖动不卡) |
步骤 5 · 验收
- 100 万点首帧 < 1s
- 拖动/缩放 ≥ 50fps
- 内存稳定(无持续增长)
- 提供数据表/文本摘要
- 低端设备自动降级(抽稀 + 减少点大小)
6. 常见误区对照表
| 误区 | 事实 |
|---|---|
| 「Canvas 一定比 SVG 快」 | 元素少时 SVG 更快且可交互 |
| 「DPR 只用设一次」 | 窗口移动/缩放会变化,要监听 |
| 「每帧重建对象没关系」 | GC 抖动会直接掉帧 |
| 「用 CSS 动画驱动 Canvas」 | Canvas 内容需 JS 重绘 |
| 「WebGL 什么都能加速」 | draw call 多、纹理切换仍会卡 |
| 「图表库能自动处理大数据」 | 大多需要抽稀/分块/WebGL 渲染器 |
| 「Canvas 不需要无障碍」 | 必须提供文本替代或数据表 |
| 「视频 preload=auto 更好」 | 浪费带宽,列表页应 none |
| 「忘记 dispose 只是小问题」 | 显存泄漏会直接崩标签页 |
| 「3D 一定比 2D 高级」 | 取决于信息表达,3D 常增加认知成本 |
7. 面试高频题(附答题思路)
Q1:Canvas 与 SVG 的区别与选型? 保留 vs 立即模式 → 事件/可访问性 → 元素量阈值 → 具体场景。
Q2:高清屏模糊怎么解决?
DPR → 像素尺寸 → setTransform → 监听变化。
Q3:如何实现 Canvas 的点击交互? 坐标换算 → 命中测试(包围盒/几何/颜色拾取)→ 分层管理。
Q4:10 万点折线怎么画? 抽稀(LTTB)→ 分块 → Canvas/WebGL → 像素宽度与点数关系。
Q5:WebGL 渲染管线? 顶点着色器 → 图元装配 → 光栅化 → 片元着色器 → 帧缓冲;再说 draw call 优化。
Q6:如何优化 3D 场景性能? 合批、实例化、纹理图集、LOD、按需渲染、资源释放。
Q7:视频内存泄漏怎么排查?
revokeObjectURL、解码帧 close()、buffer 清理、Performance Monitor。
Q8:图表如何做无障碍?
role="img" + aria-label 摘要 + 数据表兜底 + 键盘操作。
8. 面试深挖
8.1 面试官在考察什么
| 维度 | 看什么 | 加分回答 | 减分回答 |
|---|---|---|---|
| 选型判断 | 按数据量与交互选渲染方式 | 给出量化阈值与降级路径 | 「WebGL 一定快」 |
| 图形基础 | 管线与坐标变换 | 说清顶点、片元、投影 | 「调 API 就行」 |
| 性能归因 | GPU 还是 CPU 瓶颈 | 用帧时间与绘制调用数判断 | 「加个 requestAnimationFrame」 |
| 交互正确性 | 命中测试与高 DPI | 处理缩放、平移、设备像素比 | 只按 CSS 像素算 |
| 工程治理 | 大屏与图表的规范 | 统一主题、单位、空态 | 每个图表各写一套 |
8.2 追问链
链 1 · Canvas / SVG / DOM → 渲染成本 → 选型
- 面试官:图表该用 SVG 还是 Canvas?
- 要点:看元素数量与交互频率。少量节点、需要 CSS 样式与可访问性 → SVG;上千节点、高频重绘 → Canvas;需要文本选择、SEO、复杂布局 → DOM。
- 追问:Canvas 为什么节点多了反而慢?
- 要点:Canvas 没有场景图,每帧全量重绘;
clearRect+ 重画所有图元的成本随元素数线性增长。优化靠分层(静态层缓存成位图)、脏矩形、离屏 Canvas。
- 要点:Canvas 没有场景图,每帧全量重绘;
- 再追问:SVG 上千节点卡在哪?
- 要点:每个节点都是 DOM,样式计算、布局、合成都要参与;动画走 CSS 时可以只合成,但大量节点的内存与重排成本仍高。超过约 1000–2000 节点应考虑 Canvas 或分片渲染。
链 2 · WebGL 管线 → 批处理 → 性能瓶颈判断
- 面试官:WebGL 渲染一帧经过哪些步骤?
- 要点:准备顶点缓冲与着色器 → 顶点着色器做模型/视图/投影变换 → 图元装配与光栅化 → 片元着色器算颜色 → 深度测试、混合 → 写入帧缓冲。JS 只负责提交绘制命令。
- 追问:为什么绘制 1 万个点会卡?
- 要点:不是点数,而是 draw call。每个对象一次
drawArrays会产生 CPU 提交开销;用实例化(drawArraysInstanced)或合并成单个大缓冲,把 draw call 降到个位数。
- 要点:不是点数,而是 draw call。每个对象一次
- 再追问:怎么判断是 GPU 还是 CPU 瓶颈?
- 要点:看帧时间构成与
EXT_disjoint_timer_query;减少分辨率后帧率明显上升多为片元/填充率瓶颈;减少对象数才上升多为 draw call/JS 瓶颈;着色器复杂度可用简化版本对比。
- 要点:看帧时间构成与
链 3 · 交互 → 坐标变换 → 命中测试
- 面试官:Canvas 里点击一个圆,怎么判断命中?
- 要点:把鼠标坐标从屏幕空间反变换到世界空间(逆矩阵),再与图元做几何判断(圆:距离平方 ≤ 半径平方)。不要用「描边像素颜色」判断,抗锯齿会导致误判。
- 追问:缩放平移后为什么点不中?
- 要点:变换矩阵没同步或顺序错误(先平移再缩放与先缩放再平移结果不同);设备像素比没参与换算,坐标差一个
devicePixelRatio倍数。
- 要点:变换矩阵没同步或顺序错误(先平移再缩放与先缩放再平移结果不同);设备像素比没参与换算,坐标差一个
- 再追问:上千个图元逐个判断会卡吗?
- 要点:会。用空间索引(四叉树/网格)把候选集缩小到少数几个;或用离屏 Canvas 做「颜色 ID 拾取」,一次绘制后读像素反查 ID(适合不规则图形,但要注意抗锯齿与重绘成本)。
8.3 手写题
题 1 · 坐标变换与命中测试
/**
* 二维视图变换:维护缩放与平移,提供屏幕 ↔ 世界坐标互转。
* @param {HTMLCanvasElement} canvas
*/
function createViewport(canvas) {
let scale = 1;
let offsetX = 0;
let offsetY = 0;
/** 屏幕坐标转世界坐标(含高 DPI 换算) */
const toWorld = (clientX, clientY) => {
const rect = canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
const x = (clientX - rect.left) * dpr;
const y = (clientY - rect.top) * dpr;
return { x: (x - offsetX) / scale, y: (y - offsetY) / scale };
};
/** 应用变换:先缩放后平移 */
const apply = (ctx) => {
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.setTransform(scale, 0, 0, scale, offsetX, offsetY);
};
/**
* 命中测试:圆集合,返回最上层命中的元素。
* @param {{x: number, y: number, r: number, id: string}[]} circles
*/
const hitTest = (circles, clientX, clientY) => {
const p = toWorld(clientX, clientY);
for (let i = circles.length - 1; i >= 0; i -= 1) {
const c = circles[i];
const dx = p.x - c.x;
const dy = p.y - c.y;
if (dx * dx + dy * dy <= c.r * c.r) return c;
}
return null;
};
return {
toWorld,
apply,
hitTest,
zoomAt(factor, clientX, clientY) {
const before = toWorld(clientX, clientY);
scale *= factor;
const after = toWorld(clientX, clientY);
// 保持鼠标下的世界坐标不动
offsetX += (after.x - before.x) * scale;
offsetY += (after.y - before.y) * scale;
},
};
}
追问:
- 为什么高 DPI 要乘
devicePixelRatio?→ CSS 像素与画布像素不是 1:1,不换算会偏移且模糊。 - 缩放为什么要围绕鼠标点?→ 否则内容会朝左上角「跑掉」,用户需要重新寻找焦点。
- 命中测试为什么要倒序遍历?→ 后绘制的在上层,倒序保证选中视觉最上层元素。
题 2 · 固定步长的动画循环
/**
* 固定步长动画循环:逻辑按 60Hz 步进,渲染做插值。
* @param {(dt: number) => void} update 逻辑更新,固定步长
* @param {(alpha: number) => void} render 渲染,alpha 为插值系数
* @param {{step?: number}} [options]
*/
function startLoop(update, render, { step = 1000 / 60 } = {}) {
let raf = 0;
let last = performance.now();
let acc = 0;
const frame = (now) => {
// 后台标签页恢复时会有一个巨大的 delta,钳制避免「追帧」卡死
const delta = Math.min(now - last, 100);
last = now;
acc += delta;
while (acc >= step) {
update(step / 1000);
acc -= step;
}
render(acc / step);
raf = requestAnimationFrame(frame);
};
raf = requestAnimationFrame(frame);
return () => cancelAnimationFrame(raf);
}
追问:
- 为什么要固定步长?→ 物理与插值结果与帧率无关,120Hz 与 60Hz 表现一致。
alpha有什么用?→ 在两次逻辑更新之间插值渲染,避免低帧率下的抖动。- 后台标签页恢复后为什么要钳制
delta?→ 否则会一次性追几百帧,界面卡死。
8.4 场景设计题
题:一个实时监控大屏要展示 10 万个设备的点位,每秒更新一次状态,还要支持缩放、框选与悬浮详情。你怎么设计?
评估点:数据量、更新频率、交互、内存四面是否都考虑到。
参考方案:
- 数据:WebSocket 增量推送,只传变化的设备 ID 与状态;前端用
Map存,避免全量重建。 - 渲染:WebGL 点精灵(单 draw call)+ 状态用属性区分;缩放时按视口裁剪,LOD 分级(远看聚合、近看单点)。
- 交互:拾取用离屏颜色 ID 或空间索引;悬浮详情节流到 100ms;框选用世界坐标矩形与索引求交。
- 更新:状态变化写进缓冲区,每帧只上传脏区间(
bufferSubData),避免整块重传。 - 内存:固定大小缓冲池,复用对象;页面隐藏时暂停渲染。
加分:指出「每秒 10 万点全量更新」的网络与 GC 压力,必须增量化;指出要限制设备像素比(Math.min(dpr, 2))防止填充率爆炸。
减分:用 DOM 或 SVG 画 10 万点;每帧重建整个缓冲;不做 LOD 与裁剪。
8.5 最容易答错的点
| 常见说法 | 纠正 |
|---|---|
| 「Canvas 一定比 SVG 快」 | 元素少时 SVG 更快且更易维护,取决于数量与交互 |
| 「WebGL 快是因为 GPU」 | 快在批处理与并行,draw call 多时 CPU 仍是瓶颈 |
「requestAnimationFrame 每秒固定 60 次」 | 跟随屏幕刷新率,后台会暂停,回调时间也不精确 |
| 「Canvas 绘制后还能改某个图元」 | 没有场景图,只能重绘或分层缓存 |
「坐标直接用 clientX 就行」 | 要减去画布偏移并乘设备像素比 |
| 「命中测试用像素颜色最准」 | 抗锯齿与缩放会导致偏差,几何或 ID 拾取更可靠 |
| 「WebGPU 会完全取代 WebGL」 | 能力更强但兼容性与生态仍在演进,需降级方案 |
| 「大屏只要堆图表库」 | 统一主题、单位、空态与性能预算才是工程重点 |
9. 延伸资源
- MDN:Canvas API、SVG、WebGL、Web Audio、MediaSource
- WebGL2 / WebGPU 规范与 MDN 教程
- Three.js 官方示例与文档(性能章节)
- D3 官方文档(scale/shape/transition)
- ECharts / AntV 文档:大数据量与渲染器配置
- deck.gl / Mapbox GL 文档
- 「WebGL Fundamentals」与「Learn WebGPU」
- WebCodecs / WebRTC 规范与 Chrome 团队文章