Skip to main content

性能优化

在高级前端面试中,如果简历里写了 Canvas 相关经验,“如何优化 Canvas 性能” 是必考题。以下是几种核心的优化手段:

1. 离屏渲染 (Offscreen Canvas)

频繁调用 API 绘制复杂图形非常消耗性能。如果某个复杂的图形在多帧中保持不变,可以先在一个不可见的 Canvas(离屏 Canvas) 上把它画出来,然后通过 drawImage() 将这个离屏 Canvas 当作图片直接绘制到主 Canvas 上。

// 1. 创建离屏 Canvas
const offCanvas = document.createElement("canvas");
offCanvas.width = 100;
offCanvas.height = 100;
const offCtx = offCanvas.getContext("2d");

// 2. 在离屏 Canvas 上执行复杂的绘制(仅执行一次)
offCtx.beginPath();
// ...复杂的路径计算和绘制...
offCtx.fill();

// 3. 在主渲染循环中,直接把离屏 Canvas 当作图片画上去(极快)
function render() {
ctx.drawImage(offCanvas, 0, 0);
requestAnimationFrame(render);
}

注意:HTML5 原生提供了 OffscreenCanvas API,它甚至可以在 Web Worker 中使用,从而把渲染计算完全剥离出主线程。

2. 分层渲染 (动静分离)

游戏开发中的经典思想。如果背景是不变的,而上面有频繁运动的物体,可以创建两个重叠的 <canvas>

  • 底层 Canvas:绘制静态背景,只在初始化或改变时重绘。
  • 顶层 Canvas:绘制动态物体,每一帧只擦除和重绘顶层画布。 这样可以极大减少每帧的重绘面积。

3. 脏矩形更新 (Dirty Rectangle)

当画布中只有一小块区域发生变化时,不要使用 clearRect(0, 0, width, height) 清空整个画布。 做法:计算出发生变化的元素的包围盒(Bounding Box),利用 ctx.clip() 限制绘制区域,然后仅对该小矩形区域执行 clearRect() 和重绘。这在大面积背景重绘成本高时效果极佳。

// 假设有一个小球在 (100, 100) 移动到了 (105, 100),半径为 10
// 我们只需要擦除并重绘包含这两个位置的矩形区域,而不是整个 1920x1080 的画布
ctx.save();
ctx.beginPath();
// 框出需要更新的矩形范围
ctx.rect(80, 80, 40, 40);
ctx.clip(); // 限制后续的清除和绘制只在这个矩形内生效

ctx.clearRect(80, 80, 40, 40);
// ...重新绘制背景...
// ...重新绘制小球...
ctx.restore();

4. 批量绘制与避免频繁状态切换

Canvas 的上下文状态(如 fillStylelineWidth)改变是比较耗时的。

  • 优化:将颜色、样式相同的元素放在一起批量绘制。
  • 尽量使用 Path2D:提前缓存复杂的路径,而不是每次都在每一帧里执行几十次 moveTo / lineTo
// ❌ 差的写法:频繁切换状态
for (let i = 0; i < 100; i++) {
ctx.fillStyle = i % 2 === 0 ? "red" : "blue";
ctx.fillRect(i * 10, 0, 10, 10);
}

// ✅ 好的写法:按状态分组绘制
ctx.beginPath();
for (let i = 0; i < 100; i += 2) {
// 红色组
ctx.rect(i * 10, 0, 10, 10);
}
ctx.fillStyle = "red";
ctx.fill();

ctx.beginPath();
for (let i = 1; i < 100; i += 2) {
// 蓝色组
ctx.rect(i * 10, 0, 10, 10);
}
ctx.fillStyle = "blue";
ctx.fill();

5. 避免浮点数坐标

Canvas 在绘制小数坐标时,会自动使用抗锯齿(Anti-aliasing)算法去计算半像素的颜色,这会额外消耗 CPU 性能。 优化:在调用 drawImage 或是设定坐标前,使用 Math.floor()| 0 将坐标取整。

// ❌ 耗性能,边缘会因为抗锯齿变模糊
ctx.drawImage(img, 10.33, 20.88);

// ✅ 速度更快,边缘更清晰
ctx.drawImage(img, Math.floor(10.33), 20.88 | 0);

6. 避免在绘制时分配大内存

如果在 requestAnimationFrame 渲染循环中不断 new 对象或数组,会频繁触发浏览器的垃圾回收(GC),导致动画卡顿(掉帧)。

优化:使用对象池(Object Pool)或在循环外部预先分配好内存。

// ❌ 每秒 60 次触发 GC
function render() {
const position = { x: 10, y: 20 }; // 每一帧都在新建对象
draw(position);
requestAnimationFrame(render);
}

// ✅ 复用对象
const position = { x: 0, y: 0 };
function render() {
position.x = 10; // 仅修改属性
position.y = 20;
draw(position);
requestAnimationFrame(render);
}

