react-use 的 useRaf Hook:基于 requestAnimationFrame 的动画进度驱动方案
【免费下载链接】react-useReact Hooks — 👍项目地址: https://gitcode.com/gh_mirrors/re/react-use
导读
useRaf是 react-use 提供的一个动画类 Hook,它借助浏览器原生的requestAnimationFrame驱动组件在每个动画帧上重新渲染,并返回一个 0 到 1 之间的数值,表示自动画开始以来流逝时间占总时长的百分比。本文将以官方文档 docs/useRaf.md 为主体,结合 src/useRaf.ts 的源码实现与 tests/useRaf.test.ts 的测试用例,完整讲解其用法、参数语义、底层运行机制、清理逻辑及典型应用场景。读完本文,你将能够在自己的 React 组件中直接使用useRaf构建进度条、倒计时、基于时间线的动画等能力,并理解它与其他 RAF 类 Hook(如useRafLoop、useRafState)之间的区别。
useRaf 是什么
官方文档对useRaf的定位非常清晰:一个 React 动画 Hook,它强制组件在每一次requestAnimationFrame时重新渲染,并返回已流逝时间的百分比。
import {useRaf} from 'react-use'; const Demo = () => { const elapsed = useRaf(5000, 1000); return ( <div> Elapsed: {elapsed} </div> ); };在上面的示例中,组件挂载 1 秒(delay)后才开始计时,随后在 5 秒(ms)的窗口期内,每帧都会重新渲染,elapsed从 0 平滑地增长到 1。elapsed本身就是一个可直接用于驱动 UI 的进度值,比如把它渲染成进度条的宽度百分比、透明度或位移量。
函数签名与参数详解
官方文档给出了useRaf的类型签名:
useRaf(ms?: number, delay?: number): number;| 参数 | 类型 | 说明 | 默认值 |
|---|---|---|---|
ms | number | 组件持续重新渲染的毫秒时长,即动画的总时长 | 1e12 |
delay | number | 延迟多少毫秒后开始渲染,即动画开始的等待时间 | 0 |
| 返回值 | number | 已流逝时间占总时长的百分比,范围恒为[0, 1] | 0 |
参数的实际语义
从 src/useRaf.ts 的默认值可以看出,ms默认取1e12(约 31.7 年)。这意味着如果不传任何参数,useRaf()会在近乎无限的时间内持续逐帧返回一个不断增大的进度值——其行为更接近于"无限循环的时间比例指示器",这也是官方文档将其作为默认值的原因。
ms:决定动画窗口的总时长。百分比计算公式为(Date.now() - start) / ms,其中start是动画实际开始的时刻。delay:决定动画开始前的等待时间。实现中通过setTimeout(onStart, delay)来推迟启动,延迟期间返回值恒为 0。
源码实现:逐帧推进的内部机制
useRaf的实现非常精简,完整逻辑位于 src/useRaf.ts。其核心思路是:用一个状态保存当前进度,用一个 RAF 递归循环持续更新它,再用两个定时器分别控制"开始"与"结束"。
状态与副作用时机
const useRaf = (ms: number = 1e12, delay: number = 0): number => { const [elapsed, set] = useState<number>(0); useIsomorphicLayoutEffect(() => { // ... }, [ms, delay]); return elapsed; };这里使用了useIsomorphicLayoutEffect(见 src/useIsomorphicLayoutEffect.ts),它在浏览器环境等价于useLayoutEffect,在服务端渲染(SSR)环境下退化为useEffect,因此useRaf可以安全地用于同构应用,不会在服务端抛出requestAnimationFrame is not defined之类的错误。
副作用依赖数组为[ms, delay],意味着当这两个参数任一变化时,动画会重新初始化(重置进度、重新计时)。
帧循环与进度计算
const onFrame = () => { const time = Math.min(1, (Date.now() - start) / ms); set(time); loop(); }; const loop = () => { raf = requestAnimationFrame(onFrame); };每一帧执行onFrame:用当前时刻减去start得到流逝时间,除以ms得到进度比例,并通过Math.min(1, ...)将结果钳制在 1 以内(即进度永远不会超过 1),随后立即调度下一帧,形成持续到组件卸载或动画结束的循环。
启动延迟与结束收尾
const onStart = () => { timerStop = setTimeout(() => { cancelAnimationFrame(raf); set(1); }, ms); start = Date.now(); loop(); }; const timerDelay = setTimeout(onStart, delay);整个时序如下:
- 副作用执行后,立刻注册
timerDelay = setTimeout(onStart, delay),等待delay毫秒; onStart被触发:记录start = Date.now()作为计时起点,启动 RAF 循环,同时注册一个时长为ms的timerStop;- 当
timerStop到期(即到达总时长),取消当前 RAF 循环,并把状态强制置为 1,动画定格在终点; - 组件卸载时,清理函数依次
clearTimeout(timerStop)、clearTimeout(timerDelay)、cancelAnimationFrame(raf),确保不产生内存泄漏和卸载后的状态更新。
清理逻辑的测试佐证
tests/useRaf.test.ts 中的should clear pending timers on unmount用例专门验证了这一点:卸载组件后,clearTimeout恰好被调用 2 次(分别清理timerStop与timerDelay),cancelAnimationFrame被调用 1 次(清理未完成的帧请求)。
行为特性:从测试用例看返回值规律
仓库中的测试用例 tests/useRaf.test.ts 将useRaf的行为刻画得非常精确,可作为理解该 Hook 的第一手依据:
初始值为 0
无论是否传入参数,Hook 首次渲染返回的进度值都是 0(测试should init percentage of time elapsed)。
进度按比例递增
测试使用raf-stub替换requestAnimationFrame、用 Jest fake timers 与Date.nowmock 推进时间,验证了默认ms下进度依次为 0.25、0.5、0.75、1(对应should return corresponding percentage of time elapsed for default ms),自定义ms = 2000时同样精确返回 25%、50%、75%、100%(should return corresponding percentage of time elapsed for custom ms)。这从侧面印证了返回值计算公式的线性特征:progress = (Date.now() - start) / ms。
超过时长后恒为 1
测试should return always 1 after corresponding ms reached表明:当流逝时间达到总时长的 110%、300% 时,返回值依然被钳制为 1——这正是Math.min(1, ...)起的作用,进度永远不可能超过 1。
延迟期间保持 0
测试should wait until delay reached to start calculating elapsed percentage验证:useRaf(undefined, 500)在 250ms、499ms 时返回值均为 0,只有快进到恰好 500ms 后进度才开始增长,说明delay期间完全不启动帧循环。
典型应用:驱动进度与动画
useRaf的返回值天然适合作为动画进度输入。最直接的用法是进度条:
import {useRaf} from 'react-use'; const ProgressBar = ({duration = 3000}) => { const progress = useRaf(duration); return ( <div style={{width: '100%', background: '#eee'}}> <div style={{ width: `${progress * 100}%`, height: 8, background: '#3b82f6', transition: 'none', }} /> </div> ); };由于进度在每个 RAF 帧都会更新,组件会以约 60fps(取决于设备刷新率)的频率重渲染,进度条随之平滑推进。你还可以把返回值映射到任意 CSS 属性上,例如透明度opacity: 1 - progress、位移transform: translateX(${progress * 100}px)等。
需要延迟启动的倒计时场景也很自然:
const Countdown = () => { // 3 秒后开始,持续 5 秒 const remaining = 1 - useRaf(5000, 3000); return <div>剩余比例:{remaining.toFixed(2)}</div>; };与 useTween 的关系:更上层的动画构建块
useRaf在仓库内部还承担着更高级动画 Hook 的底层角色。以 src/useTween.ts 为例,它直接调用useRaf(ms, delay)取得线性进度,再将其传入ts-easing提供的缓动函数得到带缓动效果的动画值:
const useTween = (easingName: string = 'inCirc', ms: number = 200, delay: number = 0): number => { const fn: Easing = easing[easingName]; const t = useRaf(ms, delay); // ... return fn(t); };从源码结构可以推断,useRaf提供了"时间 → 线性进度"的通用映射,而useTween等 Hook 在其之上叠加缓动曲线,从而复用了帧循环与定时清理的全部基础设施。如果你需要实现自定义的缓动、往返动画或时间轴动画,也可以基于useRaf的返回值自行加工。
使用注意事项
- 副作用参数变化会重置动画:由于副作用依赖
[ms, delay],运行时修改这两个参数会取消当前动画并从头开始,实际使用中建议将时长视为常量。 - 适合浏览器环境:尽管
useIsomorphicLayoutEffect保证了 SSR 安全,但真正的帧循环只在浏览器端发生;服务端渲染阶段返回值恒为初始值 0。 - 与同类 Hook 的区别:
useRafLoop(见 src/useRafLoop.ts)关注的是"持续执行回调函数"而非返回进度值;useRafState(见 src/useRafState.ts)则是把状态更新节流到 RAF 帧内,而非按时间比例推进。三者定位不同,按需选择。 - 导出入口:
useRaf与useRafLoop、useRafState一起从 src/index.ts 统一导出,可直接通过import { useRaf } from 'react-use'引入;仓库内的演示代码位于 stories/useRaf.story.tsx,可在 Storybook 中交互查看效果。
小结
useRaf用约 30 行代码实现了"按帧推进、按时长钳制、按延迟启动、按卸载清理"的完整动画进度闭环:ms控制总时长、delay控制启动时机、返回值恒在[0, 1]区间。无论是直接驱动进度条与 CSS 动画,还是作为useTween等高级 Hook 的时间基底,它都是 react-use 动画体系中简洁而关键的一环。
【免费下载链接】react-useReact Hooks — 👍项目地址: https://gitcode.com/gh_mirrors/re/react-use
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考