Canvas 交互与动画基础 — 让画面"活"起来
前 5 天我们画的东西都是"静态"的:画一次,定住不动。但工业可视化的灵魂是实时——温度在变、曲线在滚、报警灯在闪。今天把两样东西接上:交互(鼠标/触摸 → 画布响应)和动画(requestAnimationFrame 让画面每帧重绘)。学完今天,你就能做出"跟随鼠标的动态粒子"这种效果——这也是明天画板 BOSS 战的全部技术底座。
目录
- 一、交互第一关:坐标换算
- 二、鼠标与触摸事件
- 三、动画的心脏:requestAnimationFrame
- 四、用类型建模动画状态
- 五、实战:跟随鼠标的动态粒子系统
- 六、性能意识:从第一帧就要有
- 七、常见坑点与最佳实践
- 八、自测挑战
- 九、总结与知识图谱
一、交互第一关:坐标换算
1.1 问题:鼠标给的坐标"不是画布的坐标"
鼠标事件对象提供的 clientX/clientY 是相对浏览器视口的坐标,而画布可能页面中任何位置,还可能有边框、还经过了 Day 29 的 DPR 缩放。三者不在一个坐标系里,直接用必然错位。
// ❌ 直接用 clientX 画东西 —— 只要画布不在页面左上角,位置就全错
canvas.addEventListener("mousemove", (e) => {
ctx.fillRect(e.clientX, e.clientY, 10, 10); // 偏差 = 画布在页面中的偏移
});
1.2 正确姿势:getBoundingClientRect 换算
/**
* 把鼠标事件的视口坐标换算成画布 CSS 像素坐标
* @param canvas 目标画布
* @param e 鼠标/触摸事件(用 clientX/clientY 的都适用)
* @returns 画布坐标系中的点(左上角为原点,单位 CSS 像素)
*
* 注意:返回的是 CSS 像素坐标——因为 Day 29 我们做了 ctx.scale(dpr, dpr),
* 绘制坐标系本身就是 CSS 像素思维,所以这里【不需要】再除以 DPR!
*/
function eventToCanvas(canvas: HTMLCanvasElement, e: MouseEvent): { x: number; y: number } {
const rect = canvas.getBoundingClientRect();
return {
x: e.clientX - rect.left, // 视口坐标 - 画布左上角在视口中的位置
y: e.clientY - rect.top,
};
}
推导链(面试常问,要能自己推):
clientX(视口坐标)
- rect.left(画布左边界距视口左边)
= 画布内 CSS 像素 x
(绘制时 ctx 已 scale(dpr),无需再动)
1.3 一个容易忽略的细节:border 与 CSS 缩放
// ⚠ 如果画布有 border,getBoundingClientRect 包含 border!
// canvas { border: 2px solid red; }
// rect.left 是 border 外缘 → 换算出的坐标会偏移 2px
// ⚠ 如果画布被 CSS 强行缩放过(width: 50%),显示尺寸 ≠ 内部分辨率:
function eventToCanvasStrict(canvas: HTMLCanvasElement, e: MouseEvent): { x: number; y: number } {
const rect = canvas.getBoundingClientRect();
// 用"显示尺寸 / 内部分辨率"的比例修正(我们 DPR 适配后 width = cssWidth * dpr):
const scaleX = canvas.width / rect.width; // 注意:canvas.width 是物理像素
const scaleY = canvas.height / rect.height;
return {
x: (e.clientX - rect.left) * scaleX / (window.devicePixelRatio || 1),
y: (e.clientY - rect.top) * scaleY / (window.devicePixelRatio || 1),
};
}
// 工业大屏经常缩放画布(scale 适配不同分辨率),严格版更通用
二、鼠标与触摸事件
2.1 三大鼠标事件
// 画板/图表交互的标准三件套
canvas.addEventListener("mousedown", (e) => { /* 按下:记录起点,开始动作 */ });
canvas.addEventListener("mousemove", (e) => { /* 移动:跟随、拖拽中 */ });
canvas.addEventListener("mouseup", (e) => { /* 抬起:结束动作 */ });
// ⚠ mouseup 可能在画布外抬起!要"跟到画布外也结束",得监听 window:
window.addEventListener("mouseup", handler); // 明天画板的拖拽会用到这个技巧
2.2 触摸事件:移动端必需
// 触摸事件的三件套(名字带 touch)
canvas.addEventListener("touchstart", (e: TouchEvent) => {
e.preventDefault(); // 阻止滚动/缩放等浏览器默认行为
const t = e.touches[0]; // 取第一个触点
const { x, y } = touchToCanvas(canvas, t);
startDraw(x, y);
});
canvas.addEventListener("touchmove", (e: TouchEvent) => { /* 同理 */ });
canvas.addEventListener("touchend", (e: TouchEvent) => { /* 同理 */ });
/** 触点坐标换算(Touch 对象同样有 clientX/clientY) */
function touchToCanvas(canvas: HTMLCanvasElement, t: Touch): { x: number; y: number } {
const rect = canvas.getBoundingClientRect();
return { x: t.clientX - rect.left, y: t.clientY - rect.top };
}
2.3 命中检测:点到了哪个图形?
interface HitCircle { x: number; y: number; r: number; id: string }
/**
* 检测点是否命中某个圆(设备图标最常用的命中检测)
* 原理:点到圆心的距离 < 半径
*/
function hitTestCircle(p: { x: number; y: number }, c: HitCircle): boolean {
const dx = p.x - c.x;
const dy = p.y - c.y;
return dx * dx + dy * dy < c.r * c.r; // 平方比较,避免开方(性能习惯)
}
// 点击设备图标 → 高亮 + 弹详情(工业组态的基本交互)
canvas.addEventListener("click", (e) => {
const p = eventToCanvas(canvas, e);
const device = devices.find((d) => hitTestCircle(p, d));
if (device) selectDevice(device.id); // 命中:选中该设备
});
📌 矩形命中:
p.x > r.x && p.x < r.x + r.w && p.y > r.y && p.y < r.y + r.h。第 7 周碰撞检测会系统展开。
三、动画的心脏:requestAnimationFrame
3.1 为什么不用 setInterval?
// ❌ setInterval 画动画的三宗罪:
setInterval(draw, 16);
// 1. 不跟屏幕刷新率:屏幕 60Hz 你定 100ms → 画面卡成幻灯片
// 2. 后台标签页照样跑:切走后白烧 CPU/GPU(rAF 会自动暂停)
// 3. 时间不保证:回调排队延迟,动画速度忽快忽慢
3.2 rAF 的正确打开方式
/**
* requestAnimationFrame(rAF):
* - 浏览器在【下一次屏幕重绘之前】调用你的回调
* - 自动对齐刷新率(60Hz→约16.7ms一次,120Hz→更快)
* - 页面切到后台自动暂停(省电省性能)
*/
function frame(): void {
update(); // ① 更新状态(物理、数据)
render(); // ② 重绘画面
requestAnimationFrame(frame); // ③ 预约下一帧 —— 动画循环的"永动机"
}
requestAnimationFrame(frame); // 启动
3.3 帧率无关性:用时间差驱动动画
/**
* ⚠ 新手必踩的坑:每帧移动固定像素
* → 120Hz 屏幕上速度是 60Hz 的两倍!(在高刷屏上"飞起来")
*
* 正确做法:速度按"真实经过的时间"计算(帧率无关)
*/
let lastTime = performance.now();
const SPEED = 100; // 像素/秒
function frame(now: number): void {
const dt = (now - lastTime) / 1000; // 时间差(秒)—— 关键!
lastTime = now;
ball.x += SPEED * dt; // 每秒固定移动 100px,无论帧率多少
render();
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
口诀:状态用时间推,画面每帧重绘。
3.4 清屏:动画的前提
function render(): void {
// 每帧先清掉上一帧的画面,否则会留下"拖影"
// 方式一:clearRect(清指定区域,最常用)
ctx.clearRect(0, 0, cssWidth, cssHeight);
// 方式二:整块重设(粗暴但彻底,会重置全部状态)
// canvas.width = canvas.width;
// 方式三:半透明覆盖 → 天然"拖尾"效果(粒子特效常用!)
// ctx.fillStyle = "rgba(10, 16, 30, 0.2)";
// ctx.fillRect(0, 0, cssWidth, cssHeight);
}
四、用类型建模动画状态
4.1 状态驱动渲染:Canvas 的"React 思想"
前 5 天是"指令式":写一行画一笔。动画要换成状态驱动:
┌─────────┐ ┌─────────┐ ┌─────────┐
│ 状态更新 │ ──→ │ 渲染 │ ──→ │ 画面 │
│ update() │ │ render() │ │ (可丢弃) │
└─────────┘ └─────────┘ └─────────┘
↑ │
└────── 用户交互修改状态 ─────────┘
画面只是状态的投影——状态变了重画就行,永远不用"修改"画面本身。这是第 7 周流程图编辑器、阶段2 大屏项目的核心思想。
4.2 用可辨识联合建模粒子生命周期
/**
* 粒子生命周期状态机:spawning(出生)→ alive(存活)→ dying(消亡)→ 移除
* 用可辨识联合(tag 属性 kind)建模 —— TS 阶段学的 discriminated union 这里落地了!
*/
type ParticleState =
| { kind: "spawning"; bornAt: number } // 出生:淡入中
| { kind: "alive" } // 存活:正常运动
| { kind: "dying"; fadeSpeed: number } // 消亡:淡出中
| { kind: "dead" }; // 死亡:待回收
interface Particle {
id: number;
x: number; y: number; // 位置
vx: number; vy: number; // 速度(像素/秒)
size: number;
color: string;
life: number; // 剩余寿命(秒)
state: ParticleState; // ⭐ 可辨识联合:narrow 后精确处理
}
/**
* 状态机更新:根据 kind 走不同分支,TS 自动 narrow
* 好处:新增状态时漏处理任何分支,编译器立刻报错(exhaustive check)
*/
function updateParticle(p: Particle, dt: number): void {
switch (p.state.kind) {
case "spawning":
// 出生阶段:不动,只等淡入完成(简化:0.3 秒后转 alive)
if (p.life < 2.7) p.state = { kind: "alive" };
break;
case "alive":
p.x += p.vx * dt;
p.y += p.vy * dt;
p.life -= dt;
if (p.life <= 0) p.state = { kind: "dying", fadeSpeed: 2 };
break;
case "dying":
p.size *= 0.95; // 逐渐缩小
if (p.size < 0.5) p.state = { kind: "dead" };
break;
case "dead":
break; // 等主循环回收
}
}
五、实战:跟随鼠标的动态粒子系统
今天的实战目标:鼠标移动时喷出彩色粒子,粒子受"上升力"飘动并逐渐消亡——大屏常见的"数据流光"效果的基础版。
5.1 完整代码
// day34-particles.ts —— 跟随鼠标的动态粒子系统
import { bootCanvas } from "../day29/canvas-boot.js";
const { ctx, cssWidth, cssHeight } = bootCanvas(document.querySelector("#board")!);
// ===== 1. 状态定义 =====
interface Particle {
x: number; y: number;
vx: number; vy: number;
size: number;
hue: number; // 色相(HSL,随机彩色)
life: number; // 剩余寿命(秒)
maxLife: number; // 总寿命(算透明度比例用)
}
const particles: Particle[] = []; // ⭐ 唯一的数据源:状态数组
// ===== 2. 交互:鼠标移动 → 生成粒子(改状态,不直接画!) =====
canvas.addEventListener("mousemove", (e) => {
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
// 每次移动喷 3 个粒子,方向随机偏上("上升"的感觉)
for (let i = 0; i < 3; i++) {
particles.push({
x: x + (Math.random() - 0.5) * 10, // 起点在鼠标附近抖动
y: y + (Math.random() - 0.5) * 10,
vx: (Math.random() - 0.5) * 60, // 水平随机速度 ±30px/s
vy: -30 - Math.random() * 50, // 向上飘 30~80px/s
size: 2 + Math.random() * 4,
hue: 120 + Math.random() * 80, // 绿→青色域(工业风)
life: 1 + Math.random(), // 寿命 1~2 秒
maxLife: 0,
});
particles[particles.length - 1].maxLife = particles[particles.length - 1].life;
}
// 防爆:超过 500 个删最老的(性能保险丝)
if (particles.length > 500) particles.splice(0, particles.length - 500);
});
// ===== 3. 动画主循环:时间驱动的更新 + 全量重绘 =====
let lastTime = performance.now();
function frame(now: number): void {
const dt = Math.min((now - lastTime) / 1000, 0.05); // ⭐ 限制 dt 上限防"跳帧爆炸"
lastTime = now;
// --- update:更新每个粒子,过滤掉死亡的 ---
for (const p of particles) {
p.vy += 20 * dt; // 轻微重力(上升力衰减 → 先飘后落)
p.x += p.vx * dt; // 帧率无关的速度
p.y += p.vy * dt;
p.life -= dt;
}
for (let i = particles.length - 1; i >= 0; i--) {
if (particles[i].life <= 0) particles.splice(i, 1); // 倒序删除,安全
}
// --- render:清屏 → 全量重绘 ---
ctx.clearRect(0, 0, cssWidth, cssHeight);
for (const p of particles) {
const alpha = Math.max(p.life / p.maxLife, 0); // 寿命比例 → 透明度
ctx.fillStyle = `hsla(${p.hue}, 90%, 60%, ${alpha})`;
ctx.beginPath();
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
ctx.fill();
}
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
5.2 逐行拆解三个关键设计
| 设计 | 原因 |
|---|---|
mousemove 只 push 数据不绘制 |
事件触发频率 ≠ 帧率;绘制只发生在 rAF 循环里,逻辑与渲染分离 |
dt 上限 0.05 秒 |
标签页切走再回来,dt 可能是好几秒 → 粒子瞬移出屏;钳制住 |
倒序 splice |
正序删除会跳过相邻元素(数组塌陷),经典 JS 坑 |
六、性能意识:从第一帧就要有
6.1 Canvas 动画的性能预算
60 FPS = 每帧 16.7ms,刨掉浏览器开销,你的 update + render 只有 ~10ms
| 手段 | 说明 | 本周用到吗 |
|---|---|---|
| 少画 | 合批:同色图形一条路径搞定 | ✅ 今天:多个圆各 beginPath 就是反面教材,可合并 |
| 少算 | 减少每帧对象创建(GC 抖动) | ✅ 今天:粒子用对象池优化(进阶挑战) |
| 少清 | 脏矩形:只重绘变化的区域 | 第 7 周正式课 |
| 少层 | 多层画布:静态层只画一次 | 第 7 周正式课 |
6.2 合批示例
// ❌ 慢:100 个同色粒子 = 100 次 fill(每次都是一次绘制调用)
for (const p of particles) {
ctx.beginPath();
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
ctx.fillStyle = p.color;
ctx.fill();
}
// ✅ 快:同色粒子合成一条路径,一次 fill(绘制调用从 100 → 1)
ctx.fillStyle = "#00ff88";
ctx.beginPath();
for (const p of greenParticles) {
ctx.moveTo(p.x + p.size, p.y); // 关键:moveTo 断开子路径,否则会连线!
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
}
ctx.fill();
七、常见坑点与最佳实践
| # | 坑 | 症状 | 解法 |
|---|---|---|---|
| 1 | 直接用 clientX 当画布坐标 |
图形位置整体偏移 | getBoundingClientRect() 换算 |
| 2 | 忘了 DPR 语境 | 有人除以 dpr 有人不除,混乱 | 记死:ctx.scale(dpr) 后全程 CSS 像素思维,事件坐标不再除 dpr |
| 3 | setInterval 做动画 |
高刷屏速度异常、后台白烧电 | 永远用 rAF + 时间差 |
| 4 | 每帧固定位移 | 120Hz 屏上动画快一倍 | 位移 = 速度 × dt |
| 5 | 不钳制 dt | 切标签页回来粒子"瞬移" | dt = Math.min(dt, 0.05) |
| 6 | 正序遍历中 splice | 隔一个删一个 | 倒序遍历,或 filter 生成新数组 |
| 7 | 忘了 preventDefault(触摸) | 手机上画布一划页面跟着滚 | touchstart/move 里 e.preventDefault() |
| 8 | 多个 arc 连成线 | 圆之间出现奇怪连线 | 每个子路径前 moveTo |
| 9 | mouseup 监听在 canvas 上 | 拖出画布松手,状态卡死 | 结束类事件监听 window |
最佳实践清单:
- 交互改状态,rAF 循环读状态渲染——两条线永不交叉
- 动画速度一律"单位/秒",乘 dt 使用
- 粒子上限 + 主动回收,别让数组无限增长
八、自测挑战
- 推导题:不看文档,写出
eventToCanvas函数并解释每一行为什么存在。 - 改错题:下面代码在 120Hz 屏上有什么问题?改正确。
function frame(): void {
ball.x += 5; // 每帧 5px
render();
requestAnimationFrame(frame);
}
- 设计题:给粒子系统加一个"爆炸"交互——点击画布时以点击点为圆心喷出 30 个粒子,初速度方向随机。只写出需要改动的代码段。
- 思考题:为什么
particles.splice必须倒序?用[a, b, c]删除 a、b 两个元素模拟一遍正序删除的过程。 - 进阶题:把粒子的
hsla字符串改为每帧预生成颜色缓存,想想能省多少字符串拼接开销?
九、总结与知识图谱
Day 34 交互与动画
├── 坐标换算
│ ├── getBoundingClientRect 基础版
│ └── 缩放修正严格版(大屏场景)
├── 事件
│ ├── 鼠标三件套 + window 兜底 mouseup
│ ├── 触摸三件套 + preventDefault
│ └── 命中检测(圆:距离平方比较)
├── 动画
│ ├── rAF 优于 setInterval 的三个理由
│ ├── 帧率无关:dt 时间差驱动
│ └── 清屏三姿势(clearRect / 重设 / 半透明拖尾)
├── 状态建模
│ ├── 状态驱动渲染(画面 = 状态投影)
│ └── 可辨识联合建模生命周期(TS 落地)
└── 性能
├── 合批(一条路径一次 fill + moveTo 断连)
└── dt 钳制 / 粒子上限 / 倒序删除
明天预告(Day 35 · BOSS 战):简易画板——把本周 7 天的技能全部串起来:DPR 适配(Day 29)、路径平滑(Day 30)、颜色选择(Day 31)、橡皮擦 = destination-out(Day 32)、文字工具(Day 33)、坐标换算 + mousedown/move/up 拖拽绘制(今天)。画板是 Canvas 的"毕业考",过了它,第 6 周的变换与像素操作就只剩纸老虎。