Некоторое время назад мы с коллегами столкнулись с довольно интересной проблемой, которую сразу решить не удалось. Мы довольно широко используем Неизменяемую библиотеку - особенно записи, поскольку они позволяют использовать точечные методы доступа и деструктуризацию объектов.

Допустим, у нас есть запись следующей формы:

const record = Record({
    a: 1,
    b: 2,
    c: 3
})();

Мы хотели вытащить одно из свойств из этой записи и организовать остальные в отдельный объект, поэтому мы деструктурировали, как обычно:

const { a, ...rest } = record;

Вы видите проблему в том, что мы сделали? Мы этого не сделали. И только после долгих поисков мы смогли понять, почему rest всегда был undefined.

Ответ, как мы обнаружили, заключался в том, что, хотя неизменяемые записи допускают точечный доступ и деструктуризацию, ни одно из свойств записи не является перечислимым, и угадайте, какие свойства оператор ... объекта rest группирует вместе? Верно, перечислимые свойства. И поэтому мы никогда не получим объект, который не undefined не использовал бы этот метод.

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

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

Начиная

Для начала мы рассмотрим особенности нашей записи. Было бы неплохо реализовать a (n):

  • Id
  • Имя
  • Дата последнего доступа
  • Дата последнего изменения
  • День рождения
  • SSN

Итак, давайте создадим запись в нашей базе данных:

const dbEntry = Object.create( null );

Я решил использовать Object.create вместо более знакомого синтаксиса {}, потому что первая запись позволяет нам указать, каким должен быть прототип объекта, и, передавая null, я отключаю этот объект от любого прототипа. Фактически, этот объект не будет иметь никаких функциональных возможностей, кроме тех, что мы для него определяем.

Дескрипторы данных свойств

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

  • value: фактическое значение, которое мы хотим, чтобы свойство было (по умолчанию undefined)
  • enumerable: должно ли свойство отображаться в операциях, перечисляющих ключи объекта, таких как for...in циклы или Object.keys() (по умолчанию false)
  • configurable: указывает, можем ли мы позже изменить настройки дескриптора или удалить свойство объекта (по умолчанию false)
  • writable: сообщает, можно ли изменить значение свойства (по умолчанию false)

Теперь, когда мы обычно устанавливаем свойство объекта, эти значения устанавливаются автоматически, что дает нам полный контроль над этим свойством.

const obj = {};
obj.a = 1;
Object.getOwnPropertyDescriptor(obj, 'a');
// { value: 1,
//   writable: true,
//   enumerable: true,
//   configurable: true }

Итак, как нам ограничить то, что можно сделать, когда мы устанавливаем свойство? Используйте Object.defineProperty и передайте желаемую конфигурацию.

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

Object.defineProperty(
    dbEntry,              // Object we're defining property on
    'id',                 // Property name
    {                     // Property descriptor
        value: 1,
        enumerable: true
    }
);

Мы устанавливаем значение dbEntry.id равным 1 (это, конечно, будет независимо от его фактического первичного ключа) и указываем, что хотим иметь возможность его перечислить. Нам не нужно было передавать флаги configurable или writable, потому что, когда мы определяем свойство и не указываем параметр дескриптора, JavaScript будет использовать соответствующие значения по умолчанию, указанные выше (false в этих случаях). С этого момента я буду указывать все параметры дескриптора, даже если мне нужны значения по умолчанию.

Давайте также добавим свойства для SSN и даты рождения:

Object.defineProperties(
    dbEntry,                      // Object to define props on
    {                             // Object of props and descriptors
        ssn: {
            value: '123-45-6789',
            writable: false,
            enumerable: true,
            configurable: false
        },
        birthdate: {
            value: '01/21/1980',
            writable: false,
            enumerable: true,
            configurable: false
        }
    }
);

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

Дескрипторы доступа к свойствам

