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

参考

相关推荐
threelab4 分钟前
挑战AI辅助从零构建3D模型编辑器:01基于Vue3 + Three.js的现代化架构设计
javascript·人工智能·3d·前端框架·着色器
张元清6 分钟前
React 浏览器标签页 UX:用标题、Favicon 和通知把用户拉回来
前端·javascript·面试
葛兰岱尔11 分钟前
葛兰岱尔rapid3D Loader for Three.js使用方式及7个基础API说明
开发语言·javascript·3d
Lkstar14 分钟前
读完红宝书和YDKJS,我终于搞懂了原型链、闭包和this
javascript·面试
用户114896694410517 分钟前
JavaScript原型链解析
javascript
Mr数据杨33 分钟前
【Codex】用APP绑定教程模块规范移动端接入指引
java·前端·javascript·django·codex·项目开发
博客zhu虎康36 分钟前
小程序按钮实现先表单校验再走手机号获取功能
android·javascript·小程序
超级无敌谢大脚36 分钟前
【无标题】
开发语言·前端·javascript
Hello--_--World1 小时前
React:解释什么是虚拟Dom?它的工作原理及其性能优化机制,深入理解 JSX、如何理解 UI = f(state)?
react.js·ui·性能优化
果壳~1 小时前
【Uniapp】【rich-text】富文本展示以及图片预览功能解决方案
前端·javascript·uni-app