React六官方文档总结三脱围机制

代码下载

React官网已经都是函数式组件文档,没有类组件文档,但是还是支持类组件这种写法。

脱围机制

ref 引用值

当希望组件"记住"某些信息,但又不想让这些信息 触发新的渲染 时,可以使用 ref 。

给组件添加 ref

1、通过从 React 导入 useRef Hook 来为你的组件添加一个 ref:

import { useRef } from 'react';

2、在组件内,调用 useRef Hook 并传入你想要引用的初始值作为唯一参数。例如,这里的 ref 引用的值是"0":

const ref = useRef(0);

useRef 返回一个这样的对象:

{ 
  current: 0 // 你向 useRef 传入的值
}

可以用 ref.current 属性访问该 ref 的当前值。这个值是有意被设置为可变的,意味着你既可以读取它也可以写入它。就像一个 React 追踪不到的、用来存储组件信息的秘密"口袋"。

与 state 一样,React 会在每次重新渲染之间保留 ref。但是,设置 state 会重新渲染组件,更改 ref 不会!当一条信息仅被事件处理器需要,并且更改它不需要重新渲染时,使用 ref 可能会更高效。

ref 和 state 的不同之处

也许会觉得 ref 似乎没有 state 那样"严格" ------ 例如,可以改变它们而非总是必须使用 state 设置函数。但在大多数情况下,建议使用 state。ref 是一种"脱围机制",并不会经常用到它。 以下是 state 和 ref 的对比:

ref state
useRef(initialValue)返回 { current: initialValue } useState(initialValue) 返回 state 变量的当前值和一个 state 设置函数 ( [value, setValue])
更改时不会触发重新渲染 更改时触发重新渲染
可变 ------ 你可以在渲染过程之外修改和更新 current 的值 "不可变" ------ 你必须使用 state 设置函数来修改 state 变量,从而排队重新渲染
不应在渲染期间读取(或写入) current 值 可以随时读取 state。但是,每次渲染都有自己不变的 state 快照
何时使用 ref

通常,当组件需要"跳出" React 并与外部 API 通信时,会用到 ref ------ 通常是不会影响组件外观的浏览器 API。以下是这些罕见情况中的几个:

  • 存储 timeout ID
  • 存储和操作 DOM 元素
  • 存储不需要被用来计算 JSX 的其他对象。

如果组件需要存储一些值,但不影响渲染逻辑,选择 ref。

ref 的最佳实践

遵循这些原则将使组件更具可预测性:

  • 将 ref 视为脱围机制。当使用外部系统或浏览器 API 时,ref 很有用。如果很大一部分应用程序逻辑和数据流都依赖于 ref,可能需要重新考虑实现方法。
  • 不要在渲染过程中读取或写入 ref.current。 如果渲染过程中需要某些信息,使用 state 代替。由于 React 不知道 ref.current 何时发生变化,即使在渲染时读取它也会使组件的行为难以预测。(唯一的例外是像 if (!ref.current) ref.current = new Thing() 这样的代码,它只在第一次渲染期间设置一次 ref。)

React state 的限制不适用于 ref。例如,state 就像 每次渲染的快照,并且 不会同步更新。但是当改变 ref 的 current 值时,它会立即改变:

ref.current = 5;
console.log(ref.current); // 5

ref 本身是一个普通的 JavaScript 对象, 所以它的行为就像对象那样。当使用 ref 时,也无需担心 避免变更。只要改变的对象不用于渲染,React 就不会关心对 ref 或其内容做了什么。

使用 ref 操作 DOM

由于 React 会自动处理更新 DOM 以匹配渲染输出,因此在组件中通常不需要操作 DOM。但是,有时可能需要访问由 React 管理的 DOM 元素 ------ 例如,让一个节点获得焦点、滚动到它或测量它的尺寸和位置。在 React 中没有内置的方法来做这些事情,所以需要一个指向 DOM 节点的 ref 来实现。

获取指向节点的 ref

访问由 React 管理的 DOM 节点:

1、引入 useRef Hook:

import { useRef } from 'react';

2、在组件中使用它声明一个 ref:

const myRef = useRef(null);

3、将 ref 作为 ref 属性值传递给想要获取的 DOM 节点的 JSX 标签:

<div ref={myRef}>

useRef Hook 返回一个对象,该对象有一个名为 current 的属性。最初,myRef.current 是 null。当 React 为这个
创建一个 DOM 节点时,React 会把对该节点的引用放入 myRef.current。然后,可以从 事件处理器 访问此 DOM 节点,并使用在其上定义的内置浏览器 API。

// 可以使用任意浏览器 API,例如:
myRef.current.scrollIntoView();
示例一

使文本输入框获得焦点,单击按钮将使输入框获得焦点:

function RefFocus() {
  const inputRef = useRef(null)
  return (<>
    <input ref={inputRef}></input>
    <button onClick={ () => inputRef.current.focus() }>聚焦输入框</button>
  </>)
}
示例二

滚动至一个元素,一个组件中可以有多个 ref。在这个例子中,有一个由三张图片和三个按钮组成的轮播,点击按钮会调用浏览器的 scrollIntoView() 方法,在相应的 DOM 节点上将它们居中显示在视口中:

function RefScroll() {
  const firstCatRef = useRef(null);
  const secondCatRef = useRef(null);
  const thirdCatRef = useRef(null);

  function handleScrollToFirstCat() {
    firstCatRef.current.scrollIntoView({
      behavior: 'smooth',
      block: 'nearest',
      inline: 'center'
    });
  }

  function handleScrollToSecondCat() {
    secondCatRef.current.scrollIntoView({
      behavior: 'smooth',
      block: 'nearest',
      inline: 'center'
    });
  }

  function handleScrollToThirdCat() {
    thirdCatRef.current.scrollIntoView({
      behavior: 'smooth',
      block: 'nearest',
      inline: 'center'
    });
  }

  return (
    <>
      <nav>
        <button onClick={handleScrollToFirstCat}>
          Tom
        </button>
        <button onClick={handleScrollToSecondCat}>
          Maru
        </button>
        <button onClick={handleScrollToThirdCat}>
          Jellylorum
        </button>
      </nav>
      <div style={{ width: '400px', height: '200px', overflow: 'hidden' }}>
        <ul style={{ listStyle: 'none', width:'600px', height: '200px', margin: '0', padding: '0' }}>
          <li style={{ float: 'left' }}>
            <img
              src="https://img1.baidu.com/it/u=1499135876,4212770522&fm=253&fmt=auto&app=138&f=JPEG?w=200&h=200"
              alt="Tom"
              ref={firstCatRef}
            />
          </li>
          <li style={{ float: 'left' }}>
            <img
              src="https://i02piccdn.sogoucdn.com/01bbc07842904f63"
              alt="Maru"
              ref={secondCatRef}
            />
          </li>
          <li style={{ float: 'left' }}>
            <img
              src="https://img.woyaogexing.com/touxiang/fengjing/20131125/56e8975eb0720d66.jpg%21200X200.jpg"
              alt="Jellylorum"
              ref={thirdCatRef}
            />
          </li>
        </ul>
      </div>
    </>
  );
}
使用 ref 回调管理 ref 列表

在上面的例子中,ref 的数量是预先确定的。但有时候可能需要为列表中的每一项都绑定 ref ,而又不知道会有多少项。像下面这样做是行不通的:

<ul>
  {items.map((item) => {
    // 行不通!
    const ref = useRef(null);
    return <li ref={ref} />;
  })}
