重学React(六):脱围机制二

发布于:2025-08-19 ⋅ 阅读:(16) ⋅ 点赞:(0)

背景: 话不多说,继续学习,现在是Effect时间。

前期回顾:
重学React(一):描述UI
重学React(二):添加交互
重学React(三):状态管理
重学React(四):状态管理二
重学React(五):脱围机制一

学习内容:

React官网教程:https://zh-hans.react.dev/learn/synchronizing-with-effects
其他辅助资料(看到再补充)
补充说明:这次学习更多的是以学习笔记的形式记录,看到哪记到哪

Effect这部分内容很丰富,需要仔细多品品,在实际代码中,useEffect基本上是使用频率第二高的Hook了(第一肯定是useState),但在使用过程中经常会遇到各种各样的问题,而且接触了很多人,他们对Effect的理解也不是很到位。这部分内容看下来收获还是超级大的,它通过很多实际的例子将它的原理以及使用方法解析得很透彻,最大的感受就是现在代码里很多问题都是没有正确使用Effect导致的。这篇总结笔记的顺序会按照我理解的方式来记录,可能会和原文顺序不一致,建议多看几遍,有时间可以多看看原文。

使用 Effect 进行同步

什么是 Effect

最重要的一个概念是,什么是Effect?
先复习一下之前说过的React的两种逻辑类型:渲染代码以及事件处理程序。

  • 渲染代码:位于组件的顶层。在这里可以处理 props 和 state,对它们进行转换,并返回希望在页面上显示的 JSX。它必须是纯粹的,只关注计算结果。
  • 事件处理程序: 组件内部的嵌套函数,它们不光进行计算, 还会执行一些操作。事件处理程序包含由特定用户操作(例如按钮点击或输入)引起的“副作用”(它们改变了程序的状态)。
    在这个基础上,其实还缺了一块,要如何处理在渲染过程中的操作。考虑一个聊天室的例子,当用户进入聊天室时,页面上显示时必须连接到聊天服务器。连接到服务器并不是纯粹的计算(它是一个副作用),因此它不能在渲染期间发生。但此时也没有点击某个按钮或者其他用户行为来主动触发。
    这时候就需要Effect出场了。
    Effect 允许指定由渲染自身,而不是特定事件引起的副作用。 建立服务器连接是一个 Effect,因为无论哪种交互致使组件出现,它都应该发生。Effect 在 提交 结束后、页面更新后运行。此时是将 React 组件与外部系统(如网络或第三方库)同步的最佳时机。

在本文此处和后续文本中,大写的 Effect 是 React 中的专有定义——由渲染引起的副作用。至于更广泛的编程概念(任何改变程序状态或外部系统的行为),我们则使用“副作用(side effect)” 来指代。

先来学习一下如何编写一个Effect,主要分为以下三个步骤:

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

来看一个最简单的聊天室例子:

// App.js
// 首先和其他hook一样,从react中引入useEffect
import { useState, useEffect } from 'react';
import { createConnection } from './chat.js';

export default function ChatRoom() {
// 在组件顶部调用,使用useEffect,它包括一个函数和一个依赖项
  useEffect(() => {
  // 组件渲染时React会先更新页面,然后再执行这个函数中的代码
    const connection = createConnection();
    connection.connect();
    // 同时还包括一个清理函数,用于清除连接
    // Effect会在组件卸载(被移除)时最后一次调用这个清理函数
    return () => connection.disconnect();
  }, 
  // 代码不能无限制的每次渲染都执行,因此会添加一个依赖项,React会监听这个依赖项是否改变(通过objtct.is),如果依赖项没发生改变,就不进入代码执行
  //数组里可以有多个依赖项
  // 依赖项为空表示只有在组件挂载(首次出现)后执行
  []
  );
  return <h1>欢迎来到聊天室!</h1>;
}

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

Effect的生命周期

Effect 与组件有不同的生命周期。组件可以挂载、更新或卸载。Effect 只能做两件事:开始同步某些东西,然后停止同步它。很多人在聊到useEffect的时候都会将它和React之前的生命周期联系在一起,但其实它更多的是描述了如何将外部系统与当前的 props 和 state 同步。

React组件都会有相同的生命周期:

  • 当组件被添加到屏幕上时,它会进行组件的 挂载。
  • 当组件接收到新的 props 或 state 时,通常是作为对交互的响应,它会进行组件的 更新。
  • 当组件从屏幕上移除时,它会进行组件的 卸载。

为了更好的理解Effect所谓的“生命周期”,请看这个聊天室的例子

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

function ChatRoom({ roomId }) {
// Effect将组件连接到聊天服务器
  useEffect(() => {
  // Effect的主体部分,指定了如何开始同步
    const connection = createConnection(serverUrl, roomId);
    connection.connect();
    // 清理函数指定了如何 停止同步
    return () => {
      connection.disconnect();
    };
    // 依赖项里有roomId,表示遇到roomId更新时会触发上一次的清理函数及下一次的同步
  }, [roomId]);
  // ...
}

直观上,可能会认为当组件挂载时React会开始同步,当组件卸载时会停止同步,但事实却不是这样的,在组件保持挂载的同时,可能会出现多次的开始和停止同步,关键在roomId

假设roomId包含general, travel两个值,用户可以通过下拉列表选择当前的聊天室,初始阶段,用户选择进入general聊天室,UI显示之后,React会怎么做呢?它将运行Effect来开始同步(记住,运行Effect是在UI更新后),连接到general聊天室,
接着,用户从下拉列表里选择了travel房间,React会再更新UI,这个时候用户在界面中看到的是travel的聊天页面,但此时Effect还没运行,连接的还是general聊天室。
React接下来要做的就是先停止同步,调用Effect返回的清理函数,断开与general的连接,然后运行在此渲染期间提供的Effect,这次,roomId是travel,所以它会开始同步,连接到travel聊天室。
最后,用户切换去其他页面,组件将被卸载,React将最后一次停止同步Effect,调用Effect返回的清理函数,断开与travel的连接

