【前端随笔】React中关于ref的理念与使用细节—React新官方文档笔记02

477 阅读9分钟

该文中整理了关于ref的官方教程的要点,包含官方文档应急方案中 使用 ref 操作 DOM – React (docschina.org) 以及 使用 ref 引用值 – React (docschina.org) 的内容。也记录了几个使用ref的示例,包括秒表,轮播图等。

使用ref引用值

添加ref

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

const ref = useRef(0);

useRef 返回一个这样的对象:

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

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

就像state一样,ref可以指向任何东西,同样,与 state 一样,React 会在每次重新渲染之间保留 ref。

与 state 不同的是,ref 是一个普通的 JavaScript 对象,具有可以被读取和修改的 current 属性。设置 state 会重新渲染组件,更改 ref 不会!

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

示例:结合ref和state制作秒表

  • 分析需要保存的数据

    • 开始时间,用来计算渲染在屏幕上的时间长度,所以保存在state中
    • 当前时间,用来计算渲染在屏幕上的时间长度,所以保存在state中
    • interval ID,传给clearInterval使用,用来停止秒表。此 ID 是之前用户按下 Start、调用 setInterval 时返回的。你需要将 interval ID 保留在某处。 由于 interval ID 不用于渲染,可以将其保存在 ref 中。
import { useState, useRef } from 'react';

export default function Stopwatch() {
  const [startTime, setStartTime] = useState(null);
  const [now, setNow] = useState(null);
  const intervalRef = useRef(null);

  function handleStart() {
    setStartTime(Date.now());
    setNow(Date.now());

    clearInterval(intervalRef.current);
    intervalRef.current = setInterval(() => {
      setNow(Date.now());
    }, 10);
  }

  function handleStop() {
    clearInterval(intervalRef.current);
  }

  let secondsPassed = 0;
  if (startTime != null && now != null) {
    secondsPassed = (now - startTime) / 1000;
  }

  return (
    <>
      <h1>时间过去了: {secondsPassed.toFixed(3)}</h1>
      <button onClick={handleStart}>
        开始
      </button>
      <button onClick={handleStop}>
        停止
      </button>
    </>
  );
}

深入:useRef内部如何运行

原则上 useRef 可以在 useState 的基础上 实现。 你可以想象在 React 内部,useRef 是这样实现的:

// React 内部
function useRef(initialValue) {
  const [ref, unused] = useState({ current: initialValue });
  return ref;
}

第一次渲染期间,useRef 返回 { current: initialValue }。 该对象由 React 存储,因此在下一次渲染期间将返回相同的对象。 请注意,在这个示例中,state 设置函数没有被用到。它是不必要的,因为 useRef 总是需要返回相同的对象!

何时使用ref

当组件需要“跳出” React 并与外部 API 通信时,你会用到 ref —— 通常是不会影响组件外观的浏览器 API:

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

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

遵循以下原则将使你的组件更具可预测性:

  • 将 ref 视为应急方案。
  • 不要在渲染过程中读取或写入 ref.current 如果渲染过程中需要某些信息,请使用 state 代替。由于 React 不知道 ref.current 何时发生变化,即使在渲染时读取它也会使组件的行为难以预测。(唯一的例外是像 if (!ref.current) ref.current = new Thing() 这样的代码,它只在第一次渲染期间设置一次 ref。)

ref和DOM

ref 最常见的用法是访问 DOM 元素。例如,如果你想以编程方式聚焦一个输入框,这种用法就会派上用场。当你将 ref 传递给 JSX 中的 ref 属性时,比如 <div ref={myRef}>,React 会将相应的 DOM 元素放入 myRef.current 中。会在下一部分详细整理。

官方挑战记录:

  1. 发送按钮在三秒之后触发,在这期间可以点击撤销按钮撤销定时操作(用ref记录定时器ID)
  2. 修复防抖,防抖可以让你将一些动作推迟到用户“停止动作”之后。如何让多个防抖按钮之间互不影响?(用ref在每个防抖按钮内部记录按钮自己的定时器ID)
import { useRef } from 'react';
let timeoutID;

function DebouncedButton({ onClick, children }) {
  const timeoutRef = useRef(null);
  return (
    <button onClick={() => {
      clearTimeout(timeoutRef.current);
      timeoutRef.current = setTimeout(() => {
        onClick();
      }, 1000);
    }}>
      {children}
    </button>
  );
}

export default function Dashboard() {
  return (
    <>
      <DebouncedButton
        onClick={() => alert('宇宙飞船已发射!')}
      >
        发射宇宙飞船
      </DebouncedButton>
      <DebouncedButton
        onClick={() => alert('汤煮好了!')}
      >
        煮点儿汤
      </DebouncedButton>
      <DebouncedButton
        onClick={() => alert('摇篮曲唱完了!')}
      >
        唱首摇篮曲
      </DebouncedButton>
    </>
  )
}
  1. 读取最新的state,在此示例中,当你按下“发送”后,在显示消息之前会有一小段延迟。输入“你好”,按下发送,然后再次快速编辑输入。尽管你进行了编辑,提示框仍会显示“你好”(这是按钮被点击 那一刻 state 的值)。通常,这种行为是你在应用程序中想要的。但是,有时可能需要一些异步代码来读取某些 state 的 最新 版本。你能想出一种方法,让提示框显示 当前 输入文本而不是点击时的内容吗?(把input绑到ref上)

