CC 咖啡猫的工作空间 Coding Space

图形与动画

本文档侧重「动画方案选型与性能优化」。CSS Transition/Animation 语法和 Canvas/SVG/Three.js 基础 API 已有独立文件,此处仅做引用和深入。

相关文件引用:

  • 02-前端基础/02-CSS.md — CSS Transition、Animation、Transform、will-change、View Transitions API 语法
  • 06-专项领域/04-可视化.md — Canvas 基础 API、像素操作、离屏 Canvas、SVG 基础、Three.js 三大件/光照/纹理/Raycaster

1. 动画方案全景

方案对比表

方案 适用场景 复杂度 渲染线程 可暂停 驱动方式 性能特征
CSS Transition hover、简单过渡 GPU 合成线程 声明式 最佳(仅合成层)
CSS Animation 关键帧、循环加载、引导 GPU 合成线程 是(animation-play-state 声明式 最佳(仅合成层)
requestAnimationFrame 游戏循环、复杂物理、自定义缓动 主线程 手动控制 命令式 依赖实现质量
Canvas 2D 粒子系统、像素处理、2D 游戏 中-高 主线程(逐帧) 手动控制 命令式 大量对象时优于 DOM
SVG Animation 图标 Logo 动效、矢量动画 主线程(DOM) 可(SMIL/CSS) 声明式+CSS 元素少时好,多则卡
WebGL / Three.js 3D 场景、产品展示、Web 游戏 GPU 手动控制 命令式 矩阵计算 GPU 加速
Lottie / Rive 品牌动效、引导、Loading 低-中 主线程(canvas/SVG) JSON 驱动 取决于导出复杂度
FLIP 动画 列表重排、折叠展开 合成线程 - JS+CSS 绕过 Layout,性能高
View Transitions API SPA 页面切换、共享元素过渡 浏览器原生 - 声明式 原生合成,无 JS 开销

选型决策流程

简单过渡(hover/opacity/color 变化)
  └─ CSS Transition(声明式,零 JS 开销)

复杂关键帧 / 循环动画
  └─ CSS Animation(性能最好,可暂停)

需要 JS 计算(物理引擎 / 复杂缓动 / 与数据联动)
  └─ requestAnimationFrame

大量粒子 / 像素操作 / 游戏画面(数千个独立对象)
  └─ Canvas 2D

矢量图标 / Logo / 小数据量交互图形
  └─ SVG Animation(矢量缩放不失真)

3D 场景 / 产品展示 / Web 游戏
  └─ Three.js / WebGL

设计师产出(AE / Rive 动画文件)
  └─ Lottie(AE 导出 JSON)或 Rive(状态机交互)

页面级切换过渡
  └─ View Transitions API

列表重排 / 折叠展开(布局变化)
  └─ FLIP 动画

2. CSS 动画深入

语法基础见 02-前端基础/02-CSS.md。本节深入原理和进阶技巧。

2.1 浏览器渲染流水线与动画

浏览器渲染一帧经过三大阶段:

JavaScript → Style → Layout → Paint → Composite
                              ↑            ↑
                   重排(Reflow)    重绘(Repaint)
属性变化 触发阶段 性能
transform Composite 仅合成 最佳
opacity Composite 仅合成 最佳
filter Composite + Paint(部分情况仅合成)
color / background-color Paint + Composite 中等
width / height / top / left Layout + Paint + Composite 差(触发重排)

核心原则: 动画只使用 transformopacity,确保仅触发合成阶段,完全跳过 Layout 和 Paint。

/* 坏:触发 Layout */
.element {
  transition: left 0.3s ease;
  left: 0;
}
.element.active {
  left: 100px;
}

/* 好:仅合成层 */
.element {
  transition: transform 0.3s ease;
  transform: translateX(0);
}
.element.active {
  transform: translateX(100px);
}

2.2 GPU 合成层详解

合成层(Compositing Layer):浏览器将页面拆分为独立图层,由合成器线程(Compositor Thread)独立处理,不占用主线程。

创建合成层的条件:

  • will-change: transform / opacity / filter
  • transform: translateZ(0)translate3d(0,0,0)(强制提升 Hack)
  • 显式 position: fixed
  • <video> / <canvas> / <iframe> 元素
  • -webkit-overflow-scrolling: touch
  • 相邻元素有 3D transform(隐式提升副作用)
/* 提前告知浏览器创建合成层 */
.element {
  will-change: transform, opacity;
  /* 在变化前设置,动画结束后及时移除 */
}

/* 动画结束后移除 will-change */
.element-idle {
  will-change: auto;
}

层爆炸(Layer Explosion)陷阱:

/* 坏:大量相邻 3D transform 导致层爆炸 */
.card { transform: translateZ(0); }    /* 每个卡片单独一层 */
/* 几百个卡片 → 几百个合成层 → GPU 内存耗尽 → 性能反而下降 */

/* 好:只在需要动画的元素上创建层 */
.card.active {
  will-change: transform;
}

三个不要:

  1. 不要给大量静态元素设置 translateZ(0) — 每一层消耗 GPU 内存
  2. 不要提前很久设置 will-change — 用完改为 auto
  3. 不要给 body 或容器根元素设置 will-change — 整页提升为合成层

2.3 FLIP 动画原理

FLIP = First, Last, Invert, Play。核心思想:不计算过渡中间值,只补起始和终止位置差。

四步流程:

First  → Last      → Invert              → Play
记录起始位置  改变布局后记录结束位置  transform 反向拉回起始位置  移除 transform,transition 平滑滑动
function flipAnimation(el, callback, duration = 300) {
  // 1. First:记录起始位置
  const first = el.getBoundingClientRect();

  // 执行 DOM 变化(改变布局)
  callback();

  // 2. Last:记录结束位置
  const last = el.getBoundingClientRect();

  // 3. Invert:计算差异,反向变换回起始位置
  const dx = first.left - last.left;
  const dy = first.top - last.top;
  const dw = first.width / last.width;
  const dh = first.height / last.height;

  el.style.transition = 'none';
  el.style.transform = `translate(${dx}px, ${dy}px) scale(${dw}, ${dh})`;

  // 4. Play:移除 transform,transition 产生平滑动画
  requestAnimationFrame(() => {
    el.style.transition = `transform ${duration}ms ease`;
    el.style.transform = '';
  });

  // cleanup:动画完成后清理 transition
  el.addEventListener('transitionend', () => {
    el.style.transition = '';
  }, { once: true });
}

FLIP 的价值: 无论布局变化多复杂(DOM 插入/删除/排序/尺寸变化),动画始终只操作 transform,性能与直接操作 CSS transition 一致。

FLIP 局限: 不适合子元素尺寸变化需同时调整父容器的情况(需嵌套 FLIP)。

2.4 steps() 逐帧动画

steps() 函数将动画等分为 N 段,适合精灵图(spritesheet)切换:

.sprite {
  width: 100px;
  height: 100px;
  background: url('spritesheet.png') 0 0 no-repeat;
  /* spritesheet 宽度 800px,8 帧,每帧 100px */
  animation: playSprite 0.8s steps(8) infinite;
}

@keyframes playSprite {
  from { background-position: 0 0; }
  to   { background-position: -800px 0; }
  /* steps(8) 将 800px 分为 8 步,无中间插值 */
}

steps 参数对比:

函数 行为
steps(4, end) 默认。每段开始时跳转,最后一段结束后回到初始
steps(4, start) 初始跳到第一帧,每段结束时跳转
step-start 等同 steps(1, start),直接跳到终点
step-end 等同 steps(1, end),一直显示起点

animation-delay 负值控制起始帧:

/* 多个元素错位播放 */
.item:nth-child(1) { animation-delay: 0s; }
.item:nth-child(2) { animation-delay: -0.2s; }  /* 从 0.2s 位置开始 */
.item:nth-child(3) { animation-delay: -0.4s; }

2.5 滚动驱动动画(Scroll-Driven Animations)

无需 JS,纯 CSS 实现滚动驱动的动画。

/* 元素进入视口时驱动进度 */
@keyframes reveal {
  from { opacity: 0; transform: translateY(50px); }
  to   { opacity: 1; transform: translateY(0); }
}

.reveal-element {
  animation: reveal 1s ease-out;
  animation-timeline: view();                      /* 由元素在视口中的可见性驱动 */
  animation-range: entry 0% entry 100%;            /* entry: 进入视口阶段 / exit: 离开阶段 */
}

animation-timeline 取值:

行为
scroll() 由滚动容器滚动位置驱动
scroll(nearest) 最近的可滚动祖元素
scroll(root) 文档根元素滚动驱动
view() 由元素在视口中的可见位置驱动
view(block) 基于块方向(垂直)
view(inline) 基于行方向(水平)

animation-range 控制动画区间:

/* 元素从刚进入视口到完全可见之间播放动画 */
.progress-bar {
  animation: fillProgress linear;
  animation-timeline: scroll(root);
  animation-range: 0px 500px;  /* 滚动 0~500px 完成动画 */
}

2.6 性能陷阱总结

/* 1. 尽量只用 transform 和 opacity 做动画 */
/* 2. 动画期间使用合成层 */
/* 3. 不要同时动画太多元素 */
/* 4. 尊重用户偏好 */
@media (prefers-reduced-motion: reduce) {
  /* 关闭非必要动画 */
  .parallax,
  .confetti {
    animation: none !important;
    transition: none !important;
  }

  /* 关键功能用简单过渡替代 */
  .fade-in {
    animation: simpleFade 0.3s ease-out !important;
  }
}

@keyframes simpleFade {
  from { opacity: 0; }
  to   { opacity: 1; }
}

3. JavaScript 动画

3.1 requestAnimationFrame 原理

与屏幕刷新率同步:

显示器 60Hz  → 每 16.67ms 一帧  → rAF 在每帧之前执行回调
显示器 120Hz → 每 8.33ms 一帧   → rAF 频率对应提升

setTimeout vs requestAnimationFrame

特性 setTimeout(fn, 16.67) requestAnimationFrame
同步屏幕 否(可能丢帧或重复) 是,与 vsync 同步
标签页不可见 继续执行(浪费资源) 自动暂停
回调堆积 可能累积 不会,两次回调间只执行一次
时间戳参数 提供高精度 DOMHighResTimeStamp
节流 不节流 自然节流到屏幕刷新率

回调参数 timestamp

// timestamp 是页面加载后的高精度时间(毫秒)
// 用于计算 deltaTime,保证不同刷新率设备上动画速度一致
function animate(timestamp) {
  // timestamp 约等于 performance.now()
  console.log(timestamp);
}

3.2 动画循环设计

标准循环模板:

let lastTs = 0;

function animate(timestamp) {
  // 初始化或修正第一帧
  if (!lastTs) lastTs = timestamp;

  // deltaTime:两帧之间的时间差(ms)
  const delta = timestamp - lastTs;
  lastTs = timestamp;

  // 更新逻辑:传入 delta 保证速度一致
  update(delta);

  // 渲染
  render();

  // 继续下一帧
  requestAnimationFrame(animate);
}

requestAnimationFrame(animate);

速度一致性公式:

// 速度 = 像素 / 秒
const SPEED = 300; // 300px/s

function update(delta) {
  // delta 单位为 ms,转换为秒
  x += SPEED * (delta / 1000);

  // 无论 60Hz(delta≈16.67)还是 120Hz(delta≈8.33),每秒移动距离相同
  // 60Hz:  300 * 0.01667 ≈ 5px/帧 × 60 ≈ 300px/s
  // 120Hz: 300 * 0.00833 ≈ 2.5px/帧 × 120 ≈ 300px/s
}

3.3 缓动函数(Easing Functions)

常用缓动数学公式:

// 参数 t: 0 → 1(时间进度),返回值: 0 → 1(动画进度)

const Easing = {
  // 线性
  linear: t => t,

  // 二次缓入
  easeIn: t => t * t,

  // 二次缓出
  easeOut: t => t * (2 - t),

  // 二次缓入缓出
  easeInOut: t => t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t,

  // 三次缓入
  easeInCubic: t => t * t * t,

  // 三次缓出
  easeOutCubic: t => (--t) * t * t + 1,

  // 弹性(elastic)
  elasticOut: t => {
    const p = 0.3;
    return Math.pow(2, -10 * t) * Math.sin((t - p / 4) * (2 * Math.PI) / p) + 1;
  },

  // 弹跳(bounce)
  bounceOut: t => {
    if (t < 1 / 2.75) return 7.5625 * t * t;
    if (t < 2 / 2.75) return 7.5625 * (t -= 1.5 / 2.75) * t + 0.75;
    if (t < 2.5 / 2.75) return 7.5625 * (t -= 2.25 / 2.75) * t + 0.9375;
    return 7.5625 * (t -= 2.625 / 2.75) * t + 0.984375;
  },
};

// 使用:传入 0→1 时间进度,得到 0→1 缓动结果
function animateEasing(duration, easingFn, onUpdate) {
  const start = performance.now();

  function frame(now) {
    // t: 0 → 1
    const t = Math.min((now - start) / duration, 1);
    const progress = easingFn(t);

    onUpdate(progress);

    if (t < 1) requestAnimationFrame(frame);
  }

  requestAnimationFrame(frame);
}

// 示例
animateEasing(1000, Easing.elasticOut, (p) => {
  element.style.transform = `translateX(${p * 300}px)`;
});

Penner's easing(28 个经典曲线):

easeInSine / easeOutSine / easeInOutSine
easeInQuad / easeOutQuad / easeInOutQuad
easeInCubic / easeOutCubic / easeInOutCubic
easeInQuart / easeOutQuart / easeInOutQuart
easeInQuint / easeOutQuint / easeInOutQuint
easeInExpo / easeOutExpo / easeInOutExpo
easeInCirc / easeOutCirc / easeInOutCirc
easeInElastic / easeOutElastic / easeInOutElastic
easeInBack / easeOutBack / easeInOutBack (overshoot)
easeInBounce / easeOutBounce / easeInOutBounce

完整实现搜索 Robert Penner's easing equations,约 30 行数学函数。

3.4 物理动画

弹簧动画(Spring):

class Spring {
  constructor(target = 0, stiffness = 100, damping = 10, mass = 1) {
    this.target = target;   // 目标位置
    this.position = 0;      // 当前位置
    this.velocity = 0;      // 当前速度
    this.stiffness = stiffness; // 刚度(越大越硬,弹回越快)
    this.damping = damping;     // 阻尼(越大震动消失越快)
    this.mass = mass;           // 质量(越大惯性越大)
  }

  update(delta) {
    const dt = delta / 1000; // 转换为秒

    // 胡克定律 F = -kx(弹力 = 刚度 × 位移)
    const force = -this.stiffness * (this.position - this.target);

    // 阻尼力 Fd = -damping * velocity
    const dampingForce = -this.damping * this.velocity;

    // 加速度 a = F / m
    const acceleration = (force + dampingForce) / this.mass;

    // 更新速度和位置(欧拉积分)
    this.velocity += acceleration * dt;
    this.position += this.velocity * dt;

    return this.position;
  }
}

// 使用
const spring = new Spring(300, 180, 15, 1);

function animate(ts) {
  const pos = spring.update(16.67);
  element.style.transform = `translateX(${pos}px)`;

  if (Math.abs(spring.velocity) > 0.01 || Math.abs(spring.position - spring.target) > 0.01) {
    requestAnimationFrame(animate);
  }
}

惯性滚动(Momentum / Friction):

class Momentum {
  constructor(friction = 0.95) {
    this.velocity = 0;
    this.position = 0;
    this.friction = friction; // 每帧速度衰减系数
  }

  // 开始惯性滚动
  start(initialVelocity) {
    this.velocity = initialVelocity;
    if (!this.running) {
      this.running = true;
      requestAnimationFrame((ts) => this.tick(ts));
    }
  }

  tick(ts) {
    // 速度衰减
    this.velocity *= this.friction;
    this.position += this.velocity;

    // 停止条件:速度极小
    if (Math.abs(this.velocity) < 0.1) {
      this.running = false;
      return;
    }

    requestAnimationFrame((ts) => this.tick(ts));
  }
}

弹簧 vs 固定时长选择指南:

参数 CSS 固定时长 Spring 物理
持续时间 固定(如 300ms) 由物理参数决定,时长不固定
效果 线性时间感 自然弹性,感觉更「实」
实现 CSS transition JS rAF
适用 简单 UI 过渡 拖拽释放、弹窗弹出、列表排序

4. Canvas 进阶

Canvas 基础 API、像素处理、简单动画循环、基础优化(离屏 Canvas / 分层 Canvas / 脏矩形 / willReadFrequently)见 04-可视化.md

本节深入 04-可视化.md 未覆盖的进阶技巧。

4.1 OffscreenCanvas + Worker

将复杂图像处理交给 Worker,主线程不阻塞:

// main.js
const canvas = document.getElementById('canvas');
const offscreen = canvas.transferControlToOffscreen();

const worker = new Worker('canvas-worker.js');
worker.postMessage({ canvas: offscreen }, [offscreen]);

// 不再在主线程操作 canvas
// canvas-worker.js
self.onmessage = (e) => {
  const canvas = e.data.canvas;
  const ctx = canvas.getContext('2d'); // Worker 中可直接获取 2D 上下文

  function render() {
    // 复杂绘制逻辑
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    // ... 大量粒子计算和绘制
    requestAnimationFrame(render);
  }

  render();
};

适用场景: 大量粒子计算、图像处理滤镜、物理模拟。 限制: OffscreenCanvas getContext('2d') 可用,但 getContext('webgl') 限制较多;不能访问 DOM。

4.2 脏矩形优化

class DirtyRectCanvas {
  constructor(canvas) {
    this.ctx = canvas.getContext('2d');
    this.dirtyRects = [];
  }

  // 标记需要重绘的区域
  markDirty(x, y, w, h) {
    this.dirtyRects.push({ x, y, w, h });
  }

  // 合并并绘制脏矩形
  render() {
    if (this.dirtyRects.length === 0) return;

    // 合并重叠脏矩形
    const merged = this.mergeRects(this.dirtyRects);
    this.dirtyRects = [];

    for (const rect of merged) {
      // 只清除脏区域
      this.ctx.clearRect(rect.x, rect.y, rect.w, rect.h);
      // 只重绘脏区域(需要实现局部绘制)
      this.drawContent(rect);
    }
  }

  mergeRects(rects) {
    // 简化实现:取所有脏矩形的最小包围盒
    // 生产环境可使用更复杂的合并算法(网格哈希 / R-Tree)
    if (rects.length === 1) return rects;

    const minX = Math.min(...rects.map(r => r.x));
    const minY = Math.min(...rects.map(r => r.y));
    const maxX = Math.max(...rects.map(r => r.x + r.w));
    const maxY = Math.max(...rects.map(r => r.y + r.h));

    return [{ x: minX, y: minY, w: maxX - minX, h: maxY - minY }];
  }

  drawContent(rect) {
    // 子类实现:只绘制 rect 区域内的内容
    // 配合空间索引(四叉树 / 网格)快速找到 rect 内的元素
  }
}

脏矩形性能曲线:

全屏 clearRect → 重绘全部像素 → 元素少时简单,元素多时浪费
精细脏矩形      → 只重绘变化区 → 额外计算脏矩形合并的开销

经验法则: 当屏幕变化区域 < 30% 时,脏矩形优化有价值。变化区域超过 50% 时,全屏重绘反而更快。

4.3 点击检测(Hit Detection)

Canvas 没有原生 DOM 事件,三种通用方案:

方案 A:isPointInPath(精确路径检测):

canvas.addEventListener('click', (e) => {
  const rect = canvas.getBoundingClientRect();
  const x = e.clientX - rect.left;
  const y = e.clientY - rect.top;

  // 遍历所有图形,用 isPointInPath 检测
  for (const shape of shapes) {
    ctx.beginPath();
    shape.drawPath(ctx); // 重绘路径但不填充
    if (ctx.isPointInPath(x, y)) {
      console.log('点击了:', shape);
      break;
    }
  }
});

方案 B:颜色 ID 映射(适合大量对象):

// 隐藏 Canvas(与显示 Canvas 尺寸一致)
const hitCanvas = document.createElement('canvas');
const hitCtx = hitCanvas.getContext('2d');

// 给每个对象分配唯一颜色 ID(1 → #000001, 2 → #000002 ...)
function idToColor(id) {
  return `#${id.toString(16).padStart(6, '0')}`;
}

function renderHitMap() {
  hitCtx.clearRect(0, 0, hitCanvas.width, hitCanvas.height);
  for (const shape of shapes) {
    hitCtx.fillStyle = idToColor(shape.id);
    shape.draw(hitCtx);
  }
}

// 点击时读取像素颜色
canvas.addEventListener('click', (e) => {
  const x = e.offsetX, y = e.offsetY;
  const pixel = hitCtx.getImageData(x, y, 1, 1).data;
  const id = (pixel[0] << 16) | (pixel[1] << 8) | pixel[2];
  const clicked = shapes.find(s => s.id === id);
  console.log('点击了:', clicked);
});

方案 C:AABB 包围盒快速初筛(性能最优):

function hitTestAABB(point, shapes) {
  // 空间索引:将屏幕划分为网格,只检测 point 所在网格内的对象
  // 或使用四叉树(Quadtree)加速

  const { x, y } = point;

  // 快速排除:只检测包围盒包含该点的对象
  const candidates = shapes.filter(s =>
    x >= s.x && x <= s.x + s.width &&
    y >= s.y && y <= s.y + s.height
  );

  // 精检测(需要精确路径则用 isPointInPath)
  return candidates;
}

三种方案对比:

方案 精度 性能 实现难度 适用
isPointInPath 精确 差(需重绘每对象路径) 少量对象(< 50)
颜色 ID 映射 像素级 好(一次像素读取) 大量对象,任意形状
AABB + 空间索引 包围盒 最优(O(log n)) 游戏,实时交互

5. SVG 进阶

SVG 基础(viewBox、形状、路径、渐变、滤镜)见 04-可视化.md。本节深入动画和高级应用。

5.1 SMIL 动画(原生 SVG 动画)

SMIL(Synchronized Multimedia Integration Language)是 SVG 自带动画方案,无需 CSS 或 JS。

<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
  <!-- <animate> 属性动画 -->
  <circle cx="50" cy="50" r="30" fill="red">
    <animate
      attributeName="cx"
      from="50" to="150"
      dur="2s" repeatCount="indefinite"
      begin="0s"
    />
  </circle>

  <!-- <animateTransform> 变换动画 -->
  <rect x="80" y="80" width="40" height="40" fill="blue">
    <animateTransform
      attributeName="transform"
      type="rotate"
      from="0 100 100" to="360 100 100"
      dur="3s" repeatCount="indefinite"
    />
  </rect>

  <!-- <animateMotion> 沿路径运动 -->
  <circle r="8" fill="green">
    <animateMotion
      dur="4s" repeatCount="indefinite"
      path="M 20 20 C 80 20, 120 120, 180 120"
    />
  </circle>
</svg>

SMIL 关键属性:

属性 说明 示例
attributeName 动画属性名 cx, opacity, transform
from / to / values 起始/结束/中间值列表 from="0" to="100" / values="0; 50; 100; 50; 0"
dur 时长 2s, 500ms
repeatCount 重复次数 indefinite, 3
begin 开始时间/事件 0s, click, mouseover
calcMode 插值方式 linear / discrete / spline / paced
keySplines 贝塞尔控制点(calcMode=spline 时) 0.4 0 0.6 1

SMIL 的浏览器状态: Chrome 曾宣布废弃 SMIL,后因生态压力撤回。推荐优先使用 CSS animation 实现 SVG 动画,SMIL 仅用于 CSS 无法实现的 animateMotion

5.2 CSS 驱动 SVG 动画

线条绘制动画(stroke-dasharray + stroke-dashoffset):

.path-line {
  stroke-dasharray: 1000;       /* 路径总长度 */
  stroke-dashoffset: 1000;      /* 偏移量=总长=不可见 */
  animation: drawLine 2s ease-out forwards;
}

@keyframes drawLine {
  to {
    stroke-dashoffset: 0;       /* 偏移量=0=完全可见 */
  }
}
// 获取路径实际长度(用于动态设置 dasharray)
const path = document.querySelector('path');
const length = path.getTotalLength();
path.style.strokeDasharray = length;
path.style.strokeDashoffset = length;

CSS 控制 SVG 属性动画:

.icon {
  transition: fill 0.3s ease, transform 0.3s ease;
}

.icon:hover {
  fill: #ff6600;
  transform: rotate(45deg); /* 或 scale 等 */
}

/* @keyframes 控制 SVG 属性 */
@keyframes pulse-opacity {
  0%, 100% { opacity: 1; }
  50%      { opacity: 0.3; }
}

svg .pulsing-element {
  animation: pulse-opacity 2s ease-in-out infinite;
}

5.3 SVG 滤镜深入

<svg viewBox="0 0 400 300">
  <defs>
    <!-- 毛玻璃效果 -->
    <filter id="glass">
      <feGaussianBlur in="SourceGraphic" stdDeviation="4" />
      <feColorMatrix
        in="blur"
        type="matrix"
        values="
          1 0 0 0 0
          0 1 0 0 0
          0 0 1 0 0
          0 0 0 0.5 0"
      />
    </filter>

    <!-- 投影(含偏移+模糊+颜色) -->
    <filter id="shadow">
      <feDropShadow dx="2" dy="4" stdDeviation="3" flood-color="rgba(0,0,0,0.3)" />
    </filter>

    <!-- 色彩调整(亮度+对比度) -->
    <filter id="enhance">
      <feColorMatrix type="brightness" values="1.2" />
      <feColorMatrix type="contrast" values="1.3" />
    </filter>

    <!-- 噪点纹理 -->
    <filter id="noise">
      <feTurbulence type="fractalNoise" baseFrequency="0.8" numOctaves="3" />
      <feBlend in="SourceGraphic" in2="noise" mode="multiply" />
    </filter>
  </defs>

  <rect x="30" y="30" width="150" height="100" rx="10" fill="rgba(255,255,255,0.6)" filter="url(#glass)" />
  <circle cx="280" cy="80" r="40" fill="#4a90d9" filter="url(#shadow)" />
  <rect x="30" y="170" width="150" height="100" fill="#f0f0f0" filter="url(#noise)" />
</svg>

SVG 滤镜原语组合:

原语 功能 参数
feGaussianBlur 高斯模糊 stdDeviation
feColorMatrix 颜色变换矩阵 type="matrix|hueRotate|saturate|brightness|contrast"
feBlend 混合两层 mode="normal|multiply|screen|overlay"
feComposite 像素级合成 operator="over|in|out|atop|xor|arithmetic"
feDropShadow 投影 dx, dy, stdDeviation, flood-color
feTurbulence 噪点/纹理 type="fractalNoise|turbulence", baseFrequency
feDisplacementMap 像素置换(扭曲) in, in2, scale
feMorphology 膨胀/腐蚀 operator="dilate|erode", radius

性能注意事项:

  • SVG 滤镜渲染在 CPU/GPU 上都昂贵,叠加多个滤镜会导致多次渲染 Pass
  • 滤镜区域会扩展元素的边界框(filterRes 控制精度但已废弃)
  • 大量元素使用滤镜时,缓存滤镜结果:<filter filterUnits="userSpaceOnUse"> + 固定大小

5.4 SVG vs Canvas 全面对比

对比维度 SVG Canvas
渲染模型 保留模式(Retained Mode):图形存为 DOM 节点 立即模式(Immediate Mode):逐帧像素绘制,无持久图形
分辨率 矢量,无限缩放 位图,缩放模糊(Retina 需 devicePixelRatio 缩放)
交互 原生 DOM 事件(click/hover) 需手动 hit detection
大量元素性能 差(每个图形是 DOM 节点,DOM 操作开销) 好(逐帧像素,无 DOM 开销)
少量元素性能 一样好
动画 CSS/SMIL 声明式,轻量 rAF 命令式,灵活但需编码
文本 原生文本(可选,可复制) 需额外处理文本渲染
可访问性 好(可加 title/desc,可聚焦) 差(Canvas 本身无语义)
内存 高(DOM 节点树) 低(仅有像素数据)
复杂滤镜 原生 SVG filter,丰富 需 WebGL 或手动像素计算
适用场景 图标/Logo/插画/图表/小交互 游戏/粒子/大数量可视化/滤镜处理

6. WebGL 基础

Three.js 基础(Scene/Camera/Renderer、几何体+材质、光照、纹理、Raycaster)见 04-可视化.md。本节讲 WebGL 原生概念和 GLSL。

6.1 核心概念

WebGL 是基于 OpenGL ES 2.0 的 Web 3D 图形 API,通过 Canvas 获取上下文:

const canvas = document.getElementById('glcanvas');
const gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl');

if (!gl) {
  console.error('WebGL 不支持');
}

渲染管线(Graphics Pipeline):

顶点数据 → Vertex Shader(顶点着色器) → 图元装配 → 光栅化 → Fragment Shader(片元着色器) → 逐片元操作 → 帧缓冲区
     ↑ 处理每个顶点位置               ↑ 连接顶点成三角形       ↑ 三角形转像素         ↑ 计算每个像素颜色
阶段 功能 开发者控制
Vertex Shader 处理每个顶点的位置、法线、UV,输出 gl_Position 编写 GLSL 代码
Fragment Shader 处理每个像素的颜色、纹理采样,输出 gl_FragColor 编写 GLSL 代码
光栅化 插值:Vertex 输出自动插值到 Fragment 不可编程
逐片元操作 深度测试、模板测试、混合 可配置(gl.enable/disable

6.2 GLSL 着色器极简入门

顶点着色器(Vertex Shader):

// 属性:每个顶点不同的数据(位置、颜色、UV)
attribute vec3 a_position;
attribute vec4 a_color;

// 全局变量:对所有顶点一致
uniform mat4 u_modelViewMatrix;
uniform mat4 u_projectionMatrix;

// 传递给片元着色器的数据
varying vec4 v_color;

void main() {
  // gl_Position 是内置变量,表示裁剪空间中的顶点位置
  gl_Position = u_projectionMatrix * u_modelViewMatrix * vec4(a_position, 1.0);

  // 传递颜色到片元
  v_color = a_color;
}

片元着色器(Fragment Shader):

precision highp float;

// 从顶点着色器接收的插值数据
varying vec4 v_color;

// uniform:全局常量(如时间、光源位置)
uniform float u_time;

void main() {
  // gl_FragColor 是内置变量,表示该像素最终颜色
  gl_FragColor = v_color;

  // 简单闪烁效果
  // gl_FragColor = v_color * (0.8 + 0.2 * sin(u_time));
}

ShaderToy 风格的屏幕空间效果:

// 片元着色器:纯屏幕空间效果(不依赖顶点数据)
precision highp float;

uniform vec2  u_resolution;    // 屏幕分辨率
uniform float u_time;          // 时间

void main() {
  // 归一化坐标 [0, 1]
  vec2 uv = gl_FragCoord.xy / u_resolution.xy;

  // 居中 [-1, 1]
  vec2 p = (gl_FragCoord.xy * 2.0 - u_resolution) / min(u_resolution.x, u_resolution.y);

  // 颜色随时间变化
  vec3 color = 0.5 + 0.5 * cos(u_time + uv.xyx + vec3(0, 2, 4));

  gl_FragColor = vec4(color, 1.0);
}

JavaScript 侧(加载着色器和绘制):

function createShader(gl, type, source) {
  const shader = gl.createShader(type);
  gl.shaderSource(shader, source);
  gl.compileShader(shader);
  if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
    console.error(gl.getShaderInfoLog(shader));
    gl.deleteShader(shader);
    return null;
  }
  return shader;
}

function createProgram(gl, vertSource, fragSource) {
  const vert = createShader(gl, gl.VERTEX_SHADER, vertSource);
  const frag = createShader(gl, gl.FRAGMENT_SHADER, fragSource);
  const program = gl.createProgram();
  gl.attachShader(program, vert);
  gl.attachShader(program, frag);
  gl.linkProgram(program);
  return program;
}

// 创建并编译着色器程序
const program = createProgram(gl, vertexShaderSource, fragmentShaderSource);
gl.useProgram(program);

// 获取 uniform 和 attribute 位置
const uTimeLoc = gl.getUniformLocation(program, 'u_time');
const posLoc = gl.getAttribLocation(program, 'a_position');

// 每帧更新
function animate() {
  gl.uniform1f(uTimeLoc, performance.now() / 1000);
  gl.drawArrays(gl.TRIANGLES, 0, vertexCount);
  requestAnimationFrame(animate);
}

6.3 WebGPU(下一代 Web 图形 API)

WebGPU 是 WebGL 的继承者,由 W3C "GPU for the Web" 工作组制定。

WebGL vs WebGPU:

特性 WebGL WebGPU
底层 API OpenGL ES 2.0/3.0 Vulkan/Metal/DX12 抽象
多线程 主线程 支持 Compute Shader + 多线程
渲染管线状态 全局状态机(易出错) 显式 Pipeline 对象(无状态机)
资源管理 隐式同步 显式资源屏障 + 命令缓冲区
调试信息 有限 丰富的验证层
浏览器支持 所有浏览器 Chrome 113+ / Edge 113+ / Safari 16.4+ / Firefox Nightly
// WebGPU 基本框架(浏览器兼容分支)
async function initWebGPU() {
  if (!navigator.gpu) {
    console.error('WebGPU 不支持');
    return;
  }

  const adapter = await navigator.gpu.requestAdapter();
  const device = await adapter.requestDevice();
  const canvas = document.getElementById('gpu-canvas');
  const context = canvas.getContext('webgpu');

  context.configure({
    device,
    format: navigator.gpu.getPreferredCanvasFormat(),
    alphaMode: 'premultiplied',
  });

  // 渲染循环
  function frame() {
    const commandEncoder = device.createCommandEncoder();
    const renderPass = commandEncoder.beginRenderPass({
      colorAttachments: [{
        view: context.getCurrentTexture().createView(),
        loadOp: 'clear',
        clearValue: { r: 0, g: 0, b: 0, a: 1 },
        storeOp: 'store',
      }],
    });

    // 设置管线、绑定组、绘制...
    // renderPass.setPipeline(pipeline);
    // renderPass.draw(vertexCount);

    renderPass.end();
    device.queue.submit([commandEncoder.finish()]);
    requestAnimationFrame(frame);
  }

  requestAnimationFrame(frame);
}

WebGPU 核心概念:

  • Adapter:物理 GPU 适配器(requestAdapter
  • Device:逻辑设备,负责创建资源(requestDevice
  • Pipeline:渲染/计算管线(createRenderPipeline / createComputePipeline
  • Bind Group:绑定着色器资源(类似 WebGL 的 uniform buffer)
  • Command Buffer:命令缓冲区,一次性提交所有 GPU 命令(减少 CPU-GPU 通信)
  • Compute Shader:通用计算,可在 GPU 上执行非图形计算(粒子物理/图像处理/ML 推理)

推荐路线图: 大多数项目使用 Three.js → 需要极致性能或自定义着色器时深入 WebGL 原生 → 需要 Compute Shader 或多线程时探索 WebGPU。


7. Lottie / Rive 动画工作流

7.1 Lottie

Lottie 是由 Airbnb 开源的动画方案:After Effects 设计动画 → bodymovin 插件导出 JSON → 运行时库解析渲染。

核心库:

平台
Web lottie-web(默认 SVG 渲染,可选 Canvas/HTML)
React lottie-react
Vue lottie-vue / vue-lottie
压缩版 lottie-web/build/player/lottie_light.min.js(不含 SVG 渲染器,仅 Canvas)

基本用法:

import lottie from 'lottie-web';

const anim = lottie.loadAnimation({
  container: document.getElementById('lottie-container'),
  renderer: 'svg',          // 'svg' | 'canvas' | 'html'
  loop: true,
  autoplay: true,
  path: 'animation.json',   // AE 导出的 JSON
});

// 交互控制
anim.play();
anim.pause();
anim.stop();
anim.setDirection(-1);      // 倒放
anim.setSpeed(2);           // 2 倍速
anim.goToAndStop(30, true); // 跳到第 30 帧
anim.goToAndPlay(0, true);

性能注意事项:

  • JSON 体积:复杂 AE 动画 JSON 可能 1-10 MB。使用 lottie-compress 或 TGS(Telegram Animated Stickers Format)压缩
  • 滚动列表:避免同时播放大量 Lottie 实例。配合 IntersectionObserver 只播放可见区域的动画
  • 渲染器选择:SVG 渲染质量高但 DOM 节点多;Canvas 渲染性能好但字体/渐变支持有限
  • 内存泄漏anim.destroy() 移除后释放内存
// 按需加载(IntersectionObserver 暂停不可见的动画)
const observer = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    const anim = entry.target._lottieInstance;
    if (entry.isIntersecting) {
      anim.play();
    } else {
      anim.pause();
    }
  });
});

