使用 ref 操作 DOM

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

你将会学习到

  • 如何使用 ref 属性访问由 React 管理的 DOM 节点
  • ref JSX 属性如何与 useRef Hook 相关联
  • 如何访问另一个组件的 DOM 节点
  • 在哪些情况下修改 React 管理的 DOM 是安全的

获取指向节点的 ref

要访问由 React 管理的 DOM 节点,首先,引入 useRef Hook:

import { useRef } from 'react';

然后,在你的组件中使用它声明一个 ref:

const myRef = useRef(null);

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

<div ref={myRef}>

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

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

示例: 使文本输入框获得焦点

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

import { useRef } from 'react';

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

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

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

要实现这一点:

  1. 使用 useRef Hook 声明 inputRef
  2. <input ref={inputRef}> 这样传递它。这告诉 React 将这个 <input> 的 DOM 节点放入 inputRef.current
  3. handleClick 函数中,从 inputRef.current 读取 input DOM 节点并使用 inputRef.current.focus() 调用它的 focus()
  4. onClickhandleClick 事件处理器传递给 <button>

虽然 DOM 操作是 ref 最常见的用例,但 useRef Hook 可用于存储 React 之外的其他内容,例如计时器 ID 。与 state 类似,ref 能在渲染之间保留。你甚至可以将 ref 视为设置它们时不会触发重新渲染的 state 变量!你可以在使用 Ref 引用值中了解有关 ref 的更多信息。

示例: 滚动至一个元素

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

import { useRef } from 'react';

export default function CatFriends() {
  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>
        <ul>
          <li>
            <img
              src="https://placekitten.com/g/200/200"
              alt="Tom"
              ref={firstCatRef}
            />
          </li>
          <li>
            <img
              src="https://placekitten.com/g/300/200"
              alt="Maru"
              ref={secondCatRef}
            />
          </li>
          <li>
            <img
              src="https://placekitten.com/g/250/200"
              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://placekitten.com/250/200?image=' + i
  });
}

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

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

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

访问另一个组件的 DOM 节点

当你将 ref 放在像 <input /> 这样输出浏览器元素的内置组件上时,React 会将该 ref 的 current 属性设置为相应的 DOM 节点(例如浏览器中实际的 <input /> )。

但是,如果你尝试将 ref 放在 你自己的 组件上,例如 <MyInput />,默认情况下你会得到 null。这个示例演示了这种情况。请注意单击按钮 并不会 聚焦输入框:

import { useRef } from 'react';

function MyInput(props) {
  return <input {...props} />;
}

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

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

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

为了帮助您注意到这个问题,React 还会向控制台打印一条错误消息:

Console
Warning: Function components cannot be given refs. Attempts to access this ref will fail. Did you mean to use React.forwardRef()?

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

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

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

它是这样工作的:

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

在设计系统中,将低级组件(如按钮、输入框等)的 ref 转发到它们的 DOM 节点是一种常见模式。另一方面,像表单、列表或页面段落这样的高级组件通常不会暴露它们的 DOM 节点,以避免对 DOM 结构的意外依赖。

深入探讨

使用命令句柄暴露一部分 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。我们将在下一页讨论 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 调用中:

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

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

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

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 };
    flushSync(() => {
      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)
  });
}

使用 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。 例如,如果某些 <div> 在 JSX 中始终为空,React 将没有理由去变动其子列表。 因此,在那里手动增删元素是安全的。

摘要

  • Refs 是一个通用概念,但大多数情况下你会使用它们来保存 DOM 元素。
  • 你通过传递 <div ref={myRef}> 指示 React 将 DOM 节点放入 myRef.current
  • 通常,你会将 refs 用于非破坏性操作,例如聚焦、滚动或测量 DOM 元素。
  • 默认情况下,组件不暴露其 DOM 节点。 您可以通过使用 forwardRef 并将第二个 ref 参数传递给特定节点来暴露 DOM 节点。
  • 避免更改由 React 管理的 DOM 节点。
  • 如果你确实修改了 React 管理的 DOM 节点,请修改 React 没有理由更新的部分。

1挑战 4 个挑战:
播放和暂停视频

在此示例中,按钮切换 state 变量以在播放和暂停状态之间切换。 然而,为了实际播放或暂停视频,切换状态是不够的。你还需要在 <video> 的 DOM 元素上调用 play()pause()。 向它添加一个 ref,并使按钮起作用。

import { useState, useRef } from 'react';

export default function VideoPlayer() {
  const [isPlaying, setIsPlaying] = useState(false);

  function handleClick() {
    const nextIsPlaying = !isPlaying;
    setIsPlaying(nextIsPlaying);
  }

  return (
    <>
      <button onClick={handleClick}>
        {isPlaying ? '暂停' : '播放'}
      </button>
      <video width="250">
        <source
          src="https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4"
          type="video/mp4"
        />
      </video>
    </>
  )
}

对于额外的挑战,即使用户右键单击视频并使用内置浏览器媒体控件播放,也要使“播放”按钮与视频是否正在播放同步。 您可能需要在视频中监听 onPlayonPause 才能做到这一点。