React 会自动更新DOM 以匹配你的渲染输出,因此你的组件通常不需要操作它。但是,有时你可能需要访问 React 管理的 DOM 元素——例如,聚焦节点、滚动到节点或测量其大小和位置。React 没有内置的方法来执行这些操作,因此你需要一个指向 DOM 节点的ref。
你将学习
- 如何使用
ref
属性访问 React 管理的 DOM 节点 - JSX 属性
ref
与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.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> </> ); }
要实现这一点
- 使用
useRef
Hook 声明inputRef
。 - 将其作为
<input ref={inputRef}>
传递。这告诉 React将此<input>
的 DOM 节点放入inputRef.current
中。 - 在
handleClick
函数中,从inputRef.current
读取输入 DOM 节点,并使用inputRef.current.focus()
在其上调用focus()
。 - 将
handleClick
事件处理程序传递给带有onClick
属性的<button>
元素。
虽然DOM操作是refs最常见的用例,但useRef
Hook可用于存储React外部的其他内容,例如计时器ID。与状态类似,refs在渲染之间保持不变。refs就像状态变量,在你设置它们时不会触发重新渲染。阅读关于refs的更多信息,请访问使用Refs引用值。
示例:滚动到元素
一个组件中可以有多个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}> Neo </button> <button onClick={handleScrollToSecondCat}> Millie </button> <button onClick={handleScrollToThirdCat}> Bella </button> </nav> <div> <ul> <li> <img src="https://placecats.com/neo/300/200" alt="Neo" ref={firstCatRef} /> </li> <li> <img src="https://placecats.com/millie/200/200" alt="Millie" ref={secondCatRef} /> </li> <li> <img src="https://placecats.com/bella/199/200" alt="Bella" ref={thirdCatRef} /> </li> </ul> </div> </> ); }
深入探讨
在上面的例子中,refs的数量是预先定义的。但是,有时你可能需要引用列表中的每个项目,而且你不知道会有多少个项目。像这样的方法是行不通的
<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])}>Neo</button> <button onClick={() => scrollToCat(catList[5])}>Millie</button> <button onClick={() => scrollToCat(catList[9])}>Bella</button> </nav> <div> <ul> {catList.map((cat) => ( <li key={cat} ref={(node) => { const map = getMap(); map.set(cat, node); return () => { 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。(Refs可以保存任何值!)每个列表项上的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);
};
}}
>
这允许你稍后从Map中读取各个DOM节点。
访问其他组件的DOM节点
当你在输出浏览器元素(例如<input />
)的内置组件上放置ref时,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还会向控制台打印错误。
发生这种情况是因为默认情况下,React不允许组件访问其他组件的DOM节点。甚至不包括它自己的子组件!这是故意的。Refs是一种应谨慎使用的应急措施。手动操作*其他*组件的DOM节点会使你的代码更加脆弱。
相反,*想要*公开其DOM节点的组件必须选择加入此行为。组件可以指定它将其ref“转发”给它的一个子组件。以下是MyInput
如何使用forwardRef
API的方法。
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>
元素。
现在点击按钮聚焦输入框可以正常工作了。
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> </> ); }
在设计系统中,对于低级别组件(例如按钮、输入框等),将它们的 refs 转发给它们的 DOM 节点是一种常见的模式。另一方面,高级组件(例如表单、列表或页面部分)通常不会将其 DOM 节点暴露出来,以避免意外依赖于 DOM 结构。
深入探讨
在上面的例子中,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 中,每次更新都分为两个阶段
- 在渲染 (render) 阶段,React 调用您的组件以确定屏幕上应该显示什么。
- 在提交 (commit) 阶段,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。
深入探讨
考虑以下代码,它添加一个新的待办事项并将屏幕向下滚动到列表的最后一个子项。请注意,由于某种原因,它总是滚动到刚添加的待办事项之前的那个待办事项。
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强行将其从 DOM 中移除,脱离 React 的控制。
尝试多次按下“使用 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 将没有理由触及其子元素列表。因此,在其中手动添加或移除元素是安全的。
总结
- Refs 是一个通用概念,但大多数情况下您会使用它们来保存 DOM 元素。
- 您可以通过传递
<div ref={myRef}>
来指示 React 将 DOM 节点放入myRef.current
中。 - 通常,您会将 refs 用于非破坏性操作,例如聚焦、滚动或测量 DOM 元素。
- 组件默认情况下不会公开其 DOM 节点。您可以选择通过使用
forwardRef
并将第二个ref
参数传递给特定节点来公开 DOM 节点。 - 避免更改由 React 管理的 DOM 节点。
- 如果您确实修改了由 React 管理的 DOM 节点,请修改 React 没有理由更新的部分。
挑战 1的 4: 播放和暂停视频
在此示例中,按钮切换状态变量以在播放和暂停状态之间切换。但是,为了实际播放或暂停视频,切换状态是不够的。您还需要在<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 ? 'Pause' : 'Play'} </button> <video width="250"> <source src="https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4" type="video/mp4" /> </video> </> ) }
对于一个额外的挑战,即使用户右键单击视频并使用内置浏览器媒体控件播放视频,也要使“播放”按钮与视频是否正在播放保持同步。您可能需要监听视频上的onPlay
和onPause
来做到这一点。