javascript

Особенности обработки native events в React.js

  • четверг, 25 апреля 2024 г. в 00:00:05
https://habr.com/ru/articles/810205/

В данной статье рассматриваются особенности, которые связаны с обработкой нативных событий (native events) в React-приложениях. Существует проблема частичной потери контекста функционального компонента при обработке нативных событий, которые навешиваются на элементы с помощью глобальных объектов document, window или через ссылки (refs). В статье рассматривается данная проблема и предлагается способ её решения (один из вариантов).

Мотивация

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

Я достаточно часто сталкиваюсь с необходимостью обрабатывать нативные события и до определённого времени делал это не правильно, что приводило к ошибкам. На удивление я нашёл не много информации, которая помогает разобраться с такими ошибками и ещё меньше - объясняющие их природу и почему всё работает так, а не иначе.

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

Используемая версия React.js, Node.js и других зависимостей

Версия React.js - 18.2.0.

Версия Node.js - 16.20.2.

Подробный список dependencies тестового проекта:

"dependencies": {
    "@testing-library/jest-dom": "^5.17.0",
    "@testing-library/react": "^13.4.0",
    "@testing-library/user-event": "^13.5.0",
    "react": "^18.2.0",
    "react-dom": "^18.2.0",
    "react-scripts": "5.0.1",
    "web-vitals": "^2.1.4"
}

Синтетические и нативные события

Для начала разберёмся что вообще такое синтетическое событие в контексте React.js.

Синтетическое событие - это кроссбраузерная обёртка SyntheticEvent, которая предназначена для унификации работы с событиями в DOM-дереве.

Синтетические события являются крайне полезными при разработке веб-приложений, которые ориентированы на множество браузеров (поддержка кроссбраузерности). Благодаря им гарантируется (насколько это возможно), что ваше веб-приложение будет одинаково обрабатывать события в Opera, Google, Safari, Mozilla Firefox, Yandex и других популярных браузерах. Также они позволяют избежать утечек памяти благодаря автоматическому управлению памятью (чего нет при обработке нативных событий), что делает синтетические события ещё более полезными.

Какие события можно считать синтетическими? В общем-то все, которые поддерживаются на данный момент и содержатся (важно) в определении DOM-дерева функционального или классового компонента (в JSX-коде).

То есть, все события, которые обрабатываются в следующем программном коде, являются синтетическими:

// ...

return (
    <>
      <div
        className="wrapper"
        onMouseLeave={(e) => {
          // Обработка синтетического события ...
        }}
      >
        <div
          id="containerId"
          className="container"
          onDoubleClick={(e) => {
            // Обработка синтетического события ...
          }}
        >
          <span
            onClick={(e) => {
              // Обработка синтетического события ...
            }}
          >{value}</span>
        </div>
      </div>
    </>
);

// ...

Это также не трудно понять по объекту event, который возвращается внутрь функции обработчика синтетических событий. Как правило данный объект всегда имеет класс SyntheticBaseEvent.

Пояснения по объекту event

Объект event - это первый параметр, который возвращается в функцию-обработчик какого-либо события (нативного, синтетического или, даже, кастомного).

Вот код, который описывает получение и вывод этого объекта на экран (на выходе получим объект класса SyntheticBaseEvent):

// ...

<div className="wrapper">
    <div
        id="containerId"
        className="container"
        onClick={(e) => {
          // Вывод объекта класса SyntheticBaseEvent
          console.log(e);
        }}
      >
       <span>{value}</span>
    </div>
</div>

// ...

В обработчик обычно передаётся более короткая форма параметра event - e.

Рисунок 1 - "Все мы разные, но служим одной цели - обработке событий!"
Рисунок 1 - "Все мы разные, но служим одной цели - обработке событий!"

С синтетическими событиями разобрались. Что же такое нативные события?

В контексте данной статьи к нативным событиям относятся все события, которые не являются синтетическими, но при этом связаны с обработкой события в DOM-дереве.