</ul>

因为 Hook 只能在组件的顶层被调用。不能在循环语句、条件语句或 map() 函数中调用 useRef 。

  • 一种可能的解决方案是用一个 ref 引用其父元素,然后用 DOM 操作方法如 querySelectorAll 来寻找它的子节点。然而,这种方法很脆弱,如果 DOM 结构发生变化,可能会失效或报错。

  • 另一种解决方案是将函数传递给 ref 属性。这称为 ref 回调。当需要设置 ref 时,React 将传入 DOM 节点来调用你的 ref 回调,并在需要清除它时传入 null 。这使可以维护自己的数组或 Map,并通过其索引或某种类型的 ID 访问任何 ref。

    import { useRef } from 'react';

    export default function CatFriends() {
    const itemsRef = useRef(null);

    function scrollToId(itemId) {
      const map = getMap();
      const node = map.get(itemId);
      node.scrollIntoView({
        behavior: 'smooth',
        block: 'nearest',
        inline: 'center'
      });
    }
    
    function getMap() {
      if (!itemsRef.current) {
        // 首次运行时初始化 Map。
        itemsRef.current = new Map();
      }
      return itemsRef.current;
    }
    
    return (
      <>
        <nav>
          <button onClick={() => scrollToId(0)}>
            Tom
          </button>
          <button onClick={() => scrollToId(5)}>
            Maru
          </button>
          <button onClick={() => scrollToId(9)}>
            Jellylorum
          </button>
        </nav>
        <div>
          <ul>
            {catList.map(cat => (
              <li
                key={cat.id}
                ref={(node) => {
                  const map = getMap();
                  if (node) {
                    map.set(cat.id, node);
                  } else {
                    map.delete(cat.id);
                  }
                }}
              >
                <img
                  src={cat.imageUrl}
                  alt={'Cat #' + cat.id}
                />
              </li>
            ))}
          </ul>
        </div>
      </>
    );
    

    }

    const catList = [];
    for (let i = 0; i < 10; i++) {
    catList.push({
    id: i,
    imageUrl: 'https://img1.baidu.com/it/u=1499135876,4212770522&fm=253&fmt=auto&app=138&f=JPEG?w=200&h=200'
    });
    }

访问另一个组件的 DOM 节点

默认情况下,React 不允许组件访问其他组件的 DOM 节点。甚至自己的子组件也不行!这是故意的。Refs 是一种脱围机制,应该谨慎使用。手动操作 另一个 组件的 DOM 节点会使代码更加脆弱:

  • 当将 ref 放在像 这样输出浏览器元素的内置组件上时,React 会将该 ref 的 current 属性设置为相应的 DOM 节点(例如浏览器中实际的 )。
  • 将 ref 放在 你自己的 组件上,例如 ,默认情况下会得到 null。

想要 暴露其 DOM 节点的组件必须选择该行为。一个组件可以指定将它的 ref "转发"给一个子组件。下面是 MyInput 如何使用 forwardRef API:

import { forwardRef, useRef } from 'react';
......

const MyInput = forwardRef((props, ref) => {
  return <input {...props} ref={ref} />;
});
  • <MyInput ref={inputRef} /> 告诉 React 将对应的 DOM 节点放入 inputRef.current 中。但是,这取决于 MyInput 组件是否允许这种行为, 默认情况下是不允许的。
  • MyInput 组件是使用 forwardRef 声明的。 这让从上面接收的 inputRef 作为第二个参数 ref 传入组件,第一个参数是 props 。
  • MyInput 组件将自己接收到的 ref 传递给它内部的 <input>
使用命令句柄暴露一部分 API

上面的例子中,MyInput 暴露了原始的 DOM 元素 input。这让父组件可以对其调用focus()。然而,这也让父组件能够做其他事情 ------ 例如,改变其 CSS 样式。在一些不常见的情况下,可能希望限制暴露的功能。可以用 useImperativeHandle 做到这一点:

import {
  forwardRef, 
  useRef, 
  useImperativeHandle
} from 'react';

const MyInput = forwardRef((props, ref) => {
  const realInputRef = useRef(null);
  useImperativeHandle(ref, () => ({
    // 只暴露 focus,没有别的
    focus() {
      realInputRef.current.focus();
    },
  }));
  return <input {...props} ref={realInputRef} />;
});

export default function Form() {
  const inputRef = useRef(null);

  function handleClick() {
    inputRef.current.focus();
  }

  return (
    <>
      <MyInput ref={inputRef} />
      <button onClick={handleClick}>
        聚焦输入框
      </button>
    </>
  );
}

MyInput 中的 realInputRef 保存了实际的 input DOM 节点。 但是,useImperativeHandle 指示 React 将自己指定的对象作为父组件的 ref 值。 所以 Form 组件内的 inputRef.current 将只有 focus 方法。在这种情况下,ref "句柄"不是 DOM 节点,而是在 useImperativeHandle 调用中创建的自定义对象。

React 何时添加 refs

在 React 中,每次更新都分为 两个阶段:

  • 在 渲染 阶段, React 调用你的组件来确定屏幕上应该显示什么。
  • 在 提交 阶段, React 把变更应用于 DOM。

通常不希望 在渲染期间访问 refs。这也适用于保存 DOM 节点的 refs。在第一次渲染期间,DOM 节点尚未创建,因此 ref.current 将为 null。在渲染更新的过程中,DOM 节点还没有更新。所以读取它们还为时过早。React 在提交阶段设置 ref.current。在更新 DOM 之前,React 将受影响的 ref.current 值设置为 null。更新 DOM 后,React 立即将它们设置到相应的 DOM 节点。

通常将从事件处理器访问 refs。 如果想使用 ref 执行某些操作,但没有特定的事件可以执行此操作,可能需要一个 effect。

用 flushSync 同步更新 state

添加一个新的待办事项,并将屏幕向下滚动到列表的最后一个子项。请注意,出于某种原因,它总是滚动到最后一个添加 之前 的待办事项:

import { useState, useRef } from 'react';

export default function TodoList() {
  const listRef = useRef(null);
  const [text, setText] = useState('');
  const [todos, setTodos] = useState(
    initialTodos
  );

  function handleAdd() {
    const newTodo = { id: nextId++, text: text };
    setText('');
    setTodos([ ...todos, newTodo]);
    listRef.current.lastChild.scrollIntoView({
      behavior: 'smooth',
      block: 'nearest'
    });
  }

  return (
    <>
      <button onClick={handleAdd}>
        添加
      </button>
      <input
        value={text}
        onChange={e => setText(e.target.value)}
      />
      <ul ref={listRef}>
        {todos.map(todo => (
          <li key={todo.id}>{todo.text}</li>
        ))}
      </ul>
    </>
  );
}

let nextId = 0;
let initialTodos = [];
for (let i = 0; i < 20; i++) {
  initialTodos.push({
    id: nextId++,
    text: '待办 #' + (i + 1)
  });
}

问题出在这两行:

setTodos([ ...todos, newTodo]);
listRef.current.lastChild.scrollIntoView();

在 React 中,state 更新是排队进行的。通常这是想要的。但是,在这个示例中会导致问题,因为 setTodos 不会立即更新 DOM。因此,当将列表滚动到最后一个元素时,尚未添加待办事项。这就是为什么滚动总是"落后"一项的原因。要解决此问题,可以强制 React 同步更新("刷新")DOM。 为此,从 react-dom 导入 flushSync 并将 state 更新包裹 到 flushSync 调用中:

