Кэширование обработчиков событий и улучшение производительности React-приложений +20


Сегодня мы публикуем перевод материала, автор которого, проанализировав особенности работы с объектами в JavaScript, предлагает React-разработчикам методику ускорения приложений. В частности, речь идёт о том, что переменная, которой, как принято говорить, «присвоен объект», и которую часто называют просто «объектом», на самом деле, хранит не сам объект, а ссылку на него. Функции в JavaScript тоже являются объектами, поэтому вышесказанное справедливо и для них. Если помнить об этом, проектируя React-компоненты и критически анализируя их код, можно усовершенствовать их внутренние механизмы и улучшить производительность приложений.



Особенности работы с объектами в JavaScript


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

const functionOne = function() { alert('Hello world!'); };
const functionTwo = function() { alert('Hello world!'); };
functionOne === functionTwo; // false

Теперь попробуем присвоить переменной уже существующую функцию, которая уже присвоена другой переменной, и сравнить эти две переменные:

const functionThree = function() { alert('Hello world!'); };
const functionFour = functionThree;
functionThree === functionFour; // true

Как видите, при таком подходе оператор строгого равенства выдаёт true.
Объекты, естественно, ведут себя так же:

const object1 = {};
const object2 = {};
const object3 = object1;
object1 === object2; // false
object1 === object3; // true

Тут мы говорим о JavaScript, но если у вас есть опыт разработки на других языках, то вы, возможно, знакомы с концепцией указателей. В вышеприведённом коде каждый раз, когда создаётся объект, для него выделяется участок системной памяти. Когда мы используем команду вида object1 = {}, это приводит к заполнению некими данными участка памяти, выделенного специально для объекта object1.

Вполне можно представить себе object1 в виде адреса, по которому в памяти расположены структуры данных, относящиеся к объекту. Выполнение команды object2 = {} приводит к выделению ещё одной области памяти, предназначенной специально для object2. Расположены ли объекты obect1 и object2 в одной и той же области памяти? Нет, каждому из них выделен собственный участок. Именно поэтому при попытке сравнения object1 и object2 мы получаем false. Эти объекты могут иметь идентичную структуру, но адреса в памяти, где они расположены, различаются, а при сравнении проверяются именно адреса.

Выполняя команду object3 = object1, мы записываем в константу object3 адрес объекта object1. Это — не новый объект. Данной константе назначается адрес уже существующего объекта. Проверить это можно так:

const object1 = { x: true };
const object3 = object1;
object3.x = false;
object1.x; // false

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

Функции, объекты и React


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

Какое отношение это имеет к React? В React имеются интеллектуальные механизмы экономии системных ресурсов, направленные на повышение производительности приложений: если свойства и состояние компонента не меняются, тогда не будет меняться и то, что выводит функция render. Очевидно то, что если компонент остался прежним, его не нужно рендерить повторно. Если ничего не меняется, функция render возвратит то же, что и прежде, поэтому нет нужды её выполнять. Этот механизм делает React быстрым. Что-либо выводится на экран только тогда, когда это необходимо.

React проверяет свойства и состояние компонентов на равенство, используя стандартные возможности JavaScript, то есть — просто сравнивает их с использованием оператора ==. React не выполняет «мелкого» или «глубокого» сравнения объектов для того, чтобы определить их равенство. «Мелкое» сравнение (shallow comparison) — это понятие, используемое для описания сравнения каждой пары ключ-значение объекта — в противовес сравнению, при котором сопоставляются лишь адреса объектов в памяти (ссылки на них). При «глубоком» сравнении (deep comparison) объектов идут ещё дальше, и, если значением сравниваемых свойств объектов также являются объекты, выполняют и сравнение пар ключ-значение этих объектов. Этот процесс повторяется для всех объектов, вложенных в другие объекты. React ничего подобного не делает, выполняя лишь проверку на равенство ссылок.

Если вы, например, поменяете свойство некоего компонента, представленное объектом вида { x: 1 } на другой объект, который выглядит точно так же, React выполнит повторный рендеринг компонента, так как эти объекты находятся в разных областях памяти. Если вспомнить вышеприведённый пример, то, при изменении свойства компонента с object1 на object3, React не будет повторно рендерить такой компонент, так как константы object1 и object3 ссылаются на один и тот же объект.

Работа с функциями в JavaScript организована точно так же. Если React сталкивается с одинаковыми функциями, адреса которых различаются, он запустит повторный рендеринг. Если же «новая функция» — это всего лишь ссылка на функцию, которая уже использовалась, повторного рендеринга не будет.

Типичная проблема при работе с компонентами


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

class SomeComponent extends React.PureComponent {

  get instructions() {
    if (this.props.do) {
      return 'Click the button: ';
    }
    return 'Do NOT click the button: ';
  }

  render() {
    return (
      <div>
        {this.instructions}
        <Button onClick={() => alert('!')} />
      </div>
    );
  }
}

Перед нами очень просто компонент. Он представляет собой кнопку, при нажатии на которую выводится уведомление. Рядом с кнопкой выводятся указания по её использованию, сообщающие пользователю о том, следует ли ему нажимать эту кнопку. Управляют тем, какое именно указание будет выведено, настраивая свойство do (do={true} или do={false}) компонента SomeComponent.

