Манипулирование DOM с помощью рефов
React автоматически обновляет DOM в соответствии с результатом рендеринга, поэтому компонентам обычно не нужно манипулировать им. Однако иногда вам может понадобиться доступ к DOM-элементам, которыми управляет React, — например, чтобы установить фокус на узел, прокрутить к нему или измерить его размер и положение. В React нет встроенного способа сделать это, поэтому вам понадобится реф на DOM-узел.
Вы узнаете
- Как получить доступ к DOM-узлу, которым управляет React, с помощью атрибута
ref - Как атрибут JSX
refсвязан с хукомuseRef - Как получить доступ к DOM-узлу другого компонента
- В каких случаях безопасно изменять DOM, которым управляет React
Получение рефа на узел
Чтобы получить доступ к DOM-узлу, которым управляет React, сначала импортируйте хук useRef:
import { useRef } from 'react'; Затем используйте его, чтобы объявить реф внутри компонента:
const myRef = useRef(null); Наконец, передайте свой реф как атрибут ref тегу JSX, для которого хотите получить DOM-узел:
<div ref={myRef}> Хук useRef возвращает объект с единственным свойством под названием current. Изначально значение myRef.current равно null. Когда React создаёт DOM-узел для этого <div>, 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> </> ); }
Чтобы реализовать это:
- Объявите
inputRefс помощью хукаuseRef. - Передайте его как
<input ref={inputRef}>. Так React узнает, что нужно поместить DOM-узел этого<input>вinputRef.current. - В функции
handleClickпрочитайте DOM-узел поля ввода изinputRef.currentи вызовите для негоfocus()с аргументомinputRef.current.focus(). - Передайте обработчик события
handleClickв<button>с помощьюonClick.
Хотя манипулирование DOM — наиболее распространённый вариант использования рефов, хук useRef можно применять для хранения других данных вне React, например идентификаторов таймеров. Как и состояние, рефы сохраняются между рендерингами. Рефы похожи на переменные состояния, но их изменение не запускает повторный рендеринг. Подробнее о рефах читайте в разделе «Ссылки на значения с помощью рефов».
Пример: прокрутка к элементу
В компоненте может быть несколько рефов. В этом примере показана карусель из трёх изображений. Каждая кнопка центрирует изображение, вызывая метод браузера 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> </> ); }
Подробнее
Как управлять списком рефов с помощью колбэка рефа
В примерах выше использовалось заранее известное количество рефов. Однако иногда может понадобиться реф для каждого элемента списка, причём заранее неизвестно, сколько элементов будет. Такой вариант не сработает:
<ul>
{items.map((item) => {
// Doesn't work!
const ref = useRef(null);
return <li ref={ref} />;
})}
</ul>
Это потому, что хуки можно вызывать только на верхнем уровне компонента. Нельзя вызывать useRef в цикле, условии или внутри вызова map().
Один из возможных обходных путей — получить один реф на родительский элемент, а затем использовать методы для манипулирования DOM, например querySelectorAll, чтобы «найти» в нём отдельные дочерние узлы. Однако это ненадёжный подход, который может сломаться при изменении структуры DOM.
Другое решение — передать функцию в атрибут ref. Это называется колбэком ref. React вызовет ваш колбэк рефа с DOM-узлом, когда потребуется установить реф, а когда потребуется очистить реф — вызовет функцию очистки, возвращённую колбэком. Так вы сможете поддерживать собственный массив или Map и получать доступ к любому рефу по индексу или идентификатору.
В этом примере показано, как использовать такой подход, чтобы прокрутить длинный список до произвольного узла:
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[8])}>Bella</button> </nav> <div> <ul> {catList.map((cat) => ( <li key={cat.id} ref={(node) => { const map = getMap(); map.set(cat, node); return () => { map.delete(cat); }; }} > <img src={cat.imageUrl} /> </li> ))} </ul> </div> </> ); } function setupCatList() { const catCount = 10; const catList = new Array(catCount) for (let i = 0; i < catCount; i++) { let imageUrl = ''; if (i < 5) { imageUrl = "https://placecats.com/neo/320/240"; } else if (i < 8) { imageUrl = "https://placecats.com/millie/320/240"; } else { imageUrl = "https://placecats.com/bella/320/240"; } catList[i] = { id: i, imageUrl, }; } return catList; }
В этом примере itemsRef хранит не один DOM-узел, а Map, сопоставляющий идентификаторы элементов с DOM-узлами. (Рефы могут хранить любые значения!) Колбэк 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-узлы из Map.
Примечание
При включённом Strict Mode колбэки рефов в режиме разработки выполняются дважды.
Подробнее о том, как это помогает находить ошибки в колбэках рефов.
Доступ к DOM-узлам другого компонента
Подводный камень
Рефы — это обходной путь. Ручное манипулирование DOM-узлами другого компонента может сделать ваш код ненадёжным.
Вы можете передавать рефы от родительского компонента дочерним так же, как любые другие пропсы.
import { useRef } from 'react';
function MyInput({ ref }) {
return <input ref={ref} />;
}
function MyForm() {
const inputRef = useRef(null);
return <MyInput ref={inputRef} />
} В примере выше реф создаётся в родительском компоненте MyForm и передаётся дочернему компоненту MyInput. Затем MyInput передаёт реф в <input>. Поскольку <input> — это встроенный компонент, React устанавливает для свойства .current рефа значение DOM-элемента <input>.
Реф inputRef, созданный в MyForm, теперь указывает на DOM-элемент <input>, возвращённый компонентом MyInput. Обработчик нажатия, созданный в MyForm, может получить доступ к inputRef и вызвать focus(), чтобы установить фокус на <input>.
import { useRef } from 'react'; function MyInput({ ref }) { return <input ref={ref} />; } export default function MyForm() { const inputRef = useRef(null); function handleClick() { inputRef.current.focus(); } return ( <> <MyInput ref={inputRef} /> <button onClick={handleClick}> Focus the input </button> </> ); }
Подробнее
Предоставление части API с помощью императивного дескриптора
В примере выше реф, переданный в MyInput, передаётся исходному DOM-элементу поля ввода. Благодаря этому родительский компонент может вызвать для него focus(). Но родительский компонент также может делать и что-то ещё, например изменять CSS-стили. В редких случаях может потребоваться ограничить доступную функциональность. Это можно сделать с помощью useImperativeHandle:
import { useRef, useImperativeHandle } from "react"; function MyInput({ ref }) { const realInputRef = useRef(null); useImperativeHandle(ref, () => ({ // Only expose focus and nothing else focus() { realInputRef.current.focus(); }, })); return <input 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> </> ); }
Здесь realInputRef внутри MyInput содержит фактический DOM-узел поля ввода. Однако useImperativeHandle указывает React передать родительскому компоненту в качестве значения рефа ваш собственный специальный объект. Поэтому inputRef.current внутри компонента Form будет содержать только метод focus. В этом случае «дескриптор» рефа — не DOM-узел, а созданный вами объект, переданный в вызов useImperativeHandle.
Когда React привязывает рефы
В React каждое обновление состоит из двух фаз:
- Во время рендеринга React вызывает ваши компоненты, чтобы определить, что должно отображаться на экране.
- Во время фиксации React применяет изменения к DOM.
Как правило, не следует обращаться к рефам во время рендеринга. Это относится и к рефам, содержащим DOM-узлы. При первом рендеринге DOM-узлы ещё не созданы, поэтому ref.current будет иметь значение null. При рендеринге обновлений DOM-узлы ещё не обновлены. Значит, обращаться к ним пока рано.
React устанавливает ref.current во время фиксации. Перед обновлением DOM React устанавливает значения затронутых ref.current в null. После обновления DOM React сразу устанавливает в них соответствующие DOM-узлы.
Обычно вы обращаетесь к рефам из обработчиков событий. Если вы хотите что-то сделать с рефом, но для этого нет подходящего события, возможно, вам понадобится эффект. Об эффектах мы поговорим на следующих страницах.
Подробнее
Синхронная фиксация обновлений состояния с помощью 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 немедленно. Поэтому к моменту прокрутки списка к последнему элементу задача ещё не добавлена. Из-за этого прокрутка всегда «отстаёт» на один элемент.
Чтобы устранить эту проблему, можно принудительно синхронно обновить («зафиксировать») DOM. Для этого импортируйте flushSync из react-dom и оберните обновление состояния вызовом flushSync:
flushSync(() => {
setTodos([ ...todos, newTodo]);
});
listRef.current.lastChild.scrollIntoView();
Это укажет React синхронно обновить DOM сразу после выполнения кода, обёрнутого в flushSync. В результате последняя задача уже будет в 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) }); }
Рекомендации по манипулированию DOM с помощью рефов
Рефы — это обходной путь. Используйте их только тогда, когда необходимо «выйти за пределы React». Обычно это требуется для управления фокусом и положением прокрутки или вызова API браузера, которые React не предоставляет.
Если ограничиться безопасными действиями, например установкой фокуса и прокруткой, проблем возникнуть не должно. Однако при попытке изменять DOM вручную можно столкнуться с конфликтами из-за изменений, которые вносит React.
Чтобы наглядно показать эту проблему, в примере есть приветственное сообщение и две кнопки. Первая кнопка переключает его отображение с помощью условного рендеринга и состояния, как обычно делают в React. Вторая кнопка использует DOM API remove(), чтобы принудительно удалить сообщение из 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 не знает, как дальше правильно им управлять.
Не изменяйте DOM-узлы, которыми управляет React. Изменение элементов, добавление или удаление их дочерних узлов может привести к несогласованному отображению или сбоям, как в примере выше.
Однако это не значит, что так делать нельзя вовсе. Просто нужно быть осторожным. Можно безопасно изменять те части DOM, которые React не нужно обновлять. Например, если какой-либо <div> всегда пуст в JSX, у React не будет причин изменять список его дочерних элементов. Поэтому там безопасно вручную добавлять или удалять элементы.
Итоги
- Рефы — универсальное понятие, но чаще всего их используют для хранения DOM-элементов.
- Чтобы React поместил DOM-узел в
myRef.current, передайте<div ref={myRef}>. - Обычно рефы используют для безопасных действий, например установки фокуса, прокрутки или измерения DOM-элементов.
- Компонент по умолчанию не предоставляет доступ к своим DOM-узлам. Можно разрешить доступ к DOM-узлу с помощью пропса
ref. - Не изменяйте DOM-узлы, которыми управляет React.
- Если вы всё же изменяете DOM-узлы, которыми управляет React, меняйте только те их части, которые React не нужно обновлять.
Попробуйте выполнить задания
Задание 1 из 4:
Воспроизведение и приостановка видео
В этом примере кнопка переключает переменную состояния между состояниями воспроизведения и паузы. Однако для фактического запуска или приостановки видео одного изменения состояния недостаточно. Также нужно вызвать play() и pause() для DOM-элемента <video>. Добавьте к нему реф и настройте работу кнопки.
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 видео.
© 2013–present Facebook Inc.
Licensed under the Creative Commons Attribution 4.0 International Public License.
https://react.dev/learn/manipulating-the-dom-with-refs