import { flushSync } from 'react-dom';
......

flushSync(() => {
  setTodos([ ...todos, newTodo]);
});
listRef.current.lastChild.scrollIntoView();

这将指示 React 当封装在 flushSync 中的代码执行后,立即同步更新 DOM。因此,当尝试滚动到最后一个待办事项时,它已经在 DOM 中了。

使用 refs 操作 DOM 的最佳实践

Refs 是一种脱围机制。应该只在必须"跳出 React"时使用它们。这方面的常见示例包括管理焦点、滚动位置或调用 React 未暴露的浏览器 API。如果坚持聚焦和滚动等非破坏性操作,应该不会遇到任何问题。但是,如果尝试手动修改 DOM,则可能会与 React 所做的更改发生冲突。

为了说明这个问题,这个例子包括一条欢迎消息和两个按钮。第一个按钮使用 条件渲染 和 state 切换它的显示和隐藏,就像通常在 React 中所做的那样。第二个按钮使用 remove() DOM API 将其从 React 控制之外的 DOM 中强行移除。尝试按几次"通过 setState 切换"。该消息会消失并再次出现。然后按 "从 DOM 中删除"。这将强行删除它。最后,按 "通过 setState 切换":

import { useState, useRef } from 'react';

export default function Counter() {
  const [show, setShow] = useState(true);
  const ref = useRef(null);

  return (
    <div>
      <button
        onClick={() => {
          setShow(!show);
        }}>
        通过 setState 切换
      </button>
      <button
        onClick={() => {
          ref.current.remove();
        }}>
        从 DOM 中删除
      </button>
      {show && <p ref={ref}>Hello world</p>}
    </div>
  );
}

在手动删除 DOM 元素后,尝试使用 setState 再次显示它会导致崩溃。这是因为更改了 DOM,而 React 不知道如何继续正确管理它。避免更改由 React 管理的 DOM 节点。 对 React 管理的元素进行修改、添加子元素、从中删除子元素会导致不一致的视觉结果,或与上述类似的崩溃。但这并不意味着完全不能这样做。它需要谨慎。可以安全地修改 React 没有理由更新的部分 DOM。 例如,如果某些
在 JSX 中始终为空,React 将没有理由去变动其子列表。 因此,在那里手动增删元素是安全的。

使用 Effect 同步

有些组件需要与外部系统同步。可能希望根据 React state 控制非 React 组件、设置服务器连接或在组件出现在屏幕上时发送分析日志。Effects 会在渲染后运行一些代码,以便可以将组件与 React 之外的某些系统同步。

React 组件中的两种逻辑类型:

  • 渲染逻辑代码位于组件的顶层。将在这里接收 props 和 state,并对它们进行转换,最终返回想在屏幕上看到的 JSX。渲染的代码必须是纯粹的------就像数学公式一样,它只应该"计算"结果,而不做其他任何事情。
  • 事件处理程序是嵌套在组件内部的函数,而不仅仅是计算函数。事件处理程序可能会更新输入字段、提交 HTTP POST 请求以购买产品,或者将用户导航到另一个屏幕。事件处理程序包含由特定用户操作(例如按钮点击或键入)引起的"副作用"(它们改变了程序的状态)。

Effect 允许指定由渲染本身,而不是特定事件引起的副作用。在聊天中发送消息是一个"事件",因为它直接由用户点击特定按钮引起。然而,建立服务器连接是 Effect,因为它应该发生无论哪种交互导致组件出现。Effect 在屏幕更新后的 提交阶段 运行。这是一个很好的时机,可以将 React 组件与某个外部系统(如网络或第三方库)同步。

Effect 在 React 中是专有定义------由渲染引起的副作用。为了指代更广泛的编程概念,也可以将其称为"副作用(side effect)"。
不要随意在组件中使用 Effect。记住,Effect 通常用于暂时"跳出" React 代码并与一些 外部 系统进行同步。这包括浏览器 API、第三方小部件,以及网络等等。如果想用 Effect 仅根据其他状态调整某些状态,那么 你可能不需要 Effect。

如何编写 Effect

编写 Effect 需要遵循以下三个规则:

  1. 声明 Effect。默认情况下,Effect 会在每次渲染后都会执行。
  2. 指定 Effect 依赖。大多数 Effect 应该按需执行,而不是在每次渲染后都执行。例如,淡入动画应该只在组件出现时触发。连接和断开服务器的操作只应在组件出现和消失时,或者切换聊天室时执行。文章将介绍如何通过指定依赖来控制如何按需执行。
  3. 必要时添加清理(cleanup)函数。有时 Effect 需要指定如何停止、撤销,或者清除它的效果。例如,"连接"操作需要"断连","订阅"需要"退订","获取"既需要"取消"也需要"忽略"。你将学习如何使用 清理函数 来做到这一切。

1、声明 Effect

首先在 React 中引入 useEffect Hook:

import { useEffect } from 'react';

然后,在组件顶部调用它,并传入在每次渲染时都需要执行的代码:

function MyComponent() {
  useEffect(() => {
    // 每次渲染后都会执行此处的代码
  });
  return <div />;
}

每当组件渲染时,React 将更新屏幕,然后运行 useEffect 中的代码。换句话说,useEffect 会把这段代码放到屏幕更新渲染之后执行。

示例------浏览器的 <video> 标签没有 isPlaying 属性。控制它的唯一方式是在 DOM 元素上调用 play() 和 pause() 方法。因此需要将 isPlaying prop 的值与 play() 和 pause() 等函数的调用进行同步,该属性用于告知当前视频是否应该播放。

function VideoPlayer({ isPlaying, src }) {
  const videoRef = useRef(null)
  useEffect(() => {
    isPlaying ? videoRef.current.play() : videoRef.current.pause()
  })
  return (<div>
    <video style={{width:'400px', marginTop: '10px'}} ref={videoRef} src={src} muted loop playsInline></video>
  </div>)
}
function EffectUse() {
  const [isPlaying, setIsPlaying] = useState(true)
  return (<>
    <button onClick={() => setIsPlaying(!isPlaying)}>{isPlaying ? '暂停' : '播放'}</button>
    <VideoPlayer isPlaying={isPlaying}
  src="https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4"></VideoPlayer>
  </>)
}
ReactDOM.createRoot(document.getElementById('effectUse')).render(<EffectUse></EffectUse>)

一般来说,Effect 会在 每次 渲染后执行,而以下代码会陷入死循环中。每次渲染结束都会执行 Effect;而更新 state 会触发重新渲染。但是新一轮渲染时又会再次执行 Effect,然后 Effect 再次更新 state......如此周而复始,从而陷入死循环。Effect 通常应该使组件与 外部 系统保持同步。如果没有外部系统,只想根据其他状态调整一些状态,那么也许不需要 Effect。

const [count, setCount] = useState(0);
useEffect(() => {
  setCount(count + 1);
});

2、指定 Effect 依赖

一般来说,Effect 会在 每次 渲染时执行。但更多时候,并不需要每次渲染的时候都执行 Effect。

  • 有时这会拖慢运行速度。因为与外部系统的同步操作总是有一定时耗,在非必要时可能希望跳过它。例如,没有人会希望每次用键盘打字时都重新连接聊天服务器。
  • 有时这会导致程序逻辑错误。例如,组件的淡入动画只需要在第一轮渲染出现时播放一次,而不是每次触发新一轮渲染后都播放。

