第二章前置知识:2.3 useEffect基础知识

88 阅读12分钟

本专栏致力于每周分享一个项目,如果本文对你有帮助的话,欢迎点赞或者关注☘️

React18 源码系列会随着学习 React 源码的实时进度而实时更新:约,两天一小改,五天一大改。

useEffect基本使用回顾

UseEffect是什么

useEffect是一个 React Hook,可让您将组件与外部系统同步。例如,您可能希望根据 React 状态控制非 React 组件、设置服务器连接或在组件出现在屏幕上时发送分析日志。Effects允许您在渲染后运行一些代码,以便您可以将组件与 React 之外的某些系统同步。

UseEffect语法

语法:useEffect(setup, dependencies?) // 在组件的顶层调用useEffect来声明 Effect
  • setup: 具有Effect逻辑的函数。您的设置函数还可以选择返回清理函数。当你的组件被添加到 DOM 中时,React 将运行你的设置函数。每次使用更改的依赖项重新渲染后,React 将首先使用旧值运行清理函数(如果您提供了它),然后使用新值运行设置函数。从 DOM 中删除组件后,React 将运行您的清理函数。
  • dependencies : setup代码中引用的所有reactive值的列表。reactive值包括 props、state 以及直接在组件体内声明的所有变量和函数。如果您的 linter配置为 React ,它将验证每个reactive值是否已正确指定为依赖项。依赖项列表必须具有恒定数量的项目,并且像[dep1, dep2, dep3]那样内联编写。 React 将使用[Object.is](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/is)比较将每个依赖项与其先前的值进行比较。如果省略此参数,您的 Effect 将在每次重新渲染组件后重新运行
  • return: useEffect返回undefined

UseEffect使用的注意事项

  • useEffect是一个 Hook,因此您只能在组件的顶层或您自己的 Hook 中调用它。您不能在循环或条件内调用它。如果需要,请提取一个新组件并将状态移入其中。
  • 如果您不尝试与某些外部系统同步, 则可能不需要Effect
  • 当严格模式打开时,React 将在第一次真正设置之前运行一个额外的仅开发设置+清理周期。这是一个压力测试,可确保您的清理逻辑“镜像”您的设置逻辑,并确保它停止或撤消设置正在执行的任何操作。如果这导致问题,请实施清理功能。
  • 如果您的某些依赖项是在组件内部定义的对象或函数,则存在它们会导致 Effect 重新运行频率超过所需频率的风险。 要解决此问题,请删除不必要的对象函数依赖项。您还可以在 Effect 之外提取状态更新非 reactive 逻辑
  • 如果您的 Effect 不是由交互(如点击)引起的,React 通常会让浏览器在运行您的 Effect 之前先绘制更新的屏幕。 如果您的 Effect 正在执行一些视觉操作(例如,定位工具提示),并且延迟很明显(例如,闪烁),请将useEffect替换为useLayoutEffect 。
  • 如果您的 Effect 是由交互(如点击)引起的, React 可能会在浏览器绘制更新的屏幕之前运行您的 Effect 。这保证了Effect的结果可以被事件系统观察到。通常,这会按预期工作。但是,如果您必须将工作推迟到绘制之后(例如alert() ,则可以使用setTimeout 。
  • 即使您的 Effect 是由交互(例如单击)引起的, React 也可能允许浏览器在处理 Effect 内的状态更新之前重新绘制屏幕。 通常,这会按预期工作。但是,如果必须阻止浏览器重新绘制屏幕,​​则需要将useEffect替换为useLayoutEffect 。
  • Effect仅在客户端上运行。 它们在服务器渲染期间不会运行。

Useeffect用法

连接到外部系统

某些组件在显示在页面上时需要保持与网络、某些浏览器 API 或第三方库的连接。这些系统不受 React 控制,因此称为外部系统。**如果您没有连接到任何外部系统,则可能不需要效果器。 **外部系统是指任何不受React控制的代码,例如:

  • 使用[setInterval()](https://developer.mozilla.org/en-US/docs/Web/API/setInterval)[clearInterval()](https://developer.mozilla.org/en-US/docs/Web/API/clearInterval)管理的计时器。
  • [window.addEventListener()](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener)[window.removeEventListener()](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/removeEventListener)进行事件订阅。通常,您可以使用 JSX 指定事件侦听器,但不能以这种方式侦听全局[window](https://developer.mozilla.org/en-US/docs/Web/API/Window)对象。 Effect 允许您连接到window对象并监听其事件。
  • 具有animation.start()和animation.reset()等 API 的第三方动画库。

要将组件连接到某个外部系统,请在组件的顶层调用useEffect :

import { 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();
      };
  }, [serverUrl, roomId]);
  // ...
}

React 在必要时调用您的设置和清理函数,这可能会发生多次:

  • 当您的组件添加到页面 (mount) 时,您的设置代码就会运行。
  • 每次重新渲染依赖项发生变化的组件后:
  • 首先,您的清理代码使用旧的 props 和state 运行。
  • 然后,您的设置代码将使用新的 props 和 state 运行。
  • 从页面中删除(卸载)组件后,您的清理代码将最后运行一次

