React-脱围机制(EscapeHatches)

有些组件可能需要控制和同步 React 之外的系统。例如,你可能需要使用浏览器 API 聚焦输入框,或者在没有…

有些组件可能需要控制和同步 React 之外的系统。例如,你可能需要使用浏览器 API 聚焦输入框,或者在没有 React 的情况下实现视频播放器,或者连接并监听远程服务器的消息。在本章中,你将学习到一些脱围机制,让你可以“走出” React 并连接到外部系统。大多数应用逻辑和数据流不应该依赖这些功能。

感觉这一章节讲的就是React设计哲学,设计的一些底层比较多

使用 ref 引用值

当你希望组件“记住”某些信息,但又不想让这些信息触发新的渲染时,你可以使用 ref

jsx
const ref = useRef(0);

与 state 一样,ref 在重新渲染之间由 React 保留。但是,设置 state 会重新渲染组件,而更改 ref 不会!你可以通过 ref.current 属性访问该 ref 的当前值。

jsx
import { useRef } from 'react';

export default function Counter() {
  let ref = useRef(0);

  function handleClick() {
    ref.current = ref.current + 1;
    alert('你点击了 ' + ref.current + ' 次!');
  }

  return (
    <button onClick={handleClick}>
      点我!
    </button>
  );
}

ref 就像组件的一个不被 React 追踪的秘密口袋。例如,可以使用 ref 来存储 timeout ID、DOM 元素 和其他不影响组件渲染输出的对象。就是不受react组件生命周期影响的长期存储的变量。

提示

阅读 使用 ref 引用值 以了解如何使用 ref 来记住信息。

使用 ref 操作 DOM

由于 React 会自动更新 DOM 以匹配渲染输出,因此组件通常不需要操作 DOM。但是,有时可能需要访问由 React 管理的 DOM 元素——例如聚焦节点、滚动到此节点,以及测量它的尺寸和位置。React 没有内置的方法来执行此类操作,所以需要一个指向 DOM 节点的 ref 来实现。例如,点击按钮将使用 ref 聚焦输入框:

jsx
import { useRef } from 'react';

export default function Form() {
  const inputRef = useRef(null);

  function handleClick() {
    inputRef.current.focus();
  }

  return (
    <>
      <input ref={inputRef} />
      <button onClick={handleClick}>
        聚焦输入框
      </button>
    </>
  );
}

提示