将 依赖数组 传入 useEffect 的第二个参数,以告诉 React 跳过不必要地重新运行 Effect。指定 [isPlaying] 会告诉 React,如果 isPlaying 在上一次渲染时与当前相同,它应该跳过重新运行 Effect。通过这个改变,输入框的输入不会导致 Effect 重新运行,但是按下播放/暂停按钮会重新运行 Effect:

function VideoPlayer({ isPlaying, src }) {
  const videoRef = useRef(null)
  useEffect(() => {
    isPlaying ? videoRef.current.play() : videoRef.current.pause()
  }, [isPlaying])
  return (<div>
    <video style={{width:'400px', marginTop: '10px'}} ref={videoRef} src={src} muted loop playsInline></video>
  </div>)
}
function EffectUse() {
  const [isPlaying, setIsPlaying] = useState(true)
  const [text, setText] = useState('')
  return (<>
    <input value={text} onChange={e => setText(e.target.value)}></input>
    <button onClick={() => setIsPlaying(!isPlaying)}>{isPlaying ? '暂停' : '播放'}</button>
    <VideoPlayer isPlaying={isPlaying}
  src="https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4"></VideoPlayer>
  </>)
}

依赖数组可以包含多个依赖项。当指定的所有依赖项在上一次渲染期间的值与当前值完全相同时,React 会跳过重新运行该 Effect。React 使用 Object.is 比较依赖项的值。有关详细信息,请参阅 useEffect 参考文档。请注意,不能随意选择依赖项。如果指定的依赖项不能与 Effect 代码所期望的相匹配时,lint 将会报错,这将帮助找到代码中的问题。如果不希望某些代码重新运行,那么应当 重新编辑 Effect 代码本身,使其不需要该依赖项。

没有依赖数组作为第二个参数,与依赖数组位空数组 [] 的行为是不一致的:

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

useEffect(() => {
  // 这里的代码只会在组件挂载(mount)后执行
}, []);

useEffect(() => {
  //这里的代码只会在每次渲染后,并且 a 或 b 的值与上次渲染不一致时执行
}, [a, b]);

为什么依赖数组中可以省略 ref? 上面 Effect 同时使用了 ref 与 isPlaying prop,但是只有 isPlaying 被声明为了依赖项。这是因为 ref 具有 稳定 的标识:React 保证 每轮渲染中调用 useRef 所产生的引用对象时,获取到的对象引用总是相同的,即获取到的对象引用永远不会改变,所以它不会导致重新运行 Effect。因此,依赖数组中是否包含它并不重要。当然也可以包括它,这样也可以:

  const videoRef = useRef(null)
  useEffect(() => {
    isPlaying ? videoRef.current.play() : videoRef.current.pause()
  }, [isPlaying, videoRef])

useState 返回的 set 函数 也有稳定的标识符,所以也可以把它从依赖数组中忽略掉。如果在忽略某个依赖项时 linter 不会报错,那么这么做就是安全的。但是,仅在 linter 可以"看到"对象稳定时,忽略稳定依赖项的规则才会起作用。例如,如果 ref 是从父组件传递的,则必须在依赖项数组中指定它。这样做是合适的,因为无法确定父组件是否始终是传递相同的 ref,或者可能是有条件地传递几个 ref 之一。因此 Effect 将取决于传递的是哪个 ref。

3、按需添加清理(cleanup)函数

为了帮助你快速发现问题,在开发环境中,React 会在初始挂载组件后,立即再挂载一次。可以在 Effect 中返回一个 清理(cleanup) 函数。

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

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

每次重新执行 Effect 之前,React 都会调用清理函数;组件被卸载时,也会调用清理函数。仅在开发环境下才会重复挂载组件,以帮助找到需要清理的 Effect。可以选择关闭 严格模式 来关闭开发环境下特有的行为,但建议保留它。这可以帮助发现许多上面这样的错误。

在开发环境中,React 有意重复挂载组件,以查错误。正确的态度是"如何修复 Effect 以便它在重复挂载后能正常工作",而不是"如何只运行一次 Effect"。通常的解决办法是实现清理函数。清理函数应该停止或撤销 Effect 正在执行的任何操作。简单来说,用户不应该感受到 Effect 只执行一次(如在生产环境中)和执行"挂载 → 清理 → 挂载"过程(如在开发环境中)之间的差异。

Effect 应用模式

1、控制非 React 组件

在这种情况下不需要清理,如希望调整缩放级别(zoom level)并与 React 代码中的 zoomLevel state 变量保持同步。在开发环境中,React 会调用 Effect 两次,但这两次挂载时依赖项 zoomLevel 都是相同的,所以会跳过执行第二次挂载时的 Effect。

useEffect(() => {
  const map = mapRef.current;
  map.setZoomLevel(zoomLevel);
}, [zoomLevel]);

某些 API 可能不允许连续调用两次。例如,内置的 元素的 showModal 方法在连续调用两次时会抛出异常,此时实现清理函数并使其关闭对话框:

useEffect(() => {
  const dialog = dialogRef.current;
  dialog.showModal();
  return () => dialog.close();
}, []);

2、订阅事件,如果 Effect 订阅了某些事件,清理函数应该退订这些事件:

useEffect(() => {
  function handleScroll(e) {
    console.log(window.scrollX, window.scrollY);
  }
  window.addEventListener('scroll', handleScroll);
  return () => window.removeEventListener('scroll', handleScroll);
}, []);

3、触发动画,如果 Effect 对某些内容加入了动画,清理函数应将动画重置:

useEffect(() => {
  const node = ref.current;
  node.style.opacity = 1; // 触发动画
  return () => {
    node.style.opacity = 0; // 重置为初始值
  };
}, []);

4、获取数据,如果 Effect 将会获取数据,清理函数应该要么 中止该数据获取操作,要么忽略其结果:

