【Canvas 2D】day34-interaction-animation

作者:mario 发布时间: 2026-09-01 阅读量:6 评论数:0

Canvas 交互与动画基础 — 让画面"活"起来

前 5 天我们画的东西都是"静态"的:画一次,定住不动。但工业可视化的灵魂是实时——温度在变、曲线在滚、报警灯在闪。今天把两样东西接上:交互(鼠标/触摸 → 画布响应)和动画(requestAnimationFrame 让画面每帧重绘)。学完今天,你就能做出"跟随鼠标的动态粒子"这种效果——这也是明天画板 BOSS 战的全部技术底座。


目录


一、交互第一关:坐标换算

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 使用
  • 粒子上限 + 主动回收,别让数组无限增长

八、自测挑战

  1. 推导题:不看文档,写出 eventToCanvas 函数并解释每一行为什么存在。
  2. 改错题:下面代码在 120Hz 屏上有什么问题?改正确。
function frame(): void {
  ball.x += 5;           // 每帧 5px
  render();
  requestAnimationFrame(frame);
}
  1. 设计题:给粒子系统加一个"爆炸"交互——点击画布时以点击点为圆心喷出 30 个粒子,初速度方向随机。只写出需要改动的代码段。
  2. 思考题:为什么 particles.splice 必须倒序?用 [a, b, c] 删除 a、b 两个元素模拟一遍正序删除的过程。
  3. 进阶题:把粒子的 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 周的变换与像素操作就只剩纸老虎。

评论