news 2026/2/28 16:23:01

React 终于出手了:彻底终结 useEffect 的“闭包陷阱“

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
React 终于出手了:彻底终结 useEffect 的“闭包陷阱“

React 终于解决了它最大的问题:useEffectEvent 的妙用

如果问你 React 最大的 bug 来源是什么,你会说什么?大多数人都会说useEffect。这个名字很奇怪的 Hook 允许你执行异步工作,这很好,但也会导致很多问题。特别是无限循环——我们一直在从服务器获取数据时遇到这个问题。

React 团队看到了这个问题,他们想出了一个新的 Hook 叫useEffectEvent。我知道这个名字很绕口,但当涉及到稳定你的 React 应用时,它是一个救命稻草。

让我带你经历一个非常常见的问题。我们将从今天拥有的 Hooks 开始,这样我们可以看到问题,然后我将展示useEffectEvent如何修复它。

为什么这很重要:Cloudflare 的 useEffect 事故

Cloudflare 是全球最大的部署提供商之一,他们有一个优秀的工程团队。但即使他们在 useEffect 方面也犯了错误。之前,他们在错误地将一个对象放入依赖数组时,等于对自己的仪表板发起了分布式拒绝服务攻击(DDoS)。该对象在每次重新渲染时改变其引用标识,导致无限循环,搞垮了整个仪表板。

这是一个尴尬的错误,但太容易犯了。这就是为什么 React 编译器和useEffectEvent这样的新 Hooks 如此重要。在编译器的情况下,它们稳定对象引用,有助于减少围绕对象标识的潜在 bug。useEffectEvent则完全从依赖数组中移除对象!

搭建场景

这是一个具有可编辑用户名的简单组件:

javascript

体验AI代码助手

代码解读

复制代码

function MyUserInfo() { const [userName, setUserName] = useState("Bob"); return ( <div> <input value={userName} onChange={(evt) => setUserName(evt.target.value)} /> </div> ); }

到目前为止一切顺利;我们可以更改用户名。现在假设我们想要追踪用户已登录多长时间,然后显示它:

javascript

体验AI代码助手

代码解读

复制代码

function MyUserInfo() { const [userName, setUserName] = useState("Bob"); useEffect(() => { let loggedInTime = 0; const interval = setInterval(() => { loggedInTime++; }, 1000); return () => clearInterval(interval); }, []); return ( <div> <input value={userName} onChange={(evt) => setUserName(evt.target.value)} /> </div> ); }

我们添加了一个useEffect,在组件挂载时设置一个计时器来追踪此人登录的秒数。(是的,我知道这不是真正的登录;这是演示代码。)

现在这段代码实际上运行正常,没有 bug。它仅在组件挂载时运行一次,因为有一个空的依赖数组。它通过返回一个清理函数来清理自己,该函数清除间隔,从而杀死计时器。

但在功能方面,它实际上不起作用,因为我们没有在任何地方显示那个数字。为了修复这个问题,让我们添加一个loginMessage字符串,我们可以用它来显示那个数字:

javascript

体验AI代码助手

代码解读

复制代码

function MyUserInfo() { const [userName, setUserName] = useState("Bob"); const [loginMessage, setLoginMessage] = useState(""); useEffect(() => { let loggedInTime = 0; const interval = setInterval(() => { loggedInTime++; setLoginMessage( `${userName} has been logged in for ${loggedInTime} seconds` ); }, 1000); return () => clearInterval(interval); }, []); return ( <div> <div>{loginMessage}</div> <input value={userName} onChange={(evt) => setUserName(evt.target.value)} /> </div> ); }

现在这段代码看起来应该可以工作。实际上,它多少有点有效。开始时,它说"Bob 已登录 1 秒"。然后它每秒忠实地往前走。巨大的成功!

过期闭包问题

哎呀,实际上有一个 bug。因为我们发送给useEffect的函数可能会"过期"。

如果我更改用户名会发生什么?好的,input会改变,但登录消息仍然说用户名是"Bob"。但不是;我们改变了它。

所以我们发送给useEffect的函数已经创建了一个"闭包",它在当时以当前值("Bob")捕获了userName的值。它永远不会改变。因为它现在与实际值不同步,我们会认为它是"过期的"。那意味着我们有一个"过期闭包"(stale closure)。