Вообще, я думаю, что чистых нативных событий сейчас уже нет, поскольку каждый браузер может обрабатывать их по своему и модифицировать цепочку прототипов объекта event как угодно. В каком-то браузере может быть параметр, который не поддерживается в другом (пример из вакуума не лишённый смысла). В общем, что считать чистым нативным событием в контексте React - вопрос философский и я предлагаю читателю поразмышлять на данную тему.

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

Нативные события, в основном, обрабатываются с помощью стандартного метода addEventListener, который вызывается у глобальных объектов window, document или не пустых (not null) ссылок (refs).

Вот пример обработки нативных событий:

// ...

useEffect(() => {
    const container = document.getElementById("containerId");

    // Навешиваем обработчики на нативные события
    container.addEventListener("click", mouseMoveHandler);
    window.addEventListener("mousedown", mouseDownHandler);

    return () => {
      // Удаляем обработчики с нативных событий, 
      // иначе они будут работать и после выхода со страницы
      window.removeEventListener("mousedown", mouseDownHandler);

      // Кроме данной строки - здесь можно и не удалять, 
      // ведь если нет DOM-элемента, то и нет проблемы :)
      container.removeEventListener("click", mouseMoveHandler);
    };
  }, []);

// ...

В общем-то с разницей между нативными (в контексте данной статьи) и синтетическими событиями разобрались.

Кстати, стоит упомянуть о том, что при обработке синтетических событий можно получить объект event, который мы бы получили при обработке нативного события. Для этого можно обратиться к атрибуту nativeEvent в объекте event.

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

Далее следует ввести понятие контекста React-компонента.

Контекст React-компонента (в рамках данной статьи) - это все функции, состояния, refs, переменные и константы, которые определены в рамках одного React-компонента.

Например, контекстом React-компонента (далее - контекст или контекст компонента) является всё, что определено в следующем программном коде:

// ...

const [value, setValue] = useState(0);
const [state, setState] = useState({
    arr: []
});
const timer = useRef(null);
const handler = (e) => {
    console.log(e);
}

// ...

или:

// ...

constructor(props) {
      super(props);

      this.state = {
          value: 0
      };

      this.timer = React.createRef(null);
      this.mouseMoveHandler = this.mouseMoveHandler.bind(this);
}

// ...

В общем, можно воспринимать контекст, как окружение компонента. В классовом компоненте его роль принимает объект this.

Теперь перейдём к проблеме.

В контексте компонента очень часто разработчики определяют и используют состояния (useState, this.state), которые играют если не ключевую роль, то очень важную при функционировании и разработке пользовательского интерфейса.

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

Рисунок 2 - Это рандомайзер и он генерирует 😎
Рисунок 2 - Это рандомайзер и он генерирует 😎

С помощью синтетических событий это сделать можно довольно легко. Достаточно определить какой-нибудь стейт value, обновлять его каждые 300 мс и при щелчке на кнопку мыши выводить на экран запомненное значение (которое устанавливается тоже через стейт).

Следующий код реализует такой простейший рандомайзер:

// ...

/**
 * Точка входа в React-приложение
 * @returns 
 */
const App = () => {
  // Значение, генерируемое рандомайзером
  const [value, setValue] = useState(0);
  // Зафиксированное текущее значение
  const [currentValue, setCurrentValue] = useState(0);
  // Таймер
  const timer = useRef(null);

  /**
   * Обработчик синтетического события
   * @param {*} e Объект синтетического события
   */
  const clickHandler = (e) => {
    // Установка текущего значения рандомайзера
    setCurrentValue(value);
  };

  /**
   * Обработка монтирования компонента
   */
  useEffect(() => {
    // Запуск рандомайзера
    timer.current = setInterval(() => {
      setValue(Math.random());
    }, 300);

    return () => {
      timer.current && clearInterval(timer.current);
    }
  }, []);

  return (
    <>
      <div className="wrapper">
        <div
          id="containerId"
          className="container"
          onClick={clickHandler}
        >
          <span>{value}</span>
        </div>
        <div>
          <span>{currentValue}</span>
        </div>
      </div>
    </>
  );
}

