将事件与 Effect 分离

事件处理函数只有在你再次执行同样的交互时才会重新运行。与事件处理函数不同,如果某个 Effect 读取的值(比如 props 或 state 变量)与上一次渲染时不同,Effect 会重新同步。有时候,你也会希望同时具备这两种行为:一个会根据某些值重新运行、但不会因其他值而重新运行的 Effect。本页将教你如何做到这一点。

You will learn

  • 如何在事件处理函数和 Effect 之间做选择
  • 为什么 Effect 是响应式的,而事件处理函数不是
  • 当你希望 Effect 中的一部分代码不具备响应性时该怎么做
  • 什么是 Effect Event,以及如何从 Effect 中提取它们
  • 如何使用 Effect Event 从 Effect 中读取最新的 props 和 state

在事件处理函数和 Effect 之间做选择

首先,让我们回顾一下事件处理函数和 Effect 的区别。

假设你正在实现一个聊天室组件。你的需求如下:

  1. 组件应当自动连接到所选的聊天室。
  2. 当你点击 “Send” 按钮时,它应该向聊天发送一条消息。

假设你已经为它们实现好了代码,但不确定该放在哪里。应该使用事件处理函数还是 Effect?每次你需要回答这个问题时,都要考虑 为什么 这段代码需要运行。

事件处理函数会响应特定交互而运行

从用户的角度看,发送消息应该是因为点击了特定的 “Send” 按钮才发生的。如果你在其他时间或出于其他原因发送他们的消息,用户会相当不满。这就是为什么发送消息应该是一个事件处理函数。事件处理函数让你处理特定交互:

function ChatRoom({ roomId }) {
const [message, setMessage] = useState('');
// ...
function handleSendClick() {
sendMessage(message);
}
// ...
return (
<>
<input value={message} onChange={e => setMessage(e.target.value)} />
<button onClick={handleSendClick}>发送</button>
</>
);
}

有了事件处理函数,你可以确定 sendMessage(message) 只会在用户按下按钮时运行。

Effect 会在需要同步时运行

还记得你也需要保持组件连接到聊天室。那段代码应该放在哪里?

运行这段代码的原因并不是某个特定交互。用户是如何、为什么进入聊天室界面的并不重要。现在他们正在查看它并可能与之交互,组件需要保持与所选聊天服务器的连接。即使聊天室组件是应用的初始界面,且用户根本没有进行任何交互,你仍然需要连接。这就是为什么它应该是一个 Effect:

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

有了这段代码,你可以确定始终有一个到当前所选聊天服务器的活动连接,无论用户执行了什么具体交互。无论用户只是打开了你的应用、选择了不同的房间,还是切换到别的界面再回来,你的 Effect 都会确保组件始终与当前所选房间保持同步,并且会在必要时重新连接。

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

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

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

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

  function handleSendClick() {
    sendMessage(message);
  }

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

export default function App() {
  const [roomId, setRoomId] = useState('general');
  const [show, setShow] = useState(false);
  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>
      <button onClick={() => setShow(!show)}>
        {show ? '关闭聊天' : '打开聊天'}
      </button>
      {show && <hr />}
      {show && <ChatRoom roomId={roomId} />}
    </>
  );
}

响应式值与响应式逻辑

直观地说,你可以认为事件处理函数总是被“手动”触发的,例如通过点击按钮。另一方面,Effect 是“自动”的:它们会按需运行和重新运行,以保持同步。

不过,我们可以更精确地理解这一点。

在组件函数体内声明的 props、state 和变量被称为响应式值。在这个例子中,serverUrl 不是响应式值,而 roomId 和 message 是。它们参与渲染数据流:

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

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

// ...
}

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

  • 事件处理函数中的逻辑不是响应式的。 除非用户再次执行同样的交互(例如点击)否则它不会再次运行。事件处理函数可以读取响应式值,而不会对它们的变化“做出反应”。
  • Effect 中的逻辑是响应式的。 如果你的 Effect 读取了一个响应式值,你必须将它指定为依赖项。 然后,如果一次重新渲染导致该值变化,React 会使用新值重新运行你的 Effect 逻辑。