阅读 [使用 ref 操作 DOM](#使用ref操作 DOM) 以了解如何访问 React 管理的 DOM 元素。

使用 Effect 进行同步

有些组件需要与外部系统同步。例如,可能需要根据 React 状态控制非 React 组件、设置服务器连接或在组件出现在屏幕上时发送分析日志。与处理特定事件的事件处理程序不同,Effect 在渲染后运行一些代码。使用它将组件与 React 之外的系统同步。

多按几次播放/暂停,观察视频播放器如何与 isPlaying 属性值保持同步:

jsx
import { useState, useRef, useEffect } from 'react';

function VideoPlayer({ src, isPlaying }) {
  const ref = useRef(null);

  useEffect(() => {
    if (isPlaying) {
      ref.current.play();
    } else {
      ref.current.pause();
    }
  }, [isPlaying]);

  return <video ref={ref} src={src} loop playsInline />;
}

export default function App() {
  const [isPlaying, setIsPlaying] = useState(false);
  return (
    <>
      <button onClick={() => setIsPlaying(!isPlaying)}>
        {isPlaying ? '暂停' : '播放'}
      </button>
      <VideoPlayer
        isPlaying={isPlaying}
        src="https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4"
      />
    </>
  );
}

许多 Effect 也会自行“清理”。例如,与聊天服务器建立连接的 Effect 应该返回一个 cleanup 函数,告诉 React 如何断开组件与该服务器的连接:

app.js

jsx
import { useState, useEffect } from 'react';
import { createConnection } from './chat.js';

export default function ChatRoom() {
  useEffect(() => {
    const connection = createConnection();
    connection.connect();
    return () => connection.disconnect();
  }, []);
  return <h1>欢迎前来聊天!</h1>;
}

chat.js

jsx
export function createConnection() {
  // 真正的实现实际上会连接到服务器
  return {
    connect() {
      console.log('✅ 连接中...');
    },
    disconnect() {
      console.log('❌ 断开连接。');
    }
  };
}

在开发环境中,React 将立即运行并额外清理一次 Effect。这就是为什么你会看到 "✅ 连接中..." 打印了两次。这能够确保你不会忘记实现清理功能。

useEffect就是一个监听器,监听内部变量变化,把状态同步给外部使用。

提示

阅读 使用 Effect 进行同步 以了解如何将组件与外部系统同步。

你可能不需要 Effect

Effect 是 React 范式中的一种脱围机制。它们可以“逃出” React 并使组件和一些外部系统同步。如果没有涉及到外部系统(例如,需要根据一些 props 或 state 的变化来更新一个组件的 state),不应该使用 Effect。移除不必要的 Effect 可以让代码更容易理解,运行得更快,并且更少出错。

有两种常见的不必使用 Effect 的情况:

  • 不必为了渲染而使用 Effect 来转换数据。
  • 不必使用 Effect 来处理用户事件。

例如,不需要 Effect 来根据其他状态调整某些状态:

jsx
function Form() {
  const [firstName, setFirstName] = useState('泰勒');
  const [lastName, setLastName] = useState('斯威夫特');

  // 🔴 避免:多余的 state 和不必要的 Effect
  const [fullName, setFullName] = useState('');
  useEffect(() => {
    setFullName(firstName + ' ' + lastName);
  }, [firstName, lastName]);
  // ...
}

相反,在渲染时进行尽可能多地计算:

jsx
function Form() {
  const [firstName, setFirstName] = useState('泰勒');
  const [lastName, setLastName] = useState('斯威夫特');
  // ✅ 非常好:在渲染期间进行计算
  const fullName = firstName + ' ' + lastName;
  // ...
}

的确可以使用 Effect 来和外部系统同步。

提示

阅读 你可能不需要 Effect 以了解如何移除不必要的 Effect。

响应式 Effect 的生命周期

Effect 的生命周期不同于组件。组件可以挂载、更新或卸载。Effect 只能做两件事:开始同步某些东西,然后停止同步它。如果 Effect 依赖于随时间变化的 props 和 state,这个循环可能会发生多次。

这个 Effect 依赖于 roomId props 的值。props 是 响应值,这意味着它们可以在重新渲染时改变。注意,如果 roomId 更改,Effect 将会 重新同步(并重新连接到服务器):

App.js

jsx
import { useState, useEffect } from 'react';
import { createConnection } from './chat.js';

const serverUrl = 'https://localhost:1234';

function ChatRoom({ roomId }) {
  useEffect(() => {
    const connection = createConnection(serverUrl, roomId);
    connection.connect();
    return () => connection.disconnect();
  }, [roomId]);

  return <h1>欢迎来到 {roomId} 房间!</h1>;
}

export default function App() {
  const [roomId, setRoomId] = useState('general');
  return (
    <>
      <label>
        选择聊天室:{' '}
        <select
          value={roomId}
          onChange={e => setRoomId(e.target.value)}
        >
          <option value="general">所有</option>
          <option value="travel">旅游</option>
          <option value="music">音乐</option>
        </select>
      </label>
      <hr />
      <ChatRoom roomId={roomId} />
    </>
  );
}

chat.js

jsx
export function createConnection(serverUrl, roomId) {
  // 真正的实现实际上会连接到服务器
  return {
    connect() {
      console.log('✅ 连接到 "' + roomId + '" 房间,在' + serverUrl + '...');
    },
    disconnect() {
      console.log('❌ 断开 "' + roomId + '" 房间,在' + serverUrl);
    }
  };
}

React 提供了检查工具规则来检查是否正确地指定了 Effect 的依赖项。如果忘记在上述示例的依赖项列表中指定 roomId,检查工具会自动找到该错误。

提示

阅读 响应式 Effect 的生命周期 以了解 Effect 的生命周期与组件的生命周期有何不同。

将事件从 Effect 中分开

事件处理程序仅在再次执行相同的交互时重新运行。与事件处理程序不同,如果 Effect 读取的任何值(如 props 或 state)与上次渲染期间不同,则会重新同步。有时,需要混合两种行为:Effect 重新运行以响应某些值而不是其他值。

Effect 中的所有代码都是 响应式的。如果它读取的某些响应式的值由于重新渲染而发生变化,它将再次运行。例如,如果 roomIdtheme 发生变化,这个 Effect 将重新连接到聊天:

App.js

jsx
import { useState, useEffect } from 'react';
import { createConnection, sendMessage } from './chat.js';
import { showNotification } from './notifications.js';

const serverUrl = 'https://localhost:1234';

function ChatRoom({ roomId, theme }) {
  useEffect(() => {
    const connection = createConnection(serverUrl, roomId);
    connection.on('connected', () => {
      showNotification('已连接!', theme);
    });
    connection.connect();
    return () => connection.disconnect();
  }, [roomId, theme]);

  return <h1>欢迎来到 {roomId} 房间!</h1>
}

export default function App() {
  const [roomId, setRoomId] = useState('所有');
  const [isDark, setIsDark] = useState(false);
  return (
    <>
      <label>
        选择聊天室:{' '}
        <select
          value={roomId}
          onChange={e => setRoomId(e.target.value)}
        >
          <option value="general">所有</option>
          <option value="travel">旅游</option>
          <option value="music">音乐</option>
        </select>
      </label>
      <label>
        <input
          type="checkbox"
          checked={isDark}
          onChange={e => setIsDark(e.target.checked)}
        />
        使用深色主题
      </label>
      <hr />
      <ChatRoom
        roomId={roomId}
        theme={isDark ? 'dark' : 'light'}
      />
    </>
  );
}

chat.js

jsx
export function createConnection(serverUrl, roomId) {
  // 真正的实现实际上会连接到服务器
  let connectedCallback;
  let timeout;
  return {
    connect() {
      timeout = setTimeout(() => {
        if (connectedCallback) {
          connectedCallback();
        }
      }, 100);
    },
    on(event, callback) {
      if (connectedCallback) {
        throw Error('不能添加处理程序两次。');
      }
      if (event !== 'connected') {
        throw Error('仅支持“已连接”事件。');
      }
      connectedCallback = callback;
    },
    disconnect() {
      clearTimeout(timeout);
    }
  };
}

notifications.js

jsx
import Toastify from 'toastify-js';
import 'toastify-js/src/toastify.css';

export function showNotification(message, theme) {
  Toastify({
    text: message,
    duration: 2000,
    gravity: 'top',
    position: 'right',
    style: {
      background: theme === 'dark' ? 'black' : 'white',
      color: theme === 'dark' ? 'white' : 'black',
    },
  }).showToast();
}

这并不理想。因为仅当 roomId 已更改时,才想重新连接到聊天,所以切换 theme 不应该重新连接到聊天!考虑将读取 theme 的代码从 Effect 移到 Effect Event 中:

App.js

jsx
import { useState, useEffect } from 'react';
import { useEffectEvent } from 'react';
import { createConnection, sendMessage } from './chat.js';
import { showNotification } from './notifications.js';

const serverUrl = 'https://localhost:1234';

function ChatRoom({ roomId, theme }) {
  const onConnected = useEffectEvent(() => {
    showNotification('已连接!', theme);
  });

  useEffect(() => {
    const connection = createConnection(serverUrl, roomId);
    connection.on('connected', () => {
      onConnected();
    });
    connection.connect();
    return () => connection.disconnect();
  }, [roomId]);

  return <h1>欢迎来到 {roomId} 房间!</h1>
}

export default function App() {
  const [roomId, setRoomId] = useState('所有');
  const [isDark, setIsDark] = useState(false);
  return (
    <>
      <label>
        选择聊天室:{' '}
        <select
          value={roomId}
          onChange={e => setRoomId(e.target.value)}
        >
          <option value="general">所有</option>
          <option value="travel">旅游</option>
          <option value="music">音乐</option>
        </select>
      </label>
      <label>
        <input
          type="checkbox"
          checked={isDark}
          onChange={e => setIsDark(e.target.checked)}
        />
        使用深色主题
      </label>
      <hr />
      <ChatRoom
        roomId={roomId}
        theme={isDark ? 'dark' : 'light'}
      />
    </>
  );
}

chat.js

jsx
export function createConnection(serverUrl, roomId) {
  // 真正的实现实际上会连接到服务器
  let connectedCallback;
  let timeout;
  return {
    connect() {
      timeout = setTimeout(() => {
        if (connectedCallback) {
          connectedCallback();
        }
      }, 100);
    },
    on(event, callback) {
      if (connectedCallback) {
        throw Error('不能添加处理程序两次。');
      }
      if (event !== 'connected') {
        throw Error('仅支持“已连接”事件。');
      }
      connectedCallback = callback;
    },
    disconnect() {
      clearTimeout(timeout);
    }
  };
}

Effect Events 中的代码不是响应式的,因此更改“主题”不再使 Effect 重新连接。

提示

阅读 将事件从 Effect 中分开,了解如何防止某些值重新触发 Effect。

移除 Effect 依赖

当你写 Effect 时,代码检查器会验证是否已经将 Effect 读取的每一个响应式值(如 props 和 state)包含在 Effect 的依赖列表中。这可以确保 Effect 与组件的 props 和 state 保持同步。不必要的依赖关系可能会导致 Effect 运行过于频繁,甚至产生无限循环。删除它们的方式取决于具体情况。

例如,这个 Effect 依赖于每次编辑输入时都会重新创建的 options 对象:

App.js

jsx
import { useState, useEffect } from 'react';
import { createConnection } from './chat.js';

const serverUrl = 'https://localhost:1234';

function ChatRoom({ roomId }) {
  const [message, setMessage] = useState('');

  const options = {
    serverUrl: serverUrl,
    roomId: roomId
  };

  useEffect(() => {
    const connection = createConnection(options);
    connection.connect();
    return () => connection.disconnect();
  }, [options]);

  return (
    <>
      <h1>欢迎来到 {roomId} 房间!</h1>
      <input value={message} onChange={e => setMessage(e.target.value)} />
    </>
  );
}

export default function App() {
  const [roomId, setRoomId] = useState('所有');
  return (
    <>
      <label>
        选择聊天室:{' '}
        <select
          value={roomId}
          onChange={e => setRoomId(e.target.value)}
        >
          <option value="general">general</option>
          <option value="travel">travel</option>
          <option value="music">music</option>
        </select>
      </label>
      <hr />
      <ChatRoom roomId={roomId} />
    </>
  );
}

chat.js

jsx
export function createConnection({ serverUrl, roomId }) {
  // 真正的实现实际上会连接到服务器
  return {
    connect() {
      console.log('✅ 连接到 "' + roomId + '" 房间,在' + serverUrl + '...');
    },
    disconnect() {
      console.log('❌ 断开 "' + roomId + '" 房间,在' + serverUrl);
    }
  };
}

你不希望每次开始在聊天中输入消息时聊天都重新连接。要解决这个问题,你应该在 Effect 中创建 options 对象,使得 Effect 仅依赖于 roomId 字符串:

App.js

jsx
import { useState, useEffect } from 'react';
import { createConnection } from './chat.js';

const serverUrl = 'https://localhost:1234';

function ChatRoom({ roomId }) {
  const [message, setMessage] = useState('');

  useEffect(() => {
    const options = {
      serverUrl: serverUrl,
      roomId: roomId
    };
    const connection = createConnection(options);
    connection.connect();
    return () => connection.disconnect();
  }, [roomId]);

  return (
    <>
      <h1>欢迎来到 {roomId} 房间!</h1>
      <input value={message} onChange={e => setMessage(e.target.value)} />
    </>
  );
}

export default function App() {
  const [roomId, setRoomId] = useState('所有');
  return (
    <>
      <label>
        选择聊天室:{' '}
        <select
          value={roomId}
          onChange={e => setRoomId(e.target.value)}
        >
          <option value="general">所有</option>
          <option value="travel">旅游</option>
          <option value="music">音乐</option>
        </select>
      </label>
      <hr />
      <ChatRoom roomId={roomId} />
    </>
  );
}

请注意,你并没有通过编辑依赖项列表来删除 options 依赖项,那是错误的。相反,你更改了周围的代码,使依赖关系变得 不必要。将依赖关系列表视为 Effect 代码使用的所有响应值的列表。不必刻意选择把什么放在该列表中。该列表描述了你的代码。要改变依赖性列表,请改变代码。

提示

阅读 移除 Effect 依赖 以了解如何减少 Effect 重新运行的频率。

使用自定义 Hook 复用逻辑

React 有一些内置 Hook,例如 useStateuseContextuseEffect。有时需要用途更特殊的 Hook:例如获取数据,记录用户是否在线或者连接聊天室。为了实现效果,可以根据应用需求创建自己的 Hook。

这个示例中,自定义 Hook usePointerPosition 追踪当前指针位置,而自定义 Hook useDelayedValue 返回一个“滞后”传递的值一定毫秒数的值。将光标移到沙盒预览区域上以查看跟随光标移动的点轨迹:

App.js

jsx
import { usePointerPosition } from './usePointerPosition.js';
import { useDelayedValue } from './useDelayedValue.js';

export default function Canvas() {
  const pos1 = usePointerPosition();
  const pos2 = useDelayedValue(pos1, 100);
  const pos3 = useDelayedValue(pos2, 200);
  const pos4 = useDelayedValue(pos3, 100);
  const pos5 = useDelayedValue(pos4, 50);
  return (
    <>
      <Dot position={pos1} opacity={1} />
      <Dot position={pos2} opacity={0.8} />
      <Dot position={pos3} opacity={0.6} />
      <Dot position={pos4} opacity={0.4} />
      <Dot position={pos5} opacity={0.2} />
    </>
  );
}

function Dot({ position, opacity }) {
  return (
    <div style={{
      position: 'absolute',
      backgroundColor: 'pink',
      borderRadius: '50%',
      opacity,
      transform: `translate(${position.x}px, ${position.y}px)`,
      pointerEvents: 'none',
      left: -20,
      top: -20,
      width: 40,
      height: 40,
    }} />
  );
}

usePointerPosition.js

jsx
import { useState, useEffect } from 'react';

export function usePointerPosition() {
  const [position, setPosition] = useState({ x: 0, y: 0 });
  useEffect(() => {
    function handleMove(e) {
      setPosition({ x: e.clientX, y: e.clientY });
    }
    window.addEventListener('pointermove', handleMove);
    return () => window.removeEventListener('pointermove', handleMove);
  }, []);
  return position;
}

useDelayedValue.js

jsx
import { useState, useEffect } from 'react';

export function useDelayedValue(value, delay) {
  const [delayedValue, setDelayedValue] = useState(value);

  useEffect(() => {
    setTimeout(() => {
      setDelayedValue(value);
    }, delay);
  }, [value, delay]);

  return delayedValue;
}

你可以创建自定义 Hooks,将它们组合在一起,在它们之间传递数据,并在组件之间重用它们。随着应用不断变大,你将减少手动编写的 Effect,因为你将能够重用已经编写的自定义 Hooks。React 社区也维护了许多优秀的自定义 Hooks。

提示

阅读 使用自定义 Hook 复用逻辑 以了解如何在组件之间共享逻辑。

使用ref引用值

当你希望组件“记住”某些信息,但又不想让这些信息 触发新的渲染 时,你可以使用 ref

给你的组件添加 ref

你可以通过从 React 导入 useRef Hook 来为你的组件添加一个 ref:

jsx
import { useRef } from 'react';

在你的组件内,调用 useRef Hook 并传入你想要引用的初始值作为唯一参数。例如,这里的 ref 引用的值是“0”:

jsx
const ref = useRef(0);

useRef 返回一个这样的对象:

jsx
{
  current: 0 // 你向 useRef 传入的值
}

你可以用 ref.current 属性访问该 ref 的当前值。这个值是有意被设置为可变的,意味着你既可以读取它也可以写入它。就像一个 React 追踪不到的、用来存储组件信息的秘密“口袋”。

jsx
import { useRef } from 'react';

export default function Counter() {
  let ref = useRef(0);

  function handleClick() {
    ref.current = ref.current + 1;
    alert('你点击了 ' + ref.current + ' 次!');
  }

  return (
    <button onClick={handleClick}>
      点击我!
    </button>
  );
}

这里的 ref 指向一个数字,但是,像 state 一样,你可以让它指向任何东西:字符串、对象,甚至是函数。与 state 不同的是,ref 是一个普通的 JavaScript 对象,具有可以被读取和修改的 current 属性。

请注意,组件不会在每次递增时重新渲染。 与 state 一样,React 会在每次重新渲染之间保留 ref。但是,设置 state 会重新渲染组件,更改 ref 不会!

ref 和 state 的不同之处

refstate
useRef(initialValue)返回 { current: initialValue }useState(initialValue) 返回 state 变量的当前值和一个 state 设置函数 ( [value, setValue])
更改时不会触发重新渲染更改时触发重新渲染。
可变 —— 你可以在渲染过程之外修改和更新 current 的值。“不可变” —— 你必须使用 state 设置函数来修改 state 变量,从而排队重新渲染。
你不应在渲染期间读取(或写入) current 值。你可以随时读取 state。但是,每次渲染都有自己不变的 state 快照。

遵循这些原则将使你的组件更具可预测性:

  • 将 ref 视为脱围机制。当你使用外部系统或浏览器 API 时,ref 很有用。如果你很大一部分应用程序逻辑和数据流都依赖于 ref,你可能需要重新考虑你的方法。
  • 不要在渲染过程中读取或写入 ref.current 如果渲染过程中需要某些信息,请使用 state 代替。由于 React 不知道 ref.current 何时发生变化,即使在渲染时读取它也会使组件的行为难以预测。(唯一的例外是像 if (!ref.current) ref.current = new Thing() 这样的代码,它只在第一次渲染期间设置一次 ref。)

React state 的限制不适用于 ref。例如,state 就像每次渲染的快照,并且不会同步更新。但是当你改变 ref 的 current 值时,它会立即改变:

jsx
ref.current = 5;
console.log(ref.current); // 5

这是因为 ref 本身是一个普通的 JavaScript 对象, 所以它的行为就像对象那样。

当你使用 ref 时,也无需担心避免变更。只要你改变的对象不用于渲染,React 就不会关心你对 ref 或其内容做了什么

ref 和 DOM

你可以将 ref 指向任何值。但是,ref 最常见的用法是访问 DOM 元素。例如,如果你想以编程方式聚焦一个输入框,这种用法就会派上用场。当你将 ref 传递给 JSX 中的 ref 属性时,比如 <div ref={myRef}>,React 会将相应的 DOM 元素放入 myRef.current 中。当元素从 DOM 中删除时,React 会将 myRef.current 更新为 null

使用ref操作 DOM

由于 React 会自动处理更新 DOM 以匹配你的渲染输出,因此你在组件中通常不需要操作 DOM。但是,有时你可能需要访问由 React 管理的 DOM 元素 —— 例如,让一个节点获得焦点、滚动到它或测量它的尺寸和位置。在 React 中没有内置的方法来做这些事情,所以你需要一个指向 DOM 节点的 ref 来实现。

获取指向节点的 ref

要访问由 React 管理的 DOM 节点,首先,引入 useRef Hook:

jsx
import { useRef } from 'react';

然后,在你的组件中使用它声明一个 ref:

jsx
const myRef = useRef(null);

最后,将 ref 作为 ref 属性值传递给想要获取的 DOM 节点的 JSX 标签:

jsx
<div ref={myRef}>

useRef Hook 返回一个对象,该对象有一个名为 current 的属性。最初,myRef.currentnull。当 React 为这个 <div> 创建一个 DOM 节点时,React 会把对该节点的引用放入 myRef.current。然后,你可以从 事件处理器 访问此 DOM 节点,并使用在其上定义的内置浏览器 API。

jsx
// 你可以使用任意浏览器 API,例如:
myRef.current.scrollIntoView();

在本例中,单击按钮将使输入框获得焦点:

jsx
import { useRef } from 'react';

export default function Form() {
  const inputRef = useRef(null);

  function handleClick() {
    inputRef.current.focus();
  }

  return (
    <>
      <input ref={inputRef} />
      <button onClick={handleClick}>
        聚焦输入框
      </button>
    </>
  );
}

提示

如何使用 ref 回调管理 ref 列表

在上面的例子中,ref 的数量是预先确定的。但有时候,你可能需要为列表中的每一项都绑定 ref ,而你又不知道会有多少项。像下面这样做是行不通的

jsx
<ul>
  {items.map((item) => {
    // 行不通!
    const ref = useRef(null);
    return <li ref={ref} />;
  })}
</ul>

这是因为 Hook 只能在组件的顶层被调用。不能在循环语句、条件语句或 map() 函数中调用 useRef

一种可能的解决方案是用一个 ref 引用其父元素,然后用 DOM 操作方法如 querySelectorAll 来寻找它的子节点。然而,这种方法很脆弱,如果 DOM 结构发生变化,可能会失效或报错。

另一种解决方案是将函数传递给 ref 属性。这称为 ref 回调。当需要设置 ref 时,React 将传入 DOM 节点来调用你的 ref 回调,并在需要清除它时传入 null 。这使你可以维护自己的数组或 Map,并通过其索引或某种类型的 ID 访问任何 ref。

就是可以传递函数给ref,react会将DOM节点作为参数传给你,如果是清理的时候就传入的是null

jsx
<div ref={(node) => {
  // 挂载时:node 是真实 DOM 元素
  // 卸载时:node 是 null
}} />

具体针对列表的情况:

jsx
const itemRefs = useRef(new Map());

<ul>
  {items.map(item => (
    <li
      key={item.id}
      ref={(node) => {
        if (node) {
          itemRefs.current.set(item.id, node);  // 挂载,存入
        } else {
          itemRefs.current.delete(item.id);      // 卸载,清除
        }
      }}
    >
      {item.text}
    </li>
  ))}
</ul>

React 18 及之前

ref 回调的返回值会被忽略,清理靠的是”传入 null”:

jsx
ref={(node) => {
  if (node) {
    map.set(cat, node);  // 挂载
  } else {
    map.delete(cat);     // 卸载,node 是 null
  }
}}

React 19 新增

ref 回调可以返回一个清理函数,React 在卸载时自动调用它:

jsx
ref={(node) => {
  map.set(cat, node);   // 挂载逻辑
  return () => {
    map.delete(cat);    // 清理逻辑,React 19 调用
  };
}}

效果和上面完全等价,只是写法更清晰——挂载和清理分开,不用再判断 node 是否为 null

jsx
ref={(node) => {
  if (node) {
    map.set(cat, node);  // 挂载
  } else {
    map.delete(cat);     // 卸载,React 18 传入 null
  }
  // 不写 return,React 19 下 null 判断依然有效
}}

这个写法在 React 18 和 19 下都能正确运行,React 19 只是多提供了一种更优雅的写法,旧写法没有被废弃。

此示例展示了如何使用此方法滚动到长列表中的任意节点:

jsx
...

在这个例子中,itemsRef 保存的不是单个 DOM 节点,而是保存了包含列表项 ID 和 DOM 节点的 Map。(Ref 可以保存任何值!) 每个列表项上的 ref 回调负责更新 Map:

jsx
<li
  key={cat.id}
  ref={node => {
    const map = getMap();
    // 添加到 Map 中
    map.set(cat, node);

    return () => {
      // 从 Map 中移除
      map.delete(cat);
    };
  }}
>

这使你可以之后从 Map 读取单个 DOM 节点。

访问另一个组件的 DOM 节点

警告

Ref 是一个脱围机制。手动操作 其它 组件的 DOM 节点可能会让代码变得脆弱。

你可以像其它 prop一样 将 ref 从父组件传递给子组件。

jsx
import { useRef } from 'react';

function MyInput({ ref }) {
  return <input ref={ref} />;
}

function MyForm() {
  const inputRef = useRef(null);
  return <MyInput ref={inputRef} />
}

在上面这个例子中,父组件 MyForm 创建了一个名为 inputRef 的 ref,并且将它传递给了 MyInput 子组件。MyInput 将这个 ref 传递给 <input>。因为 <input> 是一个内置组件,React 会将 ref 的 .current 属性设置为这个 <input> DOM 元素。

MyForm 中创建的 inputRef 现在指向 MyInput 返回的 <input> DOM 元素。在 MyForm 中创建的点击处理程序可以访问 inputRef 并且调用 focus() 来将焦点设置在 <input> 上。

jsx
import { useRef } from 'react';

function MyInput({ ref }) {
  return <input ref={ref} />;
}

export default function MyForm() {
  const inputRef = useRef(null);

  function handleClick() {
    inputRef.current.focus();
  }

  return (
    <>
      <MyInput ref={inputRef} />
      <button onClick={handleClick}>
        聚焦输入框
      </button>
    </>
  );
}

重要

使用命令句柄暴露一部分 API

在上面的例子中,MyInput 暴露了原始的 DOM 元素 input。这让父组件可以对其调用focus()。然而,这也让父组件能够做其他事情 —— 例如,改变其 CSS 样式。在一些不常见的情况下,你可能希望限制暴露的功能。你可以用 useImperativeHandle 来做到这一点:

jsx
import { useRef, useImperativeHandle } from "react";

function MyInput({ ref }) {
  const realInputRef = useRef(null);
  useImperativeHandle(ref, () => ({
    // 只暴露 focus,没有别的
    focus() {
      realInputRef.current.focus();
    },
  }));
  return <input ref={realInputRef} />;
};

export default function Form() {
  const inputRef = useRef(null);

  function handleClick() {
    inputRef.current.focus();
  }

  return (
    <>
      <MyInput ref={inputRef} />
      <button onClick={handleClick}>聚焦输入框</button>
    </>
  );
}

这里,MyInput 中的 realInputRef 保存了实际的 input DOM 节点。 但是,useImperativeHandle 指示 React 将你自己指定的对象作为父组件的 ref 值。 所以 Form 组件内的 inputRef.current 将只有 focus 方法。在这种情况下,ref “句柄”不是 DOM 节点,而是你在 useImperativeHandle 调用中创建的自定义对象。

React 何时添加 refs

在 React 中,每次更新都分为两个阶段:

  • 渲染 阶段, React 调用你的组件来确定屏幕上应该显示什么。
  • 提交 阶段, React 把变更应用于 DOM。

通常,你不希望在渲染期间访问 refs。这也适用于保存 DOM 节点的 refs。在第一次渲染期间,DOM 节点尚未创建,因此 ref.current 将为 null。在渲染更新的过程中,DOM 节点还没有更新。所以读取它们还为时过早。

React 在提交阶段设置 ref.current。在更新 DOM 之前,React 将受影响的 ref.current 值设置为 null。更新 DOM 后,React 立即将它们设置到相应的 DOM 节点。

通常,你将从事件处理器访问 refs。 如果你想使用 ref 执行某些操作,但没有特定的事件可以执行此操作,你可能需要一个 effect。我们将在下一页讨论 effect。

事件处理器和 effect 都在提交阶段之后执行,这能保证使用ref的时机正确

重要

用 flushSync 同步更新 state

思考这样的代码,它添加一个新的待办事项,并将屏幕向下滚动到列表的最后一个子项。请注意,出于某种原因,它总是滚动到最后一个添加 之前 的待办事项:

jsx
import { useState, useRef } from 'react';

export default function TodoList() {
  const listRef = useRef(null);
  const [text, setText] = useState('');
  const [todos, setTodos] = useState(
    initialTodos
  );

  function handleAdd() {
    const newTodo = { id: nextId++, text: text };
    setText('');
    setTodos([ ...todos, newTodo]);
    listRef.current.lastChild.scrollIntoView({
      behavior: 'smooth',
      block: 'nearest'
    });
  }

  return (
    <>
      <button onClick={handleAdd}>
        添加
      </button>
      <input
        value={text}
        onChange={e => setText(e.target.value)}
      />
      <ul ref={listRef}>
        {todos.map(todo => (
          <li key={todo.id}>{todo.text}</li>
        ))}
      </ul>
    </>
  );
}

let nextId = 0;
let initialTodos = [];
for (let i = 0; i < 20; i++) {
  initialTodos.push({
    id: nextId++,
    text: '待办 #' + (i + 1)
  });
}

问题出在这两行:

jsx
setTodos([ ...todos, newTodo]);
listRef.current.lastChild.scrollIntoView();

在 React 中,state 更新是排队进行的。在这个示例中会导致问题,因为 setTodos 不会立即更新 DOM。因此,当你将列表滚动到最后一个元素时,尚未添加待办事项。这就是滚动总是“落后”一项的原因。

要解决此问题,你可以强制 React 同步更新(“刷新”)DOM。 为此,从 react-dom 导入 flushSync将 state 更新包裹flushSync 调用中:

jsx
flushSync(() => {
  setTodos([ ...todos, newTodo]);
});
listRef.current.lastChild.scrollIntoView();

这将指示 React 当封装在 flushSync 中的代码执行后,立即同步更新 DOM。因此,当你尝试滚动到最后一个待办事项时,它已经在 DOM 中了:

jsx
import { useState, useRef } from 'react';
import { flushSync } from 'react-dom';
  function handleAdd() {
    const newTodo = { id: nextId++, text: text };
    flushSync(() => {
      setText('');
      setTodos([ ...todos, newTodo]);
    });
    listRef.current.lastChild.scrollIntoView({
      behavior: 'smooth',
      block: 'nearest'
    });
  }

使用 refs 操作 DOM 的最佳实践

Refs 是一种脱围机制。你应该只在你必须“跳出 React”时使用它们。这方面的常见示例包括管理焦点、滚动位置或调用 React 未暴露的浏览器 API。

如果你坚持聚焦和滚动等非破坏性操作,应该不会遇到任何问题。但是,如果你尝试手动修改 DOM,则可能会与 React 所做的更改发生冲突。

为了说明这个问题,这个例子包括一条欢迎消息和两个按钮。第一个按钮使用 条件渲染 和 state 切换它的显示和隐藏,就像你通常在 React 中所做的那样。第二个按钮使用 remove() DOM API 将其从 React 控制之外的 DOM 中强行移除.

尝试按几次“通过 setState 切换”。该消息会消失并再次出现。然后按 “从 DOM 中删除”。这将强行删除它。最后,按 “通过 setState 切换”:

jsx
import { useState, useRef } from 'react';

export default function Counter() {
  const [show, setShow] = useState(true);
  const ref = useRef(null);

  return (
    <div>
      <button
        onClick={() => {
          setShow(!show);
        }}>
        通过 setState 切换
      </button>
      <button
        onClick={() => {
          ref.current.remove();
        }}>
        从 DOM 中删除
      </button>
      {show && <p ref={ref}>Hello world</p>}
    </div>
  );
}

在你手动删除 DOM 元素后,尝试使用 setState 再次显示它会导致崩溃。这是因为你更改了 DOM,而 React 不知道如何继续正确管理它。

避免更改由 React 管理的 DOM 节点。 对 React 管理的元素进行修改、添加子元素、从中删除子元素会导致不一致的视觉结果,或与上述类似的崩溃。

但是,这并不意味着你完全不能这样做。它需要谨慎。 你可以安全地修改 React 没有理由更新的部分 DOM。 例如,如果某些 <div> 在 JSX 中始终为空,React 将没有理由去变动其子列表。 因此,在那里手动增删元素是安全的。

使用Effect进行同步

有些组件需要与外部系统同步。例如,你可能希望根据 React state 控制非 React 组件、建立服务器连接或当组件在页面显示时发送分析日志。Effect 允许你在渲染结束后执行一些代码,以便将组件与 React 外部的某个系统相同步。

什么是 Effect,它与事件(event)有何不同?

在接触 Effect 之前,你需要熟悉 React 组件中的两种逻辑类型:

  • 渲染代码位于组件的顶层。你在这里处理 props 和 state,对它们进行转换,并返回希望在页面上显示的 JSX。渲染代码必须是纯粹的。
  • 事件处理程序是组件内部的嵌套函数,它们不光进行计算, 还会执行一些操作。事件处理程序可能会更新输入字段、提交 HTTP POST 请求来购买产品,或者将用户导航到另一个页面。事件处理程序包含由特定用户操作引起的“副作用”(它们改变了程序的状态)。

有时这还不够。考虑一个 ChatRoom 组件,它在页面上显示时必须连接到聊天服务器。连接到服务器并不是纯粹的计算(它是一个副作用),因此它不能在渲染期间发生。然而,并没有一个特定的事件(比如点击)能让 ChatRoom 被显示。

Effect 允许你指定由渲染自身引起的副作用(而不需要触发事件)。建立服务器连接是一个 Effect,因为无论哪种交互致使组件出现,它都应该发生。Effect 在提交结束后、页面更新后运行。此时是将 React 组件与外部系统(如网络或第三方库)同步的最佳时机。

你可能不需要 Effect

不要急着在你的组件中使用 Effect。记住,Effect 通常用于暂时“跳出” React 并与一些 外部 系统进行同步。这包括浏览器 API、第三方小部件,以及网络等等。如果你的 Effect 只是根据其他状态来调整某些状态,那么你可能并不需要一个 Effect。

如何编写 Effect

要编写一个 Effect, 请遵循以下三个步骤:

  1. 声明 Effect。通常 Effect 会在每次 提交 后运行。
  2. 指定 Effect 依赖。大多数 Effect 应该按需运行,而不是在每次渲染后都运行。例如,淡入动画应该只在组件出现时触发。连接和断开服务器的操作只应在组件出现和消失时,或者切换聊天室时执行。你将通过指定 依赖项 来学习如何控制这一点。
  3. 必要时添加清理操作。一些 Effect 需要指定如何停止、撤销,或者清除它们所执行的操作。例如,“连接”需要“断开”,“订阅”需要“退订”,而“获取数据”需要“取消”或者“忽略”。你将学习如何通过返回一个 清理函数 来实现这些。

让我们详细看看每一步。

第一步:声明 Effect

先从 React 中导入 useEffect Hook:

jsx
import { useEffect } from 'react';

再在组件顶部调用, 并在其中加入一些代码:

jsx
function MyComponent() {
  useEffect(() => {
    // 每次渲染后都会执行此处的代码
  });
  return <div />;
}

每当你的组件渲染时,React 会先更新页面,然后再运行 useEffect 中的代码。换句话说,useEffect 会“延迟”一段代码的运行,直到渲染结果反映在页面上

接下来,让我们看看如何使用 Effect 来与外部系统同步。考虑一个 <VideoPlayer> React 组件。我们想要通过传递一个 isPlaying prop 来控制它播放或者暂停:

jsx
<VideoPlayer isPlaying={isPlaying} />;

这个 VideoPlayer 组件渲染了浏览器内置的 video 标签:

jsx
function VideoPlayer({ src, isPlaying }) {
  // TODO:使用 isPlaying 做一些事情
  return <video src={src} />;
}

但是,浏览器的 <video> 标签没有 isPlaying 属性。控制它的唯一方式是在 DOM 元素上调用 play()pause() 方法。因此,你需要将 isPlaying prop 的值(表示视频当前是否应该播放)与 play()pause() 等函数的调用进行同步

我们首先需要获取 <video> DOM 节点的对象引用。

你可能会尝试在渲染期间调用 play()pause(),但这样做是不对的:

jsx
import { useState, useRef, useEffect } from 'react';

function VideoPlayer({ src, isPlaying }) {
  const ref = useRef(null);

  if (isPlaying) {
    ref.current.play();  // 渲染期间不能调用 `play()`。渲染期间ref是null
  } else {
    ref.current.pause(); // 同样,调用 `pause()` 也不行。
  }

  return <video ref={ref} src={src} loop playsInline />;
}

export default function App() {
  const [isPlaying, setIsPlaying] = useState(false);
  return (
    <>
      <button onClick={() => setIsPlaying(!isPlaying)}>
        {isPlaying ? '暂停' : '播放'}
      </button>
      <VideoPlayer
        isPlaying={isPlaying}
        src="https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4"
      />
    </>
  );
}

这段代码之所以不对,是因为它试图在渲染期间对 DOM 节点进行操作。在 React 中,渲染应该是纯粹的计算 JSX,不应该包含任何像修改 DOM 这样的副作用。

而且,当第一次调用 VideoPlayer 时,对应的 DOM 节点还不存在!因为 React 在你返回 JSX 之前不知道要创建什么样的 DOM,所以没有 DOM 节点可以调用 play()pause() 方法。

解决办法是 使用 useEffect 包裹副作用,把它分离到渲染逻辑的计算过程之外

jsx
import { useEffect, useRef } from 'react';

function VideoPlayer({ src, isPlaying }) {
  const ref = useRef(null);

  useEffect(() => {
    if (isPlaying) {
      ref.current.play();
    } else {
      ref.current.pause();
    }
  });

  return <video ref={ref} src={src} loop playsInline />;
}

通过将 DOM 更新封装在 Effect 中,你可以让 React 先更新页面,然后再运行 Effect。

VideoPlayer 组件渲染时(无论是否为首次渲染),会发生以下几件事:首先 React 会更新页面,确保 <video> 标签带着正确的 props 出现在 DOM 中;接着 React 将运行 Effect;最后 Effect 将根据 isPlaying 的值调用 play()pause()

试试点击几次播放和暂停按钮,观察视频播放器的行为是如何与 isPlaying 的值相同步的:

jsx
import { useState, useRef, useEffect } from 'react';

function VideoPlayer({ src, isPlaying }) {
  const ref = useRef(null);

  useEffect(() => {
    if (isPlaying) {
      ref.current.play();
    } else {
      ref.current.pause();
    }
  });

  return <video ref={ref} src={src} loop playsInline />;
}

export default function App() {
  const [isPlaying, setIsPlaying] = useState(false);
  return (
    <>
      <button onClick={() => setIsPlaying(!isPlaying)}>
        {isPlaying ? '暂停' : '播放'}
      </button>
      <VideoPlayer
        isPlaying={isPlaying}
        src="https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4"
      />
    </>
  );
}

在这个示例中,你同步到 React state 的“外部系统”是浏览器媒体 API。你也可以使用类似的方法将传统的非 React 代码(如 jQuery 插件)封装成声明式的 React 组件。

需要注意的是,控制视频播放器在实际应用中要复杂得多:比如调用 play() 可能会失败、用户可能会使用内置的浏览器控件来进行播放或暂停等操作。本例子是一个非常简化且不完整的示例。

警告

默认情况下,Effect 会在 每次 渲染后运行。正因如此,以下代码会陷入死循环

jsx
const [count, setCount] = useState(0);
useEffect(() => {
  setCount(count + 1);
});

Effect 在渲染结束后运行。更新 state 会触发重新渲染。在 Effect 中直接更新 state 就像是把电源插座的插头插回自身:Effect 运行、更新 state、触发重新渲染、于是又触发 Effect 运行、再次更新 state,继而再次触发重新渲染。如此反复,从而陷入死循环。

Effect 应该用于将你的组件与一个 外部 的系统保持同步。如果没有外部系统,你只是想根据其他状态调整一些状态,那么你也许不需要 Effect。

第二步:指定 Effect 的依赖项

默认情况下,Effect 会在 每次 渲染后运行。但往往 这并不是你想要的

  • 有时,它可能会很慢。与外部系统的同步并不总是即时的,所以你可能希望在不必要时跳过它。例如,你不会想在每次打字时都得重新连接聊天服务器。
  • 有时,它可能会出错。例如,你不会想在每次按键时都触发组件的淡入动画。动画应该只在组件首次出现时播放。

为了演示这个问题,以下是在之前的示例中加入了一些 console.log 调用和一个更新父组件 state 的文本输入框。注意在输入时是如何触发 Effect 重新运行的:

jsx
import { useState, useRef, useEffect } from 'react';

function VideoPlayer({ src, isPlaying }) {
  const ref = useRef(null);

  useEffect(() => {
    if (isPlaying) {
      console.log('调用 video.play()');
      ref.current.play();
    } else {
      console.log('调用 video.pause()');
      ref.current.pause();
    }
  });

  return <video ref={ref} src={src} loop playsInline />;
}

export default function App() {
  const [isPlaying, setIsPlaying] = useState(false);
  const [text, setText] = useState('');
  return (
    <>
      <input value={text} onChange={e => setText(e.target.value)} />
      <button onClick={() => setIsPlaying(!isPlaying)}>
        {isPlaying ? '暂停' : '播放'}
      </button>
      <VideoPlayer
        isPlaying={isPlaying}
        src="https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4"
      />
    </>
  );
}

结果:每次重新渲染的时候控制台都会输出“调用 video.pause()”

通过在调用 useEffect 时指定一个 依赖数组 作为第二个参数,你可以让 React 跳过不必要地重新运行 Effect。首先,在上面示例的第 14 行中传入一个空数组 []

jsx
  useEffect(() => {
    // ...
  }, []);

你会看到一个错误提示:React Hook useEffect has a missing dependency: 'isPlaying'

jsx
import { useState, useRef, useEffect } from 'react';

function VideoPlayer({ src, isPlaying }) {
  const ref = useRef(null);

  useEffect(() => {
    if (isPlaying) {
      console.log('调用 video.play()');
      ref.current.play();
    } else {
      console.log('调用 video.pause()');
      ref.current.pause();
    }
  }, []); // 这将产生错误

  return <video ref={ref} src={src} loop playsInline />;
}

原因在于,你的 Effect 内部代码依赖于 isPlaying prop 来决定该做什么,但你并没有显式声明这个依赖关系。为了解决这个问题,将 isPlaying 添加至依赖数组中:

jsx
 useEffect(() => {
    if (isPlaying) { // isPlaying 在此处使用……
      // ...
    } else {
      // ...
    }
  }, [isPlaying]); // ……所以它必须在此处声明!

现在所有的依赖都已经声明,所以没有错误了。指定 [isPlaying] 作为依赖数组会告诉 React:如果 isPlaying 与上次渲染时相同,就跳过重新运行 Effect。这样一来,输入框的输入不会触发 Effect 重新运行,只有按下播放/暂停按钮会触发。

jsx
import { useState, useRef, useEffect } from 'react';

function VideoPlayer({ src, isPlaying }) {
  const ref = useRef(null);

  useEffect(() => {
    if (isPlaying) {
      console.log('调用 video.play()');
      ref.current.play();
    } else {
      console.log('调用 video.pause()');
      ref.current.pause();
    }
  }, [isPlaying]);

  return <video ref={ref} src={src} loop playsInline />;
}

依赖数组可以包含多个依赖项。只有当你指定的 所有 依赖项的值都与上一次渲染时完全相同,React 才会跳过重新运行该 Effect。React 使用 Object.is 来比较依赖项的值。

请注意,你不能随意“选择”依赖项。如果你指定的依赖项与 React 根据 Effect 内部代码所推断出的依赖项不匹配,你将收到来自 linter 的错误提示。这有助于捕捉代码中的许多 bug。如果你不希望某些代码重新运行,那么你应当 修改 Effect 代码本身,使其不再“需要”该依赖项。

警告

没有依赖数组和使用空数组 [] 作为依赖数组,行为是不同的:

jsx
useEffect(() => {
  // 这里的代码会在每次渲染后运行
});

useEffect(() => {
  // 这里的代码只会在组件挂载(首次出现)时运行
}, []);

useEffect(() => {
  // 这里的代码不但会在组件挂载时运行,而且当 a 或 b 的值自上次渲染后发生变化后也会运行
}, [a, b]);

我们会在下一步详细了解什么是 挂载(mount)

提示

为什么依赖数组中可以省略 ref?

下面的 Effect 同时使用了 refisPlaying prop,但是只有 isPlaying 被声明为依赖项:

jsx
function VideoPlayer({ src, isPlaying }) {
  const ref = useRef(null);
  useEffect(() => {
    if (isPlaying) {
      ref.current.play();
    } else {
      ref.current.pause();
    }
  }, [isPlaying]);

这是因为 ref 具有 稳定 的标识:React 确保你在每轮渲染中调用同一个 useRef 时,总能获得相同的对象。ref 不会改变,所以它不会导致重新运行 Effect。因此,在依赖数组中它可有可无。把它加进去也可以:

jsx
function VideoPlayer({ src, isPlaying }) {
  const ref = useRef(null);
  useEffect(() => {
    if (isPlaying) {
      ref.current.play();
    } else {
      ref.current.pause();
    }
  }, [isPlaying, ref]);

useState 返回的 set 函数 也具有稳定的标识,因此它们通常也会被省略。如果在省略某个依赖项时 linter 不会报错,那么这么做就是安全的。

省略始终稳定的依赖项仅在 linter 能“看到”对象是稳定的时候才有效。例如,如果 ref 是从父组件传递过来的,则必须在依赖数组中指定它。这很有必要,因为你无法确定父组件是一直传递相同的 ref,还是根据条件传递不同的 ref。所以,你的 Effect 会依赖于被传递的是哪个 ref。

第三步:按需添加清理(cleanup)函数

考虑一个不同的例子。假如你正在编写一个 ChatRoom 组件,该组件在显示时需要连接到聊天服务器。现在为你提供了 createConnection() API,该 API 返回一个包含 connect()disconnection() 方法的对象。如何确保组件在显示时始终保持连接?

从编写 Effect 的逻辑开始:

jsx
useEffect(() => {
  const connection = createConnection();
  connection.connect();
});

如果每次重新渲染后都得进行连接,这会很慢,所以你需要添加依赖数组:

jsx
useEffect(() => {
  const connection = createConnection();
  connection.connect();
}, []);

由于 Effect 中的代码没有使用任何 props 或 state,所以依赖数组为空数组 []。这告诉 React 仅在组件“挂载”(即首次显示在页面上)时运行此代码

试试运行下面的代码:

jsx
import { useEffect } from 'react';
import { createConnection } from './chat.js';

export default function ChatRoom() {
  useEffect(() => {
    const connection = createConnection();
    connection.connect();
  }, []);
  return <h1>欢迎来到聊天室!</h1>;
}

这里的 Effect 仅在组件挂载时运行,所以你可能以为 "✅ 连接中……" 只会在控制台中被打印一次。然而实际情况是 "✅ 连接中……" 被打印了两次!为什么会这样

假设 ChatRoom 组件是一个大型多页面应用中的一部分。用户最初在 ChatRoom 页面上。组件挂载并调用 connection.connect() 。接着用户可能会导航到另一个页面,比如切换到“设置”页面,于是 ChatRoom 组件被卸载。最后,当用户点击“返回”时,ChatRoom 组件再次挂载。这将建立第二个连接——但第一个连接从未被销毁!随着用户在应用中来回切换,连接将会不断累积。

这类 bug 在没有大量手动测试的情况下很容易被忽略。为了帮助你快速发现它们,在开发环境中,React 会在组件首次挂载后立即重新挂载一次。

两次出现 "✅ 连接中……" 能够帮助你注意到真正的问题:在代码中,组件被卸载时没有关闭连接。

为了解决这个问题,可以在 Effect 中返回一个 清理(cleanup)函数

jsx
  useEffect(() => {
    const connection = createConnection();
    connection.connect();
    return () => {
      connection.disconnect();
    };
  }, []);

React 会在每次 Effect 重新运行之前调用清理函数,并在组件卸载(被移除)时最后一次调用清理函数。让我们看看实现清理函数后会发生什么:

jsx
import { useState, useEffect } from 'react';
import { createConnection } from './chat.js';

export default function ChatRoom() {
  useEffect(() => {
    const connection = createConnection();
    connection.connect();
    return () => connection.disconnect();
  }, []);
  return <h1>欢迎来到聊天室!</h1>;
}

现在在开发环境下,你会看到三条控制台日志:

  1. "✅ 连接中……"
  2. "❌ 连接断开。"
  3. "✅ 连接中……"

在开发环境下,这是正确的行为。通过重新挂载你的组件,React 验证了离开页面再返回不会导致代码出错。因为本就应该先断开然后再重新连接!如果你很好地实现了清理函数,那么无论是只执行一次 Effect ,还是执行、清理、再执行,都应该没有用户可见的区别。之所以会有额外的一次 connect/disconnect 调用,是因为在开发环境下 React 在检测你代码中的 bug。因此这是正常现象,不要去试图消除它!

在生产环境下,你只会看到 "✅ 连接中……" 打印一次。这是因为重新挂载组件只会在开发环境下发生,以此帮助你找到需要清理的 Effect。你可以通过关闭 严格模式 来禁用这个行为,但我们建议保留它。它可以帮助你发现许多类似上述的 bug。

如何处理在开发环境下 Effect 运行了两次?

React 有意在开发环境下重新挂载你的组件,来找到类似上例中的 bug。你需要思考的不是“如何只运行一次 Effect”,而是“如何修复我的 Effect 来让它在重新挂载后正常运行”

通常,答案是实现清理函数。清理函数应该停止或撤销 Effect 所做的一切。原则是用户不应该感受到 Effect 只执行一次(在生产环境中)和连续执行“挂载 → 清理 → 挂载”(在开发环境中)之间的区别。

你将编写的大多数 Effect 都会符合下列的常见模式之一。

警告

不要使用 ref 来防止触发 Effect

为了防止 Effect 在开发环境中触发两次,一个常见错误是使用 ref 来让 Effect 只运行一次。例如,你可能会用 useRef “修复”上述的 bug:

jsx
  const connectionRef = useRef(null);
  useEffect(() => {
    // 🚩 这并不能修复这个 bug!!!
    if (!connectionRef.current) {
      connectionRef.current = createConnection();
      connectionRef.current.connect();
    }
  }, []);

它虽然使你在开发环境下只看到一次 “✅ 正在连接...”,但并没有修复这个 bug。

当用户离开时,连接没有被关闭,当用户返回时,又会创建一个新的连接。随着用户浏览应用,连接会不断累积,就像“修复”之前一样。

要修复这个 bug,仅仅让 Effect 只运行一次是不够的。想要 Effect 在重新挂载后正常运行,就得按照之前的方法清除连接。

请看下面的示例,了解如何处理常见模式。

管理非 React 小部件

有时你需要添加不是用 React 实现的 UI 小部件。比如说你想在你的页面添加一个地图组件。它有一个 setZoomLevel() 方法,然后你希望地图的缩放比例和代码中的 zoomLevel state 保持同步。你的 Effect 应该类似于:

jsx
useEffect(() => {
  const map = mapRef.current;
  map.setZoomLevel(zoomLevel);
}, [zoomLevel]);

请注意,这种情况下不需要清理操作。在开发环境中,虽然 React 会调用 Effect 两次,但这没关系,因为用相同的值调用 setZoomLevel 两次不会造成任何影响。虽然在开发环境下它可能会稍微慢一些,但问题不大,因为在生产环境下它不会多余地重新挂载。

有些 API 可能不允许你连续调用两次。例如,内置的 HTMLDialogElement 元素的 showModal 方法在连续被调用两次时会抛出异常。此时可以通过实现清理函数来使其关闭对话框:

jsx
useEffect(() => {
  const dialog = dialogRef.current;
  dialog.showModal();
  return () => dialog.close();
}, []);

在开发环境中,你的 Effect 会先调用 showModal(),然后立即调用 close(),之后再次调用 showModal()。这与在生产环境中只调用一次 showModal() 的用户可见行为是相同的。

订阅事件

如果你的 Effect 订阅了某些事件,清理函数应退订这些事件:

jsx
useEffect(() => {
  function handleScroll(e) {
    console.log(window.scrollX, window.scrollY);
  }
  window.addEventListener('scroll', handleScroll);
  return () => window.removeEventListener('scroll', handleScroll);
}, []);

在开发环境中,你的 Effect 会先调用 addEventListener(),然后立即调用 removeEventListener(),接着再次使用相同的处理函数调用 addEventListener()。因此,每次只会有一个有效订阅。这与在生产环境中只调用一次 addEventListener() 所产生的用户可见行为是相同的。

触发动画

如果你的 Effect 触发了一些动画,清理函数应将动画重置为初始状态:

jsx
useEffect(() => {
  const node = ref.current;
  node.style.opacity = 1; // 触发动画
  return () => {
    node.style.opacity = 0; // 重置为初始值
  };
}, []);

在开发环境中,透明度由 1 变为 0,再变为 1。这与在生产环境中,直接将其设置为 1 具有相同的用户可见行为。如果你使用了支持补间动画的第三方动画库,你的清理函数应将时间轴重置为初始状态。

获取数据

如果你的 Effect 需要获取数据,清理函数应中止请求或忽略其结果:

jsx
useEffect(() => {
  let ignore = false;

  async function startFetching() {
    const json = await fetchTodos(userId);
    if (!ignore) {
      setTodos(json);
    }
  }
  startFetching();
  return () => {
    ignore = true;
  };
}, [userId]);

你无法“撤销”已经发生的网络请求,但是你的清理函数应当确保那些不再相关的请求不会继续影响你的应用。如果 userId'Alice' 变为 'Bob',那么请确保 'Alice' 的响应数据被忽略,即使它在 'Bob' 之后到达。

在开发环境中,你会在浏览器调试工具的“网络”选项卡中看到两条请求。这是正常的。使用上述方法,第一个 Effect 将立即被清理,所以它的 ignore 变量会被设置为 true。因此,即使有额外的请求,由于有 if (!ignore) 的检查,也不会影响 state。

在生产环境中,只会有一条请求。如果开发环境中的第二次请求给你造成了困扰,最好的办法是使用一个能够对请求去重并缓存响应的方案:

jsx
function TodoList() {
  const todos = useSomeDataLibrary(`/api/user/${userId}/todos`);
  // ...

这不仅可以提高开发体验,还可以让你的应用程序响应更快。例如,当用户点击返回按钮时,不用再等待数据重新加载,因为它已经被缓存。你可以自己构建这样的缓存机制,也可以使用很多在 Effect 中手动获取数据的替代方法。

重要

在 Effect 中进行数据请求的替代方案

在 Effect 中直接编写 fetch 请求 是一种常见的数据获取方式,特别是在完全客户端渲染的应用中。然而,这种方法非常手动化,并且有明显的弊端:

① 服务端渲染时数据缺失

Effect 只在浏览器里跑,服务端不执行。所以 SSR 吐出的 HTML 里只有”加载中…”的骨架,用户要等浏览器下载完 JS、执行完渲染、再发起请求,才能看到真实数据。多了一整个来回。

② 串行请求导致网络瀑布

父组件 fetch 完成
  └→ 子组件才开始渲染
       └→ 子组件才开始 fetch
            └→ 孙组件才开始 fetch...

每一层都要等上一层请求完才能开始,本来可以并行的请求变成了串行队列。

③ 没有缓存,重复请求

组件卸载再重新挂载,Effect 重新执行,数据重新请求——即使刚刚才拿到过同样的数据。没有任何复用机制。

④ 样板代码多

光是处理竞态条件就需要额外的清理逻辑:

jsx
useEffect(() => {
  let cancelled = false;
  fetch(url).then(res => {
    if (!cancelled) setData(res); // 防止旧请求覆盖新结果
  });
  return () => { cancelled = true; };
}, [url]);

还没算错误处理、loading 状态、重试逻辑……每个请求都要写一遍。

这些弊端并不仅限于 React。任何库在组件挂载时进行数据获取都会遇到这些问题。与路由处理一样,要做好数据获取并非易事,因此我们推荐以下方法:

  • 如果你正在使用框架 ,请使用其内置的数据获取机制。现代 React 框架集成了高效的数据获取机制,不会出现上述问题。
  • 否则,请考虑使用或构建客户端缓存。流行的开源解决方案包括 TanStack QueryuseSWRReact Router v6.4+。你也可以自己构建解决方案:在底层使用 Effect,但添加对请求的去重、缓存响应以及避免网络瀑布(通过预加载数据或将数据请求提升到路由层次)的逻辑。

如果这些方法都不适合你,你可以继续直接在 Effect 中获取数据。

发送分析报告

考虑以下代码,它在页面访问时发送一个分析事件:

jsx
useEffect(() => {
  logVisit(url); // 发送 POST 请求
}, [url]);

在开发环境中,对于每个 URL,logVisit 都会被调用两次,因此你可能会尝试修复这个问题。我们建议保持不动。与之前示例类似,运行一次还是运行两次,在用户可见的行为上没有区别。从实际角度来看,logVisit 不应该在开发环境中执行任何操作,因为你不会想让开发设备的日志影响生产环境的统计数据。每次保存文件时组件都会重新挂载,因此在开发环境中会记录额外的访问日志。

在生产环境中,不会有重复的访问日志

不适用于 Effect:初始化应用

某些逻辑应该只在应用启动时运行一次。你可以将它放在组件外部:

jsx
if (typeof window !== 'undefined') { // 检查是否在浏览器中运行
  checkAuthToken();
  loadDataFromLocalStorage();
}

function App() {
  // ……
}

这可以确保此类逻辑只在浏览器加载页面后运行一次。

提示

初始化应用放于组件外部

不适用于 Effect:购买商品

有时,即使你编写了清理函数,也无法避免用户观察到 Effect 运行了两次。比如你的 Effect 发送了一个像购买商品这样的 POST 请求:

jsx
useEffect(() => {
  // 🔴 错误:此处的 Effect 在开发环境中会触发两次,暴露出代码中的问题。
  fetch('/api/buy', { method: 'POST' });
}, []);

你肯定不希望购买两次商品。这也是为什么你不应该把这种逻辑放在 Effect 中。如果用户跳转到另一个页面,然后按下“返回”按钮,你的 Effect 就会再次运行。你不希望用户在访问页面时就购买产品,而是在他们点击“购买”按钮时才购买。

购买操作并不是由渲染引起的,而是由特定的交互引起的。它应该只在用户按下按钮时执行。因此,它不应该写在 Effect 中,应当把 /api/buy 请求移动到“购买”按钮的事件处理程序中

jsx
  function handleClick() {
    // ✅ 购买行为是一个事件,因为它是由特定的交互引起的。
    fetch('/api/buy', { method: 'POST' });
  }

这说明了如果重新挂载破坏了应用的逻辑,通常便暴露了存在的 bug。对用户而言,访问一个页面不应该与访问页面后点击链接、再按下“返回”按钮查看页面有区别。React 通过在开发环境中重新挂载组件来验证你的组件是否遵守这一原则。

提示

购买商品应该放在事件处理函数里面

你可能不需要Effect

Effect 是 React 范式中的一种脱围机制。它们让你可以 “逃出” React 并使组件和一些外部系统同步,比如非 React 组件、网络和浏览器 DOM。如果没有涉及到外部系统(例如,你想根据 props 或 state 的变化来更新一个组件的 state),你就不应该使用 Effect。移除不必要的 Effect 可以让你的代码更容易理解,运行得更快,并且更少出错。

我下面省略一部分简单场景,讲一些复杂情况(可以使用Effect,但不是最好的选择,有更好的方案)

缓存昂贵的计算

这个组件使用它接收到的 props 中的 filter 对另一个 prop todos 进行筛选,计算得出 visibleTodos。你的直觉可能是把结果存到一个 state 中,并在 Effect 中更新它:

jsx
function TodoList({ todos, filter }) {
  const [newTodo, setNewTodo] = useState('');

  // 🔴 避免:多余的 state 和不必要的 Effect
  const [visibleTodos, setVisibleTodos] = useState([]);
  useEffect(() => {
    setVisibleTodos(getFilteredTodos(todos, filter));
  }, [todos, filter]);

  // ...
}

就像之前的例子一样,这既没有必要,也很低效。首先,移除 state 和 Effect:

jsx
function TodoList({ todos, filter }) {
  const [newTodo, setNewTodo] = useState('');
  // ✅ 如果 getFilteredTodos() 的耗时不长,这样写就可以了。
  const visibleTodos = getFilteredTodos(todos, filter);
  // ...
}

一般来说,这段代码没有问题!但是,getFilteredTodos() 的耗时可能会很长,或者你有很多 todos。这些情况下,当 newTodo 这样不相关的 state 变量变化时,你并不想重新执行 getFilteredTodos()

你可以使用 useMemo Hook 缓存(或者说 记忆(memoize))一个昂贵的计算。(React 编译器可以自动记忆化昂贵计算,从而减少很多手动调用 useMemo 的场景。)

jsx
import { useMemo, useState } from 'react';

function TodoList({ todos, filter }) {
  const [newTodo, setNewTodo] = useState('');
  // ✅ 除非 todos 或 filter 发生变化,否则不会重新执行 getFilteredTodos()
  const visibleTodos = useMemo(() => getFilteredTodos(todos, filter), [todos, filter]);
  // ...
}

这会告诉 React,除非 todosfilter 发生变化,否则不要重新执行传入的函数。React 会在初次渲染的时候记住 getFilteredTodos() 的返回值。在下一次渲染中,它会检查 todosfilter 是否发生了变化。如果它们跟上次渲染时一样,useMemo 会直接返回它最后保存的结果。如果不一样,React 将再次调用传入的函数(并保存它的结果)。

你传入 useMemo 的函数会在渲染期间执行,所以它仅适用于 纯函数 场景。

当 prop 变化时调整部分 state

有时候,当 prop 变化时,你可能只想重置或调整部分 state ,而不是所有 state。

List 组件接收一个 items 列表作为 prop,然后用 state 变量 selection 来保持已选中的项。当 items 接收到一个不同的数组时,你想将 selection 重置为 null

jsx
function List({ items }) {
  const [isReverse, setIsReverse] = useState(false);
  const [selection, setSelection] = useState(null);

  // 🔴 避免:当 prop 变化时,在 Effect 中调整 state
  useEffect(() => {
    setSelection(null);
  }, [items]);
  // ...
}

这不太理想。每当 items 变化时,List 及其子组件会先使用旧的 selection 值渲染。然后 React 会更新 DOM 并执行 Effect。最后,调用 setSelection(null) 将导致 List 及其子组件重新渲染,重新启动整个流程。

让我们从删除 Effect 开始。取而代之的是在渲染期间直接调整 state:

jsx
function List({ items }) {
  const [isReverse, setIsReverse] = useState(false);
  const [selection, setSelection] = useState(null);

  // 好一些:在渲染期间调整 state
  const [prevItems, setPrevItems] = useState(items);
  if (items !== prevItems) {
    setPrevItems(items);
    setSelection(null);
  }
  // ...
}

像这样 存储前序渲染的信息 可能很难理解,但它比在 Effect 中更新这个 state 要好。上面的例子中,在渲染过程中直接调用了 setSelection。当它执行到 return 语句退出后,React 将 立即 重新渲染 List。此时 React 还没有渲染 List 的子组件或更新 DOM,这使得 List 的子组件可以跳过渲染旧的 selection 值。

虽然这种方式比 Effect 更高效,但大多数组件也不需要它。无论你怎么做,根据 props 或其他 state 来调整 state 都会使数据流更难理解和调试。总是检查是否可以通过添加 key 来重置所有 state,或者在渲染期间计算所需内容。例如,你可以存储已选中的 item ID 而不是存储(并重置)已选中的 item

jsx
function List({ items }) {
  const [isReverse, setIsReverse] = useState(false);
  const [selectedId, setSelectedId] = useState(null);
  // ✅ 非常好:在渲染期间计算所需内容
  const selection = items.find(item => item.id === selectedId) ?? null;
  // ...
}

现在完全不需要 “调整” state 了。如果包含已选中 ID 的项出现在列表中,则仍然保持选中状态。如果没有找到匹配的项,则在渲染期间计算的 selection 将会是 null。行为不同了,但可以说更好了,因为大多数对 items 的更改仍可以保持选中状态。

初始化应用

有些逻辑只需要在应用加载时执行一次。

你可能想把它放在一个顶层组件的 Effect 中:

jsx
function App() {
  // 🔴 避免:把只需要执行一次的逻辑放在 Effect 中
  useEffect(() => {
    loadDataFromLocalStorage();
    checkAuthToken();
  }, []);
  // ...
}

然后,你很快就会发现它在 开发环境会执行两次。这会导致一些问题——例如,它可能使身份验证 token 无效,因为该函数不是为被调用两次而设计的。一般来说,当组件重新挂载时应该具有一致性。包括你的顶层 App 组件。

尽管在实际的生产环境中它可能永远不会被重新挂载,但在所有组件中遵循相同的约束条件可以更容易地移动和复用代码。如果某些逻辑必须在 每次应用加载时执行一次,而不是在 每次组件挂载时执行一次,可以添加一个顶层变量来记录它是否已经执行过了:

jsx
let didInit = false;

function App() {
  useEffect(() => {
    if (!didInit) {
      didInit = true;
      // ✅ 只在每次应用加载时执行一次
      loadDataFromLocalStorage();
      checkAuthToken();
    }
  }, []);
  // ...
}

你也可以在模块初始化和应用渲染之前执行它:

jsx
if (typeof window !== 'undefined') { // 检测我们是否在浏览器环境
   // ✅ 只在每次应用加载时执行一次
  checkAuthToken();
  loadDataFromLocalStorage();
}

function App() {
  // ...
}

顶层代码会在组件被导入时执行一次——即使它最终并没有被渲染。为了避免在导入任意组件时降低性能或产生意外行为,请不要过度使用这种方法。将应用级别的初始化逻辑保留在像 App.js 这样的根组件模块或你的应用入口中。

订阅外部 store

有时候,你的组件可能需要订阅 React state 之外的一些数据。这些数据可能来自第三方库或内置浏览器 API。由于这些数据可能在 React 无法感知的情况下发变化,你需要在你的组件中手动订阅它们。这经常使用 Effect 来实现,例如:

jsx
function useOnlineStatus() {
  // 不理想:在 Effect 中手动订阅 store
  const [isOnline, setIsOnline] = useState(true);
  useEffect(() => {
    function updateState() {
      setIsOnline(navigator.onLine);
    }

    updateState();

    window.addEventListener('online', updateState);
    window.addEventListener('offline', updateState);
    return () => {
      window.removeEventListener('online', updateState);
      window.removeEventListener('offline', updateState);
    };
  }, []);
  return isOnline;
}

function ChatIndicator() {
  const isOnline = useOnlineStatus();
  // ...
}

这个组件订阅了一个外部的 store 数据(在这里,是浏览器的 navigator.onLine API)。由于这个 API 在服务端不存在(因此不能用于初始的 HTML),因此 state 最初被设置为 true。每当浏览器 store 中的值发生变化时,组件都会更新它的 state。

尽管通常可以使用 Effect 来实现此功能,但 React 为此针对性地提供了一个 Hook 用于订阅外部 store。删除 Effect 并将其替换为调用 useSyncExternalStore

jsx
function subscribe(callback) {
  window.addEventListener('online', callback);
  window.addEventListener('offline', callback);
  return () => {
    window.removeEventListener('online', callback);
    window.removeEventListener('offline', callback);
  };
}

function useOnlineStatus() {
  // ✅ 非常好:用内置的 Hook 订阅外部 store
  return useSyncExternalStore(
    subscribe, // 只要传递的是同一个函数,React 不会重新订阅
    () => navigator.onLine, // 如何在客户端获取值
    () => true // 如何在服务端获取值
  );
}

function ChatIndicator() {
  const isOnline = useOnlineStatus();
  // ...
}

与手动使用 Effect 将可变数据同步到 React state 相比,这种方法能减少错误。通常,你可以写一个像上面的 useOnlineStatus() 这样的自定义 Hook,这样你就不需要在各个组件中重复写这些代码。阅读更多关于在 React 组件中订阅外部数据 store 的信息

获取数据

许多应用使用 Effect 来发起数据获取请求。像这样在 Effect 中写一个数据获取请求是相当常见的:

jsx
function SearchResults({ query }) {
  const [results, setResults] = useState([]);
  const [page, setPage] = useState(1);

  useEffect(() => {
    // 🔴 避免:没有清除逻辑的获取数据
    fetchResults(query, page).then(json => {
      setResults(json);
    });
  }, [query, page]);

  function handleNextPageClick() {
    setPage(page + 1);
  }
  // ...
}

不需要 把这个数据获取逻辑迁移到一个事件处理函数中。

这可能看起来与之前需要将逻辑放入事件处理函数中的示例相矛盾!但是,要注意到在这里触发获取数据的主要原因并不是 键入事件。搜索输入框的值通常从 URL 中预填充,用户也可以在不操作输入框的情况下通过点击 “前进” 或者 “后退” 导航页面。

pagequery 的来源其实并不重要。只要该组件可见,你就需要通过当前 pagequery 的值,保持 results 和网络数据的 同步。这就是这里是一个 Effect 的原因。

然而,上面的代码有一个问题。假设你快速地输入 “hello”。那么 query 会从 “h” 变成 “he”“hel”“hell” 最后是 “hello”。这会触发一连串不同的数据获取请求,但无法保证对应的返回顺序。例如,“hell” 的响应可能在 “hello” 的响应 之后 返回。由于它的 setResults() 是在最后被调用的,你将会显示错误的搜索结果。这种情况被称为 “竞态条件”:两个不同的请求 “相互竞争”,并以与你预期不符的顺序返回。

为了修复这个问题,你需要添加一个 清理函数 来忽略较早的返回结果:

jsx
function SearchResults({ query }) {
  const [results, setResults] = useState([]);
  const [page, setPage] = useState(1);
  useEffect(() => {
    let ignore = false;
    fetchResults(query, page).then(json => {
      if (!ignore) {
        setResults(json);
      }
    });
    return () => {
      ignore = true;
    };
  }, [query, page]);

  function handleNextPageClick() {
    setPage(page + 1);
  }
  // ...
}

这确保了当你在 Effect 中获取数据时,除了最后一次请求的所有返回结果都将被忽略。

处理竞态条件并不是实现数据获取的唯一难点。你可能还需要考虑缓存响应结果(使用户点击后退按钮时可以立即看到先前的屏幕内容),如何在服务端获取数据(使服务端初始渲染的 HTML 中包含获取到的内容而不是加载动画),以及如何避免网络瀑布(使子组件不必等待每个父组件的数据获取完毕后才开始获取数据)。

这些问题适用于任何 UI 库,而不仅仅是 React。解决这些问题并不容易,这也是现代 框架 提供了比在 Effect 中获取数据更有效的内置数据获取机制的原因。

如果你不使用框架(也不想开发自己的框架),但希望使从 Effect 中获取数据更符合人类直觉,请考虑像这个例子一样,将获取逻辑提取到一个自定义 Hook 中:

jsx
function SearchResults({ query }) {
  const [page, setPage] = useState(1);
  const params = new URLSearchParams({ query, page });
  const results = useData(`/api/search?${params}`);

  function handleNextPageClick() {
    setPage(page + 1);
  }
  // ...
}

你可能还想添加一些错误处理逻辑以及跟踪内容是否处于加载中。你可以自己编写这样的 Hook,也可以使用 React 生态中已经存在的许多解决方案。虽然仅仅使用自定义 Hook 不如使用框架内置的数据获取机制高效,但将数据获取逻辑移动到自定义 Hook 中将使后续采用高效的数据获取策略更加容易。

一般来说,当你不得不编写 Effect 时,请留意是否可以将某段功能提取到专门的内置 API 或一个更具声明性的自定义 Hook 中,比如上面的 useData。你会发现组件中的原始 useEffect 调用越少,维护应用将变得更加容易。

响应式Effect的生命周期

Effect 与组件有不同的生命周期。组件可以挂载、更新或卸载。Effect 只能做两件事:开始同步某些东西,然后停止同步它。如果 Effect 依赖于随时间变化的 props 和 state,这个循环可能会发生多次。React 提供了代码检查规则来检查是否正确地指定了 Effect 的依赖项,这能够使 Effect 与最新的 props 和 state 保持同步。

将事件从 Effect 中分开

事件处理函数只有在你再次执行同样的交互时才会重新运行。Effect 和事件处理函数不一样,它只有在读取的 props 或 state 值和上一次渲染不一样时才会重新同步。有时你需要这两种行为的混合体:即一个 Effect 只在响应某些值时重新运行,但是在其他值变化时不重新运行。

响应式值和响应式逻辑

直观上,你可以说事件处理函数总是“手动”触发的,例如点击按钮。另一方面, Effect 是自动触发:每当需要保持同步的时候他们就会开始运行和重新运行。

有一个更精确的方式来考虑这个问题。

组件内部声明的 state 和 props 变量被称为 响应式值。本示例中的 serverUrl 不是响应式值,但 roomIdmessage 是。他们参与组件的渲染数据流:image-20260614155348823

像这样的响应式值可以因为重新渲染而变化。例如用户可能会编辑 message 或者在下拉菜单中选中不同的 roomId。事件处理函数和 Effect 对于变化的响应是不一样的:

  • 事件处理函数内部的逻辑是非响应式的。除非用户又执行了同样的操作(例如点击),否则这段逻辑不会再运行。事件处理函数可以在“不响应”他们变化的情况下读取响应式值。
  • Effect 内部的逻辑是响应式的。如果 Effect 要读取响应式值,你必须将它指定为依赖项。如果接下来的重新渲染引起那个值变化,React 就会使用新值重新运行 Effect 内的逻辑。

让我们重新看看前面的示例来说明差异。

事件处理函数内部的逻辑是非响应式的

看这行代码。这个逻辑是响应式的吗?

image-20260614155408070

从用户角度出发,message 的变化并不意味着他们想要发送消息。它只能表明用户正在输入。换句话说,发送消息的逻辑不应该是响应式的。它不应该仅仅因为 响应式值 变化而再次运行。这就是应该把它归入事件处理函数的原因:

image-20260614155423765

事件处理函数是非响应式的,所以 sendMessage(message) 只会在用户点击“Send”按钮的时候运行。

Effect 内部的逻辑是响应式的

现在让我们返回这几行代码:

image-20260614155439851

从用户角度出发,roomId 的变化意味着他们的确想要连接到不同的房间。换句话说,连接房间的逻辑应该是响应式的。你 需要 这几行代码和响应式值“保持同步”,并在值不同时再次运行。这就是它被归入 Effect 的原因:

image-20260614155455672

Effect 是响应式的,所以 createConnection(serverUrl, roomId)connection.connect() 会因为 roomId 每个不同的值而运行。Effect 让聊天室连接和当前选中的房间保持了同步。

从 Effect 中提取非响应式逻辑

当你想混合使用响应式逻辑和非响应式逻辑时,事情变得更加棘手。

例如,假设你想在用户连接到聊天室时展示一个通知。并且通过从 props 中读取当前 theme(dark 或者 light)来展示对应颜色的通知:

image-20260614155521912

但是 theme 是一个响应式值(它会由于重新渲染而变化),并且 Effect 读取的每一个响应式值都必须在其依赖项中声明。现在你必须把 theme 作为 Effect 的依赖项之一:

image-20260614155542429

roomId 变化时,聊天会和预期一样重新连接。但是由于 theme 也是一个依赖项,所以每次你在 dark 和 light 主题间切换时,聊天 也会 重连。这不是很好!

换言之,即使它在 Effect 内部(这是响应式的),你也不想让这行代码变成响应式:

jsx
      // ...
      showNotification('Connected!', theme);
      // ...

你需要一个将这个非响应式逻辑和周围响应式 Effect 隔离开来的方法。

声明一个 Effect Event

使用 useEffectEvent 这个特殊的 Hook 从 Effect 中提取非响应式逻辑:

jsx
import { useEffect, useEffectEvent } from 'react';

function ChatRoom({ roomId, theme }) {
  const onConnected = useEffectEvent(() => {
    showNotification('Connected!', theme);
  });
  // ...

这里的 onConnected 被称为 Effect Event。它是 Effect 逻辑的一部分,但是其行为更像事件处理函数。它内部的逻辑不是响应式的,而且能一直“看见”最新的 props 和 state。

现在你可以在 Effect 内部调用 onConnected Effect Event:

jsx
function ChatRoom({ roomId, theme }) {
  const onConnected = useEffectEvent(() => {
    showNotification('Connected!', theme);
  });

  useEffect(() => {
    const connection = createConnection(serverUrl, roomId);
    connection.on('connected', () => {
      onConnected();
    });
    connection.connect();
    return () => connection.disconnect();
  }, [roomId]); // ✅ 声明所有依赖项
  // ...

这个方法解决了问题。注意你必须从 Effect 依赖项中 移除 theme,因为 Effect 中没有使用它。你也不需要 添加 onConnected,因为 Effect Event 是非响应式的并且必须从依赖项中删除

你可以将 Effect Event 看成和事件处理函数相似的东西。主要区别是事件处理函数只在响应用户交互的时候运行,而 Effect Event 是你在 Effect 中触发的。Effect Event 让你在 Effect 响应性和不应是响应式的代码间“打破链条”。

使用 Effect Event 读取最新的 props 和 state

Effect Event 可以修复之前许多你可能试图抑制依赖项检查工具的地方。

例如,假设你有一个记录页面访问的 Effect:

jsx
function Page() {
  useEffect(() => {
    logVisit();
  }, []);
  // ...
}

稍后向你的站点添加多个路由。现在 Page 组件接收包含当前路径的 url props。你想把 url 作为 logVisit 调用的一部分进行传递,但是依赖项检查工具会提示:

jsx
function Page({ url }) {
  useEffect(() => {
    logVisit(url);
  }, []); // 🔴 React Hook useEffect 缺少一个依赖项: 'url'
  // ...
}

想想你想要代码做什么。你 需要 为不同的 URL 记录单独的访问,因为每个 URL 代表不同的页面。换言之,logVisit 调用对于 url 应该 是响应式的。这就是为什么在这种情况下, 遵循依赖项检查工具并添加 url 作为一个依赖项很有意义:

jsx
function Page({ url }) {
  useEffect(() => {
    logVisit(url);
  }, [url]); // ✅ 声明所有依赖项
  // ...
}

现在假设你想在每次页面访问中包含购物车中的商品数量:

jsx
function Page({ url }) {
  const { items } = useContext(ShoppingCartContext);
  const numberOfItems = items.length;

  useEffect(() => {
    logVisit(url, numberOfItems);
  }, [url]); // 🔴 React Hook useEffect 缺少依赖项: ‘numberOfItems’
  // ...
}

你在 Effect 内部使用了 numberOfItems,所以代码检查工具会让你把它加到依赖项中。但是,你 想要 logVisit 调用响应 numberOfItems。如果用户把某样东西放入购物车, numberOfItems 会变化,这 并不意味着 用户再次访问了这个页面。换句话说,在某种意义上,访问页面 是一个“事件”。它发生在某个准确的时刻。

将代码分割为两部分:

jsx
function Page({ url }) {
  const { items } = useContext(ShoppingCartContext);
  const numberOfItems = items.length;

  const onVisit = useEffectEvent(visitedUrl => {
    logVisit(visitedUrl, numberOfItems);
  });

  useEffect(() => {
    onVisit(url);
  }, [url]); // ✅ 声明所有依赖项
  // ...
}

这里的 onVisit 是一个 Effect Event。里面的代码不是响应式的。这就是为什么你可以使用 numberOfItems(或者任意响应式值!)而不用担心引起周围代码因为变化而重新执行。

另一方面,Effect 本身仍然是响应式的。其内部的代码使用了 url props,所以每次因为不同的 url 重新渲染后 Effect 都会重新运行。这会依次调用 onVisit 这个 Effect Event。

结果是你会因为 url 的变化去调用 logVisit,并且读取的一直都是最新的 numberOfItems。但是如果 numberOfItems 自己变化,不会引起任何代码的重新运行。

提示

你可能想知道是否可以无参数调用 onVisit() 并且读取内部的 url

jsx
 const onVisit = useEffectEvent(() => {
    logVisit(url, numberOfItems);
  });

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

这可以起作用,但是更好的方法是将这个 url 显式传递给Effect Event。通过将 url 作为参数传给 Effect Event,你可以说从用户角度来看使用不同的 url 访问页面构成了一个独立的“事件”visitedUrl 是发生的“事件”的一部分:

jsx
  const onVisit = useEffectEvent(visitedUrl => {
    logVisit(visitedUrl, numberOfItems);
  });

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

由于 Effect 明确“要求” visitedUrl,所以现在你不会不小心地从 Effect 的依赖项中移除 url。如果你移除了 url 依赖项(导致不同的页面访问被认为是一个),代码检查工具会向你提出警告。如果你想要 onVisit 能对 url 的变化做出响应,不要读取内部的 url(这里不是响应式的),而是应该将它 Effect 中传入。

如果 Effect 内部有一些异步逻辑,这就变得非常重要了:

jsx
  const onVisit = useEffectEvent(visitedUrl => {
    logVisit(visitedUrl, numberOfItems);
  });

  useEffect(() => {
    setTimeout(() => {
      onVisit(url);
    }, 5000); // 延迟记录访问
  }, [url]);

在这里,onVisit 内的 url 对应 最新的 url(可能已经变化了),但是 visitedUrl 对应的是最开始引起这个 Effect(并且是本次 onVisit 调用)运行的 url

Effect Event 的局限性

Effect Event 的局限性在于你如何使用他们:

  • 只在 Effect 内部调用他们
  • 永远不要把他们传给其他的组件或者 Hook

例如不要像这样声明和传递 Effect Event:

jsx
function Timer() {
  const [count, setCount] = useState(0);

  const onTick = useEffectEvent(() => {
    setCount(count + 1);
  });

  useTimer(onTick, 1000); // 🔴 避免: 传递 Effect Event

  return <h1>{count}</h1>
}

function useTimer(callback, delay) {
  useEffect(() => {
    const id = setInterval(() => {
      callback();
    }, delay);
    return () => {
      clearInterval(id);
    };
  }, [delay, callback]); // 需要在依赖项中指定 “callback”
}

第一种为什么是错的

jsx
// Timer 组件里
const onTick = useEffectEvent(...);
useTimer(onTick, 1000); // ❌ 把 Effect Event 传给了自定义 Hook

onTick 被传出去之后,useTimer 内部把它放进了依赖数组:

jsx
}, [delay, callback]); // callback 就是 onTick

useEffectEvent 产生的函数本来就不该进依赖数组——它的设计目的就是跳过依赖追踪。传出去之后这个保证就被打破了,行为变得不可预测。

两种方式的依赖项也不一样,第一种需要在依赖项中指定 “callback”

取而代之的是,永远直接在使用他们的 Effect 旁边声明 Effect Event:

jsx
function Timer() {
  const [count, setCount] = useState(0);
  useTimer(() => {
    setCount(count + 1);
  }, 1000);
  return <h1>{count}</h1>
}

function useTimer(callback, delay) {
  const onTick = useEffectEvent(() => {
    callback();
  });

  useEffect(() => {
    const id = setInterval(() => {
      onTick(); // ✅ 好: 只在 Effect 内部局部调用
    }, delay);
    return () => {
      clearInterval(id);
    };
  }, [delay]); // 不需要指定 “onTick” (Effect Event) 作为依赖项
}

Effect Event 是 Effect 代码的非响应式“片段”。他们应该在使用他们的 Effect 的旁边。

移除Effect依赖

使用自定义Hook复用逻辑

React 有一些内置 Hook,例如 useStateuseContextuseEffect。有时你需要一个用途更特殊的 Hook:例如获取数据,记录用户是否在线或者连接聊天室。虽然 React 中可能没有这些 Hook,但是你可以根据应用需求创建自己的 Hook。

自定义 Hook:组件间共享逻辑

假设你正在开发一款重度依赖网络的应用(和大多数应用一样)。当用户使用应用时网络意外断开,你需要提醒他。你会怎么处理呢?看上去组件需要两个东西:

  1. 一个追踪网络是否在线的 state。
  2. 一个订阅全局 onlineoffline 事件并更新上述 state 的 Effect。

这会让组件与网络状态保持 同步。你也许可以像这样开始:

jsx
import { useState, useEffect } from 'react';

export default function StatusBar() {
  const [isOnline, setIsOnline] = useState(true);
  useEffect(() => {
    function handleOnline() {
      setIsOnline(true);
    }
    function handleOffline() {
      setIsOnline(false);
    }
    window.addEventListener('online', handleOnline);
    window.addEventListener('offline', handleOffline);
    return () => {
      window.removeEventListener('online', handleOnline);
      window.removeEventListener('offline', handleOffline);
    };
  }, []);

  return <h1>{isOnline ? '✅ Online' : '❌ Disconnected'}</h1>;
}

假设现在你想在另一个不同的组件里 使用同样的逻辑。你希望实现一个保存按钮,每当网络断开这个按钮就会不可用并且显示“Reconnecting…”而不是“Save progress”。

你可以从复制粘贴 isOnline state 和 Effect 到 SaveButton 组件开始:

jsx
import { useState, useEffect } from 'react';

export default function SaveButton() {
  const [isOnline, setIsOnline] = useState(true);
  useEffect(() => {
    function handleOnline() {
      setIsOnline(true);
    }
    function handleOffline() {
      setIsOnline(false);
    }
    window.addEventListener('online', handleOnline);
    window.addEventListener('offline', handleOffline);
    return () => {
      window.removeEventListener('online', handleOnline);
      window.removeEventListener('offline', handleOffline);
    };
  }, []);

  function handleSaveClick() {
    console.log('✅ Progress saved');
  }

  return (
    <button disabled={!isOnline} onClick={handleSaveClick}>
      {isOnline ? 'Save progress' : 'Reconnecting...'}
    </button>
  );
}

这两个组件都能很好地工作,但不幸的是他们的逻辑重复了。他们看上去有不同的 视觉外观,但你依然想复用他们的逻辑。

从组件中提取自定义 Hook

假设有一个内置 Hook useOnlineStatus,它与 useStateuseEffect 相似。那么你就可以简化这两个组件并移除他们之间的重复部分:

jsx
function StatusBar() {
  const isOnline = useOnlineStatus();
  return <h1>{isOnline ? '✅ Online' : '❌ Disconnected'}</h1>;
}

function SaveButton() {
  const isOnline = useOnlineStatus();

  function handleSaveClick() {
    console.log('✅ Progress saved');
  }

  return (
    <button disabled={!isOnline} onClick={handleSaveClick}>
      {isOnline ? 'Save progress' : 'Reconnecting...'}
    </button>
  );
}

尽管目前还没有这样的内置 Hook,但是你可以自己写。声明一个 useOnlineStatus 函数,并把组件里早前写的所有重复代码移入该函数:

jsx
function useOnlineStatus() {
  const [isOnline, setIsOnline] = useState(true);
  useEffect(() => {
    function handleOnline() {
      setIsOnline(true);
    }
    function handleOffline() {
      setIsOnline(false);
    }
    window.addEventListener('online', handleOnline);
    window.addEventListener('offline', handleOffline);
    return () => {
      window.removeEventListener('online', handleOnline);
      window.removeEventListener('offline', handleOffline);
    };
  }, []);
  return isOnline;
}

在函数结尾处返回 isOnline。这可以让组件读取到该值:

jsx
import { useOnlineStatus } from './useOnlineStatus.js';

function StatusBar() {
  const isOnline = useOnlineStatus();
  return <h1>{isOnline ? '✅ Online' : '❌ Disconnected'}</h1>;
}

function SaveButton() {
  const isOnline = useOnlineStatus();

  function handleSaveClick() {
    console.log('✅ Progress saved');
  }

  return (
    <button disabled={!isOnline} onClick={handleSaveClick}>
      {isOnline ? 'Save progress' : 'Reconnecting...'}
    </button>
  );
}

export default function App() {
  return (
    <>
      <SaveButton />
      <StatusBar />
    </>
  );
}

useOnlineStatus.js

jsx
import { useState, useEffect } from 'react';

export function useOnlineStatus() {
  const [isOnline, setIsOnline] = useState(true);
  useEffect(() => {
    function handleOnline() {
      setIsOnline(true);
    }
    function handleOffline() {
      setIsOnline(false);
    }
    window.addEventListener('online', handleOnline);
    window.addEventListener('offline', handleOffline);
    return () => {
      window.removeEventListener('online', handleOnline);
      window.removeEventListener('offline', handleOffline);
    };
  }, []);
  return isOnline;
}

现在组件里没有那么多的重复逻辑了。更重要的是,组件内部的代码描述的是想要做什么(使用在线状态!),而不是怎么做(通过订阅浏览器事件完成)

当提取逻辑到自定义 Hook 时,你可以隐藏如何处理外部系统或者浏览器 API 这些乱七八糟的细节。组件内部的代码表达的是目标而不是具体实现。

Hook 的名称必须永远以 use 开头

React 应用是由组件构成,而组件由内置或自定义 Hook 构成。可能你经常使用别人写的自定义 Hook,但偶尔也要自己写!

你必须遵循以下这些命名公约:

  1. React 组件名称必须以大写字母开头,比如 StatusBarSaveButton。React 组件还需要返回一些 React 能够显示的内容,比如一段 JSX。
  2. Hook 的名称必须以 use 开头,然后紧跟一个大写字母,就像内置的 useState 或者本文早前的自定义 useOnlineStatus 一样。Hook 可以返回任意值。

这个公约保证你始终能一眼识别出组件并且知道它的 state,Effect 以及其他的 React 特性可能“隐藏”在哪里。例如如果你在组件内部看见 getColor() 函数调用,就可以确定它里面不可能包含 React state,因为它的名称没有以 use 开头。但是像 useOnlineStatus() 这样的函数调用就很可能包含对内部其他 Hook 的调用!

自定义 Hook 共享的是状态逻辑,而不是状态本身

之前的例子里,当你开启或关闭网络时,两个组件一起更新了。但是两个组件共享 state 变量 isOnline 这种想法是错的。看这段代码:

jsx
function StatusBar() {
  const isOnline = useOnlineStatus();
  // ...
}

function SaveButton() {
  const isOnline = useOnlineStatus();
  // ...
}

它的工作方式和你之前提取的重复代码一模一样:

jsx
function StatusBar() {
  const [isOnline, setIsOnline] = useState(true);
  useEffect(() => {
    // ...
  }, []);
  // ...
}

function SaveButton() {
  const [isOnline, setIsOnline] = useState(true);
  useEffect(() => {
    // ...
  }, []);
  // ...
}

这是完全独立的两个 state 变量和 Effect!只是碰巧同一时间值一样,因为你使用了相同的外部值(网络是否开启)同步两个组件。

为了更好的说明这一点,我们需要一个不同的示例。看下面的 Form 组件:

jsx
import { useState } from 'react';

export default function Form() {
  const [firstName, setFirstName] = useState('Mary');
  const [lastName, setLastName] = useState('Poppins');

  function handleFirstNameChange(e) {
    setFirstName(e.target.value);
  }

  function handleLastNameChange(e) {
    setLastName(e.target.value);
  }

  return (
    <>
      <label>
        First name:
        <input value={firstName} onChange={handleFirstNameChange} />
      </label>
      <label>
        Last name:
        <input value={lastName} onChange={handleLastNameChange} />
      </label>
      <p><b>Good morning, {firstName} {lastName}.</b></p>
    </>
  );
}

每个表单域都有一部分重复的逻辑:

  1. 都有一个 state(firstNamelastName)。
  2. 都有 change 事件的处理函数(handleFirstNameChangehandleLastNameChange)。
  3. 都有为输入框指定 valueonChange 属性的 JSX。

你可以提取重复的逻辑到自定义 Hook useFormInput

jsx
import { useFormInput } from './useFormInput.js';

export default function Form() {
  const firstNameProps = useFormInput('Mary');
  const lastNameProps = useFormInput('Poppins');

  return (
    <>
      <label>
        First name:
        <input {...firstNameProps} />
      </label>
      <label>
        Last name:
        <input {...lastNameProps} />
      </label>
      <p><b>Good morning, {firstNameProps.value} {lastNameProps.value}.</b></p>
    </>
  );
}

useFormInput.js

jsx
import { useState } from 'react';

export function useFormInput(initialValue) {
  const [value, setValue] = useState(initialValue);

  function handleChange(e) {
    setValue(e.target.value);
  }

  const inputProps = {
    value: value,
    onChange: handleChange
  };

  return inputProps;
}

注意它只声明了 一个 state 变量,叫做 value

Form 组件调用了 两次 useFormInput

jsx
function Form() {
  const firstNameProps = useFormInput('Mary');
  const lastNameProps = useFormInput('Poppins');
  // ...

这就是为什么它工作的时候像声明了两个单独的 state 变量!

自定义 Hook 共享的只是状态逻辑而不是状态本身。对 Hook 的每个调用完全独立于对同一个 Hook 的其他调用。这就是上面两个 sandbox 结果完全相同的原因。如果愿意,你可以划上去进行比较。提取自定义 Hook 前后组件的行为是一致的。

当你需要在多个组件之间共享 state 本身时,需要 将变量提升并传递下去

在 Hook 之间传递响应值

自定义 Hook 的代码会随组件一起重新渲染,因此组件始终能拿到最新的 props 和 state,并传递给 Hook。

将聊天室连接逻辑封装进自定义 Hook:

jsx
export function useChatRoom({ serverUrl, roomId }) {
  useEffect(() => {
    const connection = createConnection({ serverUrl, roomId });
    connection.connect();
    return () => connection.disconnect();
  }, [roomId, serverUrl]);
}

组件每次重渲染都会把最新的 roomIdserverUrl 传给 Hook,Effect 检测到依赖变化后自动重连。

把事件处理函数传入自定义 Hook

消息处理逻辑不应硬编码在 Hook 内部,应该由调用方传入:

jsx
// 调用方
useChatRoom({
  roomId,
  serverUrl,
  onReceiveMessage(msg) {
    showNotification('New message: ' + msg);
  }
});

但如果直接把 onReceiveMessage 加进依赖数组,每次重渲染都会产生新函数引用,导致聊天室频繁重连。用 useEffectEvent 包裹解决这个问题:

jsx
export function useChatRoom({ serverUrl, roomId, onReceiveMessage }) {
  const onMessage = useEffectEvent(onReceiveMessage); // 始终读取最新的处理函数,但不作为依赖

  useEffect(() => {
    const connection = createConnection({ serverUrl, roomId });
    connection.connect();
    connection.on('message', (msg) => onMessage(msg));
    return () => connection.disconnect();
  }, [roomId, serverUrl]); // onMessage 不需要加入依赖
}

这样调用方可以自由定制消息处理逻辑,而不会触发不必要的重连。

什么时候使用自定义 Hook

你没必要对每段重复的代码都提取自定义 Hook。一些重复是好的。例如像早前提取的包裹单个 useState 调用的 useFormInput Hook 就是没有必要的。

但是每当你写 Effect 时,考虑一下把它包裹在自定义 Hook 是否更清晰。你不应该经常使用 Effect,所以如果你正在写 Effect 就意味着你需要“走出 React”和某些外部系统同步,或者需要做一些 React 中没有对应内置 API 的事。把 Effect 包裹进自定义 Hook 可以准确表达你的目标以及数据在里面是如何流动的。

例如,假设 ShippingForm 组件展示两个下拉菜单:一个显示城市列表,另一个显示选中城市的区域列表。你可能一开始会像这样写代码:

jsx
function ShippingForm({ country }) {
  const [cities, setCities] = useState(null);
  // 这个 Effect 拉取一个国家的城市数据
  useEffect(() => {
    let ignore = false;
    fetch(`/api/cities?country=${country}`)
      .then(response => response.json())
      .then(json => {
        if (!ignore) {
          setCities(json);
        }
      });
    return () => {
      ignore = true;
    };
  }, [country]);

  const [city, setCity] = useState(null);
  const [areas, setAreas] = useState(null);
  // 这个 Effect 拉取选中城市的区域列表
  useEffect(() => {
    if (city) {
      let ignore = false;
      fetch(`/api/areas?city=${city}`)
        .then(response => response.json())
        .then(json => {
          if (!ignore) {
            setAreas(json);
          }
        });
      return () => {
        ignore = true;
      };
    }
  }, [city]);

  // ...

尽管这部分代码是重复的,但是 把这些 Effect 各自分开是正确的。他们同步两件不同的事情,所以不应该把他们合并到同一个 Effect。而是提取其中的通用逻辑到你自己的 useData Hook 来简化上面的 ShippingForm 组件:

jsx
function useData(url) {
  const [data, setData] = useState(null);
  useEffect(() => {
    if (url) {
      let ignore = false;
      fetch(url)
        .then(response => response.json())
        .then(json => {
          if (!ignore) {
            setData(json);
          }
        });
      return () => {
        ignore = true;
      };
    }
  }, [url]);
  return data;
}

现在你可以在 ShippingForm 组件中调用 useData 替换两个 Effect:

jsx
function ShippingForm({ country }) {
  const cities = useData(`/api/cities?country=${country}`);
  const [city, setCity] = useState(null);
  const areas = useData(city ? `/api/areas?city=${city}` : null);
  // ...

提取自定义 Hook 让数据流清晰。输入 url,就会输出 data。通过把 Effect “隐藏”在 useData 内部,你也可以防止一些正在处理 ShippingForm 组件的人向里面添加 不必要的依赖。随着时间的推移,应用中大部分 Effect 都会存在于自定义 Hook 内部。

重要

从选择自定义 Hook 名称开始。如果你难以选择一个清晰的名称,这可能意味着你的 Effect 和组件逻辑剩余的部分耦合度太高,还没有做好被提取的准备。

理想情况下,你的自定义 Hook 名称应该清晰到即使一个不经常写代码的人也能很好地猜中自定义 Hook 的功能,输入和返回:

  • useData(url)
  • useImpressionLog(eventName, extraData)
  • useChatRoom(options)

当你和外部系统同步的时候,你的自定义 Hook 名称可能会更加专业,并使用该系统特定的术语。只要对熟悉这个系统的人来说名称清晰就可以:

  • useMediaQuery(query)
  • useSocket(url)
  • useIntersectionObserver(ref, options)

保持自定义 Hook 专注于具体的高级用例。避免创建和使用作为 useEffect API 本身的替代品和 wrapper 的自定义“生命周期” Hook:

  • 🔴 useMount(fn)
  • 🔴 useEffectOnce(fn)
  • 🔴 useUpdateEffect(fn)

例如这个 useMount Hook 试图保证一些代码只在“加载”时运行:

jsx
function ChatRoom({ roomId }) {
  const [serverUrl, setServerUrl] = useState('https://localhost:1234');

  // 🔴 避免:使用自定义“生命周期” Hook
  useMount(() => {
    const connection = createConnection({ roomId, serverUrl });
    connection.connect();

    post('/analytics/event', { eventName: 'visit_chat' });
  });
  // ...
}

// 🔴 避免:创建自定义“生命周期” Hook
function useMount(fn) {
  useEffect(() => {
    fn();
  }, []); // 🔴 React Hook useEffect 缺少依赖项: 'fn'
}

useMount 这样的自定义“生命周期” Hook 不是很适合 React 范式。例如示例代码有一个错误(它没有对 roomIdserverUrl 的变化做出“响应” ),但是代码检查工具并不会向你发出对应的警告,因为它只能检测 useEffect 的直接调用。并不了解你的 Hook。

如果你正在编写 Effect,请从直接使用 React API 开始:

jsx
function ChatRoom({ roomId }) {
  const [serverUrl, setServerUrl] = useState('https://localhost:1234');

  // ✅ 好:通过用途分割的两个原始 Effect

  useEffect(() => {
    const connection = createConnection({ serverUrl, roomId });
    connection.connect();
    return () => connection.disconnect();
  }, [serverUrl, roomId]);

  useEffect(() => {
    post('/analytics/event', { eventName: 'visit_chat', roomId });
  }, [roomId]);

  // ...
}

然后你可以(但不是必须的)为不同的高级用例提取自定义 Hook:

jsx
function ChatRoom({ roomId }) {
  const [serverUrl, setServerUrl] = useState('https://localhost:1234');

  // ✅ 非常好:以用途命名的自定义 Hook
  useChatRoom({ serverUrl, roomId });
  useImpressionLog('visit_chat', { roomId });
  // ...
}

好的自定义 Hook 通过限制功能使代码调用更具声明性。例如 useChatRoom(options) 只能连接聊天室,而 useImpressionLog(eventName, extraData) 只能向分析系统发送展示日志。如果你的自定义 Hook API 没有约束用例且非常抽象,那么在长期的运行中,它引入的问题可能比解决的问题更多。

不止一个方法可以做到

假设你想要使用浏览器的 requestAnimationFrame API 从头开始 实现一个 fade-in 动画。你也许会从一个设置动画循环的 Effect 开始。在动画的每一帧中,你可以修改 ref 持有的 DOM 节点的 opacity 属性直到 1。你的代码一开始可能是这样:

jsx
import { useState, useEffect, useRef } from 'react';

function Welcome() {
  const ref = useRef(null);

  useEffect(() => {
    const duration = 1000;
    const node = ref.current;

    let startTime = performance.now();
    let frameId = null;

    function onFrame(now) {
      const timePassed = now - startTime;
      const progress = Math.min(timePassed / duration, 1);
      onProgress(progress);
      if (progress < 1) {
        // 我们还有更多的帧需要绘制
        frameId = requestAnimationFrame(onFrame);
      }
    }

    function onProgress(progress) {
      node.style.opacity = progress;
    }

    function start() {
      onProgress(0);
      startTime = performance.now();
      frameId = requestAnimationFrame(onFrame);
    }

    function stop() {
      cancelAnimationFrame(frameId);
      startTime = null;
      frameId = null;
    }

    start();
    return () => stop();
  }, []);

  return (
    <h1 className="welcome" ref={ref}>
      Welcome
    </h1>
  );
}

export default function App() {
  const [show, setShow] = useState(false);
  return (
    <>
      <button onClick={() => setShow(!show)}>
        {show ? 'Remove' : 'Show'}
      </button>
      <hr />
      {show && <Welcome />}
    </>
  );
}

为了让组件更具有可读性,你可能要将逻辑提取到自定义 Hook useFadeIn

jsx
import { useState, useEffect, useRef } from 'react';
import { useFadeIn } from './useFadeIn.js';

function Welcome() {
  const ref = useRef(null);

  useFadeIn(ref, 1000);

  return (
    <h1 className="welcome" ref={ref}>
      Welcome
    </h1>
  );
}

export default function App() {
  const [show, setShow] = useState(false);
  return (
    <>
      <button onClick={() => setShow(!show)}>
        {show ? 'Remove' : 'Show'}
      </button>
      <hr />
      {show && <Welcome />}
    </>
  );
}

useFadeIn.js

jsx
import { useEffect } from 'react';

export function useFadeIn(ref, duration) {
  useEffect(() => {
    const node = ref.current;

    let startTime = performance.now();
    let frameId = null;

    function onFrame(now) {
      const timePassed = now - startTime;
      const progress = Math.min(timePassed / duration, 1);
      onProgress(progress);
      if (progress < 1) {
        // 我们还有更多的帧需要绘制
        frameId = requestAnimationFrame(onFrame);
      }
    }

    function onProgress(progress) {
      node.style.opacity = progress;
    }

    function start() {
      onProgress(0);
      startTime = performance.now();
      frameId = requestAnimationFrame(onFrame);
    }

    function stop() {
      cancelAnimationFrame(frameId);
      startTime = null;
      frameId = null;
    }

    start();
    return () => stop();
  }, [ref, duration]);
}

你可以让 useFadeIn 和原来保持一致,但是也可以进一步重构。例如你可以把设置动画循环的逻辑从 useFadeIn 提取到自定义 Hook useAnimationLoop

jsx
import { useState, useEffect } from 'react';
import { useEffectEvent } from 'react';

export function useFadeIn(ref, duration) {
  const [isRunning, setIsRunning] = useState(true);

  useAnimationLoop(isRunning, (timePassed) => {
    const progress = Math.min(timePassed / duration, 1);
    ref.current.style.opacity = progress;
    if (progress === 1) {
      setIsRunning(false);
    }
  });
}

function useAnimationLoop(isRunning, drawFrame) {
  const onFrame = useEffectEvent(drawFrame);

  useEffect(() => {
    if (!isRunning) {
      return;
    }

    const startTime = performance.now();
    let frameId = null;

    function tick(now) {
      const timePassed = now - startTime;
      onFrame(timePassed);
      frameId = requestAnimationFrame(tick);
    }

    tick();
    return () => cancelAnimationFrame(frameId);
  }, [isRunning]);
}

但是 没有必要 这样做。和常规函数一样,最终是由你决定在哪里划分代码不同部分之间的边界。你也可以采取不一样的方法。把大部分必要的逻辑移入一个 JavaScript 类,而不是把逻辑保留在 Effect 中:

useFadeIn.js

jsx
import { useState, useEffect } from 'react';
import { FadeInAnimation } from './animation.js';

export function useFadeIn(ref, duration) {
  useEffect(() => {
    const animation = new FadeInAnimation(ref.current);
    animation.start(duration);
    return () => {
      animation.stop();
    };
  }, [ref, duration]);
}

animation.js

jsx
export class FadeInAnimation {
  constructor(node) {
    this.node = node;
  }
  start(duration) {
    this.duration = duration;
    this.onProgress(0);
    this.startTime = performance.now();
    this.frameId = requestAnimationFrame(() => this.onFrame());
  }
  onFrame() {
    const timePassed = performance.now() - this.startTime;
    const progress = Math.min(timePassed / this.duration, 1);
    this.onProgress(progress);
    if (progress === 1) {
      this.stop();
    } else {
      // 我们还有更多的帧要绘制
      this.frameId = requestAnimationFrame(() => this.onFrame());
    }
  }
  onProgress(progress) {
    this.node.style.opacity = progress;
  }
  stop() {
    cancelAnimationFrame(this.frameId);
    this.startTime = null;
    this.frameId = null;
    this.duration = 0;
  }
}

Effect 可以连接 React 和外部系统。Effect 之间的配合越多(例如链接多个动画),像上面的 sandbox 一样 完整地 从 Effect 和 Hook 中提取逻辑就越有意义。然后你提取的代码 变成 “外部系统”。这会让你的 Effect 保持简洁,因为他们只需要向已经被你移动到 React 外部的系统发送消息。

上面这个示例假设需要使用 JavaScript 写 fade-in 逻辑。但使用纯 CSS 动画 实现这个特定的 fade-in 动画会更加简单和高效:

App.js

jsx
import { useState, useEffect, useRef } from 'react';
import './welcome.css';

function Welcome() {
  return (
    <h1 className="welcome">
      Welcome
    </h1>
  );
}

export default function App() {
  const [show, setShow] = useState(false);
  return (
    <>
      <button onClick={() => setShow(!show)}>
        {show ? 'Remove' : 'Show'}
      </button>
      <hr />
      {show && <Welcome />}
    </>
  );
}

welcome.css

jsx
.welcome {
  color: white;
  padding: 50px;
  text-align: center;
  font-size: 50px;
  background-image: radial-gradient(circle, rgba(63,94,251,1) 0%, rgba(252,70,107,1) 100%);

  animation: fadeIn 1000ms;
}

@keyframes fadeIn {
  0% { opacity: 0; }
  100% { opacity: 1; }
}

评论

评论加载中……