分离事件和 Effects

事件处理器只有在你再次执行相同的交互操作时才会重新运行。与事件处理器不同,如果 Effect 读取的值(例如 prop 或状态变量)与上次渲染时不同,Effects 会重新同步。有时,你还需要同时使用这两种行为:一个 Effect 会响应某些值但不会响应其他值而重新运行。本页将教你如何做到这一点。

你将学习

  • 如何在事件处理器和 Effect 之间进行选择
  • 为什么 Effects 是响应式的,而事件处理器不是
  • 当你想让 Effect 代码的一部分不具有响应性时该怎么做
  • 什么是 Effect 事件,以及如何从你的 Effects 中提取它们
  • 如何使用 Effect 事件从 Effects 中读取最新的 props 和状态

在事件处理器和 Effects 之间进行选择

首先,让我们回顾一下事件处理器和 Effects 之间的区别。

想象一下,你正在实现一个聊天室组件。你的需求如下:

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

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

事件处理器响应特定的交互操作

从用户的角度来看,发送消息应该因为点击了特定的“发送”按钮而发生。如果在任何其他时间或任何其他原因发送他们的消息,用户会非常生气。这就是为什么发送消息应该是一个事件处理器。事件处理器允许你处理特定的交互操作。

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

使用事件处理器,你可以确保sendMessage(message)在用户按下按钮时运行。

Effects 在需要同步时运行

回想一下,你还需要保持组件连接到聊天室。这段代码放在哪里?

运行此代码的原因不是某些特定的交互操作。用户如何导航到聊天室屏幕并不重要。既然他们正在查看它并且可以与它交互,则组件需要保持连接到选定的聊天服务器。即使聊天室组件是应用程序的初始屏幕,并且用户根本没有执行任何交互操作,你仍然需要连接。这就是为什么它是 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://127.0.0.1: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>Welcome to the {roomId} room!</h1>
      <input value={message} onChange={e => setMessage(e.target.value)} />
      <button onClick={handleSendClick}>Send</button>
    </>
  );
}

export default function App() {
  const [roomId, setRoomId] = useState('general');
  const [show, setShow] = useState(false);
  return (
    <>
      <label>
        Choose the chat room:{' '}
        <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 ? 'Close chat' : 'Open chat'}
      </button>
      {show && <hr />}
      {show && <ChatRoom roomId={roomId} />}
    </>
  );
}

响应式值和响应式逻辑

直观地说,事件处理程序总是“手动”触发,例如点击按钮。另一方面,Effects是“自动的”:它们根据需要运行和重新运行,以保持同步。

有一种更精确的思考方式。

组件主体内部声明的 Props、状态和变量称为响应式值。在这个例子中,serverUrl不是响应式值,但roomIdmessage是。它们参与渲染数据流。

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

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

// ...
}

这些响应式值可能会由于重新渲染而发生变化。例如,用户可能会编辑message或在下拉列表中选择不同的roomId。事件处理程序和 Effects 对变化的响应方式不同。

  • 事件处理程序内部的逻辑不是响应式的。除非用户再次执行相同的交互(例如点击),否则它不会再次运行。事件处理程序可以读取响应式值,而无需“响应”其变化。
  • Effects内部的逻辑是响应式的。如果你的 Effect 读取了响应式值,你必须将其指定为依赖项。然后,如果重新渲染导致该值发生变化,React 将使用新值重新运行你的 Effect 的逻辑。

让我们重新审视前面的示例来说明这种区别。

事件处理程序内部的逻辑不是响应式的

看一下这行代码。这个逻辑应该是响应式的还是非响应式的?

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

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

function handleSendClick() {
sendMessage(message);
}

事件处理程序不是响应式的,因此sendMessage(message) 只有在用户点击“发送”按钮时才会运行。

Effects内部的逻辑是响应式的

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

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

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

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

Effects 是响应式的,因此 createConnection(serverUrl, roomId)connection.connect() 将针对roomId 的每个不同值运行。你的 Effect 使聊天连接与当前选择的房间保持同步。

从 Effects 中提取非响应式逻辑

当你想将响应式逻辑与非响应式逻辑混合时,事情会变得更加棘手。

例如,假设你想在用户连接到聊天时显示通知。你从 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]); // ✅ All dependencies declared
// ...

使用此示例,看看你是否能发现此用户体验的问题。

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

const serverUrl = 'https://127.0.0.1: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>Welcome to the {roomId} room!</h1>
}

export default function App() {
  const [roomId, setRoomId] = useState('general');
  const [isDark, setIsDark] = useState(false);
  return (
    <>
      <label>
        Choose the chat room:{' '}
        <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)}
        />
        Use dark theme
      </label>
      <hr />
      <ChatRoom
        roomId={roomId}
        theme={isDark ? 'dark' : 'light'}
      />
    </>
  );
}