让我们回到前面的例子来说明这种区别。

事件处理函数中的逻辑不是响应式的

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

// ...
sendMessage(message);
// ...

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

function handleSendClick() {
sendMessage(message);
}

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

Effect 中的逻辑是响应式的

现在让我们回到这些代码行:

// ...
const connection = createConnection(serverUrl, roomId);
connection.connect();
// ...

从用户的角度看,roomId 的变化确实意味着他们想要连接到另一个房间。 换句话说,连接房间的逻辑应该是响应式的。你希望这些代码行能“跟上”响应式值,并在该值变化时重新运行。这就是它属于 Effect 的原因:

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

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

将非响应式逻辑从 Effect 中提取出来

当你想把响应式逻辑与非响应式逻辑混合在一起时,事情就会变得更复杂。

例如,假设你想在用户连接到聊天时显示一条通知。你从 props 中读取当前主题(深色或浅色),以便用正确的颜色显示通知:

function ChatRoom({ roomId, theme }) {
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.on('connected', () => {
showNotification('Connected!', theme);
});
connection.connect();
// ...

然而,theme 是一个响应式值(它可能会因为重新渲染而变化),并且Effect 读取到的每一个响应式值都必须被声明为依赖项。 现在你必须把 theme 指定为 Effect 的依赖项:

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

试试这个示例,看看你能否找出这个用户体验中的问题:

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('Connected!', theme);
    });
    connection.connect();
    return () => connection.disconnect();
  }, [roomId, theme]);

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

export default function App() {
  const [roomId, setRoomId] = useState('general');
  const [isDark, setIsDark] = useState(false);
  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>
      <label>
        <input
          type="checkbox"
          checked={isDark}
          onChange={e => setIsDark(e.target.checked)}
        />
        使用深色主题
      </label>
      <hr />
      <ChatRoom
        roomId={roomId}
        theme={isDark ? 'dark' : 'light'}
      />
    </>
  );
}

当 roomId 变化时,聊天会按预期重新连接。但由于 theme 也是一个依赖项,所以每次你在深色和浅色主题之间切换时,聊天也会重新连接。这不太好!

换句话说,尽管这行代码位于一个 Effect 中(而 Effect 是响应式的),你并不希望它是响应式的:

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

你需要一种方法,把这段非响应式逻辑从其外层的响应式 Effect 中分离出来。

声明一个 Effect Event

使用一个名为 useEffectEvent 的特殊 Hook 来把这段非响应式逻辑从你的 Effect 中提取出来:

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:

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]); // ✅ 已声明所有依赖项
// ...

这解决了问题。注意,你必须把 theme 从 Effect 的依赖项列表中移除,因为它不再在 Effect 中使用了。你也不需要把 onConnected 添加进去,因为Effect Event 不是响应式的,必须从依赖项中省略。

验证一下新的行为是否符合预期:

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('Connected!', 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('general');
  const [isDark, setIsDark] = useState(false);
  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>
      <label>
        <input
          type="checkbox"
          checked={isDark}
          onChange={e => setIsDark(e.target.checked)}
        />
        使用深色主题
      </label>
      <hr />
      <ChatRoom
        roomId={roomId}
        theme={isDark ? 'dark' : 'light'}
      />
    </>
  );
}

你可以把 Effect Event 看作和事件处理函数非常相似。主要区别在于,事件处理函数是响应用户交互而运行,而 Effect Events 是由你从 Effect 中触发的。Effect Events 让你可以“打断”Effect 的响应式链条与那些不应该响应式的代码之间的联系。

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

Effect Event 可以帮助你修复很多你可能想通过抑制依赖项检查器来处理的模式。

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

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

后来,你的网站增加了多个路由。现在你的 Page 组件会接收一个表示当前路径的 url prop。你希望在 logVisit 调用中传入 url,但依赖项检查器会报错:

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