Хотя мы уже можем осуществлять некоторый хороший контроль над объектом с помощью дескрипторов данных (например, обеспечивать неизменяемость, определять, насколько «доступен» ключ), у нас все еще есть немало дополнительных возможностей, которые мы можем проявить. если мы перейдем от дескрипторов данных (которые довольно статичны) к дескрипторам доступа (которые позволяют нам иметь довольно динамические свойства объекта.

Основное различие между дескриптором доступа и дескриптором данных состоит в том, что методы доступа заменяют ранее установленные флаги конфигурации value и writable функциями get и set.

Давайте используем их для определения свойства имени. За кулисами мы хотели бы хранить имя, отчество и фамилию отдельно, но предоставлять пользователю фактическое полное имя. Это позволяет базе данных выборочно выполнять поиск по любым частям имени, которые мы хотим, позволяя пользователю видеть более читаемую форму. Это также отличный способ использовать get и set.

Прежде чем мы начнем их использовать, необходимо отметить один важный момент. В некотором смысле геттеры и сеттеры являются виртуальными. Это означает, что если у нас есть obj.prop, который был определен с get и set аксессорами, get и set должны использовать другое место хранения для хранения фактических данных, которые мы получаем / устанавливаем.

Причина этого в том, что если мы вызываем obj.prop в любом месте нашего кода, механизм JavaScript вызовет функцию get, определенную для него, но поскольку get извлекает данные из obj.prop, мы вводим еще один цикл obj.prop вызова его геттера, который затем снова вызывает себя, до бесконечности. Это заканчивается Maximum call stack size exceeded ошибкой, поскольку мы входим в бесконечно повторяющийся набор вызовов функций.

В свете вышеприведенного предупреждения давайте определим новое свойство в нашем dbEntry, чтобы разместить «серверные» данные, с которыми будут работать все наши геттеры / сеттеры.

Object.defineProperty(
    dbEntry,
    '_',
    {
        value: {},
        writable: false,
        enumerable: false,
        configurable, false
    }
);

На этом этапе мы создадим свойства firstname, middlename и lastname для хранения данных имен.

Object.defineProperties(
    dbEntry._,
    {
        firstname: {
            value: 'John',
            writable: true,
            enumerable: false,
            configurable: false
        },
        middlename: {
            value: 'Jack',
            writable: true,
            enumerable: false,
            configurable: false
        },
        lastname: {
            value: 'Doe',
            writable: true,
            enumerable: false,
            configurable: false
        }
    }
);

После этого мы можем наконец собрать наше свойство dbEntry.name. Мы будем предполагать, что каждое переданное имя имеет в точности имя, отчество и фамилию, чтобы код оставался простым. Мы также установим configurable: true; это будет важно позже.

Object.defineProperty(
    dbEntry,
    'name',
    {
        enumerable: true,
        configurable: true,
        get() {
            const { firstname, middlename, lastname } = this._;
            return `${firstname} ${middlename} ${lastname}`;
        },
        set(newName) {
            const [
                firstname,
                middlename,
                lastname
            ] = newName.split(' ');
            this._.firstname = firstname;
            this._.middlename = middlename;
            this._.lastname = lastname;
        }
    }
);

Теперь, если мы извлечем dbEntry.name, мы получим «John Jack Doe», а если мы установим dbEntry.name = 'John Stuart Mill', мы увидим, что данные нашего имени в dbEntry._ действительно меняются.

Одна из действительно хороших особенностей дескрипторов доступа заключается в том, что они допускают побочные эффекты. В функциях get или set мы могли бы выполнять вызовы API, регистрироваться в консоли или выполнять какую-либо обработку ввода / вывода (и это лишь некоторые из них) каждый раз, когда они вызывались.

Так что давай сделаем это. Каждый раз, когда dbEntry.name устанавливается или извлекается, мы устанавливаем соответствующие свойства lastAccessed и lastModified для этого объекта. Поскольку мы сказали, что dbEntry.name все еще можно настраивать, добавление этой функции просто вопрос переопределения его дескриптора свойства.

Object.defineProperties(
    dbEntry,
    {
        lastAccessed: {
            value: Date.now(),
            writable: true,
            configurable: false,
            enumerable: true
        },
        lastModified: {
            value: Date.now(),
            writable: true,
            configurable: false,
            enumerable: true
        },
        name: {
            enumerable: true,
            configurable: false,
            get() {
                this.lastAccessed = Date.now();
                const { firstname, middlename, lastname } = this._;
                return `${firstname} ${middlename} ${lastname}`;
            },
            set(newName) {
                this.lastModified = Date.now();
                const [
                    firstname,
                    middlename,
                    lastname
                ] = newName.split(' ');
                this._.firstname = firstname;
                this._.middlename = middlename;
                this._.lastname = lastname;
            }
        }
    }
);

Теперь каждый раз, когда мы извлекаем или устанавливаем имя нашей записи, мы запускаем побочный эффект для обновления, когда эта запись была в последний раз изменена или открыта. Кроме того, мы устанавливаем configurable: false в нашем свойстве name, поэтому теперь мы больше не можем его изменять. Более того, если бы мы не сделали birthdate и ssn не настраиваемыми, мы могли бы изменить их на геттеры и сеттеры, чтобы аналогичным образом обновлять lastAccessed и lastModified.

Добавление метапрограммирования с помощью символов

Мы показали ряд интересных способов, с помощью которых мы можем дать нашим объектам возможность делать больше, чем обычно, используя обычное назначение (например, obj.a = 3), и тот факт, что мы можем делать все, что захотим, в геттер или сеттер - довольно мощная функция.

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

Symbol.iterator

В предыдущей статье я говорил о том, как сделать объекты итерируемыми с помощью Symbol.iterator. Это будет удобная функция, поэтому давайте рассмотрим итерацию:

Object.defineProperty(
    dbEntry,
    Symbol.iterator,
    {
        value: function* () {
            yield this.id;
            yield this.name;
            yield this.birthdate;
            yield this.ssn;
            yield this.lastAccessed;
            yield this.lastModified;
        }
    }
);

Теперь мы можем программно перебирать наши ключи в четко определенном порядке. Обратите внимание, что мы могли бы также использовать Object.keys вместо определения Symbol.iterator, но поскольку Object.keys не гарантирует порядок, он менее полезен для нас, когда речь идет о функции, которую я хочу создать поверх этой. Обратите внимание, что свойства на основе Symbol недоступны для записи, перечисления или настройки - даже если вы передаете эти флаги как true в дескрипторе.

Настоящая причина, по которой я хотел иметь итеративность, заключается в том, что есть очень аккуратный Symbol, который позволяет нам преобразовать объект в строковые или числовые примитивы на основе «подсказки», предоставляемой движком JavaScript (подсказка, которая определяется типом операции выполняется на объекте).

Symbol.toPrimitive

Работая с Python и Ruby, мне всегда нравилось определять собственное поведение для добавления двух классов вместе, и это шаг в том же направлении. Итак, давайте определимся, как примитивизировать наш объект.

Object.defineProperty(
    dbEntry,
    Symbol.toPrimitive,
    {
        value: function (hint) {
            if (hint === 'string') {
                return [ ...this ].join(', ');
            }
            return NaN;
        }
    }
);

Теперь, когда наш dbEntry оказывается в «строковой» среде, он вернет разделенный запятыми список всех свойств, которые мы определили в свойстве Symbol.iterator. Насколько мне известно, единственной такой средой является интерполяция строк (т.е., выполнение чего-то вроде `${dbEntry}`).

Во всех остальных случаях будет возвращено NaN. Это имеет смысл, потому что на самом деле нет особого смысла определять поведение для чего-то вроде dbEntry + 1, хотя, если бы это было так, мы бы хотели добавить что-то для hint === 'number'.

Полный круг

Изначально мы начали этот поход, потому что не могли использовать оператор ... rest при деструктуризации объекта. Давайте теперь посмотрим, что мы получим, когда попробуем его использовать:

const { id, ...rest } = dbEntry;
id;   // 1
rest; // {
      //     birthdate: '01/21/1980',
      //     lastAccessed: 1540940075708,
      //     lastModified: 1540940079708,
      //     name: 'John Stuart Mill',
      //     ssn: '123-45-6789'
      // }

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

const { [Symbol.iterator]: iterator, _ } = dbEntry;
iterator; // f* () { ... }
_;        // {
          //      firstname: 'John',
          //      middlename: 'Stuart',
          //      lastname: 'Mill'
          // }

Пара последних слов

Следует упомянуть еще пару вещей об использовании дескрипторов свойств.

  • Если вы определяете свойство как доступное для записи, но не настраиваемое, вы можете фактически вернуться и обновить конфигурацию этого свойства и отключить возможность записи (а также установить его значение в последний раз).
  • Если вы попытаетесь переназначить значение свойству, флаг writable которого установлен на false, операция завершится неудачно, если вы не находитесь в строгом режиме - в этом случае вы получите TypeError.
  • Мы могли бы определить наши хорошо известные свойства символа непосредственно на нашем объекте, а не использовать Object.defineProperty, и мы получили бы точно такую ​​же конфигурацию дескриптора свойства. Мы просто сделали это так, чтобы сохранить последовательность.

Существует большая свобода в реализации собственных дескрипторов свойств (особенно аксессуаров), и то, что вы можете делать с ними, на самом деле ограничивается только вашим воображением. Функции, которые у нас не было времени реализовать, были бы гораздо более сложной постобработкой данных после того, как мы set их применили к свойству (например, очистка / проверка данных) или предварительная обработка для get ( например, проверка учетных данных пользователя, маскирующая их, если они не авторизованы для просмотра этой части данных).

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

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