响应式 Effect 的生命周期

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

You will learn

  • Effect 的生命周期与组件的生命周期有何不同
  • 如何独立地看待每一个 Effect
  • 你的 Effect 何时需要重新同步,以及原因是什么
  • 你的 Effect 的依赖项是如何确定的
  • 值具有响应性是什么意思
  • 空依赖数组意味着什么
  • React 如何通过 linter 验证你的依赖项是否正确
  • 当你不同意 linter 的判断时该怎么做

Effect 的生命周期

每个 React 组件都会经历相同的生命周期:

  • 组件在被添加到屏幕上时会 挂载
  • 组件在接收到新的 props 或 state 时会 更新,通常是为了响应某个交互。
  • 组件在从屏幕上移除时会 卸载

把这用于理解组件是很好的,但不适用于理解 Effect。 相反,尝试把每个 Effect 看作是独立于组件生命周期的。Effect 描述的是如何将外部系统同步到当前的 props 和 state。随着代码变化,同步需要更频繁地发生,或者更少地发生。

为了说明这一点,考虑这个将组件连接到聊天服务器的 Effect:

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

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

你的 Effect 主体指定了如何 开始同步:

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

由你的 Effect 返回的清理函数指定了如何 停止同步:

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

直觉上,你可能会认为 React 会在组件挂载时 开始同步,并在组件卸载时 停止同步。然而,事情并没有这么简单!有时,即使组件仍然处于挂载状态,也可能需要 多次开始和停止同步

让我们看看这是 为什么 需要的,何时 会发生,以及 如何 控制这种行为。

Note

有些 Effect 根本不会返回清理函数。大多数情况下, 你都会希望返回一个清理函数——但如果你没有返回,React 的行为就会像你返回了一个空的清理函数一样。

为什么同步可能需要发生不止一次

想象一下,这个 ChatRoom 组件接收了一个 roomId prop,而用户通过下拉框来选择它。假设一开始用户选择的是 "general" 房间作为 roomId。你的应用会显示 "general" 聊天室:

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

function ChatRoom({ roomId /* "general" */ }) {
// ...
return <h1>Welcome to the {roomId} room!</h1>;
}

在 UI 显示出来之后,React 会运行你的 Effect 来 开始同步。 它会连接到 "general" 房间:

