使用 Refs 操作 DOM

React 会自动更新 DOM 以匹配你的渲染输出,因此你的组件通常不需要操作它。但是,有时你可能需要访问 React 管理的 DOM 元素,例如,聚焦节点、滚动到节点或测量节点的大小和位置。React 中没有内置的方法来执行这些操作,因此你需要一个对 DOM 节点的 引用

你将学习

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

获取节点的引用

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

import { useRef } from 'react';

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

const myRef = useRef(null);

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

<div ref={myRef}>

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

// You can use any browser APIs, for example:
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}>
        Focus the input
      </button>
    </>
  );
}

要实现这一点

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

虽然 DOM 操作是 refs 最常见的用例,但 useRef Hook 可用于在 React 之外存储其他内容,例如计时器 ID。与状态类似,refs 在渲染之间保留。Refs 就像状态变量,当你设置它们时不会触发重新渲染。阅读 使用 Refs 引用值 中有关 refs 的内容。

示例:滚动到元素

您可以在一个组件中拥有多个 ref。在这个示例中,有一个包含三张图片的轮播。每个按钮通过调用对应 DOM 节点上的浏览器 scrollIntoView() 方法来将图片居中

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) => {
// Doesn't work!
const ref = useRef(null);
return <li ref={ref} />;
})}
</ul>

这是因为 Hook 必须只在组件的顶层调用。 您不能在循环、条件或 map() 调用中调用 useRef

一种可能的解决方法是获取其父元素的单个 ref,然后使用 DOM 操作方法(如 querySelectorAll)从父元素中“查找”各个子节点。但是,这种方法很脆弱,如果您的 DOM 结构发生变化,它就会失效。

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

此示例演示如何使用此方法滚动到长列表中的任意节点

import { useRef, useState } from "react";

export default function CatFriends() {
  const itemsRef = useRef(null);
  const [catList, setCatList] = useState(setupCatList);

  function scrollToCat(cat) {
    const map = getMap();
    const node = map.get(cat);
    node.scrollIntoView({
      behavior: "smooth",
      block: "nearest",
      inline: "center",
    });
  }

  function getMap() {
    if (!itemsRef.current) {
      // Initialize the Map on first usage.
      itemsRef.current = new Map();
    }
    return itemsRef.current;
  }

  return (
    <>
      <nav>
        <button onClick={() => scrollToCat(catList[0])}>Tom</button>
        <button onClick={() => scrollToCat(catList[5])}>Maru</button>
        <button onClick={() => scrollToCat(catList[9])}>Jellylorum</button>
      </nav>
      <div>
        <ul>
          {catList.map((cat) => (
            <li
              key={cat}
              ref={(node) => {
                const map = getMap();
                if (node) {
                  map.set(cat, node);
                } else {
                  map.delete(cat);
                }
              }}
            >
              <img src={cat} />
            </li>
          ))}
        </ul>
      </div>
    </>
  );
}

function setupCatList() {
  const catList = [];
  for (let i = 0; i < 10; i++) {
    catList.push("https://loremflickr.com/320/240/cat?lock=" + i);
  }

  return catList;
}

在此示例中,itemsRef 不包含单个 DOM 节点。相反,它包含一个从项目 ID 到 DOM 节点的 Map。(Ref 可以包含任何值!)每个列表项上的 ref 回调函数 负责更新 Map

<li
key={cat.id}
ref={node => {
const map = getMap();
if (node) {
// Add to the Map
map.set(cat, node);
} else {
// Remove from the Map
map.delete(cat);
}
}}
>

这使您可以稍后从 Map 中读取单个 DOM 节点。

Canary

此示例演示了另一种使用 ref 回调函数清理函数管理 Map 的方法。

<li
key={cat.id}
ref={node => {
const map = getMap();
// Add to the Map
map.set(cat, node);

return () => {
// Remove from the Map
map.delete(cat);
};
}}
>

访问另一个组件的 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}>
        Focus the input
      </button>
    </>
  );
}

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

控制台
警告:不能为函数组件提供 ref。尝试访问此 ref 将失败。您是要使用 React.forwardRef() 吗?