从组件角度思考一下这个过程:

  • ChatRoom 组件挂载,roomId 设置为 “general”
  • ChatRoom 组件更新,roomId 设置为 “travel”
  • ChatRoom 组件卸载

在组件生命周期的每个阶段,Effect会执行不同的操作:

  • Effect 连接到了 “general” 聊天室
  • Effect 断开了与 “general” 聊天室的连接,并连接到了 “travel” 聊天室
  • Effect 断开了与 “music” 聊天室的连接

我们再来看看这段代码:

 useEffect(() => {
    // Effect 连接到了通过 roomId 指定的聊天室...
    const connection = createConnection(serverUrl, roomId);
    connection.connect();
    return () => {
      // ...直到它断开连接
      connection.disconnect();
    };
  }, [roomId]);

实际上,如果抛开组件生命周期的角度,Effect本质上就做了两件事,开始同步以及停止同步,React会解决其余的问题,无论组件是挂载,更新还是卸载,这两件事都不应该有影响。

Effect依赖项

还是聊天室的例子,我们知道依赖项的选择决定着Effect的开始同步以及停止同步,当指定的所有依赖项的值与上一次渲染时完全相同,React会跳过重新运行该Effect

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

useEffect(() => {
  // 这里的代码只会在组件挂载(首次出现)时运行
  // 空的 [] 依赖数组意味着这个 Effect 仅在组件挂载时触发,并在卸载时停止同步
}, []);

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

React 如何知道需要重新进行 Effect 的同步
其实道理也很简单,因为我们把roomId放在了依赖列表中。实际上,Effect 重新进行同步的主要原因是它所使用的某些数据发生了变化。

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

每次组件重新渲染之后,React都会检查传递的依赖数组,按照之前说的,如果发现渲染之后依赖数组发生改变了,就会进行上一轮的停止以及新一轮的同步。

但是,不要仅仅因为某个逻辑需要和已经写好的Effect一起运行,就将它添加到Effect中,比如接下来这个例子

function ChatRoom({ roomId }) {
  useEffect(() => {
    logVisit(roomId); // 想要在用户访问聊天室时发送一个分析事件
    const connection = createConnection(serverUrl, roomId);
    connection.connect();
    return () => {
      connection.disconnect();
    };
  }, [roomId]);
  // ...
}

在当前阶段看起来似乎没问题,这个分析事件确实也只依赖roomId一个依赖项。但如果,connection的过程中,serverUrl也变成一个依赖项,但logVisit并不需要它,那会发生什么?

function ChatRoom({ roomId }) {
// roomId发生改变,发送分析事件,重新连接
// serverUrl发生改变,发送分析事件,重新连接,因为React会执行整个同步过程
// 但实际上我们并不需要在serverUrl发生改变时发送分析事件
  useEffect(() => {
    logVisit(roomId); // 想要在用户访问聊天室时发送一个分析事件
    const connection = createConnection(serverUrl, roomId);
    connection.connect();
    return () => {
      connection.disconnect();
    };
  }, [serverUrl, roomId]);
  // ...
}

因此,为了避免维护起来的困难,代码中的每个 Effect 应该代表一个独立的同步过程,当删除一个 Effect 不会影响另一个 Effect 的逻辑时,可以考虑将它们拆开

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

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

Effect 会“响应”于响应式值

依赖项需要填什么呢?一个同步函数里包含了许多内容,哪些内容需要放到依赖项里,哪些又是不必要的,请记住这个原则:**Effect 会“响应”于响应式值 **。

// 在这里例子中,serverUrl时一个常量,永远不会因为重新渲染产生变化,因此把它放置在依赖项里就没有任何意义。
// 依赖项只有在随时间变化时才起作用
const serverUrl = 'https://localhost:1234';

// roomId是组件内部声明的props,它会在渲染过程中计算,并且参与React的数据流,是响应式的,因此在重新渲染时值可能会发生变化
// 比如从general变成了travel,监听它就变得有意义
function ChatRoom({ roomId }) {
  useEffect(() => {
    const connection = createConnection(serverUrl, roomId);
    connection.connect();
    return () => {
      connection.disconnect();
    };
  }, [roomId]);
  // ...
}

function ChatRoom({ roomId }) { // Props 随时间变化
// 当serverUrl变成一个state后,它也变成响应式了,那么它就必须包含在依赖项中
  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
  // ...
}

在组件主体中声明的所有变量都是响应式的

Props 和 state 并不是唯一的响应式值。从它们计算出的值也是响应式的。如果 props 或 state 发生变化,组件将重新渲染,从中计算出的值也会随之改变。这就是为什么 Effect 使用的组件主体中的所有变量都应该在依赖列表中。
那什么不能作为依赖项?

  1. 全局变量或可变值
    全局变量的例子之前已经看到了,可变值是一些在React渲染数据流之外任何时间发生变化的值,比如location.pathname,更改它并不会触发组件的重新渲染,因此就算在依赖项里指定了,React 也无法知道在其更改时重新同步 Effect。应该使用 useSyncExternalStore(一个之前没接触过的hook出现了!!!) 来读取和订阅外部可变值
  2. ref.current 或从中读取的值也不能作为依赖项
    useRef 返回的 ref 对象本身可以作为依赖项,但其 current 属性是有意可变的。它允许 跟踪某些值而不触发重新渲染。但由于更改它不会触发重新渲染,它不是响应式值,React 不会知道在其更改时重新运行 Effect。
  3. useState 返回的 set 函数
    函数具有稳定的标识,它们保证在重新渲染时不会改变。通常情况下就算作为依赖项也会被忽略
  4. 对象和函数作为依赖项需要十分谨慎
    在 JavaScript 中,每个新创建的对象和函数都被认为与其他所有对象和函数不同。即使他们的值相同。