使用 ref 操作 DOM

  • 通过传递 <div ref={myRef}> 指示 React 将 DOM 节点放入 myRef.current
  • 通常,你会将 refs 用于非破坏性操作,例如聚焦、滚动或测量 DOM 元素。

深入:如何使用 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 时,React 将传入 DOM 节点来调用你的 ref 回调,并在需要清除它时传入 null 。这使你可以维护自己的数组或 Map,并通过其索引或某种类型的 ID 访问任何 ref。

<li
  key={cat.id}
  ref={node => {
    const map = getMap();
    if (node) {
      // 添加到 Map
      map.set(cat.id, node);
    } else {
      // 从 Map 删除
      map.delete(cat.id);
    }
  }}
>

在这个例子中,itemsRef 保存的不是单个 DOM 节点,而是保存了包含列表项 ID 和 DOM 节点的 Map。(Ref 可以保存任何值!) 每个列表项上的 ref 回调负责更新 Map:

这使你可以之后从 Map 读取单个 DOM 节点。

用ref访问另一个元素的DOM节点

  • 默认情况下,组件不暴露其 DOM 节点。 您可以通过使用 forwardRef 并将第二个 ref 参数传递给特定节点来暴露 DOM 节点。

    1. <MyInput ref={inputRef} /> 告诉 React 将对应的 DOM 节点放入 inputRef.current 中。但是,这取决于 MyInput 组件是否允许这种行为, 默认情况下是不允许的。
    2. MyInput 组件是使用 forwardRef 声明的。 这让从上面接收的 inputRef 作为第二个参数 ref 传入组件,第一个参数是 props
    3. MyInput 组件将自己接收到的 ref 传递给它内部的 <input>
import { forwardRef, useRef } from 'react';

const MyInput = forwardRef((props, ref) => {
  return <input {...props} ref={ref} />;
});

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

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

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

注意:尽量避免更改由 React 管理的 DOM 节点

因为可能会与 React 所做的更改发生冲突,比如用ref删了一个DOM节点之后,用来控制该节点的state就会报错了。如果你确实修改了 React 管理的 DOM 节点,请修改 React 没有理由更新的部分。例如,如果某些 <div> 在 JSX 中始终为空,React 将没有理由去变动其子列表。 因此,在那里手动增删元素是安全的。

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

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

代码主要问题出在以下两行中,在 React 中,state 更新是排队进行的。通常,这就是你想要的。但是,在这个示例中会导致问题,因为 setTodos 不会立即更新 DOM。因此,当你将列表滚动到最后一个元素时,尚未添加待办事项。这就是为什么滚动总是“落后”一项的原因。

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

要解决此问题,你可以强制 React 同步更新(“刷新”)DOM。 为此,从 react-dom 导入 flushSync将 state 更新包裹flushSync 调用中:

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

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

⭐挑战:小猫图轮播

要点一:声明一个 selectedRef,然后根据条件将它传递给当前图像:

<li ref={index === i ? selectedRef : null}>

index === i时,表示图像是被选中的图像,相应的 <li> 将接收到 selectedRef。React 将确保 selectedRef.current 始终指向正确的 DOM 节点。

要点二:请注意,为了强制 React 在滚动前更新 DOM,flushSync 调用是必需的。否则,selectedRef.current将始终指向之前选择的项目。

import { useState, useRef } from 'react';
import { flushSync } from 'react-dom';

export default function CatFriends() {
  const [index, setIndex] = useState(0);
  const targetRef = useRef(null);
  return (
    <>
      <nav>
        <button onClick={() => {
          if (index < catList.length - 1) {
            flushSync(() => {
            setIndex(index + 1);
              });
          } else {
            flushSync(() => {
            setIndex(0);
              });
              
          }
          targetRef.current.scrollIntoView({
            behavior: 'smooth',
            block: 'nearest',
            inline: 'center'
          });
        }}>
          下一个
        </button>
      </nav>
      <div>
        <ul>
          {catList.map((cat, i) => (
            <li key={cat.id} ref={index === i ?targetRef: null}>
              <img
                className={
                  index === i ?
                    'active' :
                    ''
                }
                src={cat.imageUrl}
                alt={'猫猫 #' + cat.id}
              />
            </li>
          ))}
        </ul>
      </div>
    </>
  );
}

const catList = [];
for (let i = 0; i < 10; i++) {
  catList.push({
    id: i,
    imageUrl: 'https://placekitten.com/250/200?image=' + i
  });
}