export default App;

// ...

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

Рисунок 3 - Всё отлично работает, правда только с синтетикой ...
Рисунок 3 - Всё отлично работает, правда только с синтетикой ...

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

Определение обработчика на нативное событие клика:

// ...

/**
 * Точка входа в React-приложение
 * @returns 
 */
const App = () => {
  // Значение, генерируемое рандомайзером
  const [value, setValue] = useState(0);
  // Зафиксированное текущее значение
  const [currentValue, setCurrentValue] = useState(0);
  // Таймер
  const timer = useRef(null);
  // Ссылка на контейнер (условный блок)
  const container = useRef(null);

  /**
   * Обработчик нативного события
   * @param {*} e Объект нативного события
   */
  const clickHandler = (e) => {
    // Проверка для клика (мало ли ...)
    console.log("Проверка");
    // Установка текущего значения рандомайзера
    setCurrentValue(value);
  };

  /**
   * Обработка монтирования компонента
   */
  useEffect(() => {
    // Запуск рандомайзера
    timer.current = setInterval(() => {
      setValue(Math.random());
    }, 300);

    return () => {
      timer.current && clearInterval(timer.current);
    }
  }, []);

  useEffect(() => {
    // Подписка на обработку нативного события
    container.current.addEventListener("click", clickHandler);

    return () => {
      // Отписка на обработку нативного события
      container.current.removeEventListener("click", clickHandler);
    }
  }, []);

  return (
    <>
      <div className="wrapper">
        <div
          ref={container}
          id="containerId"
          className="container"
        >
          <span>{value}</span>
        </div>
        <div>
          <span>{currentValue}</span>
        </div>
      </div>
    </>
  );
}

export default App;

// ...
Рисунок 4 - Ничего не получилось, хотя обработчик один и тот же ...
Рисунок 4 - Ничего не получилось, хотя обработчик один и тот же ...

По какой причине обработчик нативного события не смог зафиксировать текущее значение рандомайзера и отобразить его в DOM-дереве?

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

Ранее я упомянул, что обработчики синтетических событий обязательно должны находиться в определении DOM-элементов (в JSX-коде). И именно благодаря тому, что они определены в JSX-коде "синтетический" код работает правильно, а "нативный" нет. Поскольку обработчики синтетических событий захватывают контекст компонента полностью, а обработчики нативные - нет (лишь частично).

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

Внимательный читатель обратит внимание, что для демонстрации этой проблемы я использую функциональный компонент, а не классовый. Я это делаю по причине того, что функциональный стиль сейчас достаточно популярный, но, как мы узнаем позже, эта проблема может быть одной из причин чтобы переделать функциональные компоненты на классовые.

Обработчики нативных событий не захватывают состояния, но захватывают refs. Можно предположить, что здесь стоит использовать useRef, но проблема в том, что value используется в DOM-дереве, а если value это ref, то никаких перерисовок не будет.

Что же в таком случае делать? Один из вариантов решения данной проблемы кроется в использовании механизма мемоизации и переподписке на обработку нативного события.

Следующий код демонстрирует решение этой проблемы:

// ...

/**
   * Обработчик нативного события (c мемоизацией)
   * @param {*} e Объект нативного события
   */
  const clickHandler = useCallback((e) => {
    console.log("Проверка");
    // Установка текущего значения рандомайзера
    setCurrentValue(value);
  }, [value]);

  useEffect(() => {
    // Подписка на обработку нативного события
    container.current.addEventListener("click", clickHandler);

    return () => {
      // Отписка на обработку нативного события
      container.current.removeEventListener("click", clickHandler);
    }
  }, [clickHandler]);

// ...

В данном коде обработчик нативного события clickHandler обёрнут в мемоизированную функцию с помощью хука useCallback, в массив зависимостей которого добавлено значение value.

Теперь при каждом изменении значения value внутри обработчика clickHandler будет изменятся представление о контексте компонента (в том числе и о его состояниях).

