react 开发经验分享,第一篇:闭包陷阱

6 阅读6分钟

总览

  1. react 开发中遇到的闭包问题
  2. 闭包及闭包陷阱产生的原因
  3. 解决方案

问题场景

在 React Hooks 中,你可能会遇到这种情况:

const [userInput, setUserInput] = useState<string>('');

const createSSE = (text: string) => {
    const eventSource = new EventSource(url);

    eventSource.onmessage = (event) => {
        console.log(userInput);  // ← 为什么总是 ''?
    }
}

const onSend = (text: string) => {
    setUserInput(text);  // ← 明明设置了这个值
    createSSE(text);
}

现象:在 onmessage 回调中,userInput 永远是初始值 '',拿不到最新值。


原因:闭包陷阱(Stale Closure)

Stale Closure(过期闭包):一个闭包捕获了过时的变量值,当外部变量已更新时,闭包内仍使用旧值。

这是 React 社区对这类问题的正式术语,也称为 "闭包陷阱"或"陈旧闭包问题"。

什么是闭包?

闭包 = 函数 + 函数创建时词法环境

function outer() {
    const value = 'initial';

    return function inner() {
        console.log(value);  // inner "记住"了创建时的 value
    };
}

const fn = outer();
fn();  // 输出 'initial'(value 是 outer 内部变量,闭包保持对其引用)

OK 这里需要注意,是创建时的词法环境。需要与 函数调用执行栈中的上下文 区分开。

分析一个例子

const [count, setCount] = useState(0);

// 组件第一次渲染:count = 0
useEffect(() => {
    const timer = setInterval(() => {
        console.log(count);  // ← 永远是 0
    }, 1000);

    return () => clearInterval(timer);
}, []);  // 空依赖数组,只执行一次 

// 后来调用 setCount(5)
// 但定时器回调里还是用旧值 0

注意:

  1. 空依赖数组是核心原因 !!
  2. 闭包只是问题产生的一部分,关键在于 count 变化并没有触发 Effect 更新
  3. 由于 依赖数组为 [], 因此 React 认为这个 Effect 不需要重新执行
  4. 这个才是关键 !!!

时间线分析

时刻 1:组件首次渲染
├── userInput = '' (初始值)
├── createSSE 函数被定义
└── onmessage 回调被创建,捕获了 userInput = ''

时刻 2:用户点击发送
├── onSend('hello') 被调用
├── setUserInput('hello') 被调用
└── state 被标记为需要更新

时刻 3:组件重新渲染
├── userInput = 'hello' (新值)
└── 但之前的 onmessage 回调还在用旧值 ''

时刻 4:SSE 返回消息
├── onmessage 回调执行
└── 用的还是创建时的 '' 值

关键:回调函数记住的是它被创建时的值,不会自动更新。


为什么会这样?

这就涉及到 闭包特性 和 react 的渲染机制了

JavaScript 的闭包特性

// 闭包捕获的是创建时的词法环境
function createClosure() {
    const value = 'A';
    return function inner() {
        console.log(value);  // inner 捕获了创建时的 value
    };
}

const fn1 = createClosure();
fn1();  // 输出 'A'

// 即使后来创建了新的闭包
const fn2 = createClosure();
fn2();  // 还是输出 'A',因为每次 createClosure 都创建独立的作用域

React 的渲染机制

关键概念:每次渲染都有自己的 props 和 state

✅ 正常工作的情况

function Component() {
    const [state, setState] = useState(0);

    // 每次渲染都是新的函数作用域
    const handleClick = () => {
        console.log(state);  // 这里的 state 是渲染时的值
    };

    return <button onClick={handleClick}>Click</button>;
}

// 第一次渲染:state = 0,创建 handleClick 捕获了 0
// 点击按钮 → 打印 0
// setState(5) → 触发重新渲染
// 第二次渲染:state = 5,创建新的 handleClick 捕获了 5
// 点击按钮 → 打印 5 ✅

为什么能正常工作?

因为 handleClick 是在组件体内定义的,每次渲染都会重新创建,所以它总是捕获最新的 state。


❌ 闭包陷阱的情况

const [userInput, setUserInput] = useState<string>('');