function ChatRoom({ roomId /* "general" */ }) {
useEffect(() => {
const connection = createConnection(serverUrl, roomId); // 连接到 "general" 房间
connection.connect();
return () => {
connection.disconnect(); // 从 "general" 房间断开连接
};
}, [roomId]);
// ...

到目前为止,一切都很好。

之后,用户在下拉框里选择了另一个房间(例如 "travel")。首先,React 会更新 UI:

function ChatRoom({ roomId /* "travel" */ }) {
// ...
return <h1>Welcome to the {roomId} room!</h1>;
}

想一想接下来应该发生什么。用户在 UI 中看到 "travel" 是当前选中的聊天室。然而,上一次运行的 Effect 仍然连接在 "general" 房间。roomId prop 已经变了,所以你的 Effect 之前所做的事情(连接到 "general" 房间)已经不再与 UI 匹配了。

此时,你希望 React 做两件事:

  1. 停止与旧的 roomId 同步(从 "general" 房间断开连接)
  2. 开始与新的 roomId 同步(连接到 "travel" 房间)

幸运的是,你已经教会 React 如何完成这两件事! 你的 Effect 主体指定了如何开始同步,而清理函数指定了如何停止同步。React 现在需要做的就是以正确的顺序、使用正确的 props 和 state 来调用它们。让我们看看这具体是怎么发生的。

React 如何重新同步你的 Effect

回想一下,你的 ChatRoom 组件收到了 roomId prop 的新值。它之前是 "general",现在是 "travel"。React 需要重新同步你的 Effect,把你重新连接到另一个房间。

为了 停止同步, React 会调用你的 Effect 在连接到 "general" 房间后返回的清理函数。由于 roomId"general",清理函数会断开与 "general" 房间的连接:

function ChatRoom({ roomId /* "general" */ }) {
useEffect(() => {
const connection = createConnection(serverUrl, roomId); // 连接到 "general" 房间
connection.connect();
return () => {
connection.disconnect(); // 从 "general" 房间断开连接
};
// ...

然后 React 会运行你在这次渲染中提供的 Effect。这一次,roomId"travel",所以它会 开始同步"travel" 聊天室(直到它的清理函数最终也被调用):

function ChatRoom({ roomId /* "travel" */ }) {
useEffect(() => {
const connection = createConnection(serverUrl, roomId); // 连接到 "travel" 房间
connection.connect();
// ...

得益于此,你现在已经连接到了用户在 UI 中选择的同一个房间。避免了一场灾难!

每次你的组件因为不同的 roomId 重新渲染后,你的 Effect 都会重新同步。例如,假设用户将 roomId"travel" 改为 "music"。React 会再次通过调用清理函数来 停止同步 你的 Effect(把你从 "travel" 房间断开)。然后它会再次运行主体,并使用新的 roomId prop 来 开始同步(连接到 "music" 房间)。

最后,当用户切换到另一个界面时,ChatRoom 会卸载。此时已经完全没有必要继续保持连接。React 会最后一次 停止同步 你的 Effect,并让你从 "music" 聊天室断开连接。

从 Effect 的视角思考

让我们回顾一下从 ChatRoom 组件的视角发生了什么:

  1. ChatRoom 挂载,roomId 设为 "general"
  2. ChatRoom 更新,roomId 设为 "travel"
  3. ChatRoom 更新,roomId 设为 "music"
  4. ChatRoom 卸载

在组件生命周期中的这些时刻里,你的 Effect 做了不同的事情:

  1. 你的 Effect 连接到了 "general" 房间
  2. 你的 Effect 从 "general" 房间断开,并连接到 "travel" 房间
  3. 你的 Effect 从 "travel" 房间断开,并连接到 "music" 房间
  4. 你的 Effect 从 "music" 房间断开

现在让我们从 Effect 本身的角度来思考发生了什么:

useEffect(() => {
// 你的 Effect 连接到了由 roomId 指定的房间...
const connection = createConnection(serverUrl, roomId);
connection.connect();
return () => {
// ...直到它断开连接
connection.disconnect();
};
}, [roomId]);

这段代码的结构也许会让你把发生的事情理解为一系列互不重叠的时间段:

  1. 你的 Effect 连接到了 "general" 房间(直到它断开)
  2. 你的 Effect 连接到了 "travel" 房间(直到它断开)
  3. 你的 Effect 连接到了 "music" 房间(直到它断开)

之前,你是在从组件的角度思考。当你从组件的角度看时,很容易把 Effect 想成在某个特定时间触发的“回调”或“生命周期事件”,比如“在渲染之后”或“在卸载之前”。这种思考方式很快就会变得复杂,所以最好避免。

相反,始终一次只关注一个开始/停止循环。组件是在挂载、更新还是卸载并不重要。你只需要描述如何开始同步,以及如何停止同步。如果你做得好,你的 Effect 就能在需要的任意多次启动和停止中保持健壮。

这可能会让你联想到:在编写创建 JSX 的渲染逻辑时,你并不会去考虑组件是在挂载还是更新。你只需要描述屏幕上应该显示什么,剩下的由 React 来处理。

React 如何验证你的 Effect 可以重新同步

下面是一个可以交互的实时示例。点击 “Open chat” 来挂载 ChatRoom 组件:

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>Welcome to the {roomId} room!</h1>;
}

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

请注意,当组件第一次挂载时,你会看到三条日志:

  1. ✅ 正在连接到 "general" 房间,服务器地址为 https://localhost:1234... (仅开发环境)
  2. ❌ 已从 "general" 房间断开连接,服务器地址为 https://localhost:1234. (仅开发环境)
  3. ✅ 正在连接到 "general" 房间,服务器地址为 https://localhost:1234...

前两条日志仅在开发环境中出现。在开发环境中,React 总是会让每个组件重新挂载一次。

React 会在开发环境中强制立即执行一次,以此验证你的 Effect 是否能够重新同步。 这有点像为了检查门锁是否正常工作而额外开一次门、关一次门。React 在开发环境中会额外启动和停止一次你的 Effect,以检查你是否正确实现了清理逻辑。

你的 Effect 在实际使用中需要重新同步的主要原因,是它所使用的某些数据发生了变化。在上面的沙盒中,切换所选聊天室。注意当 roomId 变化时,你的 Effect 会重新同步。

不过,在一些更少见的情况下,也需要重新同步。例如,试着在聊天打开时编辑上面沙盒中的 serverUrl。注意 Effect 会在你对代码的编辑响应中重新同步。未来,React 可能会加入更多依赖重新同步的功能。

React 如何知道它需要重新同步 Effect

你可能会好奇,React 是怎么知道 roomId 变化后你的 Effect 需要重新同步的。这是因为你通过将它包含在依赖列表中告诉了 React 它的代码依赖于 roomId

function ChatRoom({ roomId }) { // roomId prop 可能会随时间变化
useEffect(() => {
const connection = createConnection(serverUrl, roomId); // 这个 Effect 读取了 roomId
connection.connect();
return () => {
connection.disconnect();
};
}, [roomId]); // 因此你告诉 React,这个 Effect “依赖于” roomId
// ...

它的工作方式如下:

  1. 你知道 roomId 是一个 prop,这意味着它可能会随时间变化。
  2. 你知道你的 Effect 读取了 roomId(因此它的逻辑依赖于一个以后可能变化的值)。
  3. 这就是你将它指定为 Effect 依赖项的原因(这样当 roomId 变化时,它就会重新同步)。

每次组件重新渲染后,React 都会查看你传入的依赖数组。如果数组中的任意值与上一次渲染时对应位置传入的值不同,React 就会重新同步你的 Effect。

例如,如果你在初始渲染时传入了 ["general"],而在下一次渲染时传入了 ["travel"],React 就会比较 "general""travel"。这些是不同的值(使用 Object.is 比较),所以 React 会重新同步你的 Effect。另一方面,如果组件重新渲染,但 roomId 没有变化,你的 Effect 就会继续连接到同一个房间。

每个 Effect 都代表一个独立的同步过程

不要仅仅因为某段逻辑需要和你已经写好的 Effect 同时运行,就把无关的逻辑加到这个 Effect 里。例如,假设你希望在用户访问房间时发送一条分析事件。你已经有一个依赖 roomId 的 Effect,所以你可能会忍不住把分析调用也加进去:

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

但想象一下,你后来给这个 Effect 增加了另一个需要重新建立连接的依赖项。如果这个 Effect 重新同步了,它也会对同一个房间调用 logVisit(roomId),而这并不是你的本意。记录访问日志 是一个独立的过程,和连接是分开的。应当把它们写成两个单独的 Effect:

function ChatRoom({ roomId }) {
useEffect(() => {
logVisit(roomId);
}, [roomId]);

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

你代码中的每个 Effect 都应该代表一个独立的同步过程。

在上面的例子里,删除一个 Effect 并不会破坏另一个 Effect 的逻辑。这说明它们同步的是不同的事情,因此把它们拆开是合理的。另一方面,如果你把一段内聚的逻辑拆成多个 Effect,代码看起来可能更“整洁”,但会更难维护。 这就是为什么你应该思考这些过程是相同还是不同,而不是代码看起来是否更整洁。

Effects “响应” reactive 值

你的 Effect 读取了两个变量(serverUrlroomId),但你只把 roomId 指定为了依赖项:

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

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

为什么 serverUrl 不需要成为依赖项?

这是因为 serverUrl 不会因为重新渲染而改变。无论组件重新渲染多少次、因为什么原因,它始终都是同一个值。既然 serverUrl 不会改变,把它列为依赖项就没有意义。毕竟,依赖项只有在随时间发生变化时才有作用!

另一方面,roomId 在重新渲染时可能不同。Props、state,以及组件内部声明的其他值都是 reactive(响应式)的,因为它们是在渲染期间计算出来的,并且参与 React 的数据流。

如果 serverUrl 是一个 state 变量,那它就是 reactive 的。reactive 值必须包含在依赖项中:

function ChatRoom({ roomId }) { // Props 会随时间变化
const [serverUrl, setServerUrl] = useState('https://localhost:1234'); // State 可能会随时间变化

useEffect(() => {
const connection = createConnection(serverUrl, roomId); // 你的 Effect 读取了 props 和 state
connection.connect();
return () => {
connection.disconnect();
};
}, [roomId, serverUrl]); // 所以你要告诉 React,这个 Effect “依赖于” props 和 state
// ...
}

serverUrl 加入依赖项后,你就能确保它变化之后,Effect 会重新同步。

试着在这个 sandbox 中更改选中的聊天房间,或编辑服务器 URL:

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

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

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

  return (
    <>
      <label>
        Server URL:{' '}
        <input
          value={serverUrl}
          onChange={e => setServerUrl(e.target.value)}
        />
      </label>
      <h1>Welcome to the {roomId} room!</h1>
    </>
  );
}

export default function App() {
  const [roomId, setRoomId] = useState('general');
  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>
      <hr />
      <ChatRoom roomId={roomId} />
    </>
  );
}

每当你更改像 roomIdserverUrl 这样的 reactive 值时,Effect 都会重新连接到聊天服务器。

空依赖项的 Effect 意味着什么

如果你把 serverUrlroomId 都移到组件外面,会发生什么?

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

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

现在你的 Effect 代码不使用任何 reactive 值,因此它的依赖项可以为空([])。

从组件的角度看,空的 [] 依赖数组意味着:这个 Effect 只会在组件挂载时连接到聊天室,并且只会在组件卸载时断开连接。(请记住,在开发环境中,React 仍然会额外重新同步一次来对你的逻辑进行压力测试。)

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

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

function ChatRoom() {
  useEffect(() => {
    const connection = createConnection(serverUrl, roomId);
    connection.connect();
    return () => connection.disconnect();
  }, []);
  return <h1>Welcome to the {roomId} room!</h1>;
}

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

然而,如果你从 Effect 的角度思考,你根本不需要考虑挂载和卸载。重要的是,你已经明确描述了 Effect 如何开始和停止同步。到目前为止,它没有任何 reactive 依赖项。但是如果你以后希望用户随着时间更改 roomIdserverUrl(它们就会变成 reactive 的),你的 Effect 代码也不需要改变。你只需要把它们加入依赖项。

组件函数体内声明的所有变量都是 reactive 的

Props 和 state 不是唯一的 reactive 值。你从它们计算出来的值也是 reactive 的。如果 props 或 state 改变了,组件会重新渲染,从它们计算出来的值也会改变。这就是为什么 Effect 使用到的组件函数体内所有变量都应该出现在 Effect 依赖列表中。

假设用户可以在下拉框中选择一个聊天服务器,但他们也可以在设置里配置默认服务器。假设你已经把设置 state 放在了一个 context 中,因此你可以从这个 context 中读取 settings。现在你根据 props 中选中的服务器和默认服务器来计算 serverUrl

function ChatRoom({ roomId, selectedServerUrl }) { // roomId 是 reactive 的
const settings = useContext(SettingsContext); // settings 是 reactive 的
const serverUrl = selectedServerUrl ?? settings.defaultServerUrl; // serverUrl 是 reactive 的
useEffect(() => {
const connection = createConnection(serverUrl, roomId); // 你的 Effect 读取了 roomId 和 serverUrl
connection.connect();
return () => {
connection.disconnect();
};
}, [roomId, serverUrl]); // 所以当它们任意一个变化时,都需要重新同步!
// ...
}

在这个例子中,serverUrl 既不是 prop,也不是 state 变量。它只是你在渲染期间计算出来的普通变量。但它是在渲染期间计算的,所以它可能因为重新渲染而改变。这就是它为什么是 reactive 的。

组件内部的所有值(包括 props、state,以及组件函数体内的变量)都是 reactive 的。任何 reactive 值都可能在重新渲染时改变,所以你需要把 reactive 值作为 Effect 的依赖项。

换句话说,Effects 会对组件函数体中的所有值“响应”。

Deep Dive

全局值或可变值可以作为依赖项吗?

可变值(包括全局变量)不是 reactive 的。

location.pathname 这样的可变值不能作为依赖项。 它是可变的,因此它可以在完全脱离 React 渲染数据流的任何时候发生变化。更改它不会触发组件重新渲染。因此,即使你把它写进依赖项里,React 也不会知道在它变化时需要重新同步 Effect。这也违反了 React 的规则,因为在渲染期间读取可变数据(而这正是你计算依赖项的时候)会破坏渲染纯度。相反,你应该使用 useSyncExternalStore. 来读取并订阅外部可变值。

ref.current 这样的可变值,或者你从它读取到的内容,也不能作为依赖项。 useRef 返回的 ref 对象本身可以作为依赖项,但它的 current 属性是刻意设计成可变的。它可以让你在不触发重新渲染的情况下追踪某些值。但是由于修改它不会触发重新渲染,所以它不是 reactive 值,React 也不会在它变化时知道要重新运行你的 Effect。

正如下文所学,linter 会自动检查这些问题。

React 会验证你是否将每个 reactive 值都指定为依赖项

如果你的 linter 已为 React 配置,它会检查你的 Effect 代码中使用到的每个 reactive 值是否都被声明为依赖项。比如,下面这段代码会报 lint 错误,因为 roomIdserverUrl 都是 reactive 的:

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

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

  useEffect(() => {
    const connection = createConnection(serverUrl, roomId);
    connection.connect();
    return () => connection.disconnect();
  }, []); // <-- 这里有问题!

  return (
    <>
      <label>
        Server URL:{' '}
        <input
          value={serverUrl}
          onChange={e => setServerUrl(e.target.value)}
        />
      </label>
      <h1>Welcome to the {roomId} room!</h1>
    </>
  );
}

export default function App() {
  const [roomId, setRoomId] = useState('general');
  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>
      <hr />
      <ChatRoom roomId={roomId} />
    </>
  );
}

这看起来像是 React 报错了,但实际上 React 只是指出了你代码中的一个 bug。roomIdserverUrl 都可能随时间变化,但你忘了在它们变化时重新同步你的 Effect。即使用户在界面中选择了不同的值,你仍然会连接到初始的 roomIdserverUrl

要修复这个 bug,请按照 linter 的建议,把 roomIdserverUrl 指定为 Effect 的依赖项:

function ChatRoom({ roomId }) { // roomId 是 reactive 的
const [serverUrl, setServerUrl] = useState('https://localhost:1234'); // serverUrl 是 reactive 的
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.connect();
return () => {
connection.disconnect();
};
}, [serverUrl, roomId]); // ✅ 已声明所有依赖项
// ...
}

试着在上面的 sandbox 中做这个修复。确认 linter 错误消失,并且聊天会在需要时重新连接。

Note

在某些情况下,即使一个值声明在组件内部,React 也知道它永远不会改变。例如,useState 返回的 set 函数以及 useRef 返回的 ref 对象都是 stable(稳定的)——它们保证不会在重新渲染时改变。稳定值不是 reactive 的,所以你可以把它们从列表中省略。把它们加进去也可以:它们不会变,所以无所谓。

当你不想重新同步时该怎么做

在前面的例子中,你通过把 roomIdserverUrl 列为依赖项修复了 lint 错误。

**不过,你也可以向 linter“证明”这些值不是 reactive 的,**也就是说,它们不会因为重新渲染而改变。例如,如果 serverUrlroomId 不依赖于渲染,而且始终保持相同的值,你可以把它们移到组件外面。这样它们就不需要作为依赖项了:

const serverUrl = 'https://localhost:1234'; // serverUrl 不是 reactive 的
const roomId = 'general'; // roomId 不是 reactive 的

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

你也可以把它们移到 Effect 内部。它们不是在渲染期间计算出来的,所以它们不是 reactive 的:

function ChatRoom() {
useEffect(() => {
const serverUrl = 'https://localhost:1234'; // serverUrl 不是 reactive 的
const roomId = 'general'; // roomId 不是 reactive 的
const connection = createConnection(serverUrl, roomId);
connection.connect();
return () => {
connection.disconnect();
};
}, []); // ✅ 已声明所有依赖项
// ...
}

Effects 是 reactive 的代码块。 当你在其中读取的值发生变化时,它们会重新同步。与事件处理器不同,事件处理器每次交互只运行一次,而 Effects 会在需要同步时运行。

你不能“选择”你的依赖项。 依赖项必须包含你在 Effect 中读取的每一个reactive 值。linter 会强制执行这一点。有时这会导致诸如无限循环之类的问题,也会让你的 Effect 过于频繁地重新同步。不要通过关闭 linter 来修复这些问题!你应该尝试以下做法:

Pitfall

linter 是你的朋友,但它的能力有限。linter 只能知道依赖项什么时候是错的,但它不知道每种情况最好的解决方式。如果 linter 建议添加某个依赖项,但添加后导致循环,这并不意味着可以忽略 linter。你需要修改 Effect 内部(或外部)的代码,让那个值不再是 reactive 的,也就不再需要成为依赖项。

如果你已经有一个现成的代码库,你可能会看到一些 Effect 像这样抑制了 linter:

useEffect(() => {
// ...
// 🔴 不要像这样抑制 linter:
// eslint-ignore-next-line react-hooks/exhaustive-deps
}, []);

下一页后续页面中,你会学到如何在不破坏规则的情况下修复这段代码。修复它总是值得的!

Recap

  • 组件可以挂载、更新和卸载。
  • 每个 Effect 都有独立于其外层组件的生命周期。
  • 每个 Effect 都描述了一个可以开始停止的独立同步过程。
  • 在编写和读取 Effects 时,要从每个单独 Effect 的角度思考(如何开始和停止同步),而不是从组件的角度思考(它如何挂载、更新或卸载)。
  • 在组件函数体内声明的值都是“reactive”的。
  • reactive 值应该重新同步 Effect,因为它们会随时间变化。
  • linter 会验证在 Effect 内使用到的所有 reactive 值都被指定为依赖项。
  • linter 标出的所有错误都是真实存在的问题。总能找到一种修复代码且不违反规则的方法。

Challenge 1 of 5:
修复每次按键都会重新连接的问题

在这个例子中,ChatRoom 组件在组件挂载时连接到聊天室,在卸载时断开连接,并在你选择不同聊天室时重新连接。这个行为是正确的,所以你需要保持它正常工作。

不过,这里有个问题。每当你在底部的消息输入框中打字时,ChatRoom 也会重新连接到聊天。你可以通过清空控制台并在输入框中输入来观察这一点。请修复这个问题,使它不再发生。

import { useState, useEffect } from 'react';
import { createConnection } 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();
  });

  return (
    <>
      <h1>Welcome to the {roomId} room!</h1>
      <input
        value={message}
        onChange={e => setMessage(e.target.value)}
      />
    </>
  );
}

export default function App() {
  const [roomId, setRoomId] = useState('general');
  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>
      <hr />
      <ChatRoom roomId={roomId} />
    </>
  );
}