好消息是,React 对此有一个修复(这不是useEffectEvent,请耐心等待)。我们可以将userName添加到依赖数组中:

javascript

体验AI代码助手

代码解读

复制代码

useEffect(() => { let loggedInTime = 0; const interval = setInterval(() => { loggedInTime++; setLoginMessage( `${userName} has been logged in for ${loggedInTime} seconds` ); }, 1000); return () => clearInterval(interval); }, [userName]);

成功了!现在当我们编辑userName时,登录消息会改变!太棒了。哦,等等。什么?每次我们进行更改时,登录时间都会重新开始为 1 秒。

啊,所以每次我们创建一个带有新userName值的新闭包时,我们都会杀死旧计时器(这很好)。但我们也创建了一个新的loggedInTime并再次从零开始。这绝对不好。

我明白一个简单的修复方法是追踪loggedInTime状态并在 JSX 中格式化字符串。好的。但让我们假设我们做不到。

useRef 来救援

我们怎样才能解决这个问题?好吧,在useEffectEvent之前,我们可能会使用 ref:

javascript

体验AI代码助手

代码解读

复制代码

const nameRef = useRef(userName); nameRef.current = userName; useEffect(() => { let loggedInTime = 0; const interval = setInterval(() => { loggedInTime++; setLoginMessage( `${nameRef.current} has been logged in for ${loggedInTime} seconds` ); }, 1000); return () => clearInterval(interval); }, []);

这里我们做了几件事。首先,我们创建了一个 ref,其中存储了userName的当前值,我们在每次渲染时更新当前值。在渲染期间设置 ref 的current值是可以的,因为 React 不像监控状态那样监控 refs。

接下来,我们在模板字符串中使用nameRef.current而不是userName,所以我们总是获得userName的当前值,因为它在每次渲染时更新。最后,我们从依赖数组中移除了userName,这解决了重置 bug。

现在它实际上有效。没有注意事项!除了,它有点笨重,这就是useEffectEvent的用武之地。

useEffectEvent 好得多

看看这个版本:

javascript

体验AI代码助手

代码解读

复制代码

const getName = useEffectEvent(() => userName); useEffect(() => { let loggedInTime = 0; const interval = setInterval(() => { loggedInTime++; setLoginMessage( `${getName()} has been logged in for ${loggedInTime} seconds` ); }, 1000); return () => clearInterval(interval); }, []);

我们使用新的useEffectEventHook 创建一个 getter 函数,它返回userName的当前值。它可以在useEffect中被调用,它永远不会过期。它真的很干净。比useRef版本干净和清晰得多。

但实际上还能变得更好,因为它允许我们更一般地思考useEffect。我是说,如果你想一想,我们多少有一个更通用的"计时器"useEffect

javascript

体验AI代码助手

代码解读

复制代码

const onTick = useEffectEvent((tick: number) => setLoginMessage(`${userName} has been logged in for ${tick} seconds`) ); useEffect(() => { let ticks = 0; const interval = setInterval(() => onTick(++ticks), 1000); return () => clearInterval(interval); }, []);

现在我们已将所有状态相关的内容移到useEffectEvent中。看看我们的useEffect变得多干净了?useEffect只处理计时器。onTick处理该计时器的所有逻辑。

useEffectEvent 是游戏规则改变者

更好的是,useEffect对状态没有依赖。而状态依赖正是useEffect陷入困境的地方(如我们所见)。依赖于错误状态的坏依赖数组会导致过期闭包问题、无效重置或甚至无限循环。useEffectEvent允许我们从依赖数组中移除状态。这帮助我们编写更好的useEffects

我们甚至可以将其变成自定义 Hook,使其更通用:

javascript

体验AI代码助手

代码解读

复制代码

function useInterval(onTick: (tick: number) => void) { const onTickEvent = useEffectEvent(onTick); useEffect(() => { let ticks = 0; const interval = setInterval(() => onTickEvent(++ticks), 1000); return () => clearInterval(interval); }, []); }

现在我们有了一个完整的useInterval实现,它非常干净且无 bug。

一个小挑战