roomId 改变时,聊天会像预期的那样重新连接。但由于theme 也是一个依赖项,因此每次你在深色和浅色主题之间切换时,聊天也会重新连接。这不好!

换句话说,你不希望这一行是响应式的,即使它在 Effect(它是响应式的)内部。

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

你需要一种方法将这种非响应式逻辑与周围的响应式 Effect 分离开来。

声明 Effect 事件

建设中

本节描述的是尚未在 React 的稳定版本中发布的实验性 API。

使用一个名为useEffectEvent的特殊 Hook 来从你的 Effect 中提取此非响应式逻辑。

import { useEffect, useEffectEvent } from 'react';

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

在这里,onConnected 被称为Effect 事件。它是 Effect 逻辑的一部分,但它的行为更像事件处理程序。它内部的逻辑不是响应式的,并且它总是“看到”props 和 state 的最新值。

现在你可以从 Effect 内部调用onConnected Effect 事件。

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]); // ✅ All dependencies declared
// ...

这解决了问题。请注意,您必须从 Effect 的依赖项列表中移除 onConnectedEffect 事件不是响应式的,必须从依赖项中省略。

验证新行为是否符合预期。

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

const serverUrl = 'https://127.0.0.1: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>Welcome to the {roomId} room!</h1>
}

export default function App() {
  const [roomId, setRoomId] = useState('general');
  const [isDark, setIsDark] = useState(false);
  return (
    <>
      <label>
        Choose the chat room:{' '}
        <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)}
        />
        Use dark theme
      </label>
      <hr />
      <ChatRoom
        roomId={roomId}
        theme={isDark ? 'dark' : 'light'}
      />
    </>
  );
}

您可以将 Effect 事件视为与事件处理程序非常相似。主要区别在于事件处理程序响应用户交互运行,而 Effect 事件是由您从 Effects 中触发的。Effect 事件允许您“打破”Effects 的响应性和不应具有响应性的代码之间的链。

使用 Effect 事件读取最新的 props 和状态

建设中

本节描述的是尚未在 React 的稳定版本中发布的实验性 API。

Effect 事件允许您修复许多您可能想抑制依赖性代码检查器警告的模式。

例如,假设您有一个 Effect 来记录页面访问。

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

稍后,您向站点添加多个路由。现在您的 Page 组件接收一个带有当前路径的 url prop。您想将 url 作为 logVisit 调用的一个部分,但是依赖性代码检查器会报错。

function Page({ url }) {
useEffect(() => {
logVisit(url);
}, []); // 🔴 React Hook useEffect has a missing dependency: 'url'
// ...
}

考虑一下您希望代码执行的操作。您希望为不同的 URL 记录单独的访问,因为每个 URL 代表不同的页面。换句话说,此 logVisit 调用应该相对于 url 具有响应性。这就是为什么在这种情况下,遵循依赖性代码检查器并将 url 添加为依赖项是有意义的。

function Page({ url }) {
useEffect(() => {
logVisit(url);
}, [url]); // ✅ All dependencies declared
// ...
}

现在假设您想将购物车中的商品数量与每次页面访问一起包含。

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

useEffect(() => {
logVisit(url, numberOfItems);
}, [url]); // 🔴 React Hook useEffect has a missing dependency: '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]); // ✅ All dependencies declared
// ...
}

这里,onVisit 是一个 Effect 事件。其中的代码不是响应式的。这就是为什么您可以使用 numberOfItems(或任何其他响应式值!)而无需担心它会在更改时导致周围代码重新执行。

另一方面,Effect 本身保持响应性。Effect 内部的代码使用 url prop,因此 Effect 将在每次重新渲染具有不同 url 后重新运行。这反过来将调用 onVisit Effect 事件。

结果,您将为 url 的每次更改调用 logVisit,并且始终读取最新的 numberOfItems。但是,如果 numberOfItems 自行更改,这不会导致任何代码重新运行。

注意

您可能想知道是否可以不带参数调用 onVisit(),并在其中读取 url

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

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

这可以工作,但最好显式地将此 url 传递到 Effect 事件。 通过将 url 作为参数传递给您的 Effect 事件,您是在说从用户的角度来看,访问具有不同 url 的页面构成了一个单独的“事件”。 visitedUrl 是发生的“事件”的一部分

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

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

由于您的 Effect 事件显式地“请求” visitedUrl,现在您无法意外地从 Effect 的依赖项中删除 url。如果您删除 url 依赖项(导致将不同的页面访问计算为一个),代码检查器将警告您。您希望 onVisiturl 具有响应性,因此您不是在内部读取 url(在那里它不会具有响应性),而是将其从您的 Effect 中传递。

