Автор статьи, перевод которой мы публикуем сегодня, хочет рассказать о нескольких JavaScript-паттернах, направленных на отложенную инициализацию свойств объектов, для выполнения которой требуется произвести вычисления, создающие серьёзную нагрузку на систему. В основе всех этих паттернов лежит использование дескрипторов доступа — свойств объектов, описываемых парой функций — геттером и сеттером.
Сложилось так, что разработчики создают в JavaScript-классах свойства, рассчитанные на хранение любых данных, которые могут понадобиться в экземплярах этих классов. В этом нет ничего плохого в том случае, если речь идёт о небольших фрагментах информации, к которым можно свободно обратиться в конструкторе. Но если для подготовки каких-то данных к использованию в экземпляре класса нужно произвести некие вычисления — разработчику, возможно, не захочется заблаговременно тратить системные ресурсы на эти вычисления.
Например — рассмотрим этот класс:
class MyClass {
constructor() {
this.data = someExpensiveComputation();
}
}
Тут для создания свойства data
используются некие «дорогие» вычисления. Если нет полной уверенности в том, что данное свойство будет использоваться, то, возможно, нерационально будет заранее тратить ресурсы на выполнение этих вычислений. Но, к счастью, существует несколько способов выполнения подобных вычислений в отложенном режиме.
Паттерн «on-demand property»
Легче всего оптимизировать выполнение «дорогостоящих» вычислений, дождавшись момента, когда понадобятся соответствующие данные, а уже после этого проведя такие вычисления. Например, для того чтобы выполнить вычисления в нужный момент, можно воспользоваться дескриптором доступа с геттером. Это может выглядеть так:
class MyClass {
get data() {
return someExpensiveComputation();
}
}
В данном случае «дорогие» вычисления не будут выполняться до момента первой попытки считывания свойства data
. А это, в сравнении с первым примером, уже выглядит как улучшение. Правда, тут одни и те же вычисления выполняются при каждом обращении к свойству data
, что хуже, чем в первом примере, где, по крайней мере, вычисления выполнялись лишь единожды. Это — не лучшее решение, но, основываясь на нём, можно создать что-то гораздо более приличное.
Паттерн «messy lazy-loading property»
Выполнение вычислений лишь тогда, когда осуществляется обращение к свойству — это хорошая отправная точка. Но нам, на самом деле, надо кешировать информацию после выполнения вычислений, а потом просто использовать её версию из кеша. А где кешировать подобную информацию, при условии, что нам нужна возможность легко к ней обращаться? Проще всего будет объявить свойство с тем же именем и записать в него вычисленное значение:
class MyClass {
get data() {
const actualData = someExpensiveComputation();
Object.defineProperty(this, "data", {
value: actualData,
writable: false,
configurable: false,
enumerable: false
});
return actualData;
}
}
Тут, как и в прошлом примере, свойство data
объявлено в классе как геттер, но в этот раз результат выполнения вычислений кешируется. Вызов метода Object.defineProperty()
создаёт новое свойство data
с фиксированным значением actualData
. Это свойство мы настраиваем так, чтобы оно не являлось бы записываемым, настраиваемым и перечисляемым (чтобы оно совпадало с геттером). После этого возвращается само значение. Когда мы в следующий раз обратимся к свойству data
— данные будут считаны из свежесозданного свойства, а не получены в результате вызова геттера:
const object = new MyClass();
// вызов геттера
const data1 = object.data;
// чтение из свойства data
const data2 = object.data;
Фактически, все вычисления выполняются лишь после первой попытки прочесть значение свойства data
. Каждая следующая попытка его чтения приводит к возврату кешированной версии данных.
Единственный минус этого паттерна заключается в том, что в начале data
является неперечисляемым свойством прототипа, а в конце — неперечисляемым собственным свойством объекта:
const object = new MyClass();
console.log(object.hasOwnProperty("data")); // false
const data = object.data;
console.log(object.hasOwnProperty("data")); // true
Хотя во многих случаях этим различием можно пренебречь, это важно понимать при использовании данного паттерна, так как это приводит к небольшим проблемам при передаче куда-либо подобных объектов. К счастью, эту проблему можно решить, использовав улучшенный вариант рассмотренного паттерна.
Паттерн «only-own lazy-loading property» для классов
Если имеется такой сценарий использования некоего класса, при реализации которого важно, чтобы свойство, значение которого вычисляется в отложенном режиме, всегда присутствовало бы в экземплярах класса, это значит, что можно воспользоваться методом Object.defineProperty()
для создания нужного свойства в конструкторе класса. Код тут получается не таким аккуратным, как в предыдущем примере, но это позволяет добиться того, чтобы свойство всегда существовало бы в экземплярах класса. Вот пример:
class MyClass {
constructor() {
Object.defineProperty(this, "data", {
get() {
const actualData = someExpensiveComputation();
Object.defineProperty(this, "data", {
value: actualData,
writable: false,
configurable: false
});
return actualData;
},
configurable: true,
enumerable: true
});
}
}
Тут конструктор создаёт дескриптор доступа data
с использованием Object.defineProperty()
. Свойство создаётся в экземплярах класса (благодаря использованию this
), ему назначается геттер, а так же указывается то, что оно является настраиваемым и перечисляемым (это типично для собственных свойств объектов). Особенно важно сделать свойство data
настраиваемым, что позволит снова вызвать Object.defineProperty()
с передачей этому методу имени data
.
Функция-геттер выполняет вычисления и вызывает Object.defineProperty()
во второй раз. Свойство data
теперь переопределяется в виде дескриптора данных, которому назначено конкретное значение. Для защиты этого значения свойство настраивается так, чтобы оно не было бы записываемым и настраиваемым. Затем вычисленные данные возвращаются из геттера. А когда свойство data
попытаются прочитать в следующий раз — будет возвращено сохранённое значение. И, в качестве приятного дополнения, свойство data
теперь существует исключительно как собственное свойство объекта и ведёт себя одинаково и до первой попытки доступа к нему, и после неё:
const object = new MyClass();
console.log(object.hasOwnProperty("data")); // true
const data = object.data;
console.log(object.hasOwnProperty("data")); // true
Если вы, для создания объектов, пользуетесь классами, то, вероятно, вы выберете именно этот паттерн. А вот если вы применяете объектные литералы, то вам вполне подойдёт более простое решение.
Паттерн «lazy-loading property» для объектных литералов
Если вы пользуетесь объектными литералами вместо классов, это значит, что реализация в них вышеописанных механизмов сильно упрощается, так как геттеры, объявленные в объектных литералах, являются, как и обычные дескрипторы данных, собственными перечисляемыми свойствами объектов (а не свойствами прототипов). Это значит, что тут можно использовать паттерн «messy lazy-loading property», который мы рассматривали в применении к классам, но только теперь его можно переименовать в «lazy-loading property». Слово «messy» в его названии намекает на то, что при его использовании реализуется довольно-таки запутанная схема работы со свойствами (сначала свойство является свойством прототипа, а потом — собственным свойством объекта). Теперь же никакой «путаницы» в работе со свойствами не наблюдается:
const object = {
get data() {
const actualData = someExpensiveComputation();
Object.defineProperty(this, "data", {
value: actualData,
writable: false,
configurable: false,
enumerable: false
});
return actualData;
}
};
console.log(object.hasOwnProperty("data")); // true
const data = object.data;
console.log(object.hasOwnProperty("data")); // true
Итоги
Возможность переопределения свойств объектов в JavaScript открывает уникальные перспективы по кешированию данных, для получения которых требуются «тяжёлые» вычисления. Взяв за основу дескриптор доступа, который переопределяется в виде дескриптора данных, можно отложить выполнение этих вычислений до того момента, пока не будет выполнена первая попытка чтения свойства, после чего данные можно кешировать для последующего использования. Этот подход можно применять и при использовании классов, и при использовании объектных литералов. Причём, в случае с объектными литералами он оказывается немного проще, так как программисту не нужно беспокоиться о том, что геттер, объявленный в классе, является свойством прототипа.
Один из лучших подходов к оптимизации производительности представляет собой уход от многократного выполнения одних и тех же действий. В результате можно сказать, что каждый раз, когда у программиста возникает возможность что-то кешировать, у него возникает и возможность ускорения своей программы. Методы работы с данными, вроде того, который представлен паттерном «lazy-loading property», позволяют любому свойству сыграть роль кеша и внести вклад в улучшение производительности приложения.
Пользуетесь ли вы механизмами отложенной инициализации свойств объектов в своих проектах?
Автор: ru_vds