// 页面销毁时清理
function cleanup() {
  anim.destroy();
}

7.2 Rive

Rive 是设计→代码一体化工具,比 Lottie 更进一步:设计师在 Rive 编辑器中创建动画和状态机,运行时库加载 .riv 文件。

核心优势:

特性 Lottie Rive
设计端 AE + bodymovin Rive 编辑器(一体化)
文件格式 JSON .riv(二进制,更小)
运行时大小 ~500KB(lottie-web) ~150KB(rive-canvas)
状态机 无(需代码控制进度) 内置 State Machine
交互逻辑 需额外 JS 实现 编辑器配置 + 参数绑定
实时参数修改 不支持 支持(rive.getInput() 修改数值)

基本用法:

import rive from '@rive-app/canvas';

const animation = new rive.Rive({
  src: 'animation.riv',
  canvas: document.getElementById('canvas'),
  autoplay: false,

  // 状态机
  stateMachines: 'StateMachine1',

  // 自动调整尺寸
  fit: rive.Fit.Contain,
  alignment: rive.Alignment.Center,

  onLoad: () => {
    // 加载完成后播放
    animation.play('AnimationName');

    // 修改状态机输入
    const input = animation.getInput('sliderValue');
    input.value = 0.75;
  },
});

Rive 状态机 vs 线性动画:

  • 线性动画:类似 Lottie,固定时间轴播放
  • 状态机:定义状态(Idle / Hover / Click / Drag)+ 转换条件,实现复杂交互(按钮/菜单/游戏角色),运行时根据用户输入自动切换

