Иногда в своё 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 здесь захардкоден.
Основные принципы здесь:
Так как у useEffect второй аргумент пустой массив, то его колбэк будет вызван единожды при монтировании компонента, поэтому используем его для инициализации плеера.
Возвращаемая из колбэка useEffect функция будет вызвана при размонтировании компонента. Поэтому здесь нужно не забыть плеер уничтожить, чтобы освободить занимаемые им ресурсы.
Ссылка 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 должны переехать:
Переменная player.
Код инициализации player и нужные для этого параметры.
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;
}
if (props.onStatusChange) {
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);
if (props.onStatusChange) {
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 undefined:
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]);
Из интересного здесь два момента:
Для удаления предыдущего обработчика используем возвращаемое значение useEffect. Тогда не нужно нигде отдельно хранить ссылку на обработчик.
Но Typescript подсказывает, что объект props мог прийти уже другой. Поэтому, приходится скопировать ссылку на onStatusChange из объекта props в локальную переменную, чтобы в removeListener использовалась та же ссылка, которая была передана в addListener.
Часто меняющиеся свойства
У плеера есть некоторые свойства, которые могут меняться довольно часто. Например:
position — позиция в видео-потоке, номер текущего кадра.
Хочется сделать так же, как с другими свойствами:
<VideoPlayer
position={position}
onPositionChange={(position) => setPosition(position)}
source={source}
status={status}
onStatusChange={(status) => setStatus(status)}
/>
Но есть три проблемы:
onPositionChange вызывается очень часто — это будет постоянный ре-рендеринг родительского компонента.
Видео проигрывается браузером в отдельном потоке, и обновление position не будет за ним успевать. Постоянное position={position} заставит видео тормозить и дёргаться.
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.
Итак
Для того, чтобы сделать из императивного компонента декларативный, нужно:
Вынести в отдельный React-компонент его код инициализации и уничтожения. А также, DOM-элемент, к которому он будет прикрепляться.
Вынести в useEffect код, отслеживающий изменения отдельных полей и вызывающий соответствующие методы компонента.
Вынести в useEffect подписку и отписку от событий.
Часто меняющиеся свойства обернуть в специальное императивное API и предоставить его родительскому компоненту.
UPD. Спасибо @Mox за useImperativeHandle.