Spec-Zone.ru › React

Манипулирование 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>
    </>
  );
}

Чтобы реализовать это:

  1. Объявите inputRef с помощью хука useRef.
  2. Передайте его как <input ref={inputRef}>. Так React узнает, что нужно поместить DOM-узел этого <input> в inputRef.current.
  3. В функции handleClick прочитайте DOM-узел поля ввода из inputRef.current и вызовите для него focus() с аргументом inputRef.current.focus().
  4. Передайте обработчик события 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

Spec-Zone.ru

Настройки Оффлайн Что нового Помощь О нас
Spec-Zone .ru
спецификации, руководства, описания, API