useEffect(() => {
  let ignore = false;

  async function startFetching() {
    const json = await fetchTodos(userId);
    if (!ignore) {
      setTodos(json);
    }
  }

  startFetching();

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

5、发送分析报告,考虑在访问页面时发送日志分析。在开发环境中,会为每个 URL 发送两次请求,所以可能会想尝试解决这个问题。不过建议不必修改此处代码,与前面的示例一样,从用户的角度来看,运行一次和运行两次之间不会 感知 到行为差异。

useEffect(() => {
  logVisit(url); // 发送 POST 请求
}, [url]);

在生产环境中,不会产生有重复的访问日志。为了调试发送的分析事件,可以将应用部署到一个运行在生产模式下的暂存环境,或者暂时取消 严格模式 及其仅在开发环境中重新加载检查;还可以从路由变更事件处理程序中发送分析数据,而不是从 Effect 中发送。为了更精确的分析,可以使用 Intersection Observer 来跟踪哪些组件位于视口中以及它们保持可见的时间。

6、初始化应用时不需要使用 Effect 的情形,某些逻辑应该只在应用程序启动时运行一次。比如,验证登陆状态和加载本地程序数据。可以将其放在组件之外:

if (typeof window !== 'undefined') { // 检查是否在浏览器中运行
  checkAuthToken();
  loadDataFromLocalStorage();
}

function App() {
  // ......
}

这保证了这种逻辑在浏览器加载页面后只运行一次。

7、不要在 Effect 中执行购买商品一类的操作,有时即使编写了一个清理函数,也不能避免执行两次 Effect。

useEffect(() => {
  // 🔴 错误:此处的 Effect 会在开发环境中执行两次,这在代码中是有问题的。
  fetch('/api/buy', { method: 'POST' });
}, []);

一方面,开发环境下,Effect 会执行两次,这意味着购买操作执行了两次,但是这并非是预期的结果,所以不应该把这个业务逻辑放在 Effect 中。另一方面,如果用户转到另一个页面,然后按"后退"按钮回到了这个界面,该怎么办?Effect 会随着组件再次挂载而再次执行。所以,当用户重新访问某个页面时,不应当执行购买操作;当只有用户点击"购买"按钮时,才执行购买操作。

可能不需要 Effect

Effect 是 React 范式中的一种脱围机制。它们让你可以 "逃出" React 并使组件和一些外部系统同步,比如非 React 组件、网络和浏览器 DOM。如果没有涉及到外部系统(例如,你想根据 props 或 state 的变化来更新一个组件的 state),就不应该使用 Effect。移除不必要的 Effect 可以让你的代码更容易理解,运行得更快,并且更少出错。

有两种不必使用 Effect 的常见情况:

  • 不必使用 Effect 来转换渲染所需的数据。例如,想在展示一个列表前先做筛选。直觉可能是写一个当列表变化时更新 state 变量的 Effect。然而,这是低效的。当更新这个 state 时,React 首先会调用组件函数来计算应该显示在屏幕上的内容。然后 React 会把这些变化"提交"到 DOM 中来更新屏幕。然后 React 会执行 Effect。如果 Effect 也立即更新了这个 state,就会重新执行整个流程。为了避免不必要的渲染流程,应在组件顶层转换数据。这些代码会在 props 或 state 变化时自动重新执行。
  • 不必使用 Effect 来处理用户事件。例如,想在用户购买一个产品时发送一个 /api/buy 的 POST 请求并展示一个提示。在这个购买按钮的点击事件处理函数中,确切地知道会发生什么。但是当一个 Effect 运行时,却不知道用户做了什么(例如,点击了哪个按钮)。这就是为什么通常应该在相应的事件处理函数中处理用户事件。
根据 props 或 state 来更新 state

如果一个值可以基于现有的 props 或 state 计算得出,不要把它作为一个 state,而是在渲染期间直接计算这个值。这将使你的代码更快(避免了多余的 "级联" 更新)、更简洁(移除了一些代码)以及更少出错(避免了一些因为不同的 state 变量之间没有正确同步而导致的问题)。

缓存昂贵的计算

可以使用 useMemo Hook 缓存(或者说 记忆(memoize))一个昂贵的计算,useMemo 不会让 第一次 渲染变快。它只是帮助你跳过不必要的更新:

import { useMemo, useState } from 'react';

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

通常,当在相同的位置渲染相同的组件时,React 会保留状态。通过将 key 传递给组件,使 React 将两个组件视为两个不应共享任何状态的不同组件。

当 prop 变化时调整部分 state

当 prop 变化时,可能只想重置或调整部分 state ,而不是所有 state。是在渲染期间直接调整 state:

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

  // 好一些:在渲染期间调整 state
  const [prevItems, setPrevItems] = useState(items);
  if (items !== prevItems) {
    setPrevItems(items);
    setSelection(null);
  }
  // ...
}

存储前序渲染的信息 可能很难理解,但它比在 Effect 中更新这个 state 要好。上面的例子中,在渲染过程中直接调用了 setSelection。当它执行到 return 语句退出后,React 将 立即 重新渲染 List。此时 React 还没有渲染 List 的子组件或更新 DOM,这使得 List 的子组件可以跳过渲染旧的 selection 值。

虽然这种方式比 Effect 更高效,但大多数组件也不需要它。无论你怎么做,根据 props 或其他 state 来调整 state 都会使数据流更难理解和调试。总是检查是否可以通过添加 key 来重置所有 state,或者 在渲染期间计算所需内容。例如,可以存储已选中的 item ID 而不是存储(并重置)已选中的 item:

function List({ items }) {
 const [isReverse, setIsReverse] = useState(false);
 const [selectedId, setSelectedId] = useState(null);
 // ✅ 非常好:在渲染期间计算所需内容
 const selection = items.find(item => item.id === selectedId) ?? null;
 // ...
}
在事件处理函数中共享逻辑

Effect 只用来执行那些显示给用户时组件 需要执行 的代码。共享的逻辑放入一个被两个事件处理程序调用的函数中:

function ProductPage({ product, addToCart }) {
 // ✅ 非常好:事件特定的逻辑在事件处理函数中处理
 function buyProduct() {
   addToCart(product);
   showNotification(`已添加 ${product.name} 进购物车!`);
 }

 function handleBuyClick() {
   buyProduct();
 }

 function handleCheckoutClick() {
   buyProduct();
   navigateTo('/checkout');
 }
 // ...
}
发送 POST 请求

当决定将某些逻辑放入事件处理函数还是 Effect 中时,需要回答的主要问题是:从用户的角度来看它是 怎样的逻辑。如果这个逻辑是由某个特定的交互引起的,请将它保留在相应的事件处理函数中。如果是由用户在屏幕上 看到 组件时引起的,请将它保留在 Effect 中。

链式计算

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

初始化应用

有些逻辑只需要在应用加载时执行一次。如果某些逻辑必须在 每次应用加载时执行一次,而不是在 每次组件挂载时执行一次,可以添加一个顶层变量来记录它是否已经执行过了:

let didInit = false;

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

也可以在模块初始化和应用渲染之前执行它:

if (typeof window !== 'undefined') { // 检测我们是否在浏览器环境
  // ✅ 只在每次应用加载时执行一次
 checkAuthToken();
 loadDataFromLocalStorage();
}

function App() {
 // ...
}

顶层代码会在组件被导入时执行一次------即使它最终并没有被渲染。为了避免在导入任意组件时降低性能或产生意外行为,请不要过度使用这种方法。将应用级别的初始化逻辑保留在像 App.js 这样的根组件模块或应用入口中。

通知父组件有关 state 变化的信息

在同一个事件处理函数中更新 两个 组件的 state:

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

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

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

  // ...
}

通过这种方式,Toggle 组件及其父组件都在事件处理期间更新了各自的 state。React 会 批量 处理来自不同组件的更新,所以只会有一个渲染流程。

也可以完全移除该 state,并从父组件中接收 isOn:

function Toggle({ isOn, onChange }) {
  function handleClick() {
    onChange(!isOn);
  }

  // ...
}

"状态提升" 允许父组件通过切换自身的 state 来完全控制 Toggle 组件。这意味着父组件会包含更多的逻辑,但整体上需要关心的状态变少了。每当你尝试保持两个不同的 state 变量之间的同步时,试试状态提升!

将数据传递给父组件

在 React 中,数据从父组件流向子组件。当你在屏幕上看到了一些错误时,你可以通过一路追踪组件树来寻找错误信息是从哪个组件传递下来的,从而找到传递了错误的 prop 或具有错误的 state 的组件。当子组件在 Effect 中更新其父组件的 state 时,数据流变得非常难以追踪。既然子组件和父组件都需要相同的数据,那么可以让父组件获取那些数据,并将其 向下传递 给子组件。

订阅外部 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();
  // ...
}

