Доступные для записи геттеры
В моем коде несколько раз появлялся следующий шаблон: у объекта есть свойство, которое по умолчанию является выражением, основанным на других его свойствах, если оно не установлено явно, и в этом случае оно функционирует как обычное свойство. По сути, выражение действует как значение по умолчанию.
Некоторые примеры использования:
- Объект, в котором значение по умолчанию
id
создается из егоname
илиtitle
, но также может иметь пользовательские идентификаторы. - Объект с информацией о человеке, в котором
name
может быть задано явно или сгенерированным изfirstName
иlastName
, если не указан. - Объект с параметрами для рисования эллипса, где по умолчанию используется
ry
, если не задано явноrx
. - Литерал объекта с информацией о дате и свойство
readable
, которое форматирует дату, но может быть перезаписано в пользовательском удобочитаемом формате. - Объект, представляющий части URL-адреса Github (например, имя пользователя, репо, ветка) со свойством
apiCall
, которое может быть либо настроено, либо сгенерировано из частей (на самом деле это пример, который вызвал эту статью в блоге)
Итак, теперь, когда я убедил вас в полезности этого шаблона, как нам реализовать его в JS? Наша первая попытка может выглядеть примерно так:
let lea = {
name: "Lea Verou",
get id() {
return this.name.toLowerCase().replace(/\W+/g, "-");
}
}
Примечание: мы собираемся использовать объектные литералы в этом посте для простоты, но та же логика применима к вариациям, использующимObject.create()
или класс который является экземпляромPersonlea
.
Наша первая попытка не совсем сработала, как вы могли ожидать:
lea.id; // "lea-verou"
lea.id = "lv";
lea.id; // Still "lea-verou"!
Почему так происходит? Причина в том, что наличие геттера превращает свойство в метод доступа, и, следовательно, оно также не может содержать данные. Если у него нет сеттера, то при его установке просто ничего не происходит.
Однако у нас может быть сеттер, который при вызове удаляет метод доступа и заменяет его свойством данных:
let lea = {
name: "Lea Verou",
get id() {
return this.name.toLowerCase().replace(/\W+/g, "-");
},
set id(v) {
delete this.id;
return this.id = v;
}
}
Абстрагирование паттерна в хелпер
Если нам понадобится этот шаблон более чем в одном месте нашей кодовой базы, мы можем абстрагировать его до хелпера:
function writableGetter(o, property, getter, options = {}) {
Object.defineProperty(o, property, {
get: getter,
set (v) {
delete this[property];
return this[property] = v;
},
enumerable: true,
configurable: true,
...options
});
}
Обратите внимание, что здесь мы использовали Object.defineProperty()
вместо краткого синтаксиса get/set
. Первый не только более удобен для увеличения уже существующих объектов, но и позволяет нам настроить перечисляемость, в то время как второй просто по умолчанию имеет значение enumerable: true
.
Мы бы использовали хелпер так:
let lea = {name: "Lea Verou"};
writableGetter(lea, "id", function() {
return this.name.toLowerCase().replace(/\W+/g, "-");
}, {enumerable: false});
Перезапись геттера другим геттером
Это работает, когда мы хотим перезаписать статическим значением, но что, если мы хотим перезаписать другим геттером? Например, рассмотрим вариант использования даты: что, если мы хотим сохранить единый источник истины для компонентов даты и только перезаписать формат как функцию, чтобы при изменении компонентов даты форматированная дата обновлялась соответствующим образом?
Если мы уверены, что установка свойства на фактическое значение функции не имеет смысла, мы могли бы обработать этот случай специально и создать новый геттер вместо свойства данных:
function writableGetter(o, property, getter, options = {}) {
return Object.defineProperty(o, property, {
get () {
return getter.call(this);
},
set (v) {
if (typeof v === "function") {
getter = v;
}
else {
delete this[property];
return this[property] = v;
}
},
enumerable: true,
configurable: true,
...options
});
}
Обратите внимание, что если мы установим для свойства статическое значение и попытаемся установить его для функции после этого, это будет просто свойство данных, которое создает функцию, поскольку мы удалили метод доступа, который специально обрабатывал функции. Если это серьезная проблема, мы можем сохранить метод доступа и просто обновить геттер:
function writableGetter(o, property, getter, options = {}) {
return Object.defineProperty(o, property, {
get () {
return getter.call(this);
},
set (v) {
if (typeof v === "function") {
getter = v;
}
else {
getter = () => v;
}
},
enumerable: true,
configurable: true,
...options
});
}
Улучшение DX нашего хелпера
Хотя это был самый простой способ определить хелпер, он не кажется очень естественным для использования. Наше определение объекта теперь разбросано в нескольких местах, и читаемость плохая. Это часто бывает, когда мы начинаем реализацию перед проектированием пользовательского интерфейса. В этом случае написание хелпера - это реализация, а его вызывающий код-это фактически пользовательский интерфейс.
Всегда полезно начинать проектирование функций с написания вызова этой функции, как если бы неутомимый эльф, работающий на нас, уже написал реализацию нашей мечты.
Итак, как бы мы предпочли написать наш объект? На самом деле я бы предпочел использовать более читаемый синтаксис get()
и иметь все в одном месте, а затем каким-то образом преобразовать этот геттер в записываемый геттер. Что-то вроде этого:
let lea = {
name: "Lea Verou",
get id() {
return this.name.toLowerCase().replace(/\W+/g, "-");
}
}
makeGetterWritable(lea, "id", {enumerable: true});
Можем ли мы реализовать что-то подобное? Конечно. Это JS, мы все умеем!
Основная идея заключается в том, что мы читаем дескриптор или get
, играем с ним, а затем запихиваем его обратно в качестве нового свойства:
function makeGetterWritable(o, property, options) {
let d = Object.getOwnPropertyDescriptor(o, property);
let getter = d.get;
d.get = function() {
return getter.call(this);
};
d.set = function(v) {
if (typeof v === "function") {
getter = v;
}
else {
delete this[property];
return this[property] = v;
}
};
// Apply any overrides, e.g. enumerable
Object.assign(d, options);
// Redefine the property with the new descriptor
Object.defineProperty(o, property, d)
}
Другие свойства смешанных средств доступа к данным
Хотя JS очень твердо различает свойства средств доступа и свойства данных, в действительности нам часто приходится комбинировать их по-разному, и концептуально это скорее спектр средств доступа к данным, чем две отдельные категории. Вот еще несколько примеров, в которых граница между свойством данных и свойством доступа несколько… нечеткая:
- «Живые» свойства данных: свойства, которые выполняют код для создания побочных эффектов при их получении или установке, но по-прежнему содержат данные как обычные свойства данных. Это можно подделать, если использовать помощника, который создает скрытое свойство данных. Эта идея лежит в основе Bliss.live().
- Ленивая оценка: свойства, которые оцениваются при первом чтении (через геттер), а затем заменяются обычным свойством данных. Если они установлены до того, как будут прочитаны, они работают точно так же, как записываемый геттер. Эта идея лежит в основе Bliss.lazy().