[Перевод] Кэширование обработчиков событий и улучшение производительности React-приложений
Сегодня мы публикуем перевод материала, автор которого, проанализировав особенности работы с объектами в 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 (
{this.instructions}
);
}
}
Перед нами очень просто компонент. Он представляет собой кнопку, при нажатии на которую выводится уведомление. Рядом с кнопкой выводятся указания по её использованию, сообщающие пользователю о том, следует ли ему нажимать эту кнопку. Управляют тем, какое именно указание будет выведено, настраивая свойство do
(do={true}
или do={false}
) компонента SomeComponent
.
Каждый раз, когда осуществляется повторный рендеринг компонента SomeComponent
(при изменении значения свойства do
с true
на false
и наоборот), повторно рендерится и элемент Button
. Обработчик onClick
, несмотря на то, что он всегда один и тот же, создаётся заново при каждом вызове функции render
. В результате оказывается, что при каждом выводе компонента в памяти создаётся новая функция, так как её создание выполняется в функции render
, ссылка на новый адрес в памяти передаётся в , и компонент
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 (
{this.instructions}
);
}
}
В отличие от предыдущего примера, 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 (
{this.instructions}
);
}
}
В данном случае в каждом экземпляре SomeComponent
при нажатии на кнопку будут выводиться различные сообщения. Обработчик события элемента Button
должен быть уникальным для SomeComponent
. При передаче метода cteateAlertBox
неважно, будет ли выполняться повторный рендеринг SomeComponent
. Неважно и то, изменилось ли свойство message
. Адрес функции createAlertBox
не меняется, а это значит, что элемент Button
повторно рендериться не должен. Благодаря этому можно сэкономить системные ресурсы и улучшить скорость рендеринга приложения.
Всё это хорошо. Но что если функции являются динамическими?
Решение более сложной проблемы
Автор этого материала просит обратить внимание на то, что он подготовил примеры, приведённые в этом разделе, взяв первое, что пришло ему в голову, подходящее для иллюстрации повторного использования функций. Эти примеры предназначены для того, чтобы помочь читателю ухватить суть идеи. Хотя этот раздел и рекомендуется к прочтению для понимания сути происходящего, автор советует обратить внимание на комментарии к оригиналу статьи, так как некоторые читатели предложили там более совершенные варианты реализации рассматриваемых тут механизмов, в которых учтены особенности инвалидации кэша и встроенных в React механизмов управления памятью.
Итак, чрезвычайно распространённой является ситуация, когда в одном компоненте имеется множество уникальных, динамических обработчиков событий, например нечто подобное можно видеть в коде, где в методе render
применяется метод массива map
:
class SomeComponent extends React.PureComponent {
render() {
return (
{this.props.list.map(listItem =>
-
)}
);
}
}
Тут будет выводиться разное количество кнопок и создаваться разное количество обработчиков событий, каждый из которых представлен уникальной функцией, причём, заранее, при создании 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 (
{this.props.list.map(listItem =>
-
)}
);
}
}
Каждый элемент массива обрабатывается методом getClickHandler
. Этот метод, при первом вызове с неким значением, создаст функцию, уникальную для этого значения, поместит её в кэш и возвратит её. Все последующие вызовы этого метода с передачей ему того же значения приведут к тому, что он будет просто возвращать ссылку на функцию из кэша.
В результате повторный рендеринг SomeComponent
не приведёт к повторному рендерингу Button
. Аналогично, добавление элементов в свойство list
приведёт к динамическому созданию обработчиков событий для каждой кнопки.
Вам понадобится проявить изобретательность при создании уникальных идентификаторов для обработчиков в том случае, если они определяются более чем одной переменной, но это не намного сложнее, чем обычное создание уникального свойства key
для каждого JSX-объекта, получаемого в результате работы метода map
.
Тут хотелось бы предупредить вас о возможных проблемах использования в качестве идентификаторов индексов массива. Дело в том, что при таком подходе можно столкнуться с ошибками в том случае, если изменится порядок расположения элементов в массиве или некоторые его элементы будут удалены. Так, например, если сначала подобный массив выглядел как [ 'soda', 'pizza' ]
, а потом превратился в [ 'pizza' ]
, и вы при этом кэшировали обработчики событий с помощью команды вида listeners[0] = () => alert('soda')
, вы обнаружите, что когда пользователь щелкнет по кнопке, которой назначен обработчик с идентификатором 0, и которая, в соответствии с содержимым массива [ 'pizza' ]
, должна выводить сообщение pizza
, будет выведено сообщение soda
. По той же причине не рекомендуется использовать индексы массивов в качестве свойств, являющихся ключами.
Итоги
В этом материале мы разобрали особенности внутренних механизмов JavaScript, учитывая которые можно ускорить рендеринг React-приложений. Надеемся, изложенные здесь идеи вам пригодятся.
Уважаемые читатели! Если вам известны какие-нибудь интересные способы оптимизации React-приложений — просим ими поделиться.