Як зробити з імперативного компонента – декларативний React-компонент

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

Іноді у свій 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. Так як у використанняефекту другий аргумент порожній масив, то його колбек буде викликаний один раз при монтуванні компонента, тому використовуємо його для ініціалізації плеєра.
  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();
  }, []);
...

Ми додали передплату на подію зміни статусу. Відписуватися від події не обов’язково, тому що ми повертаємо з використанняефект виклику методу 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. Змінний гравець.
  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. І при їх зміні не викликаються відповідні методи плеєра.

Давайте перенесемо їх в окремий спосібефекту, щоб відстежувати їх зміни:

  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, зміни яких ми хочемо відстежувати. Тому тепер плеєр реагує на зміни джерела та статусу.

Зверніть увагу, що першим повинен бути той ефект, який створює плеєр. Тому що решті використанняефекту потрібен вже створений плеєр. Якщо його не буде, то вони не спрацюють, доки не зміниться їх масив залежностей. І відео не буде показано в плеєрі доти, доки користувач не клікне 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 міг прийти вже інший. Тому доводиться скопіювати посилання на StatusChange з об’єкта 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 відпрацює із затримкою – після завершення рендерингу. Іноді це може бути важливо. Тоді відповідний метод плеєра потрібно викликати за подією, а не за допомогоюефекту після рендерингу.

Саме тому бібліотеки, де потрібна висока швидкість оновлення компонента, часто доводиться відходити від декларативного підходи до імперативного. Або робити щось спеціально, щоб уникнути зайвого рендерингу.

Наприклад, у 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 і надати його батьківському компоненту.

Джерело