自定义 Hook 中的 Wrapping Effects

Effect 是一个“逃生舱口(escape hatch)”:当您需要“走出 React”并且没有更好的内置解决方案适合您的用例时,您可以使用它们。如果您发现自己经常需要手动编写 Effects,这通常表明您需要为组件所依赖的常见行为提取一些自定义 Hook 。

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

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

  useChatRoom({
    roomId: roomId,
    serverUrl: serverUrl
  });
  // ...

控制非 React 小部件

有时,您希望外部系统与组件的某些属性或状态保持同步。

例如,如果您有一个没有使用 React 编写的第三方地图小部件或视频播放器组件,您可以使用 Effect 来调用其上的方法,使其状态与 React 组件的当前状态匹配。

import { useRef, useEffect } from 'react';
import { MapWidget } from './map-widget.js';

export default function Map({ zoomLevel }) {
  const containerRef = useRef(null);
  const mapRef = useRef(null);

  useEffect(() => {
    if (mapRef.current === null) {
      mapRef.current = new MapWidget(containerRef.current);
    }

    const map = mapRef.current;
    map.setZoom(zoomLevel);
  }, [zoomLevel]);

  return (
    <div
      style={{ width: 200, height: 200 }}
      ref={containerRef}
    />
  );
}

在此示例中,不需要清理函数,因为MapWidget类仅管理传递给它的 DOM 节点。当Map React 组件从树中删除后,DOM 节点和MapWidget类实例都将被浏览器 JavaScript 引擎自动进行垃圾收集。

使用Effect获取数据

import { useState, useEffect } from 'react';
import { fetchBio } from './api.js';

export default function Page() {
  const [person, setPerson] = useState('Alice');
  const [bio, setBio] = useState(null);

  useEffect(() => {
    let ignore = false;
    setBio(null);
    fetchBio(person).then(result => {
      if (!ignore) {
        setBio(result);
      }
    });
    return () => {
      ignore = true;
    };
  }, [person]);

  // ...

使用[async](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/async_function) / [await](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/async_function)语法重写,但是你仍然需要提供一个清理函数:

import { useState, useEffect } from 'react';
import { fetchBio } from './api.js';

export default function Page() {
  const [person, setPerson] = useState('Alice');
  const [bio, setBio] = useState(null);
  useEffect(() => {
    async function startFetching() {
      setBio(null);
      const result = await fetchBio(person);
      if (!ignore) {
        setBio(result);
      }
    }

    let ignore = false;
    startFetching();
    return () => {
      ignore = true;
    }
  }, [person]);

  return (
    <>
      <select value={person} onChange={e => {
        setPerson(e.target.value);
      }}>
        <option value="Alice">Alice</option>
        <option value="Bob">Bob</option>
        <option value="Taylor">Taylor</option>
      </select>
      <hr />
      <p><i>{bio ?? 'Loading...'}</i></p>
    </>
  );
}

指定反应性依赖项

Effect 代码使用的每个反应值都必须声明为依赖项。你的 Effect 的依赖列表是由周围的代码决定的.反应性值包括 props 以及直接在组件内部声明的所有变量和函数。

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

  useEffect(() => {
    const connection = createConnection(serverUrl, roomId);
    connection.connect();
    return () => connection.disconnect();
  }, []); // 🔴 React Hook useEffect has missing dependencies: 'roomId' and 'serverUrl'
  // ...
}

要删除依赖项,您需要向 linter“证明”它不需要是依赖项。 例如,您可以将serverUrl移出组件,以证明它不是反应性的并且不会在重新渲染时发生变化:

const serverUrl = 'https://localhost:1234'; // Not a reactive value anymore

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

当任何组件的 props 或状态更改时,具有空依赖项的 Effect不会重新运行。

传递反应性依赖项的示例
  • 传递依赖数组

如果您指定依赖项,您的 Effect 将在初始渲染后以及使用更改的依赖项重新渲染后运行。

  • 传递一个空的依赖数组

如果您的效果确实不使用任何反应值,它只会在初始渲染后运行

  • 如果您根本不传递任何依赖项数组,则您的 Effect 将在组件的每次渲染(和重新渲染)后运行。

根据Effect的先前状态更新状态

当您想要根据 Effect 的先前状态更新状态时,您可能会遇到问题:

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

  useEffect(() => {
    const intervalId = setInterval(() => {
      setCount(count + 1); // You want to increment the counter every second...
    }, 1000)
    return () => clearInterval(intervalId);
  }, [count]); // 🚩 ... but specifying `count` as a dependency always resets the interval.
  // ...
}

由于count是一个反应值,因此必须在依赖项列表中指定它。但是,这会导致每次count更改时Effect都会重新清理和设置。这并不理想。

要解决此问题,请将c => c + 1状态更新程序传递给setCount

import { useState, useEffect } from 'react';

export default function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const intervalId = setInterval(() => {
      setCount(c => c + 1); // ✅ Pass a state updater
    }, 1000);
    return () => clearInterval(intervalId);
  }, []); // ✅ Now count is not a dependency

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

