将事件与效果分离

事件处理程序仅在您再次执行相同交互时才会重新运行。 与事件处理程序不同,如果 Effects 读取的某些值(例如 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} />}
    </>
  );
}

响应式值和响应式逻辑

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

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

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

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

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

// ...
}

像这样的响应式值可能会因重新渲染而改变。例如,用户可能会编辑 message 或在下拉菜单中选择不同的 roomId。事件处理程序和副作用以不同的方式响应这些变化。

  • 事件处理程序内的逻辑*不是*响应式的。 除非用户再次执行相同的交互(例如单击),否则它不会再次运行。事件处理程序可以在不“响应”其更改的情况下读取响应式值。
  • 副作用内的逻辑*是*响应式的。 如果你的副作用读取了一个响应式值,你必须将其指定为依赖项。 然后,如果重新渲染导致该值发生变化,React 将使用新值重新运行你的副作用逻辑。

让我们重新回顾前面的例子来说明这种差异。

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

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

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

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

function handleSendClick() {
sendMessage(message);
}

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

副作用内的逻辑是响应式的

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

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

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

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

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

从副作用中提取非响应式逻辑

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

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

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

但是,theme 是一个响应式值(它可以作为重新渲染的结果而改变),并且 副作用读取的每个响应式值都必须声明为其依赖项。 现在你必须将 theme 指定为副作用的依赖项。

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 也是一个依赖项,因此每次你在深色和浅色主题之间切换时,聊天*也会*重新连接。这可不太好!

换句话说,你*不希望*这行代码是响应式的,即使它在副作用内部(它是响应式的)。

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

你需要一种方法将此非响应式逻辑与其周围的响应式副作用分开。

声明副作用事件

建设中

本节介绍了在稳定版本的 React 中*尚未发布*的**实验性 API**。

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

import { useEffect, useEffectEvent } from 'react';

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

在这里,onConnected 被称为*副作用事件*。它是你的副作用逻辑的一部分,但它的行为更像事件处理程序。它内部的逻辑不是响应式的,它总是“看到”你的 props 和状态的最新值。

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

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
// ...

这解决了问题。请注意,你必须从副作用的依赖项列表中*删除* onConnected副作用事件不是响应式的,必须从依赖项中省略。

验证新行为是否按预期工作。

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'}
      />
    </>
  );
}

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

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

建设中

本节介绍了在稳定版本的 React 中*尚未发布*的**实验性 API**。

副作用事件允许你修复许多你可能想要抑制依赖项 linter 的模式。

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

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

之后,您向网站添加了多个路由。现在,您的 Page 组件会接收一个带有当前路径的 url 属性。您希望将 url 作为 logVisit 调用的一部分传递,但依赖项 linter 会发出警告

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

想一想您希望代码做什么。您希望为不同的 URL 记录单独的访问量,因为每个 URL 都代表一个不同的页面。换句话说,此 logVisit 调用应该url 做出响应。这就是为什么在这种情况下,遵循依赖项 linter 并将 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,因此 linter 要求您将其添加为依赖项。但是,您不希望 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 属性,因此 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 依赖项(导致将不同的页面访问计数为一次),linter 会向您发出警告。您希望 onVisiturl 做出响应,因此不要在内部读取 url(在那里它不会做出响应),而是您的 Effect 中传递它。

如果 Effect 中有一些异步逻辑,这一点尤其重要

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

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

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

深入探讨

是否可以改为抑制依赖项 linter?

在现有的代码库中,您有时可能会看到像这样抑制了 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 的稳定部分后,我们建议永远不要抑制 linter

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

下面是一个由抑制 linter 引起的令人困惑的错误示例。在此示例中,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,
      }} />
    </>
  );
}

此代码的问题在于抑制了依赖项 linter。如果您删除抑制,您将看到此 Effect 应该依赖于 handleMove 函数。这很有道理:handleMove 在组件主体内部声明,这使其成为一个响应式值。每个响应式值都必须指定为依赖项,否则它可能会随着时间的推移而变得过时!

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

如果你从不抑制 linter,你将永远不会看到过时值的问题。

使用 useEffectEvent,无需对 linter “撒谎”,并且代码会按预期工作

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 依赖项,了解抑制 linter 的其他正确替代方法。

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 变量。

但是,无论你点击加号按钮多少次,计数器仍然每秒递增一。这段代码有什么问题?为什么在 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>
    </>
  );
}