Иногда в своё React-приложение нужно встроить сторонний компонент, который не работает с 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 второй аргумент пустой массив, то его колбэк будет вызван 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: Теперь вместо костыльного forceUpdate есть нормальная установка статуса. Код стал почище, и мы на шаг ближе к React-ивности. Но проблема с таким компонентом в том, что если мы захотим где-то опять использовать плеер, то придётся в точности повторить треть этого кода. Давайте выделим плеер в отдельный декларативный React-компонент, чтобы его можно было легко переиспользовать в других местах приложения. Для этого полезно представить, как, в идеале, он будет использоваться, его интерфейс с основными свойствами. Как-то так: Пока этого хватит, а по мере использования разберёмся, чего не хватает. Получается, что в VideoPlayer должны переехать: Переменная player. Код инициализации player и нужные для этого параметры. div, в который встраивается плеер. Теперь VideoPlayer можно переиспользовать без необходимости повторять данный useEffect. Если покликать по кнопкам Play и Stop, то обнаруживается, что плеер никак на них не реагирует. Это так, потому что source и status устанавливаются единственный раз при инициализации компонента VideoPlayer. И при их изменении, не вызываются соответствующие методы плеера. Давайте перенесём их в отдельные useEffect, чтобы отслеживать их изменения: useEffect запускает свой колбэк при изменении массива зависимостей. А там у нас лежат пропсы props.source и props.status, изменения которых мы хотим отслеживать. Поэтому теперь плеер реагирует на изменения источника и статуса. Обратите внимание, что первым должен быть тот useEffect, который создаёт плеер. Потому что, остальным useEffect нужен уже созданный плеер. Если его не будет, то они не сработают, пока не изменится их массив зависимостей. И видео не будет показано в плеере до тех пор, пока пользователь не кликнет Play. Поэтому, нужно следить за порядком следования useEffect (см. The post-Hooks guide to React call order). Примечание: последняя версия документации React советует отслеживать изменения пропсов не в useEffect, а прямо в теле функции компонента. Потому что, тогда можно избежать лишних циклов рендеринга. Но это не наш случай — мы вызываем методы нативного плеера, соответственно, лишних рендерингов не будет. С обработчиком onStatusChange та же проблема — он добавляется сейчас единожды при инициализации плеера. Это плохо, т.к. его не поменяешь. Давайте сделаем по аналогии с пропсами-полями: Из интересного здесь два момента: Для удаления предыдущего обработчика используем возвращаемое значение useEffect. Тогда не нужно нигде отдельно хранить ссылку на обработчик. Но Typescript подсказывает, что объект props мог прийти уже другой. Поэтому, приходится скопировать ссылку на onStatusChange из объекта props в локальную переменную, чтобы в removeListener использовалась та же ссылка, которая была передана в addListener. У плеера есть некоторые свойства, которые могут меняться довольно часто. Например: position — позиция в видео-потоке, номер текущего кадра. Хочется сделать так же, как с другими свойствами: Но есть три проблемы: onPositionChange вызывается очень часто — это будет постоянный ре-рендеринг родительского компонента. Видео проигрывается браузером в отдельном потоке, и обновление position не будет за ним успевать. Постоянное position={position} заставит видео тормозить и дёргаться. useEffect отработает с задержкой — после завершения рендеринга. Иногда, это может быть важно. Тогда соответствующий метод плеера нужно вызывать по событию, а не в useEffect после рендеринга. Именно поэтому в библиотеках, где нужна высокая скорость обновления компонента, часто приходится отходить от декларативного подхода к императивному. Или предпринимать что-то специально, чтобы избежать лишнего рендеринга. Например, в React Spring есть понятие Animated Components — специально обёрнутых компонентов, которые используются как обычные декларативные, но «под капотом» работают напрямую с DOM-элементами. Поэтому лучше оставить часть API VideoPlayer императивным, например, так: Появляется немного лишнего кода в виде forwardRef, но именно useImperativeHandle предлагается документацией React для того, чтобы передать родительскому компоненту своё императивное API. Но если представить, что position понадобится выводить из состояния других элементов, а не задавать прямо по событию клика. Тогда, в App придётся завести отдельный useEffect, аналогично тому, как делали выше в VideoPlayer. И в нём вызывать наш API. Для того, чтобы сделать из императивного компонента декларативный, нужно: Вынести в отдельный React-компонент его код инициализации и уничтожения. А также, DOM-элемент, к которому он будет прикрепляться. Вынести в useEffect код, отслеживающий изменения отдельных полей и вызывающий соответствующие методы компонента. Вынести в useEffect подписку и отписку от событий. Часто меняющиеся свойства обернуть в специальное императивное API и предоставить его родительскому компоненту. UPD. Спасибо @Mox за useImperativeHandle.
Делаем плеер контролируемым
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> ); }Оборачиваем плеер в декларативный React-компонент
<VideoPlayer source={source} status={status} onStatusChange={(status) => setStatus(status)} />
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}/>; };Отслеживаем изменения пропсов-полей
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(() => { const onStatusChange = props.onStatusChange; if (!player.current || !onStatusChange) return; player.current.addListener("statusChange", onStatusChange); return () => player.current?.removeListener("statusChange", onStatusChange); }, [props.onStatusChange]);
Часто меняющиеся свойства
<VideoPlayer position={position} onPositionChange={(position) => setPosition(position)} source={source} status={status} onStatusChange={(status) => setStatus(status)} />
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} /> ... }Итак
ссылка на оригинал статьи https://habr.com/ru/articles/792766/
Добавить комментарий