现在您传递的是c => c + 1而不是count + 1 ,您的 Effect 不再需要依赖于count 。由于此修复,每次count更改时都不需要再次清理和设置interval。

删除不必要的对象依赖项

如果您的 Effect 依赖于渲染期间创建的对象或函数,则它可能运行得太频繁。例如,此 Effect 在每次渲染后重新连接,因为每次渲染的options对象都不同:

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

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

  const options = { // 🚩 This object is created from scratch on every re-render
    serverUrl: serverUrl,
    roomId: roomId
  };

  useEffect(() => {
    const connection = createConnection(options); // It's used inside the Effect
    connection.connect();
    return () => connection.disconnect();
  }, [options]); // 🚩 As a result, these dependencies are always different on a re-render
  // ...

避免使用渲染期间创建的对象作为依赖项。相反,在 Effect 内创建对象:

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

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

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

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

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

删除不必要的函数依赖

如果您的 Effect 依赖于渲染期间创建的对象或函数,则它可能运行得太频繁。例如,此 Effect 在每次渲染后重新连接,因为每次渲染的createOptions函数都不同:

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

  function createOptions() { // 🚩 This function is created from scratch on every re-render
    return {
      serverUrl: serverUrl,
      roomId: roomId
    };
  }

  useEffect(() => {
    const options = createOptions(); // It's used inside the Effect
    const connection = createConnection();
    connection.connect();
    return () => connection.disconnect();
  }, [createOptions]); // 🚩 As a result, these dependencies are always different on a re-render
  // ...

就其本身而言,在每次重新渲染时从头开始创建一个函数并不是问题。你不需要优化它。但是,如果您将它用作 Effect 的依赖项,它将导致您的 Effect 在每次重新渲染后重新运行。避免使用渲染期间创建的函数作为依赖项。相反,在 Effect 中声明它:

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

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

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

  useEffect(() => {
    function createOptions() {
      return {
        serverUrl: serverUrl,
        roomId: roomId
      };
    }

    const options = createOptions();
    const connection = createConnection(options);
    connection.connect();
    return () => connection.disconnect();
  }, [roomId]);

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

从 Effect 中读取最新的 props 和状态

默认情况下,当您从 Effect 读取响应值时,必须将其添加为依赖项。这可确保您的Effect对该值的每次变化做出“反应”。对于大多数依赖项,这就是您想要的行为。

然而,有时您会想要从 Effect 中读取最新的props 和状态,而不会对它们做出“反应”。 例如,假设您想要记录每次页面访问的购物车中的商品数量:

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

如果您想在每次url更改后记录新的页面访问,但如果仅shoppingCart发生更改,怎么办? 在不违反反应性规则的情况下,您无法从依赖项中排除shoppingCart

但是,您可以表示您不希望一段代码对更改做出“反应”,即使它是从 Effect 内部调用的。使用[useEffectEvent](https://react.dev/reference/react/experimental_useEffectEvent) Hook声明一个Effect Event ,并将读取shoppingCart代码移动到其中:

Effect Events 不是反应性的,必须始终从 Effect 的依赖项中省略。

这可以让您将非反应性代码(您可以在其中读取某些 props 和 state 的最新值)放入其中。通过读取onVisit内部的shoppingCart ,您可以确保shoppingCart不会重新运行您的 Effect。

在服务器和客户端上显示不同的内容

如果您的应用程序使用服务器渲染(直接或通过框架),您的组件将在两个不同的环境中渲染。在服务器上,它将呈现以生成初始 HTML。在客户端,React 将再次运行渲染代码,以便将您的事件处理程序附加到该 HTML。这就是为什么要使合作用,客户端和服务器上的初始渲染输出必须相同。

在极少数情况下,您可能需要在客户端上显示不同的内容。例如,如果您的应用程序从[localStorage](https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage)读取一些数据,则它不可能在服务器上执行此操作。以下是您可以如何实现这一点:

function MyComponent() {
  const [didMount, setDidMount] = useState(false);

  useEffect(() => {
    setDidMount(true);
  }, []);

  if (didMount) {
    // ... return client-only JSX ...
  }  else {
    // ... return initial JSX ...
  }
}

当应用程序加载时,用户将看到初始渲染输出。然后,当它加载并水合时,您的 Effect 将运行并将didMount设置为true ,从而触发重新渲染。这将切换到仅限客户端的渲染输出。Effect不在服务器上运行,因此这就是在初始服务器渲染期间didMount为false原因。

谨慎使用此模式。请记住,连接速度较慢的用户将在相当长的时间内(可能是很多秒)看到初始内容,因此您不想对组件的外观进行不和谐的更改。在许多情况下,您可以通过使用 CSS 有条件地显示不同的内容来避免这种情况。

参考链接

关于作者

作者:Wandra

内容:算法 | 趋势 |源码|Vue | React | CSS | Typescript | Webpack | Vite | GithubAction | GraphQL | Uniqpp。

专栏:欢迎关注呀🌹

本专栏致力于每周分享一个项目,如果本文对你有帮助的话,欢迎点赞或者关注☘️