You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

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
});

原因说明

  1. 代码本身存在原生漏洞:当前useLayoutEffect仅在running为true时返回清理函数,且timerId存储在effect的局部闭包中。当组件因为counter更新重渲染时,如果running状态还未同步更新,会生成新的requestAnimationFrame实例,旧的timerId会被闭包丢弃无法清理。
  2. 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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.09.25 22:36:04