深入理解 React Hooks 闭包问题:原理剖析与实战解决方案

在使用 React Hooks 的过程中 ,如果你发现总是获取不到最新的 state 值,那么你可能遇到了 "过时的闭包" ,英文叫法是 "stale closure"。

如果你在搜索引擎中查找如下关键词,会发现很多相关讨论和解决方案:

  • 中文:react 过时的闭包
  • 英文:react stale closure

我自己刚开始使用 react 时,也曾对这个问题产生困惑,完全不知道该如何查找资料或解决。希望这篇文章能帮你从实战到原理彻底理解它。

过时的闭包一些常见的场景

即使我用 React 开发多年,但有时还是会被过时的闭包问题难到。

我整理了3个开发过程中实际遇到的案例:

  • 延迟执行( setTimeoutPromise 等)
  • DOM 事件监听
  • 防抖函数

每个例子都附带可运行的在线示例,建议动手运行看看效果

延时执行

在线示例:codesandbox.io/p/sandbox/s...

jsx 复制代码
import { useEffect, useState } from "react";

export default function App() {
  const [count, setCount] = useState(0);

  const onClick = () => {
    setCount(count + 1);
  };

  useEffect(() => {
    // Promise, setInterval 类似
    setTimeout(() => {
      console.log("count", count); // ❌ stale closure
    }, 4000);
  }, []);

  return <button onClick={onClick}>count: {count}</button>;
}

这里 count 是一个过时的变量, 结合 useRef 来解决这个问题:

jsx 复制代码
// 设置值的时候
const [count, setCount] = useState(0);
const countRef = useRef(count)
countRef.current = count

// 取值时
setTimeout(() => {
	// ✅ 通过引用获取最新值
	console.log("count", countRef.current);
},4000)

DOM 事件监听

在线示例:codesandbox.io/p/devbox/li...

jsx 复制代码
import { useEffect, useState } from "react";
function App() {
  const [count,setCount] = useState(0)
  const onClickDocument = () => {
    console.log("click", count);
    setCount(count + 1)
  };
  useEffect(() => {
    // ❌ stale function onClickDocument
    document.addEventListener("click", onClickDocument);
    return () => {
      document.removeEventListener("click", onClickDocument);
    };
  }, []);
  return (
    <div className="App">
      App
      <h3>{count}</h3>
    </div>
  );
}

export default App; 

useEffect 中点击事件调用的 onClickDocument ,就是一个过时的函数,是组件在第一次渲染时创建的。

同样还是结合 useRef 来解决这个问题,只不过这次传给它的参数是一个函数:

jsx 复制代码
const onClickDocument = () => {
    console.log("click", count);
    setCount(count + 1);
  };
const onClickDocumentRef = useRef(onClickDocument);
onClickDocumentRef.current = onClickDocument;

// 取值时
useEffect(() => {
  const handler = () => {
    onClickDocumentRef.current();
  };
  document.addEventListener("click", handler);
  return () => {
    document.removeEventListener("click", handler);
  };
}, []);

防抖函数

在线示例:codesandbox.io/p/sandbox/n...

jsx 复制代码
import { useState } from "react";
import "./styles.css";
import { debounce } from "./debounce";

export default function App() {
  const [count1, setCount1] = useState(0);
  const [count2, setCount2] = useState(0);
  const onClick1 = debounce(() => {
    console.log("click1", count1, count2);
    setCount1(count1 + 1);
  }, 4000);
  const onClick2 = () => {
    setCount2(count2 + 1);
  };
  return (
    <div className="App">
      <h2>count1: {count1}</h2>
      <h2>count2: {count2}</h2>
      <button onClick={onClick1}>click1</button>
      <button onClick={onClick2}>click2 </button>
    </div>
  );
}

这个例子使用 debounce 函数,实现多次点击 onClick1 ,只有最后一次的点击在 4s 后生效。

只点击 click1 的话,你会发现最终的效果就是我们想要的,但其实它只是恰好满足了当前的需求。

现在进行如下操作:

  1. 点击 click1
  2. 点击 click2
  3. 再次点击 click1

关键点在于:第二步 点击 click2 时会由于调用 setCount2 重新执行 App 函数, debounce 函数也会重新执行创建一个全新onClick1

此时再点击 click1 执行的是点击 click2 创建的 onClick1 ,最终的效果就是 onClick1 会被执行2次。(ps: onClick1 中获取到的 count1, count2 也可能会是旧值,推荐感兴趣的小伙伴可以在在线编辑器中查看和研究)

那么要如何处理这个问题呢?还是结合 useRef

jsx 复制代码
const onClick1 = () => {
  console.log("click1", count1, count2);
  setCount1(count1 + 1);
}
const onClick1Ref = useRef(onClick1)
const memoizedOnClick1 = useMemo(() => {
	return debounce(() => onClick1Ref.current(), 4000)
},[])

通过 useMemo 传入 [] 依赖,来确保 debounce 只执行1次,这样就能保证每次执行的memoizedOnClick1 都是同一个函数。

自定义 hooks

经过上面的几个示例,可以发现解决过时的闭包问题的核心思路是使用 useRef

jsx 复制代码
const [count,setCount] = useState(0)
const countRef = useRef(count)
countRef.current = count

// 通过 countRef.current 获取最新值

而且不只是变量可以使用 useRef 来处理,函数同样也可以:

jsx 复制代码
const fn = () => {
	console.log('fn')
}
const fnRef = useRef(fn)
fnRef.current = fn

// 通过fnRef.current() 来执行最新的函数

但如果每个组件中都重复写这些逻辑,代码会变得冗余且难以维护。更推荐的方法是抽象出可复用的自定义 hook ,或直接使用成熟社区方案:

Hook 库名称 说明
ahooks 由阿里巴巴团队维护的高质量 React Hooks 库
react-use 国外非常活跃的通用 React Hooks 工具集

我们以 ahooks 为例,看看它是如何解决这些问题的

可以看到,这些 hooks 的源码都离不开 useRef 的身影:

补充一下:

你也可以通过传入回调形式的 setState 来获取最新的 state :

jsx 复制代码
setState((count) => count + 1)

但需要注意,只有函数参数 count 是最新的,如果在函数中读取外部变量,可能还是获取到旧值

过时的闭包是如何产生的

过时的闭包是由于 js 语言特性而引发的,大多数情况下我们并不会遇到。但是随着 react hooks 的推出和广泛使用,使它频繁的出现在我们的视线中。

现在我们脱离 React,看一下原生 js 中的闭包:

jsx 复制代码
let innerFn;

const outerFn = (value) => {
  if (!innerFn) {
    innerFn = () => {
      console.log(value);
    };
  }

  return innerFn;
};

const firstFn = outerFn("first call");
const secondFn = outerFn("second call");
const thirdFn = outerFn("third call");

firstFn();
secondFn();
thirdFn();

这里的 innerFn 其实和 useEffectdeps[] 的情况下类似,尽管 outerFn 调用了多次,但是调用的 innerFn 都是在第一次执行 outerFn 时创建的。

为了更好的理解这个问题 ,github.com/swyxio 用原生 js (仅几十行代码)实现了一个极简版 React codesandbox.io/p/sandbox/j...

可以看到 useEffectdeps 发生改变时才会重新执行 cb。如果 deps 没有发生改变,那么在函数组件重新 render 时, cb 就会成为过时的函数。

将代码再简化下:

jsx 复制代码
let deps = null;
const useEffect = (fn, _deps) => {
  const isSameDeps = deps ? deps.every((dep, i) => dep === _deps[i]) : false;
  if (!isSameDeps) {
    fn();
  }
  deps = _deps;
};

const App = (message) => {
  useEffect(() => {
    setTimeout(() => {
      console.log("message", message);
    }, 2000);
  }, []);
};

App("render1");
App("render2");
App("render3");

可以看到即使最新执行 App 时传入了 render3 ,但是打印的结果还是 render1

结语

在这篇文章中,我用由易到难的三个例子向大家演示了 React 中过时的闭包问题,然后结合 ahooks 介绍如何解决这个问题。

最终我们脱离 React ,从原生 js 的角度来看过时的闭包到底是如何产生的。

希望经过这一系列的探索,能帮助你更好的理解 React

参考

相关推荐
某公司摸鱼前端5 小时前
uniapp socket 封装 (可拿去直接用)
前端·javascript·websocket·uni-app
要加油哦~5 小时前
vue | 插件 | 移动文件的插件 —— move-file-cli 插件 的安装与使用
前端·javascript·vue.js
wen's7 小时前
React Native 0.79.4 中 [RCTView setColor:] 崩溃问题完整解决方案
javascript·react native·react.js
vvilkim7 小时前
Electron 自动更新机制详解:实现无缝应用升级
前端·javascript·electron
vvilkim7 小时前
Electron 应用中的内容安全策略 (CSP) 全面指南
前端·javascript·electron
aha-凯心8 小时前
vben 之 axios 封装
前端·javascript·学习
漫谈网络8 小时前
WebSocket 在前后端的完整使用流程
javascript·python·websocket
失落的多巴胺9 小时前
使用deepseek制作“喝什么奶茶”随机抽签小网页
javascript·css·css3·html5
DataGear9 小时前
如何在DataGear 5.4.1 中快速制作SQL服务端分页的数据表格看板
javascript·数据库·sql·信息可视化·数据分析·echarts·数据可视化
影子信息9 小时前
vue 前端动态导入文件 import.meta.glob
前端·javascript·vue.js