发生这种情况是因为默认情况下,React 不允许组件访问其他组件的 DOM 节点。即使是它自己的子组件也不行!这是有意为之的。Ref 是一种应该谨慎使用的逃生舱口。手动操作*另一个* 组件的 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}>
        Focus the input
      </button>
    </>
  );
}

在设计系统中,像按钮、输入框等低级组件将其 ref 转发到其 DOM 节点是一种常见模式。另一方面,像表单、列表或页面部分等高级组件通常不会公开其 DOM 节点,以避免意外依赖 DOM 结构。

深入探讨

使用指令式句柄公开 API 的子集

在上面的例子中,MyInput 公开了原始的 DOM 输入元素。这使得父组件可以调用 focus() 方法。然而,这也使得父组件可以做其他的事情,例如,改变它的 CSS 样式。在一些不常见的情况下,您可能希望限制公开的功能。您可以使用 useImperativeHandle 来做到这一点。

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

const MyInput = forwardRef((props, ref) => {
  const realInputRef = useRef(null);
  useImperativeHandle(ref, () => ({
    // Only expose focus and nothing else
    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}>
        Focus the input
      </button>
    </>
  );
}

在这里,MyInput 中的 realInputRef 持有实际的输入 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。我们将在后面的页面中讨论 Effects。

深入探讨

使用 flushSync 同步刷新状态更新

考虑以下代码,它添加了一个新的待办事项并向下滚动屏幕到列表的最后一个子项。请注意,由于某些原因,它总是滚动到紧挨着最后添加的待办事项之前的那个

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}>
        Add
      </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: 'Todo #' + (i + 1)
  });
}

问题出在这两行

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

在 React 中,状态更新是排队的。 通常情况下,这正是您想要的。然而,在这里它会导致问题,因为 setTodos 不会立即更新 DOM。因此,当您将列表滚动到最后一个元素时,待办事项还没有被添加。这就是为什么滚动总是“滞后”一个项目的原因。

为了解决这个问题,您可以强制 React 同步更新(“刷新”)DOM。为此,请从 react-dom 中导入 flushSync,并将状态更新包装到 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}>
        Add
      </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: 'Todo #' + (i + 1)
  });
}

使用 refs 操作 DOM 的最佳实践

Refs 是一种逃生舱口。您应该只在必须“跳出 React”时使用它们。常见的例子包括管理焦点、滚动位置或调用 React 没有公开的浏览器 API。

如果您坚持使用非破坏性操作,如聚焦和滚动,您应该不会遇到任何问题。然而,如果您试图手动修改 DOM,您可能会与 React 所做的更改发生冲突。

为了说明这个问题,这个例子包含一个欢迎信息和两个按钮。第一个按钮使用条件渲染状态来切换它的显示,就像您通常在 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);
        }}>
        Toggle with setState
      </button>
      <button
        onClick={() => {
          ref.current.remove();
        }}>
        Remove from the DOM
      </button>
      {show && <p ref={ref}>Hello world</p>}
    </div>
  );
}

在您手动删除 DOM 元素之后,试图使用 setState 再次显示它将导致崩溃。这是因为您已经更改了 DOM,而 React 不知道如何继续正确地管理它。

避免更改由 React 管理的 DOM 节点。 修改、添加子节点或从 React 管理的元素中删除子节点可能会导致不一致的视觉效果或像上面那样的崩溃。

然而,这并不意味着您根本不能这样做。它需要谨慎。您可以安全地修改 React *没有理由*更新的 DOM 部分。 例如,如果某个 <div> 在 JSX 中始终为空,React 就没有理由去触碰它的子节点列表。因此,在那里手动添加或删除元素是安全的。

总结

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

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

在此示例中,该按钮切换状态变量以在播放和暂停状态之间切换。但是,为了实际播放或暂停视频,仅切换状态是不够的。您还需要在 <video> 的 DOM 元素上调用 play()pause()。为其添加一个引用,并使按钮正常工作。

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 ? 'Pause' : 'Play'}
      </button>
      <video width="250">
        <source
          src="https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4"
          type="video/mp4"
        />
      </video>
    </>
  )
}

对于额外的挑战,即使用户右键单击视频并使用内置浏览器媒体控件播放视频,也要保持“播放”按钮与视频播放状态同步。您可能希望监听视频上的 onPlayonPause 来做到这一点。