function ChatRoom({ roomId }) {
  const [message, setMessage] = useState('');
  // options在组件中声明,因此它是响应式的
  // 所以理论上在Effect中需要把它放到依赖项中
  // 每次重新渲染组件时,比如message更新,都会触发整个组件的代码从头开始运行
  // 会从头开始创建一个新的options对象,就算options里的serverUrl和roomId和之前的值一样,options和以前也不是同一个了。
  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)} />
    </>
  );
}

如何避免将对象和函数作为 Effect 的依赖?有以下三种方法:

  • 将静态对象和函数移出组件
    如果对象中的内容不是变量,可以将它移出组件
const options = {
  serverUrl: 'https://localhost:1234',
  roomId: '音乐'
};
// 函数也是一样
function createOptions() {
  return {
    serverUrl: 'https://localhost:1234',
    roomId: '音乐'
  };
}

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

  useEffect(() => {
  // 如果是函数就会是这样,也是不需要添加到依赖项中
  // const options = createOptions();
    const connection = createConnection(options);
    connection.connect();
    return () => connection.disconnect();
    // 因为它是固定值,所以连依赖项都可以不加
  }, []); // ✅ 所有依赖已声明
  • 将动态对象和函数移动到 Effect 中
    如果对象依赖于一些可能因重新渲染而改变的响应值,就可以在Effect内部声明它。
const serverUrl = 'https://localhost:1234';

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

  useEffect(() => {
  // 在Effect内部声明,不是响应式值,也就不是Effect的依赖
    const options = {
      serverUrl: serverUrl,
      roomId: roomId
    };
    const connection = createConnection(options);
    connection.connect();
    return () => connection.disconnect();
    // roomId是props,属于响应值,所以放到依赖项中,确保roomId发生改变会触发同步
  }, [roomId]); // ✅ 所有依赖已声明
  • 从对象中读取原始值
    有时候这个对象可能是从父元素直接传过来的
// 父组件可能是这样的
<ChatRoom
  roomId={roomId}
  options={{
    serverUrl: serverUrl,
    roomId: roomId
  }}
/>