Каждый раз, когда осуществляется повторный рендеринг компонента SomeComponent (при изменении значения свойства do с true на false и наоборот), повторно рендерится и элемент Button. Обработчик onClick, несмотря на то, что он всегда один и тот же, создаётся заново при каждом вызове функции render. В результате оказывается, что при каждом выводе компонента в памяти создаётся новая функция, так как её создание выполняется в функции render, ссылка на новый адрес в памяти передаётся в <Button />, и компонент Button также рендерится повторно, несмотря на то, что в нём совершенно ничего не изменилось.

Поговорим о том, как это исправить.

Решение проблемы


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

const createAlertBox = () => alert('!');

class SomeComponent extends React.PureComponent {

  get instructions() {
    if (this.props.do) {
      return 'Click the button: ';
    }
    return 'Do NOT click the button: ';
  }

  render() {
    return (
      <div>
        {this.instructions}
        <Button onClick={createAlertBox} />
      </div>
    );
  }
}

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

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

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

class SomeComponent extends React.PureComponent {

  createAlertBox = () => {
    alert(this.props.message);
  };

  get instructions() {
    if (this.props.do) {
      return 'Click the button: ';
    }
    return 'Do NOT click the button: ';
  }

  render() {
    return (
      <div>
        {this.instructions}
        <Button onClick={this.createAlertBox} />
      </div>
    );
  }
}

В данном случае в каждом экземпляре SomeComponent при нажатии на кнопку будут выводиться различные сообщения. Обработчик события элемента Button должен быть уникальным для SomeComponent. При передаче метода cteateAlertBox неважно, будет ли выполняться повторный рендеринг SomeComponent. Неважно и то, изменилось ли свойство message. Адрес функции createAlertBox не меняется, а это значит, что элемент Button повторно рендериться не должен. Благодаря этому можно сэкономить системные ресурсы и улучшить скорость рендеринга приложения.

Всё это хорошо. Но что если функции являются динамическими?

Решение более сложной проблемы


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

Итак, чрезвычайно распространённой является ситуация, когда в одном компоненте имеется множество уникальных, динамических обработчиков событий, например нечто подобное можно видеть в коде, где в методе render применяется метод массива map:

class SomeComponent extends React.PureComponent {
  render() {
    return (
      <ul>
        {this.props.list.map(listItem =>
          <li key={listItem.text}>
            <Button onClick={() => alert(listItem.text)} />
          </li>
        )}
      </ul>
    );
  }
}

Тут будет выводиться разное количество кнопок и создаваться разное количество обработчиков событий, каждый из которых представлен уникальной функцией, причём, заранее, при создании SomeComponent, неизвестно, что это будут за функции. Как решить эту головоломку?

Здесь нам поможет мемоизация, или, говоря проще — кэширование. Для каждого уникального значения надо создать функцию и поместить её в кэш. Если это уникальное значение встретится снова, достаточно будет взять из кэша соответствующую ему функцию, которая до этого была помещена в кэш.

Вот как выглядит реализация этой идеи:

class SomeComponent extends React.PureComponent {

  // У каждого экземпляра SomeComponent есть кэш обработчиков события нажатия на кнопку
  // реализующих уникальный функционал.
  clickHandlers = {};

  // Создание обработчика или возврат существующего обработчика 
  // на основании уникального идентификатора.
  getClickHandler(key) {

    // Если для заданного уникального идентификатора нет обработчика, создадим его.
    if (!Object.prototype.hasOwnProperty.call(this.clickHandlers, key)) {
      this.clickHandlers[key] = () => alert(key);
    }
    return this.clickHandlers[key];
  }

  render() {
    return (
      <ul>
        {this.props.list.map(listItem =>
          <li key={listItem.text}>
            <Button onClick={this.getClickHandler(listItem.text)} />
          </li>
        )}
      </ul>
    );
  }
}

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

В результате повторный рендеринг SomeComponent не приведёт к повторному рендерингу Button. Аналогично, добавление элементов в свойство list приведёт к динамическому созданию обработчиков событий для каждой кнопки.

Вам понадобится проявить изобретательность при создании уникальных идентификаторов для обработчиков в том случае, если они определяются более чем одной переменной, но это не намного сложнее, чем обычное создание уникального свойства key для каждого JSX-объекта, получаемого в результате работы метода map.

Тут хотелось бы предупредить вас о возможных проблемах использования в качестве идентификаторов индексов массива. Дело в том, что при таком подходе можно столкнуться с ошибками в том случае, если изменится порядок расположения элементов в массиве или некоторые его элементы будут удалены. Так, например, если сначала подобный массив выглядел как [ 'soda', 'pizza' ], а потом превратился в [ 'pizza' ], и вы при этом кэшировали обработчики событий с помощью команды вида listeners[0] = () => alert('soda'), вы обнаружите, что когда пользователь щелкнет по кнопке, которой назначен обработчик с идентификатором 0, и которая, в соответствии с содержимым массива [ 'pizza' ], должна выводить сообщение pizza, будет выведено сообщение soda. По той же причине не рекомендуется использовать индексы массивов в качестве свойств, являющихся ключами.

Итоги


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

Уважаемые читатели! Если вам известны какие-нибудь интересные способы оптимизации React-приложений — просим ими поделиться.




К сожалению, не доступен сервер mySQL