Menu

React useEffect 教程:依赖数组、清理函数与示例

useEffect 在 React 更新屏幕之后运行代码,让组件与 React 之外的东西保持同步:定时器、订阅、事件监听器。学习依赖数组的三种写法、清理函数、无限循环,以及什么时候根本不需要 Effect。

本页包含可运行的编辑器。编辑、运行,立即查看输出。

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 函数再调用它,做法见数据请求一页。

Coddy 编程语言插图

和 Coddy 一起学编程

开始