8. 动画性能与最佳实践

8.1 帧预算(Frame Budget)

60fps  → 16.67ms/帧
  浏览器内部消耗 ≈ 6ms(合成、GC、事件处理)
  留给开发者     ≈ 10ms(JS 执行 + 渲染)

120fps → 8.33ms/帧
  浏览器内部消耗 ≈ 3ms
  留给开发者     ≈ 5ms

帧预算管理策略:

  1. 轻量帧:仅在必要时更新(长时间运行的循环用 requestAnimationFrame 而非 setInterval
  2. 分解长任务:超过 50ms 的 JS 任务会触发浏览器 Long Task 警告(Performance 面板红色三角)
  3. 预计算:在空闲时(requestIdleCallback)预计算数据

8.2 避免强制同步布局(Layout Thrashing)

问题模式:先读后写(或先写后读)导致强制重排:

// 坏:循环内交替读写 → 每次读都触发强制同步布局
for (const el of elements) {
  const h = el.offsetHeight;    // 读:触发强制重排
  el.style.height = h + 10 + 'px'; // 写:标记样式变更
}

// 好:先批量读取,再批量写入
const heights = [];
for (const el of elements) {
  heights.push(el.offsetHeight); // 全部读完,浏览器只重排一次
}
for (let i = 0; i < elements.length; i++) {
  elements[i].style.height = heights[i] + 10 + 'px'; // 批量写入
}

触发强制同步布局的常见操作:

读取属性 触发 Layout
offsetTop/offsetLeft/offsetWidth/offsetHeight
clientTop/clientLeft/clientWidth/clientHeight
scrollTop/scrollLeft/scrollWidth/scrollHeight
getComputedStyle() 是(某些属性)
getBoundingClientRect()
getClientRects()

FastDom 模式(参考实现):

class FastDom {
  constructor() {
    this.reads = [];
    this.writes = [];
    this.scheduled = false;
  }

  measure(fn) { this.reads.push(fn); this.schedule(); }
  mutate(fn) { this.writes.push(fn); this.schedule(); }

  schedule() {
    if (!this.scheduled) {
      this.scheduled = true;
      requestAnimationFrame(() => this.flush());
    }
  }

  flush() {
    // 先批量读取
    for (const read of this.reads) read();
    // 再批量写入
    for (const write of this.writes) write();

    this.reads = [];
    this.writes = [];
    this.scheduled = false;
  }
}

const fd = new FastDom();

// 使用
fd.measure(() => heights.push(el.offsetHeight));
fd.mutate(() => { el.style.height = h + 'px'; });

8.3 Composite 专属属性

只有三个属性在合成器线程(Compositor Thread) 上执行,不占用主线程:

transform  → 合成器线程直接处理
opacity    → 合成器线程直接处理
filter     → 部分 filter 在合成器线程处理(如 blur 在复合阶段)

渲染示意:

主线程(Main Thread):
  JS → Style → Layout → Paint
                              ↘
合成器线程(Compositor Thread):
                              接收图层 → Composite → 显示
                              ↑
  transform/opacity 变化 → 直接在合成器线程处理,不经过主线程

will-change 最佳实践:

/* 1. 动画开始前设置 */
.element {
  will-change: transform;
}

/* 2. 动画结束后移除(防止长期占用 GPU 内存) */
.element-idle {
  will-change: auto;
}

/* 3. 不要给太多元素设置(会导致层爆炸) */
/* 坏 */
.items li {
  will-change: transform; /* 1000 个 li → 1000 个合成层 */
}

/* 好:只在需要动画的特定元素上设置 */
.items li.active {
  will-change: transform;
}

8.4 分析工具

Chrome DevTools Performance 面板:

信号 含义 处理
红色条形(FPS 低) 帧率低于 60fps 检查长任务和重绘
红色三角(Long Task) JS 执行 > 50ms 分解任务,使用 Web Worker
紫色角标 强制同步布局 分离读写操作
绿色闪烁(Paint Flashing) 重绘区域 改用 transform/opacity
橙色线框(Layer Borders) 合成层边界 检查层数量是否过多

开启 Layer Borders:

  1. DevTools → 更多工具 → Rendering
  2. 勾选 Layer borders
  3. 橙色 = 合成层,蓝色 = 子层,绿色 = 正常

检测层爆炸:

许多橙色矩形重叠
→ 意味着大量合成层
→ 检查 will-change 是否滥用
→ 检查 translateZ(0) 是否给太多元素

Performance 面板录制步骤:

  1. 打开 DevTools → Performance → 录制按钮
  2. 执行动画操作(约 3-5 秒)
  3. 停止录制
  4. 检查 Summary(看哪部分耗时最多)
  5. 检查 Main 时间线(看长任务和强制布局)
  6. 检查 FPS 图表(看掉帧情况)

8.5 content-visibility: auto

/* 跳过离屏元素的渲染(不渲染子树,仅保留占位高度) */
.long-list > .item {
  content-visibility: auto;
  contain-intrinsic-size: 100px; /* 占位高度,避免滚动跳动 */
}

效果: 大幅减少长列表初始渲染成本。离屏元素不触发 Layout、Paint。

// content-visibility 在 IntersectionObserver 不可用时的备选方案
// 用于超大列表渲染优化

8.6 prefers-reduced-motion

/* 检测用户系统是否开启减少动画偏好 */
@media (prefers-reduced-motion: reduce) {
  /* 关闭所有非必要动画 */
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }

  /* 关键交互的动画保留但简化 */
  .toast-enter {
    animation: simpleFadeIn 0.2s ease-out;
  }
}

@keyframes simpleFadeIn {
  from { opacity: 0; }
  to   { opacity: 1; }
}

JS 检测:

const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)');

if (prefersReducedMotion.matches) {
  // 禁用非必要动画
  element.style.transition = 'none';
}

// 监听变化
prefersReducedMotion.addEventListener('change', (e) => {
  if (e.matches) {
    // 用户开启了减少动画
  }
});

总结速查

场景 方案 关键性能要点
hover 效果 CSS Transition 用 transform 替代 left/top
循环动画 CSS Animation will-change 提前创建合成层
物理引擎 rAF 用 deltaTime 保证速度一致
粒子系统 Canvas 脏矩形 + 分层 Canvas
3D 场景 Three.js InstancedMesh 合并 draw calls
页面切换 View Transitions API 原生合成,零开销
列表重排 FLIP 仅操作 transform
品牌动效 Lottie / Rive IntersectionObserver 控制播放
矢量动画 SVG + CSS 用 stroke-dasharray 实现线条绘制
高性能滤镜 WebGL/GPU 片元着色器像素级并行计算