如果你想要一个有趣的小挑战:你将如何实现一个版本,其中毫秒数(目前是 1000)是可调整的?

javascript

体验AI代码助手

代码解读

复制代码

function useInterval(onTick: (tick: number) => void, timeout: number = 1000) { // ???? }

我采取的方法这次有点不同。不是setInterval,我使用setTimeout并在每次迭代中调整超时:

javascript

体验AI代码助手

代码解读

复制代码

function useInterval(onTick: (tick: number) => void, timeout: number = 1000) { const onTickEvent = useEffectEvent(onTick); const getTimeout = useEffectEvent(() => timeout); useEffect(() => { let ticks = 0; let mounted = true; function onTick() { if (mounted) { onTickEvent(++ticks); setTimeout(onTick, getTimeout()); } } setTimeout(onTick, getTimeout()); return () => { mounted = false; }; }, []); }

让我知道你是否能够优化这个。

结论:带安全带的 React

React 团队承认了useEffect代码失控运行的问题。他们不仅承认了这一点,还用像编译器和useEffectEvent这样的新功能创造了优雅的解决方案,让你可以安全可靠地编写 React 代码。

不要听信反对者的声音——React 并没有停滞不前,它还在进化,而且在大多数情况下,变得更好。


译者补充

翻译这篇文章是因为它把useEffect依赖数组的痛点讲得很透彻。Cloudflare 的例子不是危言耸听,我自己也遇到过类似的问题——一个看起来无害的对象放进依赖数组,结果页面卡死。

关于useEffectEvent,补充几点:

现状:好消息是,useEffectEvent在 React 19.2(2025 年 10 月发布)中已经是稳定 API了,可以直接使用:

javascript

体验AI代码助手

代码解读

复制代码

import { useEffectEvent } from 'react';

如果你还在用 React 19.2 之前的版本,需要用实验前缀:experimental_useEffectEvent。升级到 19.2+ 后记得去掉前缀。

另外,ESLint 插件也需要升级到eslint-plugin-react-hooks@6,这样 linter 才能正确识别useEffectEvent返回的函数不需要加入依赖数组。

原理是什么:要理解useEffectEvent,得先明白过期闭包是怎么产生的。

JavaScript 的函数会"记住"创建时的词法环境,这就是闭包。当你写:

javascript

体验AI代码助手

代码解读

复制代码

useEffect(() => { console.log(userName); // 闭包捕获了 userName }, []);

这个箭头函数在第一次渲染时创建,它捕获的userName是当时的值。由于依赖数组是空的,这个函数不会重新创建,所以它永远只能访问到旧值。

useRef的解决思路是:不让闭包直接捕获值,而是捕获一个"容器"(ref),然后每次渲染时更新容器里的内容:

javascript

体验AI代码助手

代码解读

复制代码

const nameRef = useRef(userName); nameRef.current = userName; // 每次渲染都更新 useEffect(() => { console.log(nameRef.current); // 闭包捕获的是 ref 对象,读取时拿到最新值 }, []);

useEffectEvent本质上是 React 帮你做了这套事情,但更优雅:

  1. 它返回的函数引用是稳定的——每次渲染返回的都是同一个函数对象,所以不需要加入依赖数组
  2. 但这个函数内部能访问到最新的 props/state——React 在内部维护了一个类似 ref 的机制,每次渲染时更新函数要访问的值

你可以把它理解为 React 官方提供的useRef+ "自动同步" 的封装,只是语义更清晰:这是一个"事件",它描述的是"发生某事时要做什么",而不是 effect 本身的逻辑。

什么时候用:文章里的例子是计时器,但更常见的场景是埋点。比如页面加载时发送一次埋点,埋点数据里有用户信息、页面参数等。这些数据可能会变,但你不希望它们变化时重新触发埋点——这正是useEffectEvent的典型用例。

还没升级 React 19 怎么办:如果项目还在 React 18,文章里的useRef方案是靠谱的。社区也有一些封装好的 Hook,比如 ahooks 的useMemoizedFn,思路类似。

配合 React Compiler:React Compiler 能自动推断依赖,配合useEffectEvent效果更好。两者都是 React 团队为了解决useEffect心智负担而推出的方案,可以关注一下这个组合。


如果你觉得这篇文章有帮助,欢迎关注我的 GitHub,下面是我的一些开源项目:

Claude Code Skills(按需加载,意图自动识别,不浪费 token,介绍文章):

  • code-review-skill - 代码审查技能,覆盖 React 19、Vue 3、TypeScript、Rust 等约 9000 行规则(详细介绍)
  • 5-whys-skill - 5 Whys 根因分析,说"找根因"自动激活
  • first-principles-skill - 第一性原理思考,适合架构设计和技术选型

qwen/gemini/claude - cli 原理学习网站

  • coding-cli-guide(学习网站)- 学习 qwen-cli 时整理的笔记,40+ 交互式动画演示 AI CLI 内部机制

全栈项目(适合学习现代技术栈):

  • prompt-vault - Prompt 管理器,用的都是最新的技术栈,适合用来学习了解最新的前端全栈开发范式:Next.js 15 + React 19 + tRPC 11 + Supabase 全栈示例,clone 下来配个免费 Supabase 就能跑
  • chat_edit - 双模式 AI 应用(聊天+富文本编辑),Vue 3.5 + TypeScript + Vite 5 + Quill 2.0 + IndexedDB


原文:https://juejin.cn/post/7594276252459024394

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/2/23 7:16:01

如何实现高精度多语言翻译?HY-MT1.5大模型镜像全解析

如何实现高精度多语言翻译&#xff1f;HY-MT1.5大模型镜像全解析 随着全球化进程加速&#xff0c;企业出海、跨语言内容生成和本地化服务对高质量机器翻译的需求日益增长。传统商业翻译API在术语一致性、上下文连贯性和格式保留方面存在明显短板&#xff0c;难以满足专业场景的…

作者头像 李华
网站建设 2026/2/27 22:28:56

单目深度估计技术解析:MiDaS的核心算法

单目深度估计技术解析&#xff1a;MiDaS的核心算法 1. 引言&#xff1a;从2D图像到3D空间感知的跨越 在计算机视觉领域&#xff0c;如何让机器“理解”真实世界的三维结构一直是一个核心挑战。传统方法依赖双目立体视觉或多传感器融合&#xff08;如LiDAR&#xff09;&#x…

作者头像 李华
网站建设 2026/2/27 5:51:03

多语言混合翻译新标杆|腾讯HY-MT1.5大模型镜像技术亮点全揭秘

多语言混合翻译新标杆&#xff5c;腾讯HY-MT1.5大模型镜像技术亮点全揭秘 在多语言内容呈指数级增长的今天&#xff0c;传统翻译系统正面临前所未有的挑战&#xff1a;用户输入中频繁出现中英夹杂、多语种嵌套、专业术语密集等复杂场景。为应对这一趋势&#xff0c;腾讯开源了…

作者头像 李华
网站建设 2026/2/23 2:40:49

AI万能分类器大赛复盘:冠军方案云端复现指南

AI万能分类器大赛复盘&#xff1a;冠军方案云端复现指南 引言&#xff1a;为什么你需要这个冠军方案&#xff1f; 参加过AI比赛的朋友都知道&#xff0c;从获奖论文到实际可运行的代码往往隔着"三天三夜"的环境配置。去年NeurIPS的万能分类器大赛冠军方案就是一个典…

作者头像 李华
网站建设 2026/2/23 10:01:11

单目深度估计技术对比:MiDaS vs 传统方法

单目深度估计技术对比&#xff1a;MiDaS vs 传统方法 1. 引言&#xff1a;为何单目深度估计是3D感知的关键一步 在计算机视觉领域&#xff0c;从2D图像中恢复3D空间结构一直是核心挑战之一。传统的深度感知依赖双目立体视觉&#xff08;如Stereo Vision&#xff09;、结构光或…

作者头像 李华
网站建设 2026/2/20 12:27:20

数组初始化的编译模式特征

文章目录数组初始化的编译模式特征1. **局部数组存储位置**2. **显式初始化部分**3. **未显式初始化部分的处理**4. **内存布局特征**5. **编译器优化特征**6. **初始化模式识别**7. **逆向识别线索**8: int Arr[10] {1}; 00F21DE0 mov dword ptr [Arr],1 00F21DE…

作者头像 李华