const createSSE = (input: string) => {
    // 这里需要填写后端SSE 服务的 api 地址
    const eventSource = new EventSource(`http://...`);

    eventSource.onmessage = (event) => {
        console.log(userInput);  // 永远是初始值,闭包陷阱!
        const data = JSON.parse(event.data);
        // 处理消息...
    };

    return eventSource;
};

const onSend = (text: string) => {
    setUserInput(text);   // 更新 state
    createSSE(text);      // 但 onmessage 回调捕获的是创建时的旧值
};

为什么是闭包陷阱?

  • createSSE 函数本身在组件体内,每次渲染会重新创建
  • onmessage 回调是注册到外部系统(EventSource)
  • EventSource 不会随组件重新渲染而更新回调
  • 回调中使用的 userInput 永远是第一次渲染时的值

定时器中的场景同理

解决方案

方案一:参数传递(推荐)

// 把需要用的值作为参数传进去
const createSSE = (query: string) => {
    eventSource.onmessage = (event) => {
        console.log(query);  // 用参数
    };
};

createSSE(text);

特点

  • ✅ 简单直接
  • ✅ 不需要额外 hook
  • ✅ 性能好
  • ⚠️ 需要改函数签名

方案二:useRef

const valueRef = useRef(initialValue);

// 每次 render 更新 ref
valueRef.current = value;

// 异步回调中使用
eventSource.onmessage = () => {
    console.log(valueRef.current);  // 总是最新的
};

特点

  • ✅ 回调不需要改
  • ✅ ref 值立即更新
  • ⚠️ 需要额外变量
  • ⚠️ 不会触发重新渲染

方案三:函数式更新

// 只适用于 setState
setCount(prev => prev + 1);  // prev 总是最新的

特点

  • ✅ 简洁
  • ✅ 不需要依赖
  • ⚠️ 只能用于 setState
  • ⚠️ 复杂逻辑不好处理

方案四:useCallback

const handleClick = useCallback(() => {
    console.log(value);  // 依赖 value,会更新
}, [value]);

特点

  • ✅ 自动追踪依赖
  • ✅ 可以做性能优化
  • ⚠️ 有性能开销
  • ⚠️ 需要正确设置依赖

说明

  1. 几种解决方案的核心:拿到最新的值
  2. 无论是 直接传参 使用useRef 还是 useCallback 都是为了让 onmessage 拿到更新后的值。也就是所谓的打破闭包陷阱的限制。
  3. useCallback 实际上是通过 函数重新创建的方式来解决这个问题,并不适用于 SSE 场景。可能会导致 SSE 连接异常。这里只是做为演示需要 ,请谨慎使用!!!

方案对比

方案适用场景优点缺点
参数传递函数调用简单直接需要改函数签名
useRef异步回调总是最新的值需要额外变量
函数式更新setState不需要依赖只能用于 setState
useCallback复杂依赖自动追踪变化有性能开销

这里补充一下:

为什么 useRef 能绕过闭包?

根据 React 官方文档,Refs 提供了一种方式,访问 DOM 节点或在 render 中创建的 React 元素

关键特性:

  • useRef 返回一个可变的 .current 属性
  • Ref 的变化不会触发重新渲染
  • Ref 对象在组件的整个生命周期内保持稳定(同一个引用)
const valueRef = useRef(initialValue);

// 每次渲染都同步更新 ref.current
valueRef.current = value;

useEffect(() => {
    const handler = () => {
        // 闭包捕获的是 valueRef 这个对象的引用 
        // 而对象内部的内容可以变化  --- 这里是关键!!!!
        console.log(valueRef.current);
    };
}, []);  // 空依赖数组可行,因为 valueRef 引用永不改变

工作原理:闭包捕获的是 valueRef 对象的引用,而 .current 是该对象的一个可变属性。即使闭包是在第一次渲染时创建的,它仍然可以访问最新的 .current 值。

总结

闭包 = 函数 + 词法环境(对变量的引用)

React 中的闭包陷阱 = 回调使用的是创建时那次渲染的 statestate 更新后 = 新的渲染 = 新的作用域 = 新的函数
但旧的回调函数仍然引用着旧的 state

记住这个

场景解决方案
函数调用用参数传递
异步回调用 useRef
setState用函数式更新 setCount(prev => prev + 1)
事件监听用 useRef 或 useCallback
定时器用 useRef 或函数式更新

一句话

想用最新值?用参数或 useRef 或者函数式更新 ~ 

参考资料

React 官方文档

MDN 官方文档