Также при навешивании обработчика на нативное событие в массив зависимостей useEffect добавлен clickHandler, что означает "сделай переподписку на событие в случае, если функция clickHandler изменилась".

Таким образом переподписка происходит в цепочке - значение рандомайзера -> функция обработчик -> изменение функции обработчика (его "кэша"). Что и позволяет работать данному коду.

Рисунок 5 - Всё работает, даже спустя 5-ти попыток!
Рисунок 5 - Всё работает, даже спустя 5-ти попыток!

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

Оловянный, деревянный, class component

Примечательно, что в классовых компонентах такой проблемы нет. Совсем нет. Даже не нужна переподписка на обработку нативного события, всё и так отлично работает. И не важно с помощью какого объекта был добавлен обработчик нативного события (document, window, refs, etc.), обработка всё равно будет происходить корректно (как программист задумывал изначально).

Следующий код и сопровождающий его рисунок подтверждают мои наблюдения:

// ...

/**
 * Точка входа в React-приложение (классовый компонент)
 */
class AppClass extends React.Component {
    /**
     * Конструктор
     * @param {*} props Параметры
     */
    constructor(props) {
        super(props);

        this.state = {
            value: 0,
            currentValue: 0
        };

        this.container = React.createRef(null);
        this.timer = React.createRef(null);
        this.clickHandler = this.clickHandler.bind(this);
    }

    /**
     * Монтирование компонента
     */
    componentDidMount() {
        this.timer.current = setInterval(() => {
            this.setState({
                value: Math.random()
            });
        }, 300);

        // Подписка на обработку нативного события
        this.container.current.addEventListener("click", this.clickHandler);
    }

    /**
     * Обработка размонтирования компонента
     */
    componentWillUnmount() {
        this.timer.current && clearInterval(this.timer.current);

        // Отписка на обработку нативного события
        this.container.current.removeEventListener("click", this.clickHandler);
    }

    /**
     * Обработчик клика
     */
    clickHandler() {
        const { value } = this.state;
        console.log("Я в классовом компоненте!");

        this.setState({
            currentValue: value
        });
    };

    render() {
        const { value, currentValue } = this.state;

        return (
            <>
                <div className="wrapper">
                    <div
                        ref={this.container}
                        id="containerId"
                        className="container"
                    >
                        <span>{value}</span>
                    </div>
                    <div>
                        <span>{currentValue}</span>
                    </div>
                </div>
            </>
        );
    }
}

export default AppClass;

// ...

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

Рисунок 6 - А в классовом компоненте всё работает ...
Рисунок 6 - А в классовом компоненте всё работает ...

Даже если явно определить обработчик как функцию без this (анонимная функция), то результат будет тот же самый (что в принципе логично, т.к. такие функции всё равно имеют контекст this класса, в котором были определены, но попробовать стоило):

// ...

/**
  * Обработчик клика без this
  */
clickHandler = () => {
    const { value } = this.state;
    console.log("Я в классовом компоненте!");

    this.setState({
        currentValue: value
    });
  };

// ...

Что ж, теперь можно дополнить список разницы между классовыми и функциональными компонентами этой особенностью.

И да, что в классовом, что в функциональном в обработчик нативного события передаётся один и тот же объект event, даже в цепочке прототипов нет никаких примесей.

Таким образом я заключаю, что в функциональных компонентах есть проблема обработки нативных событий, которая решается рассмотренной в данной статье "эмуляцией" обработки нативного события как синтетического (переподписка при изменении состояний). В классовых компонентах такой проблемы нет.

Эту особенность стоит учитывать когда вы разрабатываете свои функциональные компоненты и навешиваете обработчики на нативные события или переписываете существующие классовые компоненты на функциональные.

Заключение

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

Была рассмотрена проблема отсутствия актуальных значений состояний (state) в обработчике нативного события и продемонстрирован способ решения данной проблемы с помощью мемоизации и переподписке на нативные события.

Список использованных источников

  1. Введение в React, которого нам не хватало.

  2. How to (really) remove eventListeners in React.

  3. Event listener functions changing when using React hooks.