理解 React 中 useState 的闭包问题及解决方案

186 阅读4分钟

在React开发中,useState 是非常常用的状态管理钩子,但其工作机制可能导致某些闭包问题,尤其是在函数组件中定义的事件处理函数中。

问题描述:闭包导致状态不更新

当在React组件中使用useState时,可能会遇到以下闭包问题:多次调用函数,但flag值始终是false,而不是随着state的变化而更新。

简化示例说明使用useState的闭包问题

场景1: 对象在组件初始渲染时就被创建和定义,造成闭包问题

const ProblematicComponent = () => {
    const [flag, setFlag] = useState(false);

    // 每次渲染都创建新的函数,但它捕获的flag值是创建时的值
    // 这里实际上形成了闭包,因为这个箭头函数会"记住"创建时的flag值
    const handleClick = () => {
      if (flag) return; // 这里的flag永远是创建闭包时的值
      setFlag(true);
      // do something
    }
    // 这个对象在组件初始渲染时就被创建和定义
    const originalState = {
        render: () => (
          <Button
            onClick={() => handleChange()}
          >按钮2</Button>
        )
    }
}
原因:
  1. 当组件首次渲染时,originalState 对象被创建。
  2. render函数捕获了那时的flag值。
  3. 即使后续flag更新,render函数仍然引用着初始值。
  4. 这是因为originalState对象没有随组件重新渲染而更新。
useState的渲染更新:
  • 在第一次渲染时,handleClick函数所用到的值始终是false
  • 触发setFlag后,会使整个组件重新渲染,重新创建函数,但函数内的值依然是之前的值。
// 第一次渲染
render() {
  // flag = false
  return onClick={() => {
    if (flag) return; // 这个flag被"冻结"在false
    setFlag(true);
  }
}

// 状态更新后的渲染
render() {
  // flag = true
  // 但是之前创建的闭包中的flag仍然是false
  return onClick={() => {
    if (flag) return; // 这个flag被"冻结"在false
    setFlag(true);
  }
}

场景2: jsx直接调用函数,造成闭包问题

const ProblematicComponent = () => {
  const [flag, setFlag] = useState(false);

  // 在组件顶层定义的函数,只会在组件首次渲染时创建一次
  const handleClick = () => {
    console.log('Current flag:', flag);
    if (flag) return;
    setFlag(true);
  };

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

原因:

在组件顶层定义的函数,只会在组件首次渲染时创建一次

场景3: jsx通过箭头函数调用函数,不会造成闭包问题

const ProblematicComponent = () => {
    const [flag, setFlag] = useState(false);

    // 每次组件重新渲染时,都会重新创建这个函数
    const handleClick = () => {
      if (flag) return;
      setFlag(true);
      // do something
    }
    return (
        <Button onClick={() => { handleClick(); }} > 按钮2 </Button>
    )
}

原因:
  1. 虽然handleClick也是在顶层创建,但通过箭头函数包装后,
  2. 每次组件重新渲染时,JSX中的这个箭头函数会重新创建。
  3. 当这个箭头函数被执行时,它会在当前渲染周期的上下文中调用handleChange
  4. 因此handleChange函数内部访问到的flag总是最新的值。

本质上,箭头函数创建了一个新的作用域,每次渲染时这个作用域都会更新,所以在调用 changePriceSaleState 时能够访问到最新的状态值。

为什么会有这种差异

React 的渲染周期

  • 组件初始化:组件初始化时,所有的代码都会执行一次

  • 组件重渲染:之后的重渲染只会执行 return 中的代码

变量作用域

  • 顶层定义的变量:组件顶层定义的变量/对象在初始化时就确定了。当组件重渲染时,这些变量的值不变,除非状态或 props 引起变化。
  • JSX 中的内容每次渲染都会重新创建(包括箭头函数)

闭包特性

  • 闭包捕获的值

    • 闭包会捕获定义时的变量值
    • 如果定义后不更新,就会一直保持旧值
  • 调用时上下文

    • 如果函数是直接引用(不通过箭头函数包装),可能会捕获旧的上下文值。
    • 使用箭头函数则使得每次渲染都创建新的上下文,捕获最新变量。

解决方案

方案一:使用函数式更新

React 的 useState 提供了一种函数式更新的方式,可以避免闭包问题。函数式更新可以获取到状态更新前的最新值,从而避免捕获旧的状态。

const SolutionWithFunctionalUpdate = () => {
  const [flag, setFlag] = useState(false);

  const handleClick = () => {
    setFlag((prevFlag) => {
      if (prevFlag) return prevFlag; // 已经是 `true`,直接返回上一次的值
      // do something
      console.log("Now setting flag to true.");
      return true;
    });
  };

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

解释

  • 传递给 setFlag 的回调函数始终获取到状态的最新值,从而解决闭包捕获的问题。
  • 不管闭包何时创建,都会访问到当前最新的状态。

方案二:使用 useRef 存储最新的状态值

const SolutionWithUseRef = () => {
  const flagRef = useRef(flag);

  const handleClick = () => {
    if (flagRef.current) return; // 使用最新的 ref 值
    flagRef.current = true;
    // do somthing
  }
}

方案三:使用 useCallback 缓存事件处理函数

import React, { useState, useCallback } from 'react';

const UseCallbackSolution = () => {
  const [flag, setFlag] = useState(false);

  const handleClick = useCallback(() => {
    if (flag) return; // 这里的 flag 始终是最新的,因为 useCallback 的依赖包含了 flag
    console.log('Setting flag to true');
    setFlag(true);
  }, [flag]); // 依赖 `flag`,当 `flag` 变化时,生成新的函数

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

export default UseCallbackSolution;

解释

  • useCallback 返回一个缓存的函数,当依赖(即 [flag])发生变化时会生成新的函数。
  • 由于函数的引用不变(如果依赖没变),可以避免闭包捕获初始状态的问题。
  • 在这里,handleClick 始终使用最新渲染时的 flag 值,而不会受之前旧的闭包影响。