【React 源码阅读】useEffectEvent 详解

1 前言

相信写过 React 的前端 er 都曾经被 useEffectdeps 问题坑过,要么就是 deps 太长了很难维护,要么就是 deps 写的有问题导致了无限刷新。

然而,在最近的 19.2 版本里,React 提供了新的 hook:useEffectEvent,它可以解决长久以来开发中的痛点吗?在这篇文章里,我们来详细看看它的用法以及原理。

2 痛点 & 常见解决方案

2.1 痛点

假设有这么一个场景:页面 url 发生变化的时候,重新执行某个函数。

在之前的版本里,我们的代码可能长这样:

typescript 复制代码
import { useEffect, useContext, useCallback } from 'react';  
import { logVisit } from 'somewhere'


function Page({ url }) {  
  const { items } = useContext(ShoppingCartContext);  
  const numberOfItems = items.length;  
  
  const onNavigate = useCallback((visitedUrl) => {  
    logVisit(visitedUrl, numberOfItems);
  }, [numberOfItems]);  

  useEffect(() => {  
    onNavigate(url);  
  }, [url, onNavigate]);  
}

这样一来,url 变化的时候,会重新触发 useEffect 来执行 onNavigate 函数。但是同样的,onNavigate 函数变化的时候也会重新触发 useEffect 重新执行,如果 onNavigate 变复杂了,那么 useEffect 的执行逻辑就会变得很难确定了。

那么之前应对这种情况有什么解决方案呢?

2.2 常见解决方案

2.2.1 方案一、不使用 useCallback

如题,我们可以直接不使用 useCallback,但是 useEffect 依然需要 deps 怎么办呢?答案是加上 eslint 注释来绕过:

typescript 复制代码
useEffect(() => {  
  onNavigate(url);  
  // eslint-disable-next-line react-hooks/exhaustive-deps
}, [url]); 

如果这么做了,后续这个 useEffect 就不建议再往里添加其他逻辑了,否则可能会出 BUG ❌

2.2.1 方案二、使用 ahooks 的 useMemorizedFn