7. 硬件加速与 getImageData 的坑

关于 Canvas 硬件加速,本质是浏览器在内部将 2D 绘图指令转换为 GPU 可执行的命令,再由 CPU 通过特定机制提交给 GPU 处理。这一过程虽能提升性能,但会有一定的 API 转换开销。

面试避坑:频繁调用 getImageData 导致页面卡死?

  • 原理:开启了硬件加速的 Canvas,其像素数据存储在 GPU 显存中。如果你调用 getImageData(比如做取色器、像素碰撞),就是强行要求 GPU 把数据从显存回传到 CPU 内存。这个跨总线的数据回读(Readback)极其缓慢,会严重阻塞主线程。
  • 解法:如果你知道这个 Canvas 会被频繁读取像素,在初始化时必须传入 { willReadFrequently: true }
    const ctx = canvas.getContext("2d", { willReadFrequently: true });
    这会强制浏览器使用软件(CPU)渲染这个画布,像素直接保存在 CPU 内存里,从而极大加快 getImageData 的读取速度。

实战案例:超多元素的地图场景优化

面试官提问:如果让你用 Canvas 实现一个类似高德/百度地图的场景,里面有 10 万个静态建筑、道路,还有 1 万辆动态行驶的小车,支持拖拽和缩放,你会怎么做才能保证 60fps 不卡顿?

这是一个非常综合的高级图形性能题。如果直接在每一帧 for 循环 11 万次并调用 ctx.draw,浏览器绝对会直接卡死。标准的高分解法必须涵盖以下几个维度:

1. 动静分离 (多层 Canvas)

千万不要把静态建筑和动态小车画在同一个 Canvas 上。

  • 底层 Canvas:绘制静态的道路、水系、建筑。只在地图缩放或平移结束时才触发重绘。
  • 顶层 Canvas:绘制动态小车。开启 requestAnimationFrame,每一帧只清空并重绘顶层画布。

2. 视口剔除 (Viewport Culling) 与 空间索引 (Spatial Indexing)

哪怕是拖拽时重绘底图,也不能遍历 10 万个静态元素。

  • 原理:只绘制当前屏幕(视口)可见范围内的元素,屏幕外的直接丢弃不执行绘图指令。
  • 空间索引:在内存中使用四叉树 (QuadTree)网格索引 (Grid) 将 10 万个元素按地理位置划分。当屏幕视口坐标发生改变时,通过四叉树极速查出当前视口相交的元素集合,将需要循环遍历绘制的元素从 10 万级瞬间降维到几百个。

3. 瓦片化渲染与离屏缓存 (Tile & Offscreen)

底图元素虽然经过了剔除,但在复杂视口下依然可能有很多元素,频繁执行路径绘制依然卡顿。

  • 做法:将整个大地图在内存中切分成多个固定大小的离屏 Canvas (瓦片)。当数据加载时,把每个瓦片的内容预先画好。
  • 交互时:用户拖拽平移时,主线程根本不进行路径计算(不调 moveTo/lineTo),而是直接算出当前视口需要哪几块瓦片,直接执行低成本的 ctx.drawImage(瓦片离屏Canvas, x, y)

4. LOD 多细节层次 (Level of Detail)

当用户把地图缩小到只能看到整个城市全貌时,强行绘制 10 万个小房子不仅看不清,还会造成极大的性能浪费。

  • 做法:根据当前的缩放层级 (Zoom Level) 决定渲染策略。
    • 放大时(Zoom > 15):绘制详细的建筑物轮廓、道路文字。
    • 缩小时(Zoom < 10):隐藏小街道和建筑物,只用粗线条绘制主干道,动态小车降级为简单的像素点甚至直接隐藏。

5. Web Worker 计算分流

1 万辆小车的坐标实时更新(比如经纬度转屏幕坐标)如果放在主线程,会挤占渲染时间。

  • 做法:将小车的业务逻辑、坐标转换、甚至是四叉树的碰撞查找全部放入 Web Worker。计算完毕后,通过 postMessage (最好使用 SharedArrayBuffer 共享内存) 将要绘制的最终坐标数组传递给主线程,主线程只做极简的 drawImage

💡 满分回答话术总结:

“面对 10 万级元素的地图场景,我会从渲染层数据层两个维度进行优化。 渲染层面上,首先使用多 Canvas 动静分离,底层画地图顶层画小车。底层静态图采用离屏 Canvas 瓦片化预渲染,拖拽时只做低成本的贴图操作。同时引入 LOD 策略,在小比例尺下丢弃非必要细节。 数据层面上,绝不遍历全量数据,而是引入四叉树做空间索引,严格执行视口剔除,只渲染屏幕内的元素。最后,将繁重的坐标换算和树查询剥离到 Web Worker 中,确保主线程的渲染稳定在 60 帧。”