这个组件订阅了一个外部的 store 数据(在这里,是浏览器的 navigator.onLine API)。由于这个 API 在服务端不存在(因此不能用于初始的 HTML),因此 state 最初被设置为 true。每当浏览器 store 中的值发生变化时,组件都会更新它的 state。尽管通常可以使用 Effect 来实现此功能,但 React 为此针对性地提供了一个 Hook 用于订阅外部 store。删除 Effect 并将其替换为调用 useSyncExternalStore:

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

与手动使用 Effect 将可变数据同步到 React state 相比,这种方法能减少错误。通常,可以写一个像上面的 useOnlineStatus() 这样的自定义 Hook,这样就不需要在各个组件中重复写这些代码。阅读更多关于在 React 组件中订阅外部数据 store 的信息

获取数据

使用 Effect 来发起数据获取请求。"竞态条件":两个不同的请求 "相互竞争",并以与你预期不符的顺序返回。为了修复这个问题,需要添加一个 清理函数 来忽略较早的返回结果:

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 中包含获取到的内容而不是加载动画),以及如何避免网络瀑布(使子组件不必等待每个父组件的数据获取完毕后才开始获取数据)。这些问题适用于任何 UI 库,而不仅仅是 React。解决这些问题并不容易,这也是为什么现代 框架 提供了比在 Effect 中获取数据更有效的内置数据获取机制的原因。

如果不使用框架(也不想开发自己的框架),但希望使从 Effect 中获取数据更符合人类直觉,请考虑像这个例子一样,将获取逻辑提取到一个自定义 Hook 中:

function SearchResults({ query }) {
  const [page, setPage] = useState(1);
  const params = new URLSearchParams({ query, page });
  const results = useData(`/api/search?${params}`);

  function handleNextPageClick() {
    setPage(page + 1);
  }
  // ...
}

function useData(url) {
  const [data, setData] = useState(null);
  useEffect(() => {
    let ignore = false;
    fetch(url)
      .then(response => response.json())
      .then(json => {
        if (!ignore) {
          setData(json);
        }
      });
    return () => {
      ignore = true;
    };
  }, [url]);
  return data;
}

可能还想添加一些错误处理逻辑以及跟踪内容是否处于加载中。可以自己编写这样的 Hook,也可以使用 React 生态中已经存在的许多解决方案。虽然仅仅使用自定义 Hook 不如使用框架内置的数据获取机制高效,但将数据获取逻辑移动到自定义 Hook 中将使后续采用高效的数据获取策略更加容易。一般来说,当你不得不编写 Effect 时,请留意是否可以将某段功能提取到专门的内置 API 或一个更具声明性的自定义 Hook 中,比如上面的 useData。会发现组件中的原始 useEffect 调用越少,维护应用将变得更加容易。

总结
  • 如果可以在渲染期间计算某些内容,则不需要使用 Effect。
  • 想要缓存昂贵的计算,请使用 useMemo 而不是 useEffect。
  • 想要重置整个组件树的 state,请传入不同的 key。
  • 想要在 prop 变化时重置某些特定的 state,请在渲染期间处理。
  • 组件 显示 时就需要执行的代码应该放在 Effect 中,否则应该放在事件处理函数中。
  • 如果需要更新多个组件的 state,最好在单个事件处理函数中处理。
  • 当尝试在不同组件中同步 state 变量时,请考虑状态提升。
  • 可以使用 Effect 获取数据,但需要实现清除逻辑以避免竞态条件。

响应式 Effect 的生命周期

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

Effect 的生命周期

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

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

相反,尝试从组件生命周期中跳脱出来,独立思考 Effect。Effect 描述了如何将外部系统与当前的 props 和 state 同步。始终专注于单个启动/停止周期。无论组件是挂载、更新还是卸载,都不应该有影响。只需要描述如何开始同步和如何停止。如果做得好,Effect 将能够在需要时始终具备启动和停止的弹性。

React 如何知道需要重新进行 Effect 的同步

每次在组件重新渲染后,React 都会查看传递的依赖项数组。如果数组中的任何值与上一次渲染时在相同位置传递的值不同,React 将重新同步 Effect。

每个 Effect 表示一个独立的同步过程,删除一个 Effect 不会影响另一个 Effect 的逻辑。这表明它们同步不同的内容,因此将它们拆分开是有意义的。另一方面,如果将一个内聚的逻辑拆分成多个独立的 Effects,代码可能会看起来更加"清晰",但 维护起来会更加困难。这就是为什么你应该考虑这些过程是相同还是独立的,而不是只考虑代码是否看起来更整洁。

Effect 会"响应"于响应式值

空的 [] 依赖数组意味着这个 Effect 仅在组件挂载时启动,并在组件卸载时停止。(请记住,在开发环境中,React 仍会 额外执行一次 来对逻辑进行压力测试。)如果从 Effect 的角度思考,根本不需要考虑挂载和卸载。重要的是,已经指定了 Effect 如何开始和停止同步。

Props 和 state 并不是唯一的响应式值。从它们计算出的值也是响应式的。如果 props 或 state 发生变化,组件将重新渲染,从中计算出的值也会随之改变。这就是为什么 Effect 使用的组件主体中的所有变量都应该在依赖列表中。组件内部的所有值(包括 props、state 和组件体内的变量)都是响应式的。任何响应式值都可以在重新渲染时发生变化,所以需要将响应式值包括在 Effect 的依赖项中。
可变值(包括全局变量)不是响应式的。例如,像 location.pathname 这样的可变值不能作为依赖项。它是可变的,因此可以在 React 渲染数据流之外的任何时间发生变化。更改它不会触发组件的重新渲染。因此,即使在依赖项中指定了它,React 也无法知道在其更改时重新同步 Effect。这也违反了 React 的规则,因为在渲染过程中读取可变数据(即在计算依赖项时)会破坏 纯粹的渲染。相反,应该使用 useSyncExternalStore 来读取和订阅外部可变值。另外,像 ref.current 或从中读取的值也不能作为依赖项。useRef 返回的 ref 对象本身可以作为依赖项,但其 current 属性是有意可变的。它允许 跟踪某些值而不触发重新渲染。但由于更改它不会触发重新渲染,它不是响应式值,React 不会知道在其更改时重新运行 Effect。

React 会验证是否将每个响应式值都指定为了依赖项

如果检查工具 配置了 React,它将检查 Effect 代码中使用的每个响应式值是否已声明为其依赖项。

在某些情况下,React 知道 一个值永远不会改变,即使它在组件内部声明。例如,从 useState 返回的 set 函数和从 useRef 返回的 ref 对象是 稳定的 ------它们保证在重新渲染时不会改变。稳定值不是响应式的,因此可以从列表中省略它们。包括它们是允许的:它们不会改变,所以无关紧要。

当不想进行重新同步时该怎么办

可以通过向检查工具"证明"这些值不是响应式值,即它们 不会 因为重新渲染而改变。如果不依赖于渲染并且始终具有相同的值,可以将它们移到组件外部。也可以将它们 移动到 Effect 内部。它们不是在渲染过程中计算的,因此它们不是响应式的。

Effect 是一段响应式的代码块。它们在读取的值发生变化时重新进行同步。与事件处理程序不同,事件处理程序只在每次交互时运行一次,而 Effect 则在需要进行同步时运行。