typescript 复制代码
const callback = useMemoizedFn(() => { // do something })

ahooks 内部使用了 useRef 来保存传入的 callback,可以做到在 useEffect 里拿到最新的 callback 但不因为它的更新引起重新执行。

2.2.2 方案三(最新)、useEffectEvent

还是以最开始的场景为例,代码可以这么写:

typescript 复制代码
import { useEffect, useContext, useCallback } from 'react';  
import { logVisit } from 'somewhere'


function Page({ url }) {  
  const { items } = useContext(ShoppingCartContext);  
  const numberOfItems = items.length;  
  
  const onNavigate = useEffectEvent((visitedUrl) => {  
    logVisit(visitedUrl, numberOfItems);
  }, [numberOfItems]);  

  useEffect(() => {  
    onNavigate(url);  
  }, [url]);  
}

3 原理

看过上面的例子之后,相信你也一定很好奇 useEffectEvent 是怎么实现的,下面我们来简单看一下源码👇

3.1 mountEvent

typescript 复制代码
function mountEvent<Args, Return, F: (...Array<Args>) => Return>(
  callback: F,
): F {
  const hook = mountWorkInProgressHook();
  const ref = {impl: callback};
  hook.memoizedState = ref;
  // $FlowIgnore[incompatible-return]
  return function eventFn() {
    if (isInvalidExecutionContextForEventFunction()) {
      throw new Error(
        "A function wrapped in useEffectEvent can't be called during rendering.",
      );
    }
    return ref.impl.apply(undefined, arguments);
  };
}
  1. mountEvent 方法内,会把传入的 callback 存入到一个 ref 对象内,最终和 hook.memoizedState = ref 进行绑定
  2. 返回一个 function,内部执行 ref.impl.apply(undefined, arguments)

3.2 updateEvent

typescript 复制代码
function updateEvent<Args, Return, F: (...Array<Args>) => Return>(
  callback: F,
): F {
  const hook = updateWorkInProgressHook();
  const ref = hook.memoizedState;
  useEffectEventImpl({ref, nextImpl: callback});
  // $FlowIgnore[incompatible-return]
  return function eventFn() {
    if (isInvalidExecutionContextForEventFunction()) {
      throw new Error(
        "A function wrapped in useEffectEvent can't be called during rendering.",
      );
    }
    return ref.impl.apply(undefined, arguments);
  };
}
  1. hook.memoizedState 里取出 ref 对象
  2. 执行 updateEffectEventImpl 方法
  3. mount 阶段一样,这里会重新返回一个 function

3.3 updateEffectEventImpl

typescript 复制代码
function useEffectEventImpl<Args, Return, F: (...Array<Args>) => Return>(
  payload: EventFunctionPayload<Args, Return, F>,
) {
  currentlyRenderingFiber.flags |= UpdateEffect;
  let componentUpdateQueue: null | FunctionComponentUpdateQueue =
    (currentlyRenderingFiber.updateQueue: any);
  if (componentUpdateQueue === null) {
    componentUpdateQueue = createFunctionComponentUpdateQueue();
    currentlyRenderingFiber.updateQueue = (componentUpdateQueue: any);
    componentUpdateQueue.events = [payload];
  } else {
    const events = componentUpdateQueue.events;
    if (events === null) {
      componentUpdateQueue.events = [payload];
    } else {
      events.push(payload);
    }
  }
}
  1. 如果 componentUpdateQueue 为 null,那么在调用 createFunctionComponentUpdateQueue 之后将 payload 存入 componentUpdateQueue.events
  2. 否则,将会把 payload push 到 componentUpdateQueue.events 数组里

3.4 疑问

3.4.1 componentUpdateQueue.events 数组是用来干嘛的?

看到这里有点奇怪:为什么要把 callback 存一份放到 events 数组里呢? 如果全局搜索代码的话可以发现:

因此,callback 如果出现变化并不会马上更新,而是会等到 commit 阶段再更新到原先的 ref.impl 对象上。

3.4.2 useEffectEvent 直接返回的函数,为什么能避免 useEffect 依赖?

看到源码的时候一开始就有这种疑惑,为什么命名 useEffectEvent 没有给传入的 callback 套一层稳定的外壳,但是它却同样可以解决问题呢?

其实对于 deps 的检查主要还是依赖 eslint-plugin-react-hooks 这个插件的,因此只要它适配 useEffectEvent,那么自然就不会有报错了。

并且,如果你非要将 useEffectEvent 返回的函数放入 deps 数组,它还会检测出来并报错:

4 总结

方案 描述 优点 缺点 推荐指数
1. ESLint 注释绕过 deps 数组内省略依赖,通过 // eslint-disable-next-line 注释绕过 ESLint 检查。 快速,编码量少。 产生陈旧闭包。容易引入 Bug,难以调试。破坏 ESLint 检查的保护作用。 ⭐ (极不推荐)
2. 使用 useMemoizedFn (来自 ahooks) 使用 useMemoizedFn(callback) 来封装函数。它在内部使用 useRef 来存储最新的 callback,并返回一个稳定的函数引用。 解决了陈旧闭包不必要重跑的问题。社区成熟解决方案,易于使用。对于不支持 React 19.x 的项目仍是最佳选择。 引入第三方库依赖。不如官方 useEffectEvent 的语义清晰。 ⭐⭐⭐⭐ (高度推荐,尤其在旧版 React 时)
3. 使用 useEffectEvent (官方) callback 函数传入官方 useEffectEvent Hook,获取一个稳定的 Event Function,并在 useEffect 中调用。 官方推荐的解决方案 。自动处理 Ref 更新和闭包问题。与 ESLint 完美配合。代码简洁。已在 React 19.2 (稳定版) 中发布。 仅限于 React 19.2+ 版本。 ⭐⭐⭐⭐⭐ (强烈推荐,若项目版本支持)

如果你的项目已经升到了最新版本,那么不妨试试 useEffectEvent~

参考资料:

相关推荐
前端不太难6 小时前
从 Navigation State 反推架构腐化
前端·架构·react
前端程序猿之路7 小时前
Next.js 入门指南 - 从 Vue 角度的理解
前端·vue.js·语言模型·ai编程·入门·next.js·deepseek
大布布将军7 小时前
⚡️ 深入数据之海:SQL 基础与 ORM 的应用
前端·数据库·经验分享·sql·程序人生·面试·改行学it
川贝枇杷膏cbppg7 小时前
Redis 的 RDB 持久化
前端·redis·bootstrap
JIngJaneIL8 小时前
基于java+ vue农产投入线上管理系统(源码+数据库+文档)
java·开发语言·前端·数据库·vue.js·spring boot
天外天-亮8 小时前
v-if、v-show、display: none、visibility: hidden区别
前端·javascript·html
jump_jump8 小时前
手写一个 Askama 模板压缩工具
前端·性能优化·rust
be or not to be8 小时前
HTML入门系列:从图片到表单,再到音视频的完整实践
前端·html·音视频
90后的晨仔9 小时前
在macOS上无缝整合:为Claude Code配置魔搭社区免费API完全指南
前端
沿着路走到底9 小时前
JS事件循环
java·前端·javascript