Recoil.js使用recoilState控制animationFrame时出现异常行为
问题结论
该异常和Recoil读取状态的延迟无关,核心是闭包陷阱与React批量更新时序叠加导致的requestAnimationFrame ID泄漏,Recoil的状态更新逻辑只是放大了这个原本就存在的写法漏洞。
问题代码复现
import React, { useState, useLayoutEffect } from "react"; import { useRecoilState, atom } from "recoil"; function Counter() { const [counter, setCounter] = useState(0); const [running, setRunning] = useRecoilState(isRunning); useLayoutEffect(() => { if (running) { let timerId; const f = () => { setCounter((x) => x + 1); timerId = requestAnimationFrame(f); }; timerId = requestAnimationFrame(f); return () => cancelAnimationFrame(timerId); } }, [running]); return ( <p> Counter: {counter} ...{" "} <button onClick={() => setRunning(!running)}> {!running ? "恢复" : "暂停"} </button> </p> ); } export default Counter; const isRunning = atom({ key: "isRunning", default: false });
原因说明
- 代码本身存在原生漏洞:当前
useLayoutEffect仅在running为true时返回清理函数,且timerId存储在effect的局部闭包中。当组件因为counter更新重渲染时,如果running状态还未同步更新,会生成新的requestAnimationFrame实例,旧的timerId会被闭包丢弃无法清理。 - Recoil与useState的表现差异:useState是组件内部状态,更新优先级更高,点击暂停时running状态会立刻同步更新,赶在新的帧回调生成前执行清理,所以漏洞不会暴露;而Recoil的原子状态是跨组件管理的,在React 18自动批量更新机制下,更新时序会比内部useState稍晚,刚好给了新的帧回调生成的窗口,导致timerId泄漏,最终出现无法暂停的现象。
修复方案
用useRef存储最新的timerId,避免闭包捕获旧值,同时统一处理清理逻辑,不受更新时序影响:
import React, { useState, useLayoutEffect, useRef } from "react"; import { useRecoilState, atom } from "recoil"; function Counter() { const [counter, setCounter] = useState(0); const [running, setRunning] = useRecoilState(isRunning); // 用ref存储最新的timer id,不受闭包影响 const timerRef = useRef(null); useLayoutEffect(() => { if (running) { const f = () => { setCounter((x) => x + 1); timerRef.current = requestAnimationFrame(f); }; timerRef.current = requestAnimationFrame(f); } else { // running为false时直接清理当前timer if (timerRef.current) { cancelAnimationFrame(timerRef.current); timerRef.current = null; } } // 全局清理逻辑,组件卸载或依赖变化时一定会执行 return () => { if (timerRef.current) { cancelAnimationFrame(timerRef.current); } }; }, [running]); return ( <p> Counter: {counter} ...{" "} <button onClick={() => setRunning(!running)}> {!running ? "恢复" : "暂停"} </button> </p> ); } export default Counter; const isRunning = atom({ key: "isRunning", default: false });
内容的提问来源于stack exchange,提问作者maxd
相关产品推荐
相关产品推荐

