useEffect 会在 React 渲染完组件、更新屏幕之后运行一段代码。你用它让组件与 React 之外的东西保持同步,比如定时器、网络连接或事件监听器,并通过依赖数组控制它什么时候再次运行。
打开预览下方的控制台:组件出现时 Effect 输出一次日志,之后每次点击后再输出一次。把 [count] 改成 [] 再点击:第一次日志之后,Effect 就不再输出了。
语法
useEffect(() => {
// setup: runs after the render is on the screen
return () => {
// cleanup: runs before the next setup, and on unmount
};
}, [dependencies]);
- 第一个参数是 setup 函数。它可以返回一个清理函数,也可以什么都不返回。
- 第二个参数是依赖数组:setup 中读取的、来自组件的每一个值(props、state,以及由它们计算出来的任何值)。
- 和所有 Hook 一样,在组件的顶层调用
useEffect。
Effect 在渲染之后运行
渲染就是 React 调用你的组件函数,弄清楚屏幕上应该显示什么。Effect 会等这项工作完成、DOM 更新之后才运行,所以在 Effect 里,页面已经显示出新的值。
每次点击都会先输出渲染日志,再输出 Effect 日志,而且 Effect 读到的正是 React 刚刚写入的按钮文字。这就是为什么读取 DOM、启动定时器或连接服务器要放在 Effect 里,而不是放在组件函数体中:函数体在渲染期间运行,那时屏幕上还什么都没有,而且 React 调用它的次数可能比你预想的多。
Effect 通常在浏览器绘制之后运行。如果 Effect 必须在绘制之前测量 DOM,请改用 useLayoutEffect,它总是在绘制之前运行。
依赖数组的三种写法
依赖数组决定 Effect 什么时候再次运行。它有三种写法,下面这段代码把三种都用上了,方便你观察区别。
页面加载时三个 Effect 都会输出日志。点击 a + 1,其中两个会运行:没有依赖数组的那个,以及依赖 a 的那个。点击 b + 1,只有没有依赖数组的那个会运行,因为 [] 和 [a] 中的值都没有变。
| 依赖数组 | Effect 何时运行 |
|---|---|
| 无 | 每次渲染之后 |
[] | 只运行一次,在第一次渲染之后(挂载时) |
[a, b] | 第一次渲染之后,以及之后每当 a 或 b 变化时 |
React 用 Object.is 把每个依赖与它上一次的值进行比较。数字、字符串和布尔值按值比较。对象、数组和函数按引用比较,这一点非常重要(见下文)。
不要为了让 Effect 少运行几次就把某个值从数组里删掉。那样 Effect 会一直使用某次旧渲染中的值。react-hooks/exhaustive-deps lint 规则能发现这个问题;Vite 的 React 模板和 Next.js 的 ESLint 配置都包含了这条规则。
清理函数
很多 Effect 会启动某个需要停止的东西:连接、订阅、定时器。从 Effect 中返回一个函数,React 会在两个时刻调用它:用新的依赖再次运行 Effect 之前,以及组件离开屏幕(卸载)时。
换一个房间:控制台先显示 disconnect from general,再显示 connect to music。旧连接总是在新连接打开之前关闭。点击 Hide,清理函数会最后运行一次,因为 ChatRoom 被卸载了。
这里的 connect 函数是真实聊天客户端或 WebSocket 客户端的替身。不管用哪一种,写法都一样:在 setup 中创建,在清理函数中关闭。
清除定时器
在 Effect 中启动的定时器,如果清理函数不停止它,组件消失后它还会继续运行。
删掉 clearInterval(id); 这一行,然后隐藏计时器:控制台里还会不断出现 tick,而发出它的组件已经不存在了。每点一次 Show,又会多启动一个。
注意 setSeconds((s) => s + 1)。这个 Effect 只运行一次,所以普通的 setSeconds(seconds + 1) 会永远读到第一次渲染时的 seconds,计到 1 就停住了。更新函数总能拿到最新的值。
移除事件监听器
window 或 document 上的事件监听器也是同样的写法。传给 removeEventListener 的函数,必须和传给 addEventListener 的是同一个:
useEffect(() => {
function handleKeyDown(e) {
if (e.key === 'Escape') onClose();
}
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [onClose]);
无限循环
如果 Effect 设置的 state 同时也是它的依赖,就会形成循环:state 改变,React 重新渲染,依赖变了,Effect 运行,再次设置 state。
// Never stops: every render runs the effect, every effect causes a render
useEffect(() => {
setCount(count + 1);
});
// Same loop: count changes, so the effect runs again
useEffect(() => {
setCount(count + 1);
}, [count]);
组件会无休止地渲染,标签页一直处于忙碌状态。React 不会终止这个循环:在开发环境中,它会在控制台输出一条 "Maximum update depth exceeded" 错误,在生产环境中则什么都不输出。下面这段代码运行的是第二种写法,但加了停止条件,这样你可以观察循环而不会让页面卡死:
控制台会输出 render 0 到 render 5:一次挂载渲染了六次,每次都是由 Effect 引起的。怎么修复取决于你本来想做什么:
- 你只想运行一次。 使用
[]和更新函数写法setCount((c) => c + 1),这样count就不是依赖了。 - 你在根据其他 state 计算一个值。 根本不要存它;在渲染时计算,做法见本页最后一节。
- 你在响应用户操作。 把代码放进事件处理函数。
把对象和函数作为依赖
在组件函数体中创建的对象、数组或函数,每次渲染都是一个新值,所以依赖它的 Effect 在每次渲染后都会运行,即使它里面的内容什么都没变。
切换颜色和搜索毫无关系,但每次切换都会再输出一次 search for react。把 options 这一行移到 Effect 内部,并把数组改成 [query]:切换颜色时就安静了,只有在输入框里打字才会触发搜索。
这就是通用的修复方法。在用到对象和函数的 Effect 内部创建它们,并依赖构造它们的那些原始值。当一个函数必须放在 Effect 外部时,useCallback 能让它的引用在多次渲染之间保持不变。
为什么 Effect 在开发环境中运行两次
开启 <StrictMode> 后(新建的 Vite 应用会用它包裹根组件,Next.js App Router 也默认开启它),React 会在开发环境中做一次测试:挂载每个组件,运行它的 Effect,执行清理,然后再运行一次 Effect。对于上面的聊天室,开发环境的控制台会显示:
connect to general
disconnect from general
connect to general
这只在挂载时发生一次,而且只在开发环境中出现。它的目的是暴露缺少清理函数的 Effect:如果连续运行两次就会出问题,那么用户离开再回来时也会出问题。正确的修复从来不是用 ref 拦住第二次运行,而是写好清理函数。本页的预览按生产构建的方式运行,所以在这里看不到运行两次的情况。
你可能不需要 Effect
Effect 用于与 React 之外的系统保持同步。有两种常见用法不用 Effect 反而更好。
由 props 或 state 计算出来的值。 在渲染时计算。把它们存到 state 里再用 Effect 同步,会多一次渲染,而且其中第一次渲染会把过时的值放进 DOM:
每次点击都会让 WithEffect 渲染两次,而且它的第一次渲染显示的仍是旧名字。Computed 只渲染一次,显示的就是正确的值。
响应用户操作的代码。 提交表单、购买后显示提示消息、为一次点击上报分析事件,这些都应该放在事件处理函数中。在那里你清楚地知道发生了什么,而 Effect 只知道某个值变了。
useEffectEvent
有时 Effect 需要某个 prop 的最新值,但又不想在这个 prop 变化时重新运行。在聊天室里,通知应该使用当前的 theme,但切换主题不应该导致重新连接。React 19.2 为此新增了 useEffectEvent:
import { useEffect, useEffectEvent } from 'react';
function ChatRoom({ roomId, theme }) {
const onConnected = useEffectEvent(() => {
showNotification('Connected!', theme); // always the latest theme
});
useEffect(() => {
const connection = createConnection(roomId);
connection.on('connected', () => onConnected());
connection.connect();
return () => connection.disconnect();
}, [roomId]); // theme is not a dependency
}
useEffectEvent 返回的函数只能在 Effect 内部调用。不要把它传给其他组件,也不要把它写进依赖数组。
常见问题
useEffect 什么时候运行?
在 React 渲染完组件并更新 DOM 之后,通常是在浏览器绘制之后。没有依赖数组时,它在每次渲染后都运行;依赖数组是 [] 时,只在第一次渲染后运行;依赖数组是 [a, b] 时,在第一次渲染后运行,之后每当某次渲染中 a 或 b 发生变化时再运行。
useEffect 的清理函数是什么?
就是你从 Effect 中返回的那个函数。React 在用新的依赖再次运行 Effect 之前调用它,并在组件从屏幕上移除时再调用一次。用它来清除定时器、移除事件监听器、关闭订阅。
为什么我的 useEffect 运行了两次?
在开发环境中,<StrictMode> 会挂载每个组件,运行它的 Effect,执行清理,然后再运行一次 Effect,以便让你发现缺少清理函数的 Effect。这只在挂载时发生一次,而且只在开发环境中出现。生产构建只会运行一次 Effect。
为什么我的 useEffect 会造成无限循环?
Effect 设置了一个 state,而这个 state 又在它的依赖数组里(或者根本没有依赖数组)。设置 state 会触发重新渲染,依赖变了,于是 Effect 又运行一次。可以改用更新函数并去掉这个依赖,加一个判断条件,或者改为在渲染时直接计算这个值。
useEffect 的回调可以是 async 函数吗?
不可以。回调只能什么都不返回,或者返回一个清理函数,而 async 函数返回的是 promise。在 Effect 内部定义一个 async 函数再调用它,做法见数据请求一页。