HTML 游戏用 Canvas 为什么快:从渲染管线到帧率优化
【摘要】 <h2>前言:一个反直觉的现象</h2><p>同样是「屏幕上有 1000 个元素在动」这件事,用 DOM 写,帧率很容易掉到 20 帧;换成 Canvas 之后,同样 1000 个圆点却能跑满 60 帧。于是很多人得出一个结论:<strong>Canvas 比 DOM 快</strong>。</p><p>这个结论不算错,但它只是结果,不是原因。真正的原因是:Canvas 走的是<strong...
<h2>前言:一个反直觉的现象</h2>
<p>同样是「屏幕上有 1000 个元素在动」这件事,用 DOM 写,帧率很容易掉到 20 帧;换成 Canvas 之后,同样 1000 个圆点却能跑满 60 帧。于是很多人得出一个结论:<strong>Canvas 比 DOM 快</strong>。</p>
<p>这个结论不算错,但它只是结果,不是原因。真正的原因是:Canvas 走的是<strong>另一条完全不同的渲染路径</strong>,它绕开了浏览器最昂贵的三件事——建树、布局、以及成千上万个节点的合成调度。理解这条路径,才知道该在哪里优化、什么时候反而会变慢。</p>
<h2>一、先看清楚:DOM 的一次「动」到底发生了什么</h2>
<p>在 DOM 里移动一个元素,浏览器大致要经历这样一条管线:</p>
<p>JS 改样式 → 样式计算(Recalculate Style)→ 布局(Layout)→ 绘制(Paint)→ 合成(Composite)→ 显示</p>
<ul>
<li><strong>样式计算</strong>:每个元素都带着一整套 CSS,需要重新匹配、重新计算生效值。</li>
<li><strong>布局</strong>:只要改到 width、left、top、margin 这类属性,就会触发重排(reflow),而且可能连累父节点与兄弟节点。</li>
<li><strong>绘制</strong>:每个元素都要被光栅化成像素,元素多、层级多,绘制指令就多。</li>
<li><strong>合成</strong>:现代浏览器会把动画元素提升为独立图层,用 GPU 合成。但图层不是免费的:每个图层都是一块纹理,占显存,也会有调度与上传开销。</li>
</ul>
<p>即使你很聪明地用 <code>transform</code> 代替 <code>left</code>,跳过了布局与重绘,<strong>依然是 1000 个独立节点、1000 份样式、1000 次图层变换</strong>。这 1000 份管理成本,就是 DOM 方案的固定开销。</p>
<blockquote>换句话说,DOM 是为「文档」设计的:它假设页面上的元素数量有限、变化频率不高、并且需要被搜索、被选中、被无障碍工具读取。它的每一项能力都在为这种场景付出代价。</blockquote>
<h2>二、Canvas 快在哪:五个真正的原因</h2>
<h3>1. 立即模式:没有节点树,只有像素</h3>
<p>Canvas 是典型的<strong>立即模式(immediate mode)</strong>渲染。你调用一次 <code>ctx.fillRect()</code>,浏览器就直接把矩形画进位图里,画完即忘,不会在内存里留下「一个矩形对象」。</p>
<p>也就是说,屏幕上无论有 10 个还是 10 万个图形,对 DOM 而言 Canvas 永远只是<strong>一个元素</strong>:一份样式、一个布局盒、一个图层。数量的增长被压缩在 Canvas 内部,不再线性地转嫁给浏览器。</p>
<h3>2. 跳过布局与样式计算</h3>
<p>Canvas 内部的绘制不参与布局。你画的圆点在 (100, 200),浏览器不需要知道它和邻居谁先谁后、会不会把别的元素挤开。这就直接砍掉了整条管线中耗时最不可控的两个阶段。</p>
<p>游戏的画面本来就是「一帧一张图」,不存在文档流的概念,让引擎去做布局完全是浪费。</p>
<h3>3. 一张位图,一次合成</h3>
<p>Canvas 的内容最终存放在一张 backing store 位图里。合成器每帧只需要处理这一个图层:把它当作一张纹理,交给 GPU 贴到屏幕上。</p>
<p>相比之下,DOM 动画元素即便被提升为合成层,也是 N 张纹理、N 次变换矩阵计算、N 次图层排序。这在桌面端还能撑住,在移动端就是发热与掉帧的来源。</p>
<h3>4. 与 vsync 对齐的 requestAnimationFrame</h3>
<p>Canvas 游戏的标准写法是 <code>requestAnimationFrame</code>。它由浏览器在下一帧开始前统一回调,天然与屏幕刷新率(通常 60Hz,高刷屏 90/120Hz)对齐。</p>
<ul>
<li>回调在样式计算与布局之前执行,不会和渲染管线抢时间片;</li>
<li>页面不可见(切到后台标签页)时会被暂停,避免白白耗电;</li>
<li>所有的绘制集中在一次回调里完成,天然形成「一帧一次」的批处理节奏。</li>
</ul>
<p>而 DOM 方案里,如果你在循环中「读一下 offsetLeft 再写一次 style」,就会触发强制同步布局(layout thrashing),一次循环能把帧时间放大数倍。</p>
<h3>5. 批处理与状态切换成本极低</h3>
<p>在 Canvas 里,一次 <code>fillStyle</code> 设置之后可以连续画几十个同色图形;绘制指令在底层被合并、批量提交。而在 DOM 里,每个元素的每个属性都是一次独立的样式解析。</p>
<p>更重要的是:<strong>游戏的数据结构可以自己定</strong>。用 <code>Float32Array</code> 存放坐标,用对象池复用子弹,用空间网格做碰撞检测——这些在 DOM 里完全做不到,因为你没法绕开浏览器替你管理的元素对象。</p>
<h2>三、代码对比:1000 个运动的小球</h2>
<p>先看 DOM 版本。每个球都是一个真实节点,每次运动都要写样式:</p>
<pre><code>const stage = document.getElementById('stage');
const balls = [];
for (let i = 0; i < 1000; i++) {
const el = document.createElement('div');
el.className = 'ball';
stage.appendChild(el);
balls.push({ el, x: Math.random() * 800, y: Math.random() * 600 });
}
function tick() {
for (const b of balls) {
b.x += 1;
if (b.x > 800) b.x = 0;
b.el.style.transform = `translate(${b.x}px, ${b.y}px)`;
}
requestAnimationFrame(tick);
}
requestAnimationFrame(tick);</code></pre>
<p>再看 Canvas 版本,全部绘制发生在一次回调、一张位图里:</p>
<pre><code>const canvas = document.getElementById('stage');
const ctx = canvas.getContext('2d');
const balls = [];
for (let i = 0; i < 1000; i++) {
balls.push({
x: Math.random() * canvas.width,
y: Math.random() * canvas.height,
r: 6,
color: '#' + ((Math.random() * 0xffffff) | 0).toString(16).padStart(6, '0')
});
}
function tick() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
for (const b of balls) {
b.x += 1;
if (b.x > canvas.width) b.x = 0;
}
ctx.beginPath();
for (const b of balls) {
ctx.moveTo(b.x + b.r, b.y);
ctx.arc(b.x, b.y, b.r, 0, Math.PI * 2);
}
ctx.fillStyle = '#2f6fed';
ctx.fill();
requestAnimationFrame(tick);
}
requestAnimationFrame(tick);</code></pre>
<p>两段代码的差别不只是语法,而是<strong>渲染债主换了人</strong>:DOM 版本把 1000 个对象的调度权交给浏览器;Canvas 版本只把一帧位图交出去,其余全部由你的循环掌控。</p>
<p>顺手加一个帧率探针,优化前后才有可比性:</p>
<pre><code>let last = performance.now(), frames = 0;
function fps(now) {
frames++;
if (now - last >= 1000) {
console.log('FPS:', frames);
frames = 0;
last = now;
}
requestAnimationFrame(fps);
}
requestAnimationFrame(fps);</code></pre>
<h2>四、但 Canvas 不是免费的午餐</h2>
<p>说清楚「为什么快」之后,更要紧的是知道它什么时候会慢。下面这些坑,几乎每个 Canvas 项目都会踩一遍。</p>
<ul>
<li><strong>填充率(fill rate)</strong>:每帧全屏 <code>clearRect</code> 再重绘整个世界,在 2K/4K 分辨率下就是纯粹的像素搬运。分辨率越高越明显。</li>
<li><strong>读回像素</strong>:<code>getImageData</code> / <code>putImageData</code> 会把数据从 GPU 拉回 CPU,一次就可能吃掉十几毫秒。做碰撞检测、特效时要尽量换算法。</li>
<li><strong>高频创建对象</strong>:每帧 <code>new</code> 出大量临时对象,GC 一触发就是明显的卡顿。用对象池。</li>
<li><strong>逐帧改 canvas.width</strong>:会重置整块画布与上下文状态,代价极高,只在 resize 时做。</li>
<li><strong>文字渲染</strong>:<code>fillText</code> 比 DOM 文本昂贵,而且不可选中、不利于 SEO 与无障碍。</li>
<li><strong>高 DPI 模糊</strong>:不按 <code>devicePixelRatio</code> 放大画布会糊;放大之后像素量按平方增长,又会反过来吃性能。要按需取舍。</li>
</ul>
<h2>五、把 Canvas 用「快」的几个常规动作</h2>
<h3>1. 静态内容做离屏缓存</h3>
<p>背景、地形、UI 贴图这类不变的东西,先画进一个离屏 Canvas,之后每帧只 <code>drawImage</code> 一次。</p>
<pre><code>const bg = document.createElement('canvas');
bg.width = canvas.width;
bg.height = canvas.height;
const bgCtx = bg.getContext('2d');
renderBackground(bgCtx); // 只执行一次
// 每帧
ctx.drawImage(bg, 0, 0);
drawDynamicLayer(ctx);</code></pre>
<h3>2. 脏矩形:只重绘变化的区域</h3>
<p>对于改动面积小的场景,记录每帧需要更新的矩形区域,只清理并重绘这些区域,能省掉大量填充开销。</p>
<h3>3. 精灵图集 + 减少状态切换</h3>
<p>把零散小图打进一张图集,能避免纹理切换与重复上传;把同色、同状态的图形合并成一次 <code>fill()</code>,能让绘制指令更连续。</p>
<h3>4. OffscreenCanvas + Web Worker</h3>
<p>把渲染搬到 Worker 线程,主线程专心处理输入与逻辑,卡顿感会明显下降:</p>
<pre><code>const offscreen = canvas.transferControlToOffscreen();
const worker = new Worker('render.js');
worker.postMessage({ canvas: offscreen }, [offscreen]);</code></pre>
<h3>5. 量大就上 WebGL / WebGPU</h3>
<p>当图元数量到几万甚至几十万,2D 上下文会成为瓶颈。此时用 WebGL 把绘制交给 GPU,配合实例化渲染,数量级的差距就出来了。这是 Canvas 家族内部的一次升级,而不是回到 DOM。</p>
<h2>六、一句话总结</h2>
<table border="1" cellpadding="8" cellspacing="0">
<tr>
<th>对比项</th>
<th>DOM 方案</th>
<th>Canvas 方案</th>
</tr>
<tr>
<td>渲染模型</td>
<td>保留模式,浏览器维护节点树</td>
<td>立即模式,自己维护画面</td>
</tr>
<tr>
<td>元素开销</td>
<td>每个图形都是节点 + 样式 + 可能独立的图层</td>
<td>整块画布只算一个元素</td>
</tr>
<tr>
<td>布局阶段</td>
<td>参与,可能触发重排</td>
<td>不参与,直接画像素</td>
</tr>
<tr>
<td>每帧工作</td>
<td>样式计算 + 绘制 + 多层合成</td>
<td>执行绘制指令 + 一次合成</td>
</tr>
<tr>
<td>适用场景</td>
<td>内容型页面、表单、文本密集</td>
<td>游戏、图表、动画、编辑器</td>
</tr>
</table>
<p>所以,「Canvas 为什么快」的答案可以压缩成一句:<strong>它把成千上万个元素的管理成本,换成了你自己掌控的一条绘制循环。</strong></p>
<p>它不是魔法,而是把选择权交还给你。你少交了浏览器的通用性税,代价是要自己处理命中测试、缩放适配、脏区域和内存复用。对游戏这种「每帧重画一切」的场景,这笔交易非常划算;对一个以文字和表单为主的页面,那就是主动找麻烦。</p>
<p>先想清楚你的画面是「文档」还是「图像」,再决定用哪个 API——这比记住任何一条性能技巧都管用。</p>
【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱:
cloudbbs@huaweicloud.com
- 点赞
- 收藏
- 关注作者
评论(0)