想一想你希望这段代码做什么。你希望针对不同的 URL 记录分别的访问,因为每个 URL 都代表一个不同的页面。换句话说,这次 logVisit 调用应该对 url 保持响应式。这就是为什么在这种情况下,遵循依赖项检查器并把 url 加入依赖项是合理的:

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

现在假设你想在每次页面访问时都把购物车中的商品数量一起记录:

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 变化,这并不意味着用户再次访问了页面。换句话说,访问页面在某种意义上是一个“事件”。它发生在时间上的某个精确时刻。

把代码拆成两部分:

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 本身仍然是响应式的。Effect 内部的代码使用了 url prop,因此 Effect 会在每次重新渲染且 url 不同时重新运行。而这又会调用 onVisit 这个 Effect Event。

结果就是,你会在 url 每次变化时都调用 logVisit,并且始终读取到最新的 numberOfItems。不过,如果 numberOfItems 自己变化了,这不会导致任何代码重新运行。

Note

你可能会想,是否可以直接不带参数地调用 onVisit(),然后在其中读取 url:

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

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

这也能工作,但最好还是把这个 url 显式作为参数传给 Effect Event。通过把 url 作为参数传给你的 Effect Event,你是在说,从用户的角度看,访问一个不同 url 的页面构成了一个单独的“事件”。 visitedUrl 是发生的“事件”的一部分:

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

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

由于你的 Effect Event 明确“要求”传入 visitedUrl,现在你就不可能意外地从 Effect 的依赖项中移除 url。如果你移除了 url 依赖项(导致不同页面访问被算作一次),检查器会提醒你。你希望 onVisit 对 url 保持响应式,所以不要在内部读取 url(那样它就不会响应式),而是从你的 Effect 中把它传进去。

如果 Effect 中存在某些异步逻辑,这一点尤其重要:

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

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

这里 onVisit 内部的 url 对应的是最新的 url(它可能已经改变),而 visitedUrl 对应的是最初触发这个 Effect(以及这次 onVisit 调用)运行的那个 url。

Deep Dive

直接抑制依赖项检查器可以吗?

在现有代码库中,你有时可能会看到像这样抑制 lint 规则:

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

useEffect(() => {
logVisit(url, numberOfItems);
// 🔴 不要像这样抑制检查器:
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [url]);
// ...
}

我们建议永远不要抑制检查器。

抑制规则的第一个缺点是:当你的 Effect 需要“响应”你后来添加到代码中的新响应式依赖时,React 将不再提醒你。在前面的例子里,你之所以把 url 加入依赖项,正是因为 React 提醒了你这么做。如果你禁用了检查器,以后对这个 Effect 的任何修改都不会再得到这样的提醒。这会导致 bug。

下面是一个因抑制检查器而导致的令人困惑的 bug 示例。在这个例子里,handleMove 函数本应读取当前 canMove state 变量的值,以决定这个点是否应该跟随光标。然而,handleMove 内部的 canMove 总是 true。

你能看出原因吗?

import { useState, useEffect } from 'react';