// 如果父组件options是个对象,每次父组件重新渲染都会导致options更新
function ChatRoom({ options }) {
  const [message, setMessage] = useState('');
// 从 Effect 外部 读取对象信息,并避免依赖对象和函数类型
// 先把它们都拆开变成简单类型,在Effect中监听这两个简单类型
  const { roomId, serverUrl } = options;
  useEffect(() => {
  // 虽然这样看上去有点多此一举,拆了对象又重新拼接成对象
  // 但这使得 Effect 实际 依赖的信息非常明确。
    const connection = createConnection({
      roomId: roomId,
      serverUrl: serverUrl
    });
    connection.connect();
    return () => connection.disconnect();
  }, [roomId, serverUrl]); // ✅ 所有依赖已声明

移除不必要的依赖

React中会有linter(很多时候是和eslint之类的结合使用)来验证是否已经将 Effect 读取的每一个响应式值(如 props 和 state)包含在 Effect 的依赖中
。不必要的依赖可能会导致 Effect 运行过于频繁,甚至产生无限循环。因此在写代码过程中要注意审查并移除Effect中不必要的依赖。

移除一个依赖,你需要向 linter 证明其不需要这个依赖。因为Effect会对响应式值做出“反应”,所以如果你想移除一个值,那必须要证明它不是响应式的,方法在上面都讲过了,这里就不再赘述。

在调整 Effect 的依赖以适配代码时,请注意一下当前的依赖。当这些依赖发生变化时,让 Effect 重新运行是否有意义?

  • 可能想在不同的条件下重新执行 Effect 的 不同部分。
  • 可能想只读取某个依赖的 最新值,而不是对其变化做出“反应”。
  • 依赖可能会因为它的类型是对象或函数而 无意间 改变太频繁。
    为了找到正确的解决方案,通常需要思考下面几个问题:
  1. 这段代码是否应该移到事件处理程序中
    最开始需要思考的就是,这段代码是否应该成为Effect,这部分涉及的内容比较多,会在后面专门讲。
  2. Effect是否在做几件不相关的事情
    之前说过,代码中的每个 Effect 应该代表一个独立的同步过程,所以在写Effect时需要思考,这个Effect是不是只做了一件事,如果它同时做了两个独立的事情,不妨把代码独立开来。
  3. 是否在读取一些状态下来计算下一个状态
    看聊天过程中的这个例子,每次有新的消息到达时,这个 Effect 会用新创建的数组更新 messages state
    写的代码可能是这样的:
function ChatRoom({ roomId }) {
  const [messages, setMessages] = useState([]);
  useEffect(() => {
    const connection = createConnection();
    connection.connect();
    // 每次有新消息到达时,都要更新消息
    connection.on('message', (receivedMessage) => {
      setMessages([...messages, receivedMessage]);
    });
    return () => connection.disconnect();
  }, [roomId, messages]); // ✅ 所有依赖已声明

这段代码的问题也十分明显,每次message发生改变,都会触发Effect,导致重新连接,显然这不符合用户行为,解决方案也很简单,将一个 state 更新函数 传递给 setMessages

function ChatRoom({ roomId }) {
  const [messages, setMessages] = useState([]);
  useEffect(() => {
    const connection = createConnection();
    connection.connect();
    connection.on('message', (receivedMessage) => {
    // React 将更新程序函数放入队列 并将在下一次渲染期间向其提供 msgs 参数
    // 这样Effect本身并不会需要message这个依赖项
      setMessages(msgs => [...msgs, receivedMessage]);
    });
    return () => connection.disconnect();
  }, [roomId]); // ✅ 所有依赖已声明
  1. 想实现读取一个值而不对其变化做出“反应”吗
    可以实现,但实现的前提是在稳定版本的 React 中 尚未发布的实验性 API。先学着,都写在教程里了大概率都是很有用的。
    这个问题主要出现在这种场景下,有时候,我们需要混合响应式和非响应式的逻辑。例如,假设你想在用户连接到聊天室时展示一个通知。并且通过从 props 中读取当前 theme(dark 或者 light)来展示对应颜色的通知,那代码可能是这样的:
function ChatRoom({ roomId, theme }) {
  useEffect(() => {
    const connection = createConnection(serverUrl, roomId);
    connection.on('connected', () => {
      showNotification('Connected!', theme);
    });
    connection.connect();
    return () => {
      connection.disconnect()
    };
  }, [roomId, theme]); // ✅ 声明所有依赖项

按照之前学习的知识,这样写是完全符合React规范的。但是,看到这里的大部分人应该已经能看出来这段代码在交互上的问题了——修改主题也会触发同步连接。在这个场景下,更加适用的是每次连接时获取最新的theme,但不希望theme改变时触发同步,这时需要一个新的概念: Effect Event,与之一起的是一个新的hook:useEffectEvent
Effect Event是 Effect 逻辑的一部分,但是其行为更像事件处理函数。它内部的逻辑不是响应式的,而且能一直“看见”最新的 props 和 state。

function ChatRoom({ roomId, theme }) {
// useEffectEvent会从 Effect 中提取非响应式逻辑
// 它是 Effect 逻辑的一部分,但是其行为更像事件处理函数。它内部的逻辑不是响应式的,而且能一直“看见”最新的 props 和 state
// 因为它不是响应式的,所以可以在里面使用任意响应式值,而不用担心周围代码因为变化而重新执行
  const onConnected = useEffectEvent(() => {
    showNotification('Connected!', theme);
  });

  useEffect(() => {
    const connection = createConnection(serverUrl, roomId);
    connection.on('connected', () => {
      onConnected();
    });
    connection.connect();
    return () => connection.disconnect();
    // onConnected是非响应式的,所以不需要声明
    // 
  }, [roomId]); // ✅ 声明所有依赖项

Effect Event 也是有一定的局限性:

  • 只在 Effect 内部调用他们。
  • 永远不要把他们传给其他的组件或者 Hook。
    Effect Event 是 Effect 代码的非响应式“片段”。他们应该在使用他们的 Effect 的旁边。
function Timer() {
  const [count, setCount] = useState(0);

// 不能写成这样,避免传递 Effect Event
 // const onTick = useEffectEvent(() => {
 //   setCount(count + 1);
 //  });

 //  useTimer(onTick, 1000); // 🔴 避免: 传递 Effect Event
  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) 作为依赖项
}
  1. 一些响应式值是否无意中改变了?
    需要检查一下,依赖项中是否出现了对象或者函数这种每次重新渲染都是新的值的数据结构,毕竟依赖项内对比是否发生改变使用的是object.is

Effect清理函数

还是聊天室的例子,如果用户第一次进入包含ChatRoom的页面,那么很正常的会触发connect,接着,用户切换到其他页面,然后又重新回来,此时会再次触发connect,表面上看上去毫无问题,但实际上会发现,现在程序中会有两个connect连接,第一个连接始终没有被销毁!!!如果用户不断切换退出进入,连接就会不断累积


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

这时候就需要添加清理函数,在组件被卸载或者重新渲染时Effect会调用清理函数,在这个例子中就是断开连接。

然后,你会很惊奇的发现,在开发环境中,初次挂载时,Effect会运行两次,为什么要这么设计呢?答案是实现清理函数,清理函数应该停止或撤销 Effect 所做的一切。原则是用户不应该感受到 Effect 只执行一次(在生产环境中)和连续执行“挂载 → 清理 → 挂载”(在开发环境中)之间的区别。需要思考的不是“如何只运行一次 Effect”,而是“如何修复我的 Effect 来让它在重新挂载后正常运行”
下面来看一些场景:

  1. 管理非 React 小部件
    有时需要添加不是用 React 实现的 UI 小部件。例如添加一个地图组件。它有一个 setZoomLevel() 方法,希望地图的缩放比例和代码中的 zoomLevel state 保持同步。你的 Effect 应该类似于:
useEffect(() => {
  const map = mapRef.current;
  // 在开发环境中,虽然 React 会调用 Effect 两次,但这没关系,因为用相同的值调用 setZoomLevel 两次不会造成任何影响。只是可能会慢点
  map.setZoomLevel(zoomLevel);
}, [zoomLevel]);

// 有些 API 可能不允许你连续调用两次。例如,内置的 <dialog> 元素的 showModal 方法在连续被调用两次时会抛出异常。此时可以通过实现清理函数来使其关闭对话框
// 在测试环境中,这个Effect会先调用showModal(),然后调用close(),再调用一次showModal(),这和生产环境只调用一次showModal()行为是一致的
useEffect(() => {
  const dialog = dialogRef.current;
  dialog.showModal();
  return () => dialog.close();
}, []);

但是,还会有一些组件在初次渲染和二次渲染中表现不一致,遇到这种表现不一致的行为,还是需要做一些额外处理(比如最近项目中遇到的pdf-preview组件就属于这个类型)
2. 订阅事件
如果 Effect 订阅了某些事件,清理函数应退订这些事件:

useEffect(() => {
  function handleScroll(e) {
    console.log(window.scrollX, window.scrollY);
  }
  window.addEventListener('scroll', handleScroll);
  return () => window.removeEventListener('scroll', handleScroll);
}, []);
  1. 触发动画
    如果 Effect 触发了一些动画,清理函数应将动画重置为初始状态,如果使用了支持补间动画的第三方动画库,清理函数应将时间轴重置为初始状态
useEffect(() => {
  const node = ref.current;
  node.style.opacity = 1; // 触发动画
  return () => {
    node.style.opacity = 0; // 重置为初始值
  };
}, []);
  1. 获取数据
    如果Effect 需要获取数据,清理函数应 中止请求 或忽略其结果
useEffect(() => {
  let ignore = false;

  async function startFetching() {
    const json = await fetchTodos(userId);
    // 第二次发送请求时,忽略返回的数据
    if (!ignore) {
      setTodos(json);
    }
  }

  startFetching();

  return () => {
    ignore = true;
  };
}, [userId]);

但是在开发环境中,避免不了的是会触发两次请求,但有时两次请求会导致一些问题,比如数据量特别大时,或者网络特别不好时,此时可以构建一个缓存机制,第一次请求之后就将结果缓存下来,再次触发一模一样的请求(接口一致,参数一致),直接从缓存中获取结果,这样就能避免重复的网络请求。

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

  • Effect 不会在服务端运行。这意味着最初由服务器渲染的 HTML 只会包含加载状态,而没有实际数据。客户端必须先下载所有的 JavaScript 并渲染应用,才会发现它需要加载数据——这并不高效。
  • 直接在 Effect 中进行数据请求,容易产生“网络瀑布(network waterfall)”。首先父组件渲染时请求一些数据,随后渲染子组件,接着子组件开始请求它们的数据。如果网络速度不快,这种方式会比并行获取所有数据慢得多。
  • 直接在 Effect 中进行数据请求往往无法预加载或缓存数据。例如,如果组件卸载后重新挂载,它必须重新获取数据。
    不够简洁。编写 fetch 请求时为了避免 竞态条件(race condition) 等问题,会需要很多样板代码。
    这些弊端并不仅限于 React。任何库在组件挂载时进行数据获取都会遇到这些问题。与路由处理一样,要做好数据获取并非易事,因此我们推荐以下方法:
  • 如果正在使用 框架 ,请使用其内置的数据获取机制。现代 React 框架集成了高效的数据获取机制,不会出现上述问题。
  • 考虑使用或构建客户端缓存。流行的开源解决方案包括 React QueryuseSWRReact Router v6.4+。你也可以自己构建解决方案:在底层使用 Effect,但添加对请求的去重、缓存响应以及避免网络瀑布(通过预加载数据或将数据请求提升到路由层次)的逻辑。
  1. 发送分析报告
    考虑一个在页面访问时发送分析事件的场景,代码可能是这样的:
useEffect(() => {
 logVisit(url); // 发送 POST 请求
}, [url]);

在开发环境中,这个请求可能会被发送两次,从用户角度上来说,发送一次还是两次没有本质上的区别,但对收集者而言,会造成分析数据的偏差。因此不建议在开发环境开启这种功能。

如果确实不需要加载两次的功能,可以暂时禁用严格模式,但React不鼓励这样做,因为这样容易发现不了隐藏的问题,导致函数或者事件未被清除。

你可能不需要Effect

本质上,Effect也是一种脱围机制(不然也不会归纳在脱围机制这章里),它通常用于暂时“跳出” React 并与一些 外部 系统进行同步。这包括浏览器 API、第三方小部件,以及网络等等。如果发现编写的Effect只是根据其他状态来调整某些状态,那你可能不需要一个Effect。

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

在使用Effect之前,首先要判断这段代码是否应该成为Effect。
假设你正在实现一个聊天室组件,需求如下:

  • 组件应该自动连接选中的聊天室。
  • 每当你点击“Send”按钮,组件应该在当前聊天界面发送一条消息。

经过之前的学习,应该能很容易就判断出来,第一个需求应该用Effect,第二个需求用事件处理函数。因为事件处理函数只在响应特定的交互操作时运行,而每当需要同步,Effect 就会运行
直观上,可以说事件处理函数总是“手动”触发的,例如点击按钮。另一方面, Effect 是自动触发:每当需要保持同步的时候他们就会开始运行和重新运行。

  • 事件处理函数内部的逻辑是非响应式的。除非用户又执行了同样的操作(例如点击),否则这段逻辑不会再运行。事件处理函数可以在“不响应”他们变化的情况下读取响应式值。
  • Effect 内部的逻辑是响应式的。如果 Effect 要读取响应式值,你必须将它指定为依赖项。如果接下来的重新渲染引起那个值变化,React 就会使用新值重新运行 Effect 内的逻辑。
    如果想要在Effect中实现非响应式的逻辑,可以参考Effect Event(上面讲过的,不记得了可以返回去复习一下)
    来看几个例子:
    例子1: 想象一个表单,提交时将 submitted 状态变量设置为 true,并在 submitted 为 true 时,需要发送 POST 请求并显示通知。
// 如果把事件放在Effect里,代码就会变成这样
// 但这里的问题是,代码不应该以 Effect 实现,参考上面的原则,发送消息并在提交时显示通知,这是一个特定的动作,一般需要手动触发
// 不是说不能用Effect实现,而是这样会多了些不必要的渲染和消耗
function Form() {
  const [submitted, setSubmitted] = useState(false);

  useEffect(() => {
    if (submitted) {
      // 🔴 避免: Effect 中有特定事件的逻辑
      post('/api/register');
      showNotification('Successfully registered!');
    }
  }, [submitted]);

  function handleSubmit() {
    setSubmitted(true);
  }
  // ...
}

// 它其实只需要这样:
// 代码简洁了不说,也不会导致不必要的开销
function Form() {
  function handleSubmit() {
    // ✅ 好:从事件处理程序调用特定于事件的逻辑
    post('/api/register');
    showNotification('Successfully registered!', theme);
  }  
  // ...
}

例子2: 假设一个产品页面,上面有两个按钮(购买和付款),都可以满足购买产品。当用户将产品添加进购物车时,显示一个通知。如果两个按钮的 click 事件处理函数中都调用 showNotification() 感觉有点重复,所以可能想把这个逻辑放在一个 Effect 中:

function ProductPage({ product, addToCart }) {
  // 🔴 避免:在 Effect 中处理属于事件特定的逻辑
  // 这个 Effect 是多余的。而且很可能会导致问题
  // 假设应用在页面重新加载之前 “记住” 了购物车中的产品。然后把一个产品添加到购物车中并刷新页面,通知将再次出现。
  // 每次刷新该产品页面时,它都会出现。
  useEffect(() => {
    if (product.isInCart) {
      showNotification(`已添加 ${product.name} 进购物车!`);
    }
  }, [product]);

  function handleBuyClick() {
    addToCart(product);
  }

  function handleCheckoutClick() {
    addToCart(product);
    navigateTo('/checkout');
  }
  // ...
}
// 当不确定某些代码应该放在 Effect 中还是事件处理函数中时,先自问 为什么 要执行这些代码。
// Effect 只用来执行那些显示给用户时组件 需要执行 的代码。
function ProductPage({ product, addToCart }) {
  // ✅ 非常好:事件特定的逻辑在事件处理函数中处理
  function buyProduct() {
    addToCart(product);
    showNotification(`已添加 ${product.name} 进购物车!`);
  }
  function handleBuyClick() {
    buyProduct();
  }

  function handleCheckoutClick() {
    buyProduct();
    navigateTo('/checkout');
  }
  // ...
}

根据 props 或 state 来更新 state

假设有一个包含了两个 state 变量的组件:firstName 和 lastName。你想通过把它们联结起来计算出 fullName。此外,每当 firstName 和 lastName 变化时,希望 fullName 都能更新,在学了Effect之后,可能会写成这样:

// 这样写的问题在于
// 先是用 fullName 的旧值执行了整个渲染流程,然后立即使用更新后的值又重新渲染了一遍,造成了多余的渲染

function Form() {
  const [firstName, setFirstName] = useState('Taylor');
  const [lastName, setLastName] = useState('Swift');

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

// 修改成这样之后,首先代码变得更加简洁
// 如果一个值可以基于现有的 props 或 state 计算得出,不要把它作为一个 state,而是在渲染期间直接计算这个值
// 这将使代码更快(避免了多余的 “级联” 更新)、更简洁(移除了一些代码)以及更少出错(避免了一些因为不同的 state 变量之间没有正确同步而导致的问题)
function Form() {
  const [firstName, setFirstName] = useState('Taylor');
  const [lastName, setLastName] = useState('Swift');
  // ✅ 非常好:在渲染期间进行计算
  const fullName = firstName + ' ' + lastName;
  // ...
}

缓存昂贵的计算

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

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

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

  // ...
}

// 和上面的例子本质是一样的,getFilteredTodos是通过props更新来的,那就直接在渲染过程中计算
function TodoList({ todos, filter }) {
  const [newTodo, setNewTodo] = useState('');
  // ✅ 如果 getFilteredTodos() 的耗时不长,这样写就可以了。
  const visibleTodos = getFilteredTodos(todos, filter);
  // ...
}
// 注意一个情况是,如果getFilteredTodos是个非常耗时的计算,容易阻塞渲染
// 这个时候可以用useMemo(memo函数)把这种长耗时的计算缓存起来
// 传入 useMemo 的函数会在渲染期间执行,所以它仅适用于 纯函数 场景
import { useMemo, useState } from 'react';

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

useMemo 在每次重新渲染的时候能够缓存计算的结果
开发环境的严格模式下,它也会触发两次计算函数的调用
它并不会让 第一次 渲染变快。只是会跳过不必要的更新

当props变化时重置或调整states

首先来讲重置,场景有很多,比如一直在说的聊天室例子,当切换聊天室时(也就是roomId改变时),原则上会希望都切换成当前聊天室初始化状态,脑袋里的第一个蹦出来的想法是利用Effect,监听roomId,当roomId发生改变时,将所有的state设置成初始值:

function ChatRoom({ roomId }) {
const clearAll = () => {
	// 某些清除函数
}
  useEffect(() => {
    const connection = createConnection(serverUrl, roomId);
    connection.on('connected', () => {
      showNotification('Connected!');
    });
    connection.connect();
    return () => {
      connection.disconnect()
      // 监听到roomId发生改变时,先执行清理函数
      // 但是这样就违背了把两件不相干的事分开来处理的原则
      clearAll()
    };
  }, [roomId]); 
//
}
// 代码还可以改成这样
function ChatRoom({ roomId }) {
const clearAll = () => {
	// 某些清除函数
}
  useEffect(() => {
    const connection = createConnection(serverUrl, roomId);
    connection.on('connected', () => {
      showNotification('Connected!');
    });
    connection.connect();
    return () => {
      connection.disconnect()
    };
  }, [roomId]); 
// 这样写确实把不同功能的事件分开了,但这样做就变得低效了
// 因为这个组件首先会用旧值渲染,然后再用新值重新渲染,而且中间状态还可能会出现一些状况外的问题
  useEffect(() => {
      clearAll()
  }, [roomId]); 

//
}

还有一个弊端是,如果这个组件是个超级复杂的组件,里面包含了很多很多子组件,那每次修改roomId,所有的子组件都需要执行一次它们自身的清理函数。那有没有更简单的方法,回想一下之前的知识点,可以通过为每个聊天室组件提供一个明确的键(就是之前用过的key值啦)来告诉 React 它们原则上是 不同 的。

// 假设有个page组件是ChatRoom的父组件
// 每当 key(这里是 roomId)变化时,React 将重新创建 DOM,并 重置 ChatRoom 组件和它的所有子组件的 state。
function Page(){
// ...
 return (
 	<ChatRoom 
 	roomId={roomId}
 	key={roomId}
 	/>
 )
}

有些场景下,只想重置或调整部分 state ,而不是所有 state
List 组件接收一个 items 列表作为 prop,然后用 state 变量 selection 来保持已选中的项。当 items 接收到一个不同的数组时,想将 selection 重置为 null

// 
function List({ items }) {
  const [isReverse, setIsReverse] = useState(false);
  const [selection, setSelection] = useState(null);
// 每当 items 变化时,List 及其子组件会先使用旧的 selection 值渲染。然后 React 会更新 DOM 并执行 Effect。
// 最后,调用 setSelection(null) 将导致 List 及其子组件重新渲染,重新启动整个流程。
  // 🔴 避免:当 prop 变化时,在 Effect 中调整 state
  useEffect(() => {
    setSelection(null);
  }, [items]);
  // ...
}

你可以修改成这样:

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

  // 好一些:在渲染期间调整 state
  // 在渲染过程中直接调用了 setSelection。当它执行到 return 语句退出后,React 将 立即 重新渲染 List。此时 React 还没有渲染 List 的子组件或更新 DOM,这使得 List 的子组件可以跳过渲染旧的 selection 值。
  // 条件判断 items !== prevItems 是必要的,它可以避免无限循环。你可以像这样调整 state,但任何其他副作用(比如变化 DOM 或设置的延时)应该留在事件处理函数或 Effect 中,以 保持组件纯粹。
  
  const [prevItems, setPrevItems] = useState(items);
  if (items !== prevItems) {
    setPrevItems(items);
    setSelection(null);
  }
  // ...
}

// 可以通过添加 key 来重置所有 state,或者 在渲染期间计算所需内容的方式
// 换一种思路来解决问题,可能能获取到最优解
function List({ items }) {
  const [isReverse, setIsReverse] = useState(false);
  const [selectedId, setSelectedId] = useState(null);
  // ✅ 非常好:在渲染期间计算所需内容
  const selection = items.find(item => item.id === selectedId) ?? null;
  // ...
}

链式计算

有时候可能想链接多个 Effect,每个 Effect 都基于某些 state 来调整其他的 state

function Game() {
  const [card, setCard] = useState(null);
  const [goldCardCount, setGoldCardCount] = useState(0);
  const [round, setRound] = useState(1);
  const [isGameOver, setIsGameOver] = useState(false);

  // 🔴 避免:链接多个 Effect 仅仅为了相互触发调整 state
  useEffect(() => {
    if (card !== null && card.gold) {
      setGoldCardCount(c => c + 1);
    }
  }, [card]);

  useEffect(() => {
    if (goldCardCount > 3) {
      setRound(r => r + 1)
      setGoldCardCount(0);
    }
  }, [goldCardCount]);

  useEffect(() => {
    if (round > 5) {
      setIsGameOver(true);
    }
  }, [round]);

  useEffect(() => {
    alert('游戏结束!');
  }, [isGameOver]);

  function handlePlaceCard(nextCard) {
    if (isGameOver) {
      throw Error('游戏已经结束了。');
    } else {
      setCard(nextCard);
    }
  }

这段代码有两个问题:
第一个问题是它非常低效:在链式的每个 set 调用之间,组件(及其子组件)都不得不重新渲染。在上面的例子中,在最坏的情况下(setCard → 渲染 → setGoldCardCount → 渲染 → setRound → 渲染 → setIsGameOver → 渲染)有三次不必要的重新渲染。

第二个问题是,即使不考虑渲染效率问题,随着代码不断扩展,你会遇到这条 “链式” 调用不符合新需求的情况。试想一下,你现在需要添加一种方法来回溯游戏的历史记录,可以通过更新每个 state 变量到之前的值来实现。然而,将 card 设置为之前的的某个值会再次触发 Effect 链并更改你正在显示的数据。这样的代码往往是僵硬而脆弱的。
更好的做法是:尽可能在渲染期间进行计算,以及在事件处理函数中调整 state:

function Game() {
  const [card, setCard] = useState(null);
  const [goldCardCount, setGoldCardCount] = useState(0);
  const [round, setRound] = useState(1);

  // ✅ 尽可能在渲染期间进行计算
  const isGameOver = round > 5;

  function handlePlaceCard(nextCard) {
    if (isGameOver) {
      throw Error('游戏已经结束了。');
    }

    // ✅ 在事件处理函数中计算剩下的所有 state
    setCard(nextCard);
    if (nextCard.gold) {
      if (goldCardCount <= 3) {
        setGoldCardCount(goldCardCount + 1);
      } else {
        setGoldCardCount(0);
        setRound(round + 1);
        if (round === 5) {
          alert('游戏结束!');
        }
      }
    }
  }

但在某些情况下是无法 在事件处理函数中直接计算出下一个 state。例如,试想一个具有多个下拉菜单的表单,如果下一个下拉菜单的选项取决于前一个下拉菜单选择的值。这时,Effect 链是合适的,因为需要与网络进行同步。

初始化应用

有些逻辑只需要在应用加载时执行一次。第一反应可能是把它放在一个顶层组件的 Effect 中。

// 在开发环境中,这会被调用两次,但对于组件挂载这个场景而言,不管是开发环境还是生产环境,都应该行为一致,只挂载一次
function App() {
  // 🔴 避免:把只需要执行一次的逻辑放在 Effect 中
  useEffect(() => {
    loadDataFromLocalStorage();
    checkAuthToken();
  }, []);
  // ...
}
// 避免这个问题,可以考虑在顶层添加一个变量来记录是否挂载的状态
let didInit = false;

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

// 也可以在模块初始化和应用渲染之前执行它
if (typeof window !== 'undefined') { // 检测我们是否在浏览器环境
   // ✅ 只在每次应用加载时执行一次
  checkAuthToken();
  loadDataFromLocalStorage();
}

function App() {
  // ...
}

与父组件的交互

通知父组件有关 state 变化的信息
假设你正在编写一个有具有内部 state isOn 的 Toggle 组件,该 state 可以是 true 或 false。有几种不同的方式来进行切换(通过点击或拖动)。你希望在 Toggle 的 state 变化时通知父组件,因此你暴露了一个 onChange 事件并在 Effect 中调用它:

function Toggle({ onChange }) {
  const [isOn, setIsOn] = useState(false);

  // 🔴 避免:onChange 处理函数执行的时间太晚了
  // 这个事件会在子组件渲染之后才通知到父组件,父组件这个时候才会更新它的state,然后渲染更新,此时也有可能会再次触发子组件更新
  useEffect(() => {
    onChange(isOn);
  }, [isOn, onChange])

  function handleClick() {
    setIsOn(!isOn);
  }

  function handleDragEnd(e) {
    if (isCloserToRightEdge(e)) {
      setIsOn(true);
    } else {
      setIsOn(false);
    }
  }

  // ...
}

// 更好的方式是在单个流程中完成所有操作。在这个例子中,可以删除 Effect,并在同一个事件处理函数中更新 两个 组件的 state
function Toggle({ onChange }) {
  const [isOn, setIsOn] = useState(false);

  function updateToggle(nextIsOn) {
    // ✅ 非常好:在触发它们的事件中执行所有更新
    setIsOn(nextIsOn);
    onChange(nextIsOn);
  }

  function handleClick() {
    updateToggle(!isOn);
  }

  function handleDragEnd(e) {
    if (isCloserToRightEdge(e)) {
      updateToggle(true);
    } else {
      updateToggle(false);
    }
  }

  // ...
}

// 又或者,可以做状态提升,把state移除,并从父组件中接收 isOn,
// ✅ 也很好:该组件完全由它的父组件控制
function Toggle({ isOn, onChange }) {
  function handleClick() {
    onChange(!isOn);
  }

  function handleDragEnd(e) {
    if (isCloserToRightEdge(e)) {
      onChange(true);
    } else {
      onChange(false);
    }
  }
  // ...
}

将数据传递给父组件
还有一个常见的和父组件交互的场景是将数据传递给父组件。
在React中,数据从父组件流向子组件,因此这个过程中进行debug或一些数据追踪操作是容易的

function Parent() {
  const [data, setData] = useState(null);
  // ...
  return <Child onFetched={setData} />;
}

function Child({ onFetched }) {
  const data = useSomeAPI();
  // 🔴 避免:在 Effect 中传递数据给父组件
  useEffect(() => {
    if (data) {
      onFetched(data);
    }
  }, [onFetched, data]);
  // ...
}

// 在这个例子中,子组件和父组件都需要用到同样的数据,可以将数据的获取提升到父组件中,由父组件传入
// 这更简单,并且可以保持数据流的可预测性:数据从父组件流向子组件。
function Parent() {
  const data = useSomeAPI();
  // ...
  // ✅ 非常好:向子组件传递数据
  return <Child data={data} />;
}

function Child({ data }) {
  // ...
}

订阅外部 store

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

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

// 不是不行,但React提供了一个更好的hook:useSyncExternalStore专门用于订阅外部store
// 与手动使用 Effect 将可变数据同步到 React state 相比,这种方法能减少错误。
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();
  // ...
}

useSyncExternalStore 是一个让你订阅外部 store 的 React Hook

获取数据

这可能是最常见的一个需求了

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

  useEffect(() => {
    // 🔴 避免:没有清除逻辑的获取数据
    // 在这个场景中,这个不是需要用户主动触发的事件,所以不应该放到事件处理函数中
    // 但这段代码有一个问题。假设你快速地输入 “hello”。那么 query 会从 “h” 变成 “he”,“hel”,“hell” 最后是 “hello”。这会触发一连串不同的数据获取请求,但无法保证对应的返回顺序。
    // 这种情况被称为 “竞态条件”:两个不同的请求 “相互竞争”,并以与你预期不符的顺序返回。
    fetchResults(query, page).then(json => {
      setResults(json);
    });
  }, [query, page]);

  function handleNextPageClick() {
    setPage(page + 1);
  }
  // ...
}
// 修复这个问题有个简易版的方式:添加一个 清理函数 来忽略较早的返回结果
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);
  }
  // ...
}

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

~~~~~~~~~~~~~~~~~~~~~~~~~~~~
到此为止,Effect的知识点终于梳理完成啦,官网的React教程也学习得差不多了(剩了一个自定义hooks的章节留到下次和已有的hook一起)
完结撒花~下次见~


网站公告

今日签到

点亮在社区的每一天
去签到