16

可视化与多媒体

977 行约 44 分钟27 个代码块22 张表格更新于 2026-11-03

16 · 可视化与多媒体(详解版)

一句话定位:可视化是把数据映射成视觉元素(位置、长度、颜色、面积),多媒体是把音视频正确地解码、播放与合成。两者都要求你绕过框架,直接控制渲染与时间。

读完本篇你应该能做到

  1. 在 Canvas、SVG、WebGL/WebGPU 之间做出有依据的选择;
  2. 独立实现坐标变换、动画循环、命中测试与高清屏适配;
  3. 让万级/百万级数据在浏览器里流畅渲染;
  4. 处理音视频播放、流式加载与内存问题。

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>
特性SVGCanvas
渲染方式保留模式(每个图形是节点)立即模式(像素)
事件每个元素可绑定需自己做命中测试
可访问性原生可读需额外提供文本替代
大量元素慢(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 / RechartsReact 组件React 项目大数据需优化
Three.js3D 场景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、字幕是基本盘

自检题

  1. canvas.width 与 canvas.style.width 有什么区别?——前者是画布像素缓冲尺寸,后者是 CSS 显示尺寸;两者不一致会模糊或拉伸,高 DPI 下要按 devicePixelRatio 设置前者。
  2. 什么时候必须用 Canvas 而不是 SVG?——元素上千、高频重绘、像素级处理或 WebGL 场景;少量可交互节点用 SVG 更易维护、可访问性更好。
  3. 视频为什么需要 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);
}

三个原则:

  1. 不要在 rAF 里做 getBoundingClientRect 等强制布局操作;
  2. 不要每帧创建新对象/数组(GC 抖动);
  3. 页面不可见时暂停(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 处理

自检题

  1. 为什么 Canvas 里 y 轴要翻转?——Canvas 原点在左上、y 向下;数学与图表坐标系原点在左下、y 向上,绘制时需 scale(1, -1) 或统一坐标转换。
  2. 多个 rAF 循环有什么问题?——各自清理重绘会互相覆盖,重复占用帧预算;应统一一个循环分发所有更新。
  3. 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

维度WebGLWebGPU
抽象层状态机显式管线/命令缓冲
计算无(要 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();

优化清单:

  1. 合并几何体、复用材质;
  2. 用 InstancedMesh 渲染重复对象;
  3. 控制纹理尺寸与数量;
  4. 按需渲染(无变化不重绘);
  5. 释放资源(dispose()),否则显存泄漏。

3.5 大屏与实时看板的架构

数据层:WebSocket / SSE 推送增量
   ↓
状态层:环形缓冲(只保留窗口内数据)
   ↓
渲染层:Canvas/WebGL + 抽稀 + 分层
   ↓
交互层:缩放/筛选(改变数据窗口,不重算全部)

常见坑:每次推送都全量重绘 → 帧率暴跌;应做「脏标记 + 增量绘制」。

3.6 音视频进阶:WebCodecs 与 WebRTC

能力API用途
解码/编码WebCodecs精确帧控制、视频编辑
实时通话WebRTC低延迟音视频
流式播放MSEHLS/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场景图 + 资源释放
大屏增量数据 + 脏标记渲染
可访问必须有文本替代

自检题

  1. WebGL 里 draw call 为什么比顶点数更关键?——每次绘制都有 CPU 提交与状态切换开销,顶点可由 GPU 并行处理;减少 draw call 靠合并缓冲与实例化。
  2. Three.js 忘记 dispose() 会怎样?——几何体、材质、纹理占用的 GPU 内存不释放,长时间运行内存持续上涨,严重时上下文丢失。
  3. 大屏每帧全量重绘怎么优化?——静态层缓存成位图、脏矩形重绘、离屏 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 自检

  1. 一个百万点的轨迹回放,如何选型与分阶段优化?——先抽稀与 LOD,再用 Canvas 分层或 WebGL 实例化;按「数据量 → 帧率 → 交互」分阶段验证,每阶段设指标。
  2. 公司有 5 个团队各写一套图表,如何收敛?——统一图表规范与主题 token,封装基础图表组件与数据适配层,复杂场景允许扩展;用采纳率与重复代码量度量。
  3. 视频播放内存持续增长,怎么定位与治理?——查是否反复创建 MediaSource / SourceBuffer、未 revokeObjectURL、监听未解绑、解码缓存过大;用 Memory 与媒体面板定位后按生命周期释放。

5. 综合实战:百万点轨迹回放

需求:在地图上回放 100 万个 GPS 点,支持缩放、时间轴拖动、实时跟随。

步骤 1 · 分层

层技术理由
底图地图引擎(WebGL)瓦片与投影
轨迹WebGL 线/点(实例化)百万级图元
高亮/选中Canvas 覆盖层少量元素、易交互
UIDOM表单与控件

步骤 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 2D3fps
抽稀 + Canvas30fps
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。
  • 再追问:SVG 上千节点卡在哪?
    • 要点:每个节点都是 DOM,样式计算、布局、合成都要参与;动画走 CSS 时可以只合成,但大量节点的内存与重排成本仍高。超过约 1000–2000 节点应考虑 Canvas 或分片渲染。

链 2 · WebGL 管线 → 批处理 → 性能瓶颈判断

  • 面试官:WebGL 渲染一帧经过哪些步骤?
    • 要点:准备顶点缓冲与着色器 → 顶点着色器做模型/视图/投影变换 → 图元装配与光栅化 → 片元着色器算颜色 → 深度测试、混合 → 写入帧缓冲。JS 只负责提交绘制命令。
  • 追问:为什么绘制 1 万个点会卡?
    • 要点:不是点数,而是 draw call。每个对象一次 drawArrays 会产生 CPU 提交开销;用实例化(drawArraysInstanced)或合并成单个大缓冲,把 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 万个设备的点位,每秒更新一次状态,还要支持缩放、框选与悬浮详情。你怎么设计?

评估点:数据量、更新频率、交互、内存四面是否都考虑到。

参考方案:

  1. 数据:WebSocket 增量推送,只传变化的设备 ID 与状态;前端用 Map 存,避免全量重建。
  2. 渲染:WebGL 点精灵(单 draw call)+ 状态用属性区分;缩放时按视口裁剪,LOD 分级(远看聚合、近看单点)。
  3. 交互:拾取用离屏颜色 ID 或空间索引;悬浮详情节流到 100ms;框选用世界坐标矩形与索引求交。
  4. 更新:状态变化写进缓冲区,每帧只上传脏区间(bufferSubData),避免整块重传。
  5. 内存:固定大小缓冲池,复用对象;页面隐藏时暂停渲染。

加分:指出「每秒 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 团队文章