export default function App() {
  const [position, setPosition] = useState({ x: 0, y: 0 });
  const [canMove, setCanMove] = useState(true);

  function handleMove(e) {
    if (canMove) {
      setPosition({ x: e.clientX, y: e.clientY });
    }
  }

  useEffect(() => {
    window.addEventListener('pointermove', handleMove);
    return () => window.removeEventListener('pointermove', handleMove);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  return (
    <>
      <label>
        <input type="checkbox"
          checked={canMove}
          onChange={e => setCanMove(e.target.checked)}
        />
        允许这个点移动
      </label>
      <hr />
      <div style={{
        position: 'absolute',
        backgroundColor: 'pink',
        borderRadius: '50%',
        opacity: 0.6,
        transform: `translate(${position.x}px, ${position.y}px)`,
        pointerEvents: 'none',
        left: -20,
        top: -20,
        width: 40,
        height: 40,
      }} />
    </>
  );
}

这段代码的问题在于抑制了依赖项检查器。如果你移除这个抑制,React 会告诉你这个 Effect 的代码依赖于 handleMove 函数。这是有道理的:handleMove 定义在组件函数体内,因此它是一个响应式值。每个响应式值都必须被指定为依赖项,否则它就可能随着时间推移变得陈旧!

原始代码的作者对 React “撒了谎”,声称这个 Effect 不依赖任何响应式值([])。这就是为什么 canMove 改变后,React 没有重新同步这个 Effect(以及其中的 handleMove)。因为 React 没有重新同步这个 Effect,作为监听器附加上的 handleMove 就是初始渲染时创建的那个 handleMove 函数。在初始渲染时,canMove 是 true,这就是为什么初始渲染中的 handleMove 永远看到这个值。

如果你从不抑制检查器,就永远不会看到陈旧值的问题。

使用 useEffectEvent 时,就没有必要对检查器“撒谎”,代码也会如你所期望的那样工作:

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

export default function App() {
  const [position, setPosition] = useState({ x: 0, y: 0 });
  const [canMove, setCanMove] = useState(true);

  const onMove = useEffectEvent(e => {
    if (canMove) {
      setPosition({ x: e.clientX, y: e.clientY });
    }
  });

  useEffect(() => {
    window.addEventListener('pointermove', onMove);
    return () => window.removeEventListener('pointermove', onMove);
  }, []);

  return (
    <>
      <label>
        <input type="checkbox"
          checked={canMove}
          onChange={e => setCanMove(e.target.checked)}
        />
        允许这个点移动
      </label>
      <hr />
      <div style={{
        position: 'absolute',
        backgroundColor: 'pink',
        borderRadius: '50%',
        opacity: 0.6,
        transform: `translate(${position.x}px, ${position.y}px)`,
        pointerEvents: 'none',
        left: -20,
        top: -20,
        width: 40,
        height: 40,
      }} />
    </>
  );
}

这并不意味着 useEffectEvent 总是正确的解决方案。你只应将它应用于那些你不希望具备响应性的代码行。在上面的沙盒中,你不希望 Effect 的代码对 canMove 保持响应式。这就是为什么提取一个 Effect Event 是合理的。

请阅读移除 Effect 依赖项了解其他可以正确替代抑制检查器的方法。

Effect Event 的限制

Effect Event 的使用非常受限:

  • 只能在 Effect 内部调用它们。
  • 绝不要把它们传给其他组件或 Hook。

例如,不要像这样声明并传递一个 Effect Event:

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"
}

相反,始终把 Effect Event 直接声明在使用它的 Effect 附近:

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 附近。

Recap

  • 事件处理函数会响应特定交互而运行。
  • Effect 会在需要同步时运行。
  • 事件处理函数中的逻辑不是响应式的。
  • Effect 中的逻辑是响应式的。
  • 你可以把 Effect 中的非响应式逻辑移动到 Effect Event 中。
  • 只能在 Effect 内部调用 Effect Event。
  • 不要把 Effect Event 传给其他组件或 Hook。

Challenge 1 of 4:
修复一个不会更新的变量

这个 Timer 组件维护了一个 count state 变量,它每秒增加一次。它每次增加的值存储在 increment state 变量中。你可以通过加号和减号按钮控制 increment 变量。

然而,不管你点击多少次加号按钮,计数器每秒仍然只会加一。这个代码哪里有问题?为什么在 Effect 的代码里 increment 总是等于 1?找出错误并修复它。

import { useState, useEffect } from 'react';

export default function Timer() {
  const [count, setCount] = useState(0);
  const [increment, setIncrement] = useState(1);

  useEffect(() => {
    const id = setInterval(() => {
      setCount(c => c + increment);
    }, 1000);
    return () => {
      clearInterval(id);
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  return (
    <>
      <h1>
        计数器:{count}
        <button onClick={() => setCount(0)}>重置</button>
      </h1>
      <hr />
      <p>
        每秒增加:
        <button disabled={increment === 0} onClick={() => {
          setIncrement(i => i - 1);
        }}>–</button>
        <b>{increment}</b>
        <button onClick={() => {
          setIncrement(i => i + 1);
        }}>+</button>
      </p>
    </>
  );
}