如果 Effect 中存在一些异步逻辑,这将变得尤为重要。

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

useEffect(() => {
setTimeout(() => {
onVisit(url);
}, 5000); // Delay logging visits
}, [url]);

这里,urlonVisit 中对应于最新url(可能已经更改),但 visitedUrl 对应于最初导致此 Effect(以及此 onVisit 调用)运行的 url

深入探讨

是否可以改为抑制依赖性代码检查器警告?

在现有的代码库中,您有时可能会看到 lint 规则被这样抑制。

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

useEffect(() => {
logVisit(url, numberOfItems);
// 🔴 Avoid suppressing the linter like this:
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [url]);
// ...
}

useEffectEvent 成为 React 的稳定部分后,我们建议永远不要抑制代码检查器

抑制规则的第一个缺点是 React 将不再警告您何时您的 Effect 需要对您引入代码的新响应式依赖项“做出反应”。在之前的示例中,您将 url 添加到依赖项中,因为React 提醒您这样做。如果您禁用代码检查器,您将不再收到对该 Effect 的任何未来编辑的此类提醒。这会导致错误。

这是一个由抑制代码检查器引起的令人困惑的bug示例。在这个例子中,handleMove 函数应该读取当前的 canMove 状态变量的值,以决定点是否应该跟随光标。但是,在 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)}
        />
        The dot is allowed to move
      </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,
      }} />
    </>
  );
}

这段代码的问题在于抑制了依赖性代码检查器。如果删除抑制,你会发现这个Effect应该依赖于handleMove 函数。这是有道理的:handleMove 在组件主体内部声明,这使其成为一个响应式值。每个响应式值都必须指定为依赖项,否则它可能会随着时间的推移而过时!

原始代码的作者通过声明 Effect 不依赖于 ([]) 任何响应式值来“欺骗”React。这就是为什么在 canMove 改变(以及 handleMove 随之改变)后,React 没有重新同步 Effect。因为 React 没有重新同步 Effect,所以作为监听器附加的 handleMove 是在初始渲染期间创建的 handleMove 函数。在初始渲染期间,canMovetrue,这就是为什么来自初始渲染的 handleMove 将永远看到该值。

如果你从未抑制代码检查器,你将永远不会看到过时值的问题。

使用 useEffectEvent,无需“欺骗”代码检查器,代码就能按预期工作。

import { useState, useEffect } from 'react';
import { experimental_useEffectEvent as 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)}
        />
        The dot is allowed to move
      </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 事件有意义的原因。

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

Effect 事件的局限性

建设中

本节描述的是尚未在 React 的稳定版本中发布的实验性 API。

Effect 事件的使用方式非常有限。

  • 只能从 Effect 内部调用它们。
  • 绝不要将它们传递给其他组件或 Hooks。

例如,不要像这样声明和传递 Effect 事件

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

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

useTimer(onTick, 1000); // 🔴 Avoid: Passing Effect Events

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

function useTimer(callback, delay) {
useEffect(() => {
const id = setInterval(() => {
callback();
}, delay);
return () => {
clearInterval(id);
};
}, [delay, callback]); // Need to specify "callback" in dependencies
}

相反,始终直接在使用它们的 Effect 旁边声明 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(); // ✅ Good: Only called locally inside an Effect
}, delay);
return () => {
clearInterval(id);
};
}, [delay]); // No need to specify "onTick" (an Effect Event) as a dependency
}

Effect 事件是 Effect 代码的非响应式“片段”。它们应该与使用它们的 Effect 放在一起。

总结

  • 事件处理程序响应特定交互而运行。
  • Effect 在需要同步时运行。
  • 事件处理程序内部的逻辑是非响应式的。
  • Effect 内部逻辑是响应式的。
  • 您可以将 Effect 中的非响应式逻辑移动到 Effect 事件中。
  • 只能从 Effect 内部调用 Effect 事件。
  • 不要将 Effect 事件传递给其他组件或 Hooks。

挑战 1 4:
修复不更新的变量

这个 Timer 组件维护一个 count 状态变量,该变量每秒增加一次。它增加的值存储在 increment 状态变量中。您可以使用加号和减号按钮来控制 increment 变量。

但是,无论您点击加号按钮多少次,计数器每秒仍然只增加 1。这段代码哪里错了?为什么在 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>
        Counter: {count}
        <button onClick={() => setCount(0)}>Reset</button>
      </h1>
      <hr />
      <p>
        Every second, increment by:
        <button disabled={increment === 0} onClick={() => {
          setIncrement(i => i - 1);
        }}></button>
        <b>{increment}</b>
        <button onClick={() => {
          setIncrement(i => i + 1);
        }}>+</button>
      </p>
    </>
  );
}