图形与动画
本文档侧重「动画方案选型与性能优化」。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 | 差(触发重排) |
核心原则: 动画只使用 transform 和 opacity,确保仅触发合成阶段,完全跳过 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/filtertransform: 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;
}
三个不要:
- 不要给大量静态元素设置
translateZ(0)— 每一层消耗 GPU 内存 - 不要提前很久设置
will-change— 用完改为auto - 不要给
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
帧预算管理策略:
- 轻量帧:仅在必要时更新(长时间运行的循环用
requestAnimationFrame而非setInterval) - 分解长任务:超过 50ms 的 JS 任务会触发浏览器
Long Task警告(Performance 面板红色三角) - 预计算:在空闲时(
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:
- DevTools → 更多工具 → Rendering
- 勾选 Layer borders
- 橙色 = 合成层,蓝色 = 子层,绿色 = 正常
检测层爆炸:
许多橙色矩形重叠
→ 意味着大量合成层
→ 检查 will-change 是否滥用
→ 检查 translateZ(0) 是否给太多元素
Performance 面板录制步骤:
- 打开 DevTools → Performance → 录制按钮
- 执行动画操作(约 3-5 秒)
- 停止录制
- 检查 Summary(看哪部分耗时最多)
- 检查 Main 时间线(看长任务和强制布局)
- 检查 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 | 片元着色器像素级并行计算 |