问小白 wenxiaobai
资讯
历史
科技
环境与自然
成长
游戏
财经
文学与艺术
美食
健康
家居
文化
情感
汽车
三农
军事
旅行
运动
教育
生活
星座命理

React Hooks闭包陷阱问题详解与解决方案

创作时间:
作者:
@小白创作中心

React Hooks闭包陷阱问题详解与解决方案

引用
1
来源
1.
http://www.ferecord.com/react-hooks-closure-traps-problem.html

React Hooks的闭包陷阱是一个常见但容易被忽视的问题。本文通过一个实际开发案例,深入分析了闭包陷阱产生的原因,并提供了三种有效的解决方案:使用useRef、函数式更新和useReducer。这些方案不仅解决了闭包陷阱问题,还在批量更新、依赖管理等场景下提供了实用的解决方案。

近日,在开发过程中再次遇到了React Hooks的闭包陷阱问题,特此记录下来,希望能帮助其他开发者避免类似问题。

发现bug

需求很简单:一个卡片上有多个下载按钮,点击后请求文件地址。大概实现如下:

export default function App() {
  const [loadingMap, setLoadingMap] = useState({});
  
  const handleClick = async (key) => {
    setLoadingMap({ ...loadingMap, [key]: true });
    await delay(3000);
    setLoadingMap({ ...loadingMap, [key]: false });
  };

  return (
    <div className="App">
      <button onClick={() => handleClick("a")}>
        {loadingMap.a ? "下载中" : "下载a"}
      </button>
      <button onClick={() => handleClick("b")}>
        {loadingMap.b ? "下载中" : "下载b"}
      </button>
    </div>
  );
}

测试时发现问题:如果连续点击 a 和 b 按钮,当 b 下载结束时,a 按钮会被重置为”下载中“。

显然,这段简单的代码忽略了闭包问题。下图描述了这段逻辑里闭包问题产生的原因:

每次渲染都有独立的 state 上下文。b 按钮的点击发生在第二次渲染后,对应的闭包中 loadingMap 的值为 {a: true},当前渲染中的 eventHandler 只能获取到本次渲染环境对应的 state。因此,当在 eventHandler 中通过 setState 触发第五次渲染时,此处的 loadingMap 依然是第二次渲染时的旧值,而非最新的第四次渲染时的值。

解决方案

关键点在于:在执行 setState 时,如何能获取到最新的 state,而不是本次渲染时的 state。

方案一:useRef

useRef 是 React 开发者的老朋友,是解决 Hooks 问题的万金油。它能存储一个指向不变的变量。

代码做如下更改:

const loadingMap = useRef({});
const forceUpdate = useForceUpdate();

const setLoading = (key, value) => {
  loadingMap.current[key] = value;
  forceUpdate();
};

loadingMap 存在 ref.current 里,就永远能找到它了,所有的渲染里,它的指向都是唯一的。唯一的问题是,修改 ref.current 是不会触发 re-render 的,因此需要使用 forceUpdate 强制刷新。

方案二:setState 的函数式更新

函数式更新好,获取 state 没烦恼。setState 可以接收一个函数作为参数,即函数式更新,我们把这个函数称为更新器,这种更新方式不会直接触发 re-render,而是会把更新器推到一个队列里,最后经过计算统一触发一次更新。更新器的参数是上一个计算后该状态的值,可以理解为最新的 state。

代码做如下更改:

const setMap = (key, value) => {
  setLoadingMap((v) => ({
    ...v,
    [key]: value,
  }));
};

const handleClick = async (key) => {
  setMap(key, true);
  await delay(3000);
  setMap(key, false);
};

函数式更新有如下优点:

  • 总是可以拿到最新的 state
  • 没有依赖,如果要在 useEffect 中更新 state,但又不想因 state 更新触发 effect 时,用函数式更新很合适
  • 批量更新时只会触发一次 re-render,这可以避免不必要的 re-render

干净简洁、没有负担,函数式更新可以说是处女座之友。不过函数式更新只能用在 setState 时,如果需要获取到最新的 state 做其他事情(比如发送一个请求),就无法使用这个方案。

方案三:useReducer

useReducer 的情况跟 ref 类似,它也是将 state 的状态管理转移了,得以避免 Hooks 的闭包陷阱。

代码修改如下:

const reducer = (state, action) => {
  return { ...state, ...action };
};

const [loadingMap, dispatch] = useReducer(reducer, {});

const handleClick = async (key) => {
  dispatch({ [key]: true });
  await delay(3000);
  dispatch({ [key]: false });
};

某种程度上,reducer 被称为 Hooks 的作弊模式,它可以解耦业务逻辑和更新方法的关系。和函数式更新一样,它也不需要依赖当前渲染上下文的 state,因此很适合在以下场景使用:需要在 useEffect 这类需要传入依赖的 Hooks 中修改状态,但又不想因依赖改变触发 effect 的场景。不过使用 useReducer 往往意味着更多的代码逻辑(action、reducer、dispatch... )。

总结

Hooks 的闭包陷阱是非常常见的问题,当代码中有【异步逻辑+get/setState】的场景时,需要注意可能产生闭包陷阱。本文的三种方法除了解决闭包陷阱问题,在批量更新、依赖诚实、避免不必要的 effect 等场景下也都提供了解决方案。最后,希望 useEvent 尽快到来。

© 2023 北京元石科技有限公司 ◎ 京公网安备 11010802042949号