【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~

参考资料:

相关推荐
品克缤1 小时前
vue项目配置代理,解决跨域问题
前端·javascript·vue.js
m0_740043731 小时前
Vue简介
前端·javascript·vue.js
我叫张小白。1 小时前
Vue3 v-model:组件通信的语法糖
开发语言·前端·javascript·vue.js·elementui·前端框架·vue
undsky1 小时前
【RuoYi-SpringBoot3-UniApp】:一套代码,多端运行的移动端开发方案
前端·uni-app
FanetheDivine1 小时前
Next.js 学习笔记5 使用心得
react.js·next.js
天天向上10241 小时前
vue3 封装一个在el-table中回显字典的组件
前端·javascript·vue.js
哆啦A梦15881 小时前
66 导航守卫
前端·javascript·vue.js·node.js
苏打水com1 小时前
2026年前端开发就业指导:把握趋势,构建不可替代的竞争力
前端
海边的云2 小时前
你还在为画各种流程图头疼吗?
前端