1 前言
相信写过 React 的前端 er 都曾经被 useEffect 的 deps 问题坑过,要么就是 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);
};
}
- 在
mountEvent方法内,会把传入的callback存入到一个ref对象内,最终和hook.memoizedState = ref进行绑定 - 返回一个
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);
};
}
- 从
hook.memoizedState里取出ref对象 - 执行
updateEffectEventImpl方法 - 和
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);
}
}
}
- 如果
componentUpdateQueue为 null,那么在调用createFunctionComponentUpdateQueue之后将payload存入componentUpdateQueue.events里 - 否则,将会把
payloadpush 到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~
参考资料: