大家好,坐好,把手机收起来。今天我们不聊业务逻辑,不聊怎么把饼画圆,我们聊聊“动”。
在 Web 开发的世界里,动画就是灵魂。没有动画的界面就像是一潭死水,或者是那种十年没更新过的政府官网。但是,我们要小心,别让动画变成“肉”。如果你为了一个按钮的点击效果引入了 Framer Motion 或者 GSAP,那你就像是用核弹打蚊子——虽然准,但太重了,而且邻居可能会投诉。
今天,我们要讲的是一种更优雅、更底层、甚至有点“黑客”味道的玩法:直接调用 Web Animations API (WAAPI),绕过 React 的重型抽象,直接与浏览器的合成器线程握手。
准备好了吗?让我们把 React 当作一个指挥家,把浏览器当作一个庞大的交响乐团,而 WAAPI 就是那个能直接控制乐器的指挥棒。
第一部分:为什么我们要“反叛”?
首先,让我们直面一个痛点。在 React 生态里,我们习惯了声明式编程。我想让一个元素从左边移到右边?我写个 className="animate-slide"。我想让它淡入?我写个 animate={{ opacity: 1 }}。
这很棒,非常 React。但是,这也有代价。
当你使用 CSS 动画或者基于 React 状态驱动的动画(比如 useEffect 去改 style 属性)时,你往往是在和浏览器的“主线程”抢夺资源。主线程还要负责 JS 计算、虚拟 DOM Diff、事件监听。一旦主线程卡顿,动画就会掉帧,变成那种令人尴尬的“一卡一顿”。
更糟糕的是,React 的状态更新机制。当你点击一个按钮,状态改变,React 重新渲染,DOM 节点被销毁重建。这时候,如果你还在运行一个 CSS 动画,React 的强制同步布局更新往往会打断动画的连贯性,导致动画“重置”或者“跳跃”。
这时候,我们需要一种更硬核的解决方案。
Web Animations API (WAAPI) 是浏览器原生的接口。它是现代浏览器(Chrome, Firefox, Safari, Edge)都支持的标准。它的核心思想非常简单:element.animate(keyframes, options)。
这不仅仅是一个 API,它是通往 GPU 加速的大门。WAAPI 运行在合成器线程上(大部分情况下),这意味着你的动画逻辑不会阻塞 JS 的主线程。而且,它返回一个 Animation 对象,你可以像操作 Promise 一样控制它,甚至可以暂停、倒带、加速。
我们今天要做的,就是写一个 Hook,把这个原生的野兽驯化成 React 的一只乖巧的宠物。
第二部分:实战演练——从“Hello World”到“真·高性能”
让我们先看看最基础的用法。假设我们有一个按钮,我们想让它点击后有一个旋转效果。
普通 React 方式(慢动作回放版):
import React, { useState } from 'react';
const SlowButton = () => {
const [isAnimating, setIsAnimating] = useState(false);
const handleClick = () => {
setIsAnimating(true);
setTimeout(() => setIsAnimating(false), 500); // 这里的 setTimeout 就是为了骗过 React
};
return (
<button
className={isAnimating ? 'spin' : ''}
onClick={handleClick}
>
Click Me
</button>
);
};
// CSS:
// .spin { animation: rotate 0.5s linear; }
// @keyframes rotate { 100% { transform: rotate(360deg); } }
看看这代码,丑陋吗?是的。它依赖 setTimeout 来手动管理生命周期,这简直是维护噩梦。而且,如果用户手速快,疯狂点击,React 的状态更新会混乱,动画会乱飞。
现在,让我们用 WAAPI 重写它。
WAAPI 方式(丝滑流光版):
import React, { useRef, useEffect } from 'react';
const FastButton = () => {
const buttonRef = useRef(null);
const animationRef = useRef(null);
const handleClick = () => {
if (!buttonRef.current) return;
// 1. 如果已经有一个动画在跑,先把它杀掉
if (animationRef.current) {
animationRef.current.cancel();
}
// 2. 创建动画:360度旋转,持续 0.5秒,缓动曲线 ease-out
animationRef.current = buttonRef.current.animate(
[
{ transform: 'rotate(0deg)' },
{ transform: 'rotate(360deg)' }
],
{
duration: 500,
easing: 'cubic-bezier(0.4, 0, 0.2, 1)', // 贝塞尔曲线让动作更有质感
fill: 'forwards' // 动画结束后保持最后一帧的状态
}
);
// 3. 监听结束事件(可选)
animationRef.current.onfinish = () => {
console.log('动画结束了,你可以在这里清理状态');
// 如果需要重置 transform,可以在这里操作
};
};
return (
<button ref={buttonRef} onClick={handleClick}>
WAAPI Click
</button>
);
};
看到了吗?没有 setTimeout,没有乱七八糟的 CSS 类名切换。代码逻辑非常清晰:检测 -> 取消 -> 创建 -> 结束处理。
这就是高性能的起点。但是,这只是热身。真正的挑战在于,如何把 React 的“声明式”和 WAAPI 的“命令式”完美融合。
第三部分:构建 useWAAPI Hook —— 专家的武器库
直接在组件里写 useRef 和 animate 虽然可行,但如果你在一个项目里到处都是这种代码,那就是代码屎山。我们需要封装。
我们需要一个 Hook,它接收 DOM 元素的引用、关键帧和选项,然后返回控制权。
代码示例:一个健壮的 useWAAPI 实现
import { useRef, useEffect, useCallback } from 'react';
export const useWAAPI = (elementRef, keyframes, options = {}) => {
const animationRef = useRef(null);
const [playState, setPlayState] = useState('idle'); // 'idle', 'running', 'paused'
// 初始化动画
useEffect(() => {
const element = elementRef.current;
if (!element) return;
// 如果有旧动画,先杀掉
if (animationRef.current) {
animationRef.current.cancel();
}
// 启动新动画
const anim = element.animate(keyframes, options);
animationRef.current = anim;
setPlayState('running');
// 动画结束清理
const handleFinish = () => setPlayState('finished');
anim.onfinish = handleFinish;
// 清理函数
return () => {
anim.cancel();
};
}, [elementRef, keyframes, options]);
// 控制方法
const play = useCallback(() => {
if (animationRef.current) animationRef.current.play();
setPlayState('running');
}, []);
const pause = useCallback(() => {
if (animationRef.current) animationRef.current.pause();
setPlayState('paused');
}, []);
const cancel = useCallback(() => {
if (animationRef.current) animationRef.current.cancel();
setPlayState('idle');
}, []);
const updatePlaybackRate = useCallback((rate) => {
if (animationRef.current) animationRef.current.updatePlaybackRate(rate);
}, []);
return {
play,
pause,
cancel,
updatePlaybackRate,
playState,
animation: animationRef.current
};
};
这里有个大坑,我们要特别强调:闭包陷阱。
注意上面的 useEffect 依赖项。如果我们把 keyframes 设为空数组,动画只会播放一次。如果我们想动态改变动画(比如根据 props 改变颜色),我们需要把 keyframes 作为依赖传入。这意味着每次 props 变化,动画都会被销毁并重建。
这没问题,但如果你在动画进行中突然改变了 keyframes,动画会瞬间重置。这是 WAAPI 的特性,也是 React 的特性。要解决这个问题,我们需要更高级的技巧,比如使用 requestAnimationFrame 结合 transform 属性来实现连续的数值更新,但这已经超出了 WAAPI 的范畴,进入了自定义 Hook 的领域。
第四部分:解决 React 的“幽灵”问题
在 React 中使用 WAAPI 时,最大的敌人不是性能,而是“幽灵”。
想象一下,你有一个列表项,你用 WAAPI 让它 translateX 移动。然后,React 检测到数据变了,重新渲染了整个列表。DOM 节点被销毁了,动画也消失了。
但有时候,如果你没有正确处理 cancel(),动画会继续在旧的 DOM 节点上运行,导致页面上的元素突然飞走,或者闪烁。这就是幽灵。
解决方案:useLayoutEffect
useEffect 在浏览器绘制之后运行,而 useLayoutEffect 在浏览器绘制之前同步运行。对于动画来说,我们希望 DOM 元素一旦挂载,动画就立刻开始,而不是等一帧之后。
代码示例:useLayoutEffect 的正确姿势
import { useLayoutEffect, useRef } from 'react';
const UseWAAPI = () => {
const boxRef = useRef(null);
const animationRef = useRef(null);
useLayoutEffect(() => {
const box = boxRef.current;
if (!box) return;
// 立即执行,不等待浏览器重绘
animationRef.current = box.animate(
[
{ transform: 'translateX(0)' },
{ transform: 'translateX(300px)' }
],
{ duration: 1000 }
);
return () => {
// 组件卸载时,强制取消动画
if (animationRef.current) {
animationRef.current.cancel();
}
};
}, []);
return <div ref={boxRef} style={{ width: 50, height: 50, background: 'red', margin: 10 }} />;
};
使用 useLayoutEffect 可以确保动画开始时,元素已经真实存在于 DOM 中,避免了那些“先出现再消失”的视觉瑕疵。
第五部分:进阶玩法——值插值与状态同步
React 的强大在于状态管理。WAAPI 的强大在于它能精确控制每一帧。如何把它们结合起来?
假设你有一个进度条,进度条的颜色要从绿变红,并且长度在变。
传统 CSS 变量方式:
<div style={{ width: `${progress}%`, background: `hsl(${progress * 1.2}, 70%, 50%)` }}>
这种方式简单,但在进度变化很快的时候,颜色变化会显得很生硬,因为 CSS 变量更新和重绘之间有间隔。
WAAPI + React 状态(高性能版):
我们可以让 WAAPI 直接驱动 transform 和 opacity,而让 React 驱动逻辑状态。
const ProgressCircle = ({ progress }) => {
const circleRef = useRef(null);
const animationRef = useRef(null);
// 我们不在这里直接用 progress 来设置样式,因为那样会触发 React 的重渲染
// 我们只在初始化或重置时设置一次
useEffect(() => {
const circle = circleRef.current;
if (!circle) return;
// 计算关键帧:从 0% 到 100%
const keyframes = [
{ strokeDashoffset: 440 }, // 2 * PI * 70 (半径)
{ strokeDashoffset: 0 }
];
if (animationRef.current) {
animationRef.current.cancel(); // 重置
}
animationRef.current = circle.animate(keyframes, {
duration: 1000,
fill: 'forwards'
});
return () => {
animationRef.current?.cancel();
};
}, []); // 初始化一次
// 这里的 progress 只用来更新文字,不驱动动画
return (
<div>
<svg width="100" height="100">
<circle
ref={circleRef}
r="70"
cx="50"
cy="50"
fill="transparent"
stroke="#333"
strokeWidth="10"
/>
<circle
r="70"
cx="50"
cy="50"
fill="transparent"
stroke="orange"
strokeWidth="10"
strokeDasharray="440"
strokeDashoffset="440" // 初始状态
style={{ transform: 'rotate(-90deg)', transformOrigin: '50% 50%' }}
/>
</svg>
<p>{progress}%</p>
</div>
);
};
在这个例子中,我们利用了 stroke-dashoffset。WAAPI 非常擅长处理这种基于路径的动画。React 只负责告诉用户“进度是 50%”,而动画引擎负责展示“进度条是怎么转过去的”。
更高级的玩法:动态数值插值
如果你需要根据鼠标位置动态改变动画属性,WAAPI 可以直接读取 DOM 上的属性。
const Draggable = () => {
const elRef = useRef(null);
const animationRef = useRef(null);
useEffect(() => {
const el = elRef.current;
if (!el) return;
// 初始动画:从顶部掉下来
animationRef.current = el.animate(
[{ transform: 'translateY(-100px)', opacity: 0 }],
{ duration: 500, fill: 'forwards' }
);
}, []);
const handleDrag = (e) => {
if (!animationRef.current) return;
// 直接修改 playbackRate 来实现拖拽时的速度感
// 或者更高级点,我们可以利用 WAAPI 的 getCurrentTime 和 seek
// 但这里我们用最简单的:直接修改样式
const x = e.clientX;
const y = e.clientY;
// 注意:这里直接修改 style 会触发 React 的重渲染
// 为了性能,我们最好只操作 transform
elRef.current.style.transform = `translate(${x}px, ${y}px)`;
};
return <div ref={elRef} style={{ width: 50, height: 50, background: 'blue', position: 'absolute' }} />;
};
第六部分:性能剖析——为什么它比 Framer Motion 快?
你们可能会问:“Framer Motion 不是也很快吗?为什么我要费劲去学 WAAPI?”
好问题。让我们来做个解剖。
Framer Motion 是基于 React 的。它需要构建一个复杂的虚拟树,需要计算布局,需要处理大量的状态变化。它是一个“重量级”选手。当你在一个复杂的列表里使用 Framer Motion 做交错动画时,它的开销是指数级增长的。
WAAPI 是“轻量级”选手。它直接操作 DOM。它不需要虚拟树。它不需要计算布局。
关键点:合成器线程
这是 WAAPI 性能的护城河。
当你使用 CSS 动画时,浏览器会创建一个“合成器层”。动画是在这个独立的层上渲染的,不占用主线程。
当你使用 WAAPI 时,如果只修改 transform 和 opacity,浏览器同样会把它放到合成器层。
但是,如果你在动画过程中,在 React 组件里修改了 width 或者 top/left,浏览器必须回到主线程去计算布局,然后触发重绘。这就导致了动画卡顿。
结论:
如果你使用 WAAPI,永远只修改 transform 和 opacity。不要去动 width 或 height,除非万不得已。这是性能优化的铁律。
代码示例:错误示范 vs 正确示范
// ❌ 错误示范:动画过程中修改 width
const BadAnimation = () => {
const elRef = useRef(null);
const [width, setWidth] = useState(50);
useEffect(() => {
const anim = elRef.current.animate(
[{ width: '50px' }, { width: '200px' }],
{ duration: 1000 }
);
return () => anim.cancel();
}, []);
return <div ref={elRef} style={{ width }} />;
};
// ✅ 正确示范:利用 scale 来模拟宽度变化
const GoodAnimation = () => {
const elRef = useRef(null);
const [scale, setScale] = useState(1);
useEffect(() => {
const anim = elRef.current.animate(
[{ transform: 'scale(1)' }, { transform: 'scale(2)' }],
{ duration: 1000 }
);
return () => anim.cancel();
}, []);
return <div ref={elRef} style={{ width: 50, height: 50, transform: `scale(${scale})` }} />;
};
在这个例子中,transform: scale(2) 依然在合成器线程上运行,动画依然流畅。而 width: 200px 会强制浏览器重排,导致动画断断续续。
第七部分:并发模式下的 WAAPI
React 18 带来了并发渲染。这意味着组件的渲染可能会被打断,挂起,然后再恢复。
这对 WAAPI 有什么影响?
如果你的动画正在播放,而 React 突然决定“暂停一下渲染,等会儿再说”,那么你的动画也会被暂停。这在某些情况下可能不是你想要的。
但是,WAAPI 有一个特性:它是基于时间的,而不是基于帧的。
即使 React 暂停了更新 DOM,WAAPI 依然在后台运行。当你恢复渲染时,WAAPI 的 currentTime 会保持不变,动画会无缝继续。
这给了我们一个巨大的优势:我们可以利用 animation.currentTime 来强制同步 React 的状态和动画的进度。
场景:加载中的列表项
想象你有一个列表,每个列表项都在同时加载。你希望它们依次出现。
const StaggeredList = ({ items }) => {
return (
<div>
{items.map((item, index) => {
const ref = useRef(null);
const animationRef = useRef(null);
useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
// 计算延迟:每个元素比前一个晚 200ms
const delay = index * 200;
animationRef.current = el.animate(
[{ opacity: 0, transform: 'translateY(20px)' }, { opacity: 1, transform: 'translateY(0)' }],
{
duration: 500,
fill: 'forwards',
delay: delay // 关键:利用 delay 实现交错
}
);
return () => animationRef.current?.cancel();
}, [index]); // 依赖 index
return (
<div key={item.id} ref={ref} style={{ marginBottom: 10 }}>
{item.name}
</div>
);
})}
</div>
);
};
这就是 WAAPI 的魔力。你不需要复杂的 JS 循环来计算每一帧的位置,你只需要告诉浏览器:“嘿,给这个元素加个 200ms 的延迟”。浏览器会自动处理所有的计算,而且是在合成器线程上完成的。
第八部分:真实世界的“杀手级”应用
光说不练假把式。让我们看一个稍微复杂点的场景:拖拽排序。
通常,我们用 SortableJS 或者 react-beautiful-dnd。它们很重。
如果我们用 WAAPI 做拖拽反馈呢?
当用户拖拽一个元素时,我们不需要立即移动它(那会阻塞 UI)。我们可以先让元素“浮”起来(增加 z-index,轻微放大),然后在 mousemove 事件中,使用 requestAnimationFrame 来平滑更新它的 transform 位置。
代码示例:简易的拖拽反馈
const DraggableCard = ({ item }) => {
const ref = useRef(null);
const dragAnimRef = useRef(null);
const isDragging = useRef(false);
const handleMouseDown = (e) => {
isDragging.current = true;
// 创建一个“浮起”的动画效果
if (ref.current) {
dragAnimRef.current = ref.current.animate(
[{ transform: 'scale(1)' }, { transform: 'scale(1.05)' }],
{ duration: 200, fill: 'forwards' }
);
}
};
const handleMouseMove = (e) => {
if (!isDragging.current || !ref.current) return;
// 使用 requestAnimationFrame 保证性能
window.requestAnimationFrame(() => {
const x = e.clientX - 75; // 75 是卡片宽度的一半
const y = e.clientY - 75;
// 直接修改 transform,不触发 React 重渲染
ref.current.style.transform = `translate(${x}px, ${y}px)`;
});
};
const handleMouseUp = () => {
isDragging.current = false;
// 恢复原始位置(这里简化处理,实际项目可能需要记录原始坐标)
// 或者你可以做一个“落地”的动画效果
if (ref.current) {
dragAnimRef.current?.cancel();
ref.current.style.transform = 'translate(0, 0)';
}
};
useEffect(() => {
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mouseup', handleMouseUp);
return () => {
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', handleMouseUp);
};
}, []);
return (
<div
ref={ref}
onMouseDown={handleMouseDown}
style={{
width: 150,
height: 150,
background: 'white',
boxShadow: '0 4px 6px rgba(0,0,0,0.1)',
borderRadius: 8,
cursor: 'grab',
position: 'absolute', // 绝对定位用于拖拽
transition: 'box-shadow 0.2s' // CSS 过渡处理 hover 效果
}}
>
{item.title}
</div>
);
};
在这个例子中,我们完全绕过了 React 的状态管理系统来处理位置更新。只有当拖拽结束时,我们才可能需要更新 React 状态来保存新的顺序。这使得拖拽过程极其流畅,没有任何延迟。
第九部分:总结与思考
好了,今天我们聊了这么多。我们绕过了那些花里胡哨的库,直接接触了浏览器的心脏——Web Animations API。
WAAPI 的核心优势:
- 原生性能: 直接运行在合成器线程,几乎零 JS 开销。
- 精确控制:
play(),pause(),cancel(),updatePlaybackRate(),你想怎么玩就怎么玩。 - 简单直接: 不需要构建虚拟树,不需要复杂的配置文件。
- 与 React 兼容: 通过
useRef和useLayoutEffect可以完美融合。
什么时候该用它?
- 当你需要高性能的微交互(按钮点击、悬停、加载)。
- 当你需要实现复杂的序列动画(一个接一个的元素出现)。
- 当你需要在动画过程中动态修改属性(比如根据鼠标位置改变旋转角度)。
- 当你不想为了一个简单的动画引入几百 KB 的库代码。
什么时候别用它?
- 当你需要复杂的物理引擎(比如布娃娃系统、重力模拟)。虽然 WAAPI 可以做,但写起来会很痛苦。这时候你应该用 Matter.js。
- 当你需要极其复杂的布局动画(比如瀑布流重新排列)。React 的布局系统在这里依然有优势,WAAPI 只能处理视觉上的移动,不能处理流式布局。
最后的建议:
React 是关于“声明”的,而 WAAPI 是关于“命令”的。最好的架构往往是在这两者之间找到平衡点。用 React 来管理数据流和业务逻辑,用 WAAPI 来管理视觉表现。
不要害怕直接操作 DOM。在 React 生态中,ref 就是你通往自由的大门。当你掌握了 WAAPI,你就掌握了浏览器原生的动画能力。你不再是那个只能依赖 framer-motion 的跟班,你成为了真正的动画工程师。
现在,去你的项目里试试吧。找一个简单的按钮,用 element.animate() 把它变酷。你会发现,那种掌控全局的快感,是任何第三方库都给不了的。
好了,讲座结束。散会!记得把电脑关好,我们下周再见。