不能"选择"依赖项。依赖项必须包括 Effect 中读取的每个 响应式值。代码检查工具会强制执行此规则。有时,这可能会导致出现无限循环的问题,或者 Effect 过于频繁地重新进行同步。不要通过禁用代码检查来解决这些问题!下面是一些解决方案:

  • 检查 Effect 是否表示了独立的同步过程。如果 Effect 没有进行任何同步操作,可能是不必要的。如果它同时进行了几个独立的同步操作,将其拆分为多个 Effect。
  • 如果想读取 props 或 state 的最新值,但又不想对其做出反应并重新同步 Effect,可以将 Effect 拆分为具有反应性的部分(保留在 Effect 中)和非反应性的部分(提取为名为 "Effect Event" 的内容)。
  • 避免将对象和函数作为依赖项。如果在渲染过程中创建对象和函数,然后在 Effect 中读取它们,它们将在每次渲染时都不同。这将导致 Effect 每次都重新同步。

将事件从 Effect 中分开

事件处理函数只有在你再次执行同样的交互时才会重新运行。Effect 和事件处理函数不一样,它只有在读取的 props 或 state 值和上一次渲染不一样时才会重新同步。有时需要这两种行为的混合体:即一个 Effect 只在响应某些值时重新运行,但是在其他值变化时不重新运行。

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

事件处理函数和 Effect 对于变化的响应是不一样的:

  • 事件处理函数内部的逻辑是非响应式的。除非用户又执行了同样的操作(例如点击),否则这段逻辑不会再运行。事件处理函数可以在"不响应"他们变化的情况下读取响应式值。
  • Effect 内部的逻辑是响应式的。如果 Effect 要读取响应式值,必须将它指定为依赖项。如果接下来的重新渲染引起那个值变化,React 就会使用新值重新运行 Effect 内的逻辑。

事件处理函数是非响应式的,只会在用户操作的时候运行。Effect 是响应式的,所以会因为依赖性每个不同的值而运行。

从 Effect 中提取非响应式逻辑

当想混合使用响应式逻辑和非响应式逻辑时,事情变得更加棘手。

假设想在用户连接到聊天室时展示一个通知。并且通过从 props 中读取当前 theme(dark 或者 light)来展示对应颜色的通知。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]); // ✅ 声明所有依赖项
  // ...

但是由于 theme 也是一个依赖项,所以每次在 dark 和 light 主题间切换时,聊天 也会 重连,这不是很好!此时需要一个将这个非响应式逻辑和周围响应式 Effect 隔离开来的方法。

Effect Event

Effect Event 在 React 稳定版中 还没有发布的实验性 API。使用 useEffectEvent 这个特殊的 Hook 从 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]); // ✅ 声明所有依赖项
  // ...

这里的 onConnected 被称为 Effect Event。它是 Effect 逻辑的一部分,但是其行为更像事件处理函数。它内部的逻辑不是响应式的,而且能一直"看见"最新的 props 和 state。这个方法解决了问题。注意必须从 Effect 依赖项中 移除 onConnected。Effect Event 是非响应式的并且必须从依赖项中删除。

可以将 Effect Event 看成和事件处理函数相似的东西。主要区别是事件处理函数只在响应用户交互的时候运行,而 Effect Event 是在 Effect 中触发的。Effect Event 让在 Effect 响应性和不应是响应式的代码间"打破链条"。

等 useEffectEvent 成为 React 稳定部分后,会推荐 永远不要抑制代码检查工具。抑制规则的第一个缺点是当 Effect 需要对一个已经在代码中出现过的新响应式依赖项做出"响应"时,React 不会再发出警告。

Effect Event 的局限性

Effect Event 的局限性在于你如何使用他们:

  • 只在 Effect 内部调用他们。
  • 永远不要把他们传给其他的组件或者 Hook。

Effect Event 是 Effect 代码的非响应式"片段"。他们应该在使用他们的 Effect 的旁边。

移除 Effect 依赖

当编写 Effect 时,linter 会验证是否已经将 Effect 读取的每一个响应式值(如 props 和 state)包含在 Effect 的依赖中。这可以确保 Effect 与组件的 props 和 state 保持同步。不必要的依赖可能会导致 Effect 运行过于频繁,甚至产生无限循环。

依赖应该和代码保持一致

当编写 Effect 时,无论这个 Effect 要做什么,首先要明确其 生命周期。如果你设置 Effect 的依赖是空数组([]),那么 linter 将会建议合适的依赖,按照 linter 的建议,把它们填进去。Effect "反应"响应式值(它可能随重新渲染而改变),所以 linter 会验证你是否将它指定为依赖。

当要移除一个依赖时,请证明它不是一个依赖

注意,不能"选择" Effect 的依赖。每个被 Effect 所使用的响应式值,必须在依赖中声明。依赖是由 Effect 的代码决定的。

移除一个依赖,需要向 linter 证明其不需要这个依赖。证明它不是响应的,也不会在重新渲染时改变。指定 空([])依赖,Effect 真的不 依赖任何响应式值了,也 真的不 需要在组件的 props 或 state 改变时重新运行。

如果想改变依赖,首先要改变所涉及到的代码。可以把依赖看作是 Effect的代码所依赖的所有响应式值的列表。不要 选择 把什么放在这个列表上。该列表 描述了 代码。要改变依赖,请改变代码。

抑制 linter 会导致非常不直观的 bug,这将很难发现和修复。建议将依赖性 lint 错误作为一个编译错误来处理。

移除非必需的依赖

特定的交互请将该逻辑直接放到相应的事件处理程序中。

每个 Effect 应该代表一个独立的同步过程,删除一个 Effect 并不会影响到另一个 Effect 的逻辑。这意味着他们 同步不同的事情,分开他们处理是一件好事。如果担心重复代码的问题,可以通过 提取相同逻辑到自定义 Hook 来提升代码质量。

Effect 可以通过传递一个更新函数来设置 state,不要在 Effect 里面读取 state。

读取一个值而不对其变化做出"反应"

Effect Events 可以将 Effect 分成响应式部分(应该"反应"响应式值)和非响应式部分(只读取它们的最新值

Effect Events 不是响应式的,因此不需要将它们指定为依赖,即使父组件传递的函数在每次重新渲染时都不同。

对象和函数作为依赖,会使 Effect 比你需要的更频繁地重新同步。这就是为什么你应该尽可能避免将对象和函数作为 Effect 的依赖。所以,尝试将它们移到组件外部、Effect 内部,或从中提取原始值:

  • 将静态对象和函数移出组件,如果该对象不依赖于任何 props 和 state,可以将该对象移到组件之外。
  • 将动态对象和函数移动到 Effect 中。如果对象依赖于一些可能因重新渲染而改变的响应式值,那么不能将它放置于组件 外部,可以在 Effect 内部 创建它。
  • 从对象中读取原始值,可以从 Effect 外部的对象读取一些值,然后在 Effect 内部创建具有相同值的对象,这使得 Effect 实际 依赖的信息非常明确。
  • 从函数中计算原始值,为避免使其成为依赖,请在 Effect 外部调用它。这仅适用于 纯 函数,因为它们在渲染期间可以安全调用。

使用自定义 Hook 复用逻辑

自定义 Hook 复用逻辑使组件里没有那么多的重复逻辑了。更重要的是,组件内部的代码描述的是想要做什么,而不是怎么做。当提取逻辑到自定义 Hook 时,可以隐藏如何处理外部系统或者浏览器 API 这些乱七八糟的细节。组件内部的代码表达的是目标而不是具体实现。

Hook 的名称必须永远以 use 开头,必须遵循以下这些命名公约:

  • React 组件名称必须以大写字母开头,比如 StatusBar 和 SaveButton。React 组件还需要返回一些 React 能够显示的内容,比如一段 JSX。
  • Hook 的名称必须以 use 开头,然后紧跟一个大写字母,就像内置的 useState 或者本文早前的自定义 useOnlineStatus 一样。Hook 可以返回任意值。

如果为 React 配置了 代码检查工具,它会强制执行这个命名公约,只有 Hook 和组件可以调用其他 Hook!没有 调用 Hook 的函数不需要 变成 Hook,如果创建的函数没有调用任何 Hook 方法,在命名时应避免使用 use 前缀,把它当成一个常规函数去命名。

自定义 Hook 共享的只是状态逻辑而不是状态本身。对 Hook 的每个调用完全独立于对同一个 Hook 的其他调用。需要在多个组件之间共享 state 本身时,需要 将变量提升并传递下去。

每当组件重新渲染,自定义 Hook 中的代码就会重新运行。这就是组件和自定义 Hook 都 需要是纯函数 的原因。应该把自定义 Hook 的代码看作组件主体的一部分。由于自定义 Hook 会随着组件一起重新渲染,所以组件可以一直接收到最新的 props 和 state。

把事件处理函数传到自定义 Hook 中,可以通过 将这个事件处理函数包裹到 Effect Event 中来将它从依赖中移除。

什么时候使用自定义 Hook

没必要对每段重复的代码都提取自定义 Hook。一些重复是好的。但是每当写 Effect 时,考虑一下把它包裹在自定义 Hook 是否更清晰。不应该经常使用 Effect,所以如果正在写 Effect 就意味着需要"走出 React"和某些外部系统同步,或者需要做一些 React 中没有对应内置 API 的事。把 Effect 包裹进自定义 Hook 可以准确表达目标以及数据在里面是如何流动的。好的自定义 Hook 通过限制功能使代码调用更具声明性。

自定义 Hook 帮助迁移到更好的模式

Effect 是一个 脱围机制:当需要"走出 React"且用例没有更好的内置解决方案时可以使用他们。随着时间的推移,React 团队的目标是通过给更具体的问题提供更具体的解决方案来最小化应用中的 Effect 数量。把 Effect 包裹进自定义 Hook,当这些解决方案可用时升级代码会更加容易。这是把 Effect 包裹进自定义 Hook 有益的原因:

  • 让进出 Effect 的数据流非常清晰。
  • 让组件专注于目标,而不是 Effect 的准确实现。
  • 当 React 增加新特性时,可以在不修改任何组件的情况下移除这些 Effect。

Effect 可以连接 React 和外部系统。Effect 之间的配合越多,完整地 从 Effect 和 Hook 中提取逻辑就越有意义。然后提取的代码 变成 "外部系统"。这会让 Effect 保持简洁,因为他们只需要向已经被移动到 React 外部的系统发送消息。

案例------实现一个 fade-in 动画

写原始 Effect 实现:

function Welcome() {
  const ref = useRef(null)

  // 动画
  useEffect(() => {
    const duration = 1000
    const node = ref.current

    let startTime = performance.now()
    let frameId = null

    // 动画进度更新
    function onProgress(progress) {
      node.style.opacity = progress
    }

    // 动画循环
    function onFrame(now) {
      const timePassed = now - startTime;
      const progress = Math.min(timePassed/duration, 1)
      onProgress(progress)
      if (progress < 1) {
        frameId = requestAnimationFrame(onFrame)
      }
    }

    // 开始动画
    function start() {
      onProgress(0)
      startTime = performance.now()
      frameId = requestAnimationFrame(onFrame)
    }

    // 终止动画
    function stop() {
      cancelAnimationFrame(frameId)
      startTime = null
      frameId = null
    }

    start()

    return () => stop()
  }, [])

  return (<>
    <h1 ref={ref} style={{width: '200px', height: '100px', textAlign: 'center', lineHeight: '100px', backgroundColor: 'red'}}>Welcome</h1>
  </>)
}

function CustomHook() {
  const [show, setShow] = useState(false)
  return (<>
    <p>案例------实现一个 fade-in 动画</p>
    <button onClick={() => setShow(!show)}>{show ? '隐藏' : '显示'}</button>
    {show && <Welcome></Welcome>}
  </>)
}

为了让组件更具有可读性,要将逻辑提取到自定义 Hook useFadeIn:

function useFadeIn(ref, duration) {
  // 动画
  useEffect(() => {
    const node = ref.current

    let startTime = performance.now()
    let frameId = null

    // 动画进度更新
    function onProgress(progress) {
      node.style.opacity = progress
    }

    // 动画循环
    function onFrame(now) {
      const timePassed = now - startTime;
      const progress = Math.min(timePassed/duration, 1)
      onProgress(progress)
      if (progress < 1) {
        frameId = requestAnimationFrame(onFrame)
      }
    }

    // 开始动画
    function start() {
      onProgress(0)
      startTime = performance.now()
      frameId = requestAnimationFrame(onFrame)
    }

    // 终止动画
    function stop() {
      cancelAnimationFrame(frameId)
      startTime = null
      frameId = null
    }

    start()

    return () => stop()
  }, [duration])
}

function Welcome() {
  const ref = useRef(null)
  useFadeIn(ref, 2000)

  return (<>
    <h1 ref={ref} style={{width: '200px', height: '100px', textAlign: 'center', lineHeight: '100px', backgroundColor: 'red'}}>Welcome</h1>
  </>)
}

可以进一步重构,把设置动画循环的逻辑从 useFadeIn 提取到自定义 Hook useAnimationLoop:

function useAnimationLoop(isRunning, drawFrame) {
  const event = useEffectEvent(drawFrame)
  useEffect(() => {
    if (isRunning) {
      let startTime = performance.now()
      let frameId = null
  
      // 动画循环
      function onFrame(now) {
        const timePassed = now - startTime;
        event(timePassed)
        frameId = requestAnimationFrame(onFrame)
      }
  
      // 开始动画
      function start() {
        event(0)
        startTime = performance.now()
        frameId = requestAnimationFrame(onFrame)
      }
  
      // 终止动画
      function stop() {
        cancelAnimationFrame(frameId)
        startTime = null
        frameId = null
      }
  
      start()
  
      return () => stop()
    }
  }, [isRunning])
}

function useFadeIn(ref, duration) {
  // 提取刷帧动画
  const [isRunning, setIsRunning] = useState(true)
  useAnimationLoop(isRunning, (timePassed) => {
    const progress = Math.min(timePassed/duration, 1)
    ref.current.style.opacity = progress
    if (progress == 1) {
      setIsRunning(false)
    }
  })
}
相关推荐
hackeroink1 小时前
【2024版】最新推荐好用的XSS漏洞扫描利用工具_xss扫描工具
前端·xss
迷雾漫步者3 小时前
Flutter组件————FloatingActionButton
前端·flutter·dart
向前看-3 小时前
验证码机制
前端·后端
燃先生._.4 小时前
Day-03 Vue(生命周期、生命周期钩子八个函数、工程化开发和脚手架、组件化开发、根组件、局部注册和全局注册的步骤)
前端·javascript·vue.js
高山我梦口香糖5 小时前
[react]searchParams转普通对象
开发语言·前端·javascript
m0_748235245 小时前
前端实现获取后端返回的文件流并下载
前端·状态模式
m0_748240256 小时前
前端如何检测用户登录状态是否过期
前端
black^sugar6 小时前
纯前端实现更新检测
开发语言·前端·javascript
寻找沙漠的人7 小时前
前端知识补充—CSS
前端·css