深入理解 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

参考

相关推荐
清风徐来QCQ18 小时前
SpringMvC
前端·javascript·vue.js
Swift社区18 小时前
ArkTS Web 组件里,如何通过 javaScriptProxy 让 JS 同步调用原生方法
开发语言·前端·javascript
Hi_kenyon18 小时前
快速入门VUE与JS(二)--getter函数(取值器)与setter(存值器)
前端·javascript·vue.js
3秒一个大18 小时前
模块化 CSS:解决样式污染的前端工程化方案
css·vue.js·react.js
全栈前端老曹19 小时前
【前端路由】React Router 权限路由控制 - 登录验证、私有路由封装、高阶组件实现路由守卫
前端·javascript·react.js·前端框架·react-router·前端路由·权限路由
zhuà!19 小时前
uv-picker在页面初始化时,设置初始值无效
前端·javascript·uv
Amumu1213819 小时前
React应用
前端·react.js·前端框架
摸鱼的春哥19 小时前
实战:在 Docker (Windows) 中构建集成 yt-dlp 的“满血版” n8n 自动化工作流
前端·javascript·后端
_Rookie._19 小时前
关于迭代协议:可迭代协议和迭代器协议,生成器函数 生成器对象的理解
javascript·python
测试游记19 小时前
基于 FastGPT 的 LangChain.js + RAG 系统实现
开发语言·前端·javascript·langchain·ecmascript