动画与交互:rAF / 清屏 / 时间驱动 / 拾取
基于 Canvas 2D API(2026 浏览器基线)· 核于 2026-07
速查
- 每帧四步(MDN 原文):① 清空 canvas(clearRect)→ ② save 状态 → ③ 绘制 → ④ restore。
- rAF 优先:与显示器刷新同步(通常 60fps)、后台标签页自动暂停省电;回调收到高精度时间戳。
- setInterval/setTimeout 的问题:不与渲染对齐,可能掉帧/空转——动画驱动一律 rAF。
- rAF 断链坑:回调内忘了再
requestAnimationFrame(frame)→ 动画只走一帧;固定模板「清屏 → 画 → 尾部 rAF(next)」。 - 忘清屏 = 拖影满屏:每帧开头
clearRect整屏(或脏矩形,见性能页)。 - 循环内变换/样式:每轮
save()→ 变换 → 画 →restore(),防累积偏移(见变换与状态)。 - 基于时间的动画(必考):
- 初始化
let last = performance.now();每帧用 rAF 时间戳算dt = (now - last) / 1000(毫秒 → 秒); - 速度按「像素/秒」定义:
x += 120 * dt,与帧率无关; - 基于帧数递增(
x += 2)在 120Hz 高刷屏上速度翻倍——必须用 delta time。
- 初始化
- 清屏三法:
clearRect(0, 0, w, h)—— 常规最优:只清像素、保留状态;- 重设
canvas.width(即使同值)—— 清像素 + 重置全部状态 + 重分配缓冲,慢且副作用大; ctx.reset()(2023-12 Baseline)—— 标准化「回到全新上下文」:清位图 + 状态栈 + 路径 + 样式 + 变换 + 裁剪全重置。
- 重置类清屏后重跑初始化:重设 width /
ctx.reset()连 dpr 的scale一起清掉,记得重跑三步法。 - 带变换清屏清不净:translate 后
clearRect(0,0,w,h)清的是变换后的矩形,残影。标准姿势:save()→setTransform(1,0,0,1,0,0)→clearRect→restore();或ctx.reset()。 - 蚂蚁线:
setLineDash([4, 2])+ 递增lineDashOffset。 - 时钟:translate 到中心 + rotate 逐针;秒针平滑用
getSeconds() + getMilliseconds() / 1000。 - 太阳系:
globalCompositeOperation = "destination-over"先画前景、后垫背景。 - 交互全靠自建(立即模式推论):canvas 内部图形没有 DOM 事件,事件只落在
<canvas>元素上——点击命中、拖拽都要自己建模。 - 拾取三条路:
- isPointInPath:配 Path2D 最顺;四种重载
(x,y)/(x,y,fillRule)/(path,x,y)/(path,x,y,fillRule);传入坐标是未经变换的画布坐标(不受当前 transform 影响)。 - 数学命中:圆 = 距离比半径;矩形 = 区间判断;自绘场景图 = 对象数组 + 倒序遍历命中最上层。
- hit-canvas 颜色拾取:每个对象以唯一纯色画到同尺寸隐藏 canvas,点击时
getImageData(x, y, 1, 1)查「颜色 → 对象」映射(Konva 内部方案,任意形状 O(1))。
- isPointInPath:配 Path2D 最顺;四种重载
- isPointInStroke:测描边命中(与 isPointInPath 同族)。
- Path2D 复用:同一个 Path2D 既给
fill/stroke绘制、又给isPointInPath拾取——一份图形定义两处使用。 - 事件坐标换算(必做):事件给的是 CSS 像素;缓冲尺寸 ≠ CSS 尺寸(dpr 适配后必现)时须换算,否则命中区错位一倍:
x = (event.clientX - rect.left) * (canvas.width / rect.width);y = (event.clientY - rect.top) * (canvas.height / rect.height)。
- 动画性能红线:循环里规避
shadowBlur、大量fillText、每帧getImageData(对策见性能优化)。
一、动画范式:rAF 每帧四步
立即模式下「动」= 不断重画。MDN 给出的每帧四步:① 清空 canvas → ② save 状态 → ③ 绘制 → ④ restore。驱动用 requestAnimationFrame:与显示器刷新同步(通常 60fps)、后台标签页自动暂停(省电),优于 setInterval/setTimeout(不与渲染对齐,可能掉帧或空转)。
let last = performance.now();
let x = 0;
function frame(now) { // rAF 回调收到高精度时间戳
const dt = (now - last) / 1000; // 秒
last = now;
x += 120 * dt; // 【基于时间】:120px/秒,与帧率无关
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillRect(x, 20, 40, 40);
requestAnimationFrame(frame); // 忘了这句动画只走一帧
}
requestAnimationFrame(frame);两个模板级细节:
- 尾部续链:回调内没再
requestAnimationFrame→ 动画只走一帧;忘clearRect→ 拖影满屏。固定模板「清屏 → 画 → 尾部 rAF(next)」。 - 循环内有变换/样式:套上一页的「save → 变换 → 画 → restore」,防累积。
二、基于时间的动画(帧率无关)
上面代码里 x += 120 * dt 是关键:速度按「像素/秒」定义,用 delta time 推进。如果写成基于帧数的 x += 2,在 60Hz 屏上是 120px/s,在 120Hz 高刷屏上就变成 240px/s——速度翻倍。rAF 的回调频率跟随显示器刷新率,不是常量,所以必须用时间差驱动。
经典应用:时钟的秒针平滑摆动——不是每秒跳一格,而是用 getSeconds() + getMilliseconds() / 1000 得到连续秒数再转角度(MDN 时钟例:translate 到中心 + rotate 逐针)。
三、清屏策略三法
| 方法 | 清什么 | 保留什么 | 适用 |
|---|---|---|---|
clearRect(0, 0, w, h) | 像素(清成透明黑) | 全部状态(变换/样式/裁剪) | 常规每帧清屏最优 |
重设 canvas.width(即使同值) | 像素 + 全部状态 | 无(缓冲也重新分配) | 基本别用:慢、dpr 适配失效 |
ctx.reset()(2023-12 Baseline) | 像素 + 状态栈 + 路径 + 样式 + 变换 + 裁剪 | 无 | 标准化「回到全新上下文」 |
带变换时 clearRect 清不净是高频坑:translate 之后 clearRect(0, 0, w, h) 清的是变换后坐标系里的矩形,画布上留残影。标准姿势:
ctx.save();
ctx.setTransform(1, 0, 0, 1, 0, 0); // 回单位矩阵
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.restore();或者直接 ctx.reset()(连状态一起归零,注意 dpr 的 scale 也要重设)。
顺带两个 MDN 教程的合成小套路:太阳系例用 globalCompositeOperation = "destination-over" 先画前景、后垫背景;蚂蚁线动画靠递增 lineDashOffset(见绘图基础)。
四、交互拾取:canvas 里没有「点击图形」
canvas 内部图形没有 DOM 事件——事件只落在 <canvas> 元素本身上。「点到了哪个图形」要自己算,三条路:
1. isPointInPath / isPointInStroke(配 Path2D 最顺)
const circle = new Path2D();
circle.arc(150, 75, 50, 0, 2 * Math.PI);
canvas.addEventListener("mousemove", (event) => {
const hit = ctx.isPointInPath(circle, event.offsetX, event.offsetY);
// 四种重载:isPointInPath(x, y) / (x, y, fillRule) / (path, x, y) / (path, x, y, fillRule)
// 传入坐标是【未经变换的画布坐标】(不受当前 transform 影响)
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = hit ? "green" : "red";
ctx.fill(circle);
});isPointInStroke 同理,测的是描边命中(细线可点区域小,常配加宽的隐形路径)。
2. 数学命中检测
圆 = 「点到圆心距离 ≤ 半径」;矩形 = 区间判断。编辑器类应用自建场景图:对象数组按绘制顺序存放,命中时倒序遍历(后画的在上层,先命中)。这正是立即模式的本质推论——Excalidraw 类编辑器都是「自建场景模型 + 全量/增量重绘」。
3. hit-canvas 颜色拾取
每个对象以唯一纯色画到一张同尺寸的隐藏 canvas 上;点击时 getImageData(x, y, 1, 1) 读那一点的颜色,查「颜色 → 对象」映射表。任意复杂形状都是 O(1) 命中(Konva 内部就是这个方案)。
坐标换算:dpr 适配后的必修课
事件给的是 CSS 像素坐标;一旦缓冲尺寸 ≠ CSS 尺寸(dpr 适配后必然如此),直接把 event.offsetX 喂给绘制/拾取会错位一倍。换算公式:
const rect = canvas.getBoundingClientRect();
const x = (event.clientX - rect.left) * (canvas.width / rect.width);
const y = (event.clientY - rect.top) * (canvas.height / rect.height);(或者统一在 CSS 坐标系里画 + 测,让 ctx.scale(dpr, dpr) 消化差异。)
动画每帧都在烧 CPU/GPU——下一页性能优化讲怎么让重绘便宜下来:离屏预渲染、分层、脏矩形,以及把渲染整个搬进 Worker 的 OffscreenCanvas。