Иногда в своё React-приложение нужно встроить сторонний компонент, который не работает с React и часто оказывается императивным.

Такой компонент приходится каждый раз инициализировать, уничтожать и, главное, проверять его состояние, прежде чем вызывать его методы.

Верным признаком того, что компонент нужно обернуть в декларативный компонент является обилие useEffect-ов, где проверяются разные сочетания параметров компонента. И в зависимости от этих сочетаний вызываются соответствующие методы компонента.

В статье я хочу разобрать по шагам, как превратить такой компонент в декларативный React-компонент.

Пример использования императивного компонента

Предположим, есть видео-плеер в виде класса с обычными для плееров методами:

  • play(), stop(), pause() — управлять проигрыванием.

  • setSource("nice-cats.mp4") — задать видео для проигрывания.

  • applyElement(divRef) — встроить плеер в нужный элемент DOM.

Кроме того, пользователь может запустить/остановить проигрывание, просто кликнув по видео в плеере.

Наша цель: встроить плеер в наше React-приложение и программно управлять проигрыванием.

Если делать в лоб, то получится примерно так:

import { useEffect, useRef } from "react";
import { Player } from "video-player";

const SOURCES_MOCK = "nice-cats.mp4";

export default function App() {
  const playerElem = useRef<HTMLDivElement>(null);
  const player = useRef<Player>();

  useEffect(() => {
    player.current = new Player();
    player.current.applyElement(playerElem.current);
    player.current.setSource(SOURCES_MOCK);
    return () => player.current?.destroy();
  }, []);

  return (
    <div className="App">
      <button
        disabled={player.current?.getState().status === "playing"}
        onClick={() => player.current?.play()}
      >
        Play
      </button>
      <button
        disabled={player.current?.getState().status === "stopped"}
        onClick={() => player.current?.stop()}
      >
        Stop
      </button>
      <div ref={playerElem} />
    </div>
  );
}

Для простоты, SOURCE_MOCK здесь захардкоден.

Основные принципы здесь:

  1. Так как у useEffect второй аргумент пустой массив, то его колбэк будет вызван единожды при монтировании компонента, поэтому используем его для инициализации плеера.

  2. Возвращаемая из колбэка useEffect функция будет вызвана при размонтировании компонента. Поэтому здесь нужно не забыть плеер уничтожить, чтобы освободить занимаемые им ресурсы.

  3. Ссылка playerElem будет заполнена после рендеринга соответствующего div-а, до вызова колбэка useEffect. Поэтому можно вызвать applyElement без проверки, что ссылка уже готова.

Обновление родительского компонента при изменении дочернего

Мы замечаем, что при старте приложения, а также, если пользователь остановил проигрывание кликнув на видео, а не нажав на нашу кнопку "Stop", то наши кнопки не знают об этом, и не disable-ятся соответствующим образом.

Происходит это, потому что при клике мы вызываем методы плеера, но не меняем состояние компонента App. Поэтому нет ре-рендера, и кнопки не обновляются.

Но у плеера есть стандартный способ подписаться на события:

export default function App() {
  const playerElem = useRef<HTMLDivElement>(null);
  const player = useRef<Player>();
  const [, forceUpdate] = useReducer((x) => x + 1, 0); // новое

  useEffect(() => {
    player.current = new Player();
    player.current.applyElement(playerElem.current);
    player.current.setSource(SOURCES_MOCK);
    player.current.addListener("statusChange", forceUpdate); // новое
    return () => player.current?.destroy();
  }, []);
...

Мы добавили подписку на событие изменения статуса. Отписываться от события не обязательно, потому что мы возвращаем из useEffect вызов метода destroy(), который запустится при размонтировании компонента, и сам отпишет плеер от всех событий.

forceUpdate — это костыльная функция (см. React FAQ), чтобы перерендерить App, и наши кнопки узнали о новом состоянии плеера.

У этого подхода есть плюс:

  • Единственный источник правды о состоянии плеера — это сам объект плеера. И наши кнопки однозначным образом выводят своё состояние из состояния плеера.

Но это не React-way. В React принято делать контролируемые компоненты.

Делаем плеер контролируемым

Несмотря на то, что хорошо иметь единственный источник правды, ещё лучше, когда состояние всех дочерних компонентов выводится из состояния родительского компонента. Тогда точно не будет гонки.

Т.е. сейчас состояние кнопок выводится напрямую из состояния дочернего компонента — плеера. И теперь мы инвертируем поток данных: состояние как кнопок, так и плеера будет выводится из состояния App, а не наоборот.

Поэтому компоненты делают контролируемыми: интересующие нас параметры дочернего компонента, как бы, копируют в состояние (useState) родительского компонента. И тогда, родительский компонент "знает", с какими свойствами нужно рендерить дочерний.

Бонусом, в React мы получаем автоматический ре-рендер родительского компонента, в частности, обновление кнопок. Что нам, в конечном итоге, и нужно.

Давайте сделаем плеер более контролируемым, чтобы изменения его состояния отражались в изменении состояния App:

export default function App() {
  const playerElem = useRef<HTMLDivElement>(null);
  const player = useRef<Player>();
  const [status, setStatus] = useState("stopped"); // новое

  useEffect(() => {
    player.current = new Player();
    player.current.applyElement(playerElem.current);
    player.current.setSource(SOURCES_MOCK);
    player.current.addListener("statusChange", setStatus); // новое
    return () => player.current?.destroy();
  }, []);

  return (
    <div className="App">
      <button
        disabled={status === "playing"} // новое
        onClick={() => player.current?.play()}
      >
        Play
      </button>
      <button
        disabled={status === "stopped"} // новое
        onClick={() => player.current?.stop()}
      >
        Stop
      </button>
      <div ref={playerElem} />
    </div>
  );
}

Теперь вместо костыльного forceUpdate есть нормальная установка статуса. Код стал почище, и мы на шаг ближе к React-ивности.

Но проблема с таким компонентом в том, что если мы захотим где-то опять использовать плеер, то придётся в точности повторить треть этого кода.

Оборачиваем плеер в декларативный React-компонент

Давайте выделим плеер в отдельный декларативный React-компонент, чтобы его можно было легко переиспользовать в других местах приложения.

Для этого полезно представить, как, в идеале, он будет использоваться, его интерфейс с основными свойствами. Как-то так:

<VideoPlayer 
  source={source} 
  status={status} 
  onStatusChange={(status) => setStatus(status)}
/>

Пока этого хватит, а по мере использования разберёмся, чего не хватает.

Получается, что в VideoPlayer должны переехать:

  1. Переменная player.

  2. Код инициализации player и нужные для этого параметры.

  3. div, в который встраивается плеер.

type PlayerProps = { 
  source: string;
  status: Status;
  onStatusChange: (status: Status) => void;
}

const VideoPlayer: React.FC<PlayerProps> = (props) => {
  const playerElem = useRef<HTMLDivElement>(null);
  const player = useRef<Player>();
  
  useEffect(() => {
    player.current = new Player();
    player.current.applyElement(playerElem.current);
    player.current.setSource(props.source);
    switch (props.status) {
      case "playing": player.current.play(); break;
      case "paused":  player.current.pause(); break;
      case "stopped": player.current.stop();  break;
    }
    player.current?.addListener("statusChange", props.onStatusChange);
    return () => player.current?.destroy();
  }, []);
  
  return <div ref={playerElem}/>;
};

Теперь VideoPlayer можно переиспользовать без необходимости повторять данный useEffect.

Отслеживаем изменения пропсов-полей

Если покликать по кнопкам Play и Stop, то обнаруживается, что плеер никак на них не реагирует.

Это так, потому что source и status устанавливаются единственный раз при инициализации компонента VideoPlayer. И при их изменении, не вызываются соответствующие методы плеера.

Давайте перенесём их в отдельные useEffect, чтобы отслеживать их изменения:

  const VideoPlayer: React.FC<PlayerProps> = (props) => {
  const playerElem = useRef<HTMLDivElement>(null);
  const player = useRef<Player>();
  
  useEffect(() => {
    player.current = new Player();
    player.current.applyElement(playerElem.current);
    player.current.addListener("statusChange", props.onStatusChange);
    return () => player.current?.destroy();
  }, []);
  
  useEffect(() => {
    player.current?.setSource(props.source); // перенесли
  }, [props.source])
  
  useEffect(() => {
    switch (props.status) { // перенесли и обработали все значения
      case "playing": player.current?.play(); break;
      case "paused":  player.current?.pause(); break;
      case "stopped": player.current?.stop();  break;
    }
  }, [props.status]);
  
  return <div ref={playerElem}/>;
};

useEffect запускает свой колбэк при изменении массива зависимостей. А там у нас лежат пропсы props.source и props.status, изменения которых мы хотим отслеживать. Поэтому теперь плеер реагирует на изменения источника и статуса.

Обратите внимание, что первым должен быть тот useEffect, который создаёт плеер. Потому что, остальным useEffect нужен уже созданный плеер. Если его не будет, то они не сработают, пока не изменится их массив зависимостей. И видео не будет показано в плеере до тех пор, пока пользователь не кликнет Play.

Поэтому, нужно следить за порядком следования useEffect (см. The post-Hooks guide to React call order).

Примечание: последняя версия документации React советует отслеживать изменения пропсов не в useEffect, а прямо в теле функции компонента. Потому что, тогда можно избежать лишних циклов рендеринга. Но это не наш случай — мы вызываем методы нативного плеера, соответственно, лишних рендерингов не будет.

Отслеживаем изменения пропсов-событий

С обработчиком onStatusChange та же проблема — он добавляется сейчас единожды при инициализации плеера. Это плохо, т.к. его не поменяешь. Давайте сделаем по аналогии с пропсами-полями:

  useEffect(() => {
    const onStatusChange = props.onStatusChange;
    if (!player.current || !onStatusChange) return;

    player.current.addListener("statusChange", onStatusChange);
    return () => player.current?.removeListener("statusChange", onStatusChange);
  }, [props.onStatusChange]);

Из интересного здесь два момента:

  1. Для удаления предыдущего обработчика используем возвращаемое значение useEffect. Тогда не нужно нигде отдельно хранить ссылку на обработчик.

  2. Но Typescript подсказывает, что объект props мог прийти уже другой. Поэтому, приходится скопировать ссылку на onStatusChange из объекта props в локальную переменную, чтобы в removeListener использовалась та же ссылка, которая была передана в addListener.

Часто меняющиеся свойства

У плеера есть некоторые свойства, которые могут меняться довольно часто. Например:

  • position — позиция в видео-потоке, номер текущего кадра.

Хочется сделать так же, как с другими свойствами:

<VideoPlayer
  position={position}
  onPositionChange={(position) => setPosition(position)}
  source={source} 
  status={status} 
  onStatusChange={(status) => setStatus(status)}
/>

Но есть три проблемы:

  1. onPositionChange вызывается очень часто — это будет постоянный ре-рендеринг родительского компонента.

  2. Видео проигрывается браузером в отдельном потоке, и обновление position не будет за ним успевать. Постоянное position={position} заставит видео тормозить и дёргаться.

  3. useEffect отработает с задержкой — после завершения рендеринга. Иногда, это может быть важно. Тогда соответствующий метод плеера нужно вызывать по событию, а не в useEffect после рендеринга.

Именно поэтому в библиотеках, где нужна высокая скорость обновления компонента, часто приходится отходить от декларативного подхода к императивному. Или предпринимать что-то специально, чтобы избежать лишнего рендеринга.

Например, в React Spring есть понятие Animated Components — специально обёрнутых компонентов, которые используются как обычные декларативные, но "под капотом" работают напрямую с DOM-элементами.

Поэтому лучше оставить часть API VideoPlayer императивным, например, так:

type PlayerApi = {
  seek: (position: number) => void;
};

const VideoPlayer = forwardRef<PlayerApi, PlayerProps>((props, ref) => {
  ...
  useImperativeHandle(ref, () => {
    return {
        seek: (position: number) => player.current?.seek(position)
      };
  }, []);
  ...
}

export default function App() {
  const playerApi = useRef<PlayerApi>(null);
  ...
    <button
      onClick={() => playerApi.current?.seek(0)}
    >
      Seek beginning
    </button>
    <VideoPlayer
      ref={playerApi}
      source={SOURCES_MOCK}
      status={status}
      onStatusChange={setStatus}
    />
  ...
}

Появляется немного лишнего кода в виде forwardRef, но именно useImperativeHandle предлагается документацией React для того, чтобы передать родительскому компоненту своё императивное API.

Но если представить, что position понадобится выводить из состояния других элементов, а не задавать прямо по событию клика. Тогда, в App придётся завести отдельный useEffect, аналогично тому, как делали выше в VideoPlayer. И в нём вызывать наш API.

Итак

Для того, чтобы сделать из императивного компонента декларативный, нужно:

  1. Вынести в отдельный React-компонент его код инициализации и уничтожения. А также, DOM-элемент, к которому он будет прикрепляться.

  2. Вынести в useEffect код, отслеживающий изменения отдельных полей и вызывающий соответствующие методы компонента.

  3. Вынести в useEffect подписку и отписку от событий.

  4. Часто меняющиеся свойства обернуть в специальное императивное API и предоставить его родительскому компоненту.

UPD. Спасибо @Mox за useImperativeHandle.

Комментарии (9)


  1. Mox
    07.04.2024 15:11
    +1

    Лучше сделать передачу не api, а ref, а методы в ref добавить через useImperativeHandle


    1. anton_nix Автор
      07.04.2024 15:11

      Спасибо! Дополнил. Хотя, преимущество использовать ref для этого не совсем понятно. Разве что то, что ref-ы документация React считает "окном" в императивную работу с DOM.


      1. Alexandroppolus
        07.04.2024 15:11
        +1

        Название ref устоявшееся, традиционное. А useImperativeHandle дополнительно может работать с рефом в виде функции, и обнуляет его при размонтировании (заодно отметим, он работает на том же этапе, что и useLayoutEffect, т.е. не будет сюрприза, если мы сами вдруг захотим использовать api в useLayoutEffect).

        Нет требования, чтобы в useImperativeHandle закидывать именно реф, который пришел из forwardRef - подойдет любой реф.


  1. i360u
    07.04.2024 15:11
    +2

    А еще, плеер можно завернуть в нативный Custom Element и сильно упростить себе жизнь, опираясь на нативные колбеки жизненного цикла, которые браузер вызовет сам, при необходимости. Но осторожнее с этим, есть риск перестать быть "реакт-разработчиком" и познать дзен.


    1. KataevAS
      07.04.2024 15:11
      +1

      А можно не рисковать и использовать Custom Element оставаясь "реакт-разработчиком" https://legacy.reactjs.org/docs/web-components.html. А при необходимости, можно прокинуть нужные связки с внешним реакт, например router, для ссылок, которые должны оставаться в рамках SPA, а не перезагружать всю страницу.


  1. EdgarAbgaryan
    07.04.2024 15:11
    +1

    спасибо за статью!

    кажется один кейс тут упущен. если родительский компонент, например, хочет чтобы видео всегда проигрывалось (не реагировало на внутренние кнопки стоп), то это следует написать так

        <VideoPlayer
          source={SOURCES_MOCK}
          status="playing"
          onStatusChange={() => {}}
        />

    но это просто не сработает


    1. anton_nix Автор
      07.04.2024 15:11

      Работать будет. Но вы правы в том плане, что когда видео доиграет до конца, оно не начнёт проигрываться заново. Для этого более тонкая логика нужна в VideoPlayer.


      1. EdgarAbgaryan
        07.04.2024 15:11

        точно всё сработает? может, конечно, я не всё в голове удержал. но тогда укажите где я ошибся.

        1. пользователь нажал на внутреннюю кнопку паузы (или пробел, не важно)

        2. плеер встанет в паузу

        3. отработает колбек этой подписки player.current.addListener("statusChange", props.onStatusChange);

        4. props.onStatusChange передаётся пустой (или вовсе не передаётся). соответсвенно в родителе ничего не меняется и в props.status всё ещё лежит "playing"

        и того имеем props.status === "playing" а плеер на паузе

        этот useEffect(() => {...}, [props.status]); не сработает, потому что props.status не поменялся


        1. anton_nix Автор
          07.04.2024 15:11

          Теперь понятно, какой кейс вы имеете ввиду. Да, в статье не обрабатывается такой случай. Доработаю, спасибо.