Дескрипторы

Не все свойства объектов работают одинаково! Задаём особые режимы работы свойств с помощью дескрипторов.

Время чтения: 8 мин

Кратко

Скопировано

Объекты, как мы знаем, содержат свойства. У каждого из свойств объекта, кроме значения, есть ещё три флага конфигурации, которые могут принимать значения true или false. Эти флаги называются дескрипторами:

  • writable — доступно ли свойство для записи;
  • enumerable — является ли свойство видимым при перечислениях (например, в цикле for..in);
  • configurable — доступно ли свойство для переконфигурирования.

Когда мы создаём свойство объекта «обычным способом», эти три флага устанавливаются в значение true.

Для изменения значений дескрипторов применяется статический метод Object.defineProperty(), а для чтения значений — Object.getOwnPropertyDescriptors().

Другими словами, дескрипторы — это пары ключ-значение, которые описывают поведение свойства объекта при выполнении операций над ним (например, чтения или записи).

Схема дескрипторов под капотом
К каждому свойству объекта под капотом привязан набор дескрипторов.

Пример

Скопировано

Создадим объект и добавим в него свойство ОС для ноутбука. Сделаем это с помощью дескрипторов и статического метода Object.defineProperty().

Передаём в метод:

  • объект, которому добавляем свойство;
  • название свойства строкой;
  • объект со значениями дескрипторов и ключом value, содержащим значение свойства.
        
          
          const laptop = {}Object.defineProperty(laptop, 'os', {  value: 'MacOS',  writable: false,  enumerable: true,  configurable: true})
          const laptop = {}

Object.defineProperty(laptop, 'os', {
  value: 'MacOS',
  writable: false,
  enumerable: true,
  configurable: true
})

        
        
          
        
      

Свойство os будет недоступно для перезаписи, но будет видно при перечислении и доступно для переконфигурирования.

Попробуем перезаписать свойство os и выведем полученный результат:

        
          
          laptop.os = 'Windows'console.log(laptop)// { 'os': 'MacOS' }
          laptop.os = 'Windows'

console.log(laptop)
// { 'os': 'MacOS' }

        
        
          
        
      

Как пишется

Скопировано
        
          
          Object.defineProperty(объект, имяСвойства, дескрипторы)
          Object.defineProperty(объект, имяСвойства, дескрипторы)

        
        
          
        
      

Функция принимает следующие параметры:

  • объект — объект, свойство которого изменяем или добавляем;
  • имяСвойства — свойство, для которого нужно применить дескриптор;
  • дескриптор — дескриптор, описывающий поведение свойства.

Если свойство уже существует, Object.defineProperty() обновит флаги.
Если свойство не существует, метод создаёт новое свойство с указанным значением и флагами. Если какой-либо флаг не указан явно, ему присваивается значение false.

Как понять

Скопировано

Дескрипторы, которые мы можем передать в Object.defineProperty() могут быть двух типов — дескриптор данных и дескриптор доступа. Каждый тип дескриптора имеет свой набор свойств.

В обоих типах можно использовать общие свойства configurable и enumerable.

Дескриптор, передаваемый в Object.defineProperty() может быть только одним типом дескриптора. Он не может быть одновременно обоими! Если передать в Object.defineProperty() объект, содержащий и свойства дескриптора данных, и свойства дескриптора доступа, то метод выбросит ошибку Invalid property descriptor. Cannot both specify accessors and a value or writable attribute.

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

Скопировано

Дескриптор данных — это дескриптор, который определяет значение свойства и возможность изменить это значение.

  • value — значение свойства, по умолчанию undefined.
  • writable — можно ли изменить значение с помощью оператора присваивания.

value

Скопировано

Свойство value дескриптора данных отвечает за значение свойства объекта.

Добавим ноутбуку свойство «Размер экрана»:

        
          
          Object.defineProperty(laptop, 'displaySize', {  value: '15'})
          Object.defineProperty(laptop, 'displaySize', {
  value: '15'
})

        
        
          
        
      

Выведем полученные данные:

        
          
          const descriptor = Object.getOwnPropertyDescriptor(laptop, 'displaySize')console.log(descriptor)
          const descriptor = Object.getOwnPropertyDescriptor(laptop, 'displaySize')
console.log(descriptor)

        
        
          
        
      

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

        
          
          {  "value": "15",  "writable": false,  "enumerable": false,  "configurable": false}
          {
  "value": "15",
  "writable": false,
  "enumerable": false,
  "configurable": false
}

        
        
          
        
      

writable

Скопировано

Свойство writable дескриптора определяет, можно ли изменить значение свойства с помощью оператора присваивания. По умолчанию устанавливается в false для свойств, созданных через Object.defineProperty() и в true, если свойство добавлено через оператор ..

Изменим значение writable:

        
          
          const laptop = {}Object.defineProperty(laptop, 'displaySize', {    value: '15',    writable: false, // не перезаписываемо!    configurable: true,    enumerable: true})laptop.displaySize = '18'console.log(laptop.displaySize)// { 'displaySize': '15' }
          const laptop = {}

Object.defineProperty(laptop, 'displaySize', {
    value: '15',
    writable: false, // не перезаписываемо!
    configurable: true,
    enumerable: true
})

laptop.displaySize = '18'

console.log(laptop.displaySize)
// { 'displaySize': '15' }

        
        
          
        
      

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

Дескриптор доступа

Скопировано

Дескриптор доступа — это дескриптор, который определяет работу свойства через функции чтения и записи свойства (геттера и сеттера).

get — функция, используемая для получения значения свойства, возвращает значение или undefined.

set — функция, используемая для установки значения свойства. Принимает единственным аргументом новое значение, присваиваемое свойству.

Сравним простой объект с полем name и объект с геттером name, созданным через Object.defineProperty():

        
          
          const animal = { _hiddenName : 'Кот' }Object.defineProperty(animal, 'name', {    get: function() { return this._hiddenName }})const animal2 = {  name: 'И здесь тоже кот',}console.log(animal.name)// Котconsole.log(animal2.name)// И здесь тоже кот
          const animal = { _hiddenName : 'Кот' }
Object.defineProperty(animal, 'name', {
    get: function() { return this._hiddenName }
})

const animal2 = {
  name: 'И здесь тоже кот',
}

console.log(animal.name)
// Кот
console.log(animal2.name)
// И здесь тоже кот

        
        
          
        
      

Оба объекта имеют одинаковое поведение. Стоит только сказать, что за свойством в первом случае стоит функция, которая вызывается автоматически. Достаточно написать animal.name.

Если нам понадобится изменить значение свойства name, мы выполним animal.name = 'Серый кот', ничего не произойдёт. Дело в том, что с ключом name не связана функция-сеттер, поэтому значение этому свойству установить невозможно.

Добавим сеттер:

        
          
          const animal = { _hiddenName : 'Кот' }Object.defineProperty(animal, 'name', {    get: function() { return this._hiddenName },    set: function(value){ this._hiddenName = value }})animal.name = 'Собака'console.log(animal.name)// Собака
          const animal = { _hiddenName : 'Кот' }
Object.defineProperty(animal, 'name', {
    get: function() { return this._hiddenName },
    set: function(value){ this._hiddenName = value }
})

animal.name = 'Собака'
console.log(animal.name)
// Собака

        
        
          
        
      

По сути, мы можем регулировать возможность читать и получать значение свойства, как и в дескрипторе данных, только более тонко. Такой подход используется часто, поэтому для объявления геттеров и сеттеров придумали синтаксис без вызова Object.defineProperty():

        
          
          const animal = {  get name() {    return this._name  },  set name(value) {    this._name = value  }}console.log(animal.name)// undefinedanimal.name = 'Кот'console.log(animal.name)// Кот
          const animal = {
  get name() {
    return this._name
  },
  set name(value) {
    this._name = value
  }
}

console.log(animal.name)
// undefined

animal.name = 'Кот'
console.log(animal.name)
// Кот

        
        
          
        
      

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

        
          
          const updatedAt = {  get date() {    return this._date  },  set date(value) {    this._date = new Intl.DateTimeFormat('en-US').format(value)  }}
          const updatedAt = {
  get date() {
    return this._date
  },

  set date(value) {
    this._date = new Intl.DateTimeFormat('en-US').format(value)
  }
}

        
        
          
        
      

Запишем дату и время в поле date:

        
          
          updatedAt.date = new Date(2030, 11, 12)console.log(updatedAt.date)// 12/12/2030
          updatedAt.date = new Date(2030, 11, 12)
console.log(updatedAt.date)
// 12/12/2030

        
        
          
        
      

И получим дату в нужном формате: 12/12/2030.

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

Общие свойства

Скопировано

Общие свойства можно указывать в обоих типах дескрипторов.

enumerable

Скопировано

Свойство определяет, является ли создаваемое свойство объекта видимым при перечислениях.

Создадим два свойства у объекта laptop — одно будет перечисляемым, а другое — нет:

        
          
          const laptop = {}Object.defineProperty(laptop, 'processor',    // сделаем `processor` перечисляемым, как обычно    { enumerable: true, value: 'Intel Core' })Object.defineProperty(laptop, 'touchID',    // сделаем `touchID` НЕперечисляемым    { enumerable: false, value: true })console.log(laptop.touchID)// trueconsole.log(('touchID' in laptop))// trueconsole.log(laptop.hasOwnProperty('touchID'))// truefor (let key in laptop) {  console.log(key, laptop[key])}// 'processor': 'Intel Core'
          const laptop = {}

Object.defineProperty(laptop, 'processor',
    // сделаем `processor` перечисляемым, как обычно
    { enumerable: true, value: 'Intel Core' }
)

Object.defineProperty(laptop, 'touchID',
    // сделаем `touchID` НЕперечисляемым
    { enumerable: false, value: true }
)

console.log(laptop.touchID)
// true
console.log(('touchID' in laptop))
// true
console.log(laptop.hasOwnProperty('touchID'))
// true


for (let key in laptop) {
  console.log(key, laptop[key])
}
// 'processor': 'Intel Core'

        
        
          
        
      

Заметьте, что laptop.touchID существует и имеет значение, но не отображается в цикле for..in (при этом, оно существует, если воспользоваться оператором in). «Перечислимое» означает: «будет учтено, если пройти перебором по свойствам объекта».

configurable

Скопировано

Свойство configurable определяет, доступно ли создаваемое свойство объекта для переконфигурирования.

Изменим значение configurable:

        
          
          const laptop = {}Object.defineProperty(laptop, 'processor', {    value: 'Intel Core',    writable: true,    configurable: false, // запрещаем переконфигурирование!    enumerable: true})console.log(laptop.processor)// Intel Corelaptop.processor = 'M1'console.log(laptop.processor)// 'M1'Object.defineProperty(laptop, 'processor', {    value: 'M1 TOP',    writable: true,    configurable: true,    enumerable: true})// TypeError: Cannot redefine property: processor
          const laptop = {}

Object.defineProperty(laptop, 'processor', {
    value: 'Intel Core',
    writable: true,
    configurable: false, // запрещаем переконфигурирование!
    enumerable: true
})

console.log(laptop.processor)
// Intel Core
laptop.processor = 'M1'
console.log(laptop.processor)
// 'M1'

Object.defineProperty(laptop, 'processor', {
    value: 'M1 TOP',
    writable: true,
    configurable: true,
    enumerable: true
})
// TypeError: Cannot redefine property: processor

        
        
          
        
      

Попытка переписать дескриптор свойства processor приводит к ошибке TypeError, даже если вы находитесь не в строгом режиме.

Если для свойства уже задано configurable: false, то writable может быть изменено с true на false без ошибки, но не обратно в true если оно уже false.

А ещё configurable: false препятствует возможности использовать оператор delete для удаления существующего свойства. Ошибки не случится, но и свойство не удалится:

        
          
          delete laptop.processorconsole.log(laptop)// { processor: 'M1' }
          delete laptop.processor
console.log(laptop)
// { processor: 'M1' }

        
        
          
        
      

Периодически разработчику нужно защищать объекты от вмешательства извне. По ошибке легко изменить свойство объекта. Для защиты объектов от подобных изменений и управления их иммутабельностью предлагается использовать дескрипторы, такие как writable и configurable, сеттеры, а также методы Object.preventExtensions(), Object.seal(), и Object.freeze() для ограничения доступа к объекту целиком.

На практике

Скопировано

Антон Горелов советует

Скопировано

🛠 В жизни проще использовать именно Object.seal() и Object.freeze()потому что чаще всего нужно ограничить доступ ко всему объекту целиком.

Сначала скажу, что есть метод Object.preventExtensions(), чтобы проще объяснить принцип работы Object.seal().

Object.preventExtensions() запрещает добавление новых свойств объекта, но в то же время оставляет существующие свойства нетронутыми.

        
          
          const laptop = {    displaySize: 15}Object.preventExtensions(laptop)laptop.storage = 256console.log(laptop.storage)// undefined
          const laptop = {
    displaySize: 15
}
Object.preventExtensions(laptop)
laptop.storage = 256
console.log(laptop.storage)
// undefined

        
        
          
        
      

В нестрогом режиме, создание storage завершится неудачей без ошибок. В строгом режиме это приведёт к ошибке TypeError.

Метод Object.seal() запечатывает переданный ему объект, одновременно запрещая добавление новых свойств и конфигурирование существующих свойств, значения свойств при этом изменять можно. Другими словами Object.seal() является эквивалентом применения Object.preventExtensions() к объекту и configurable:false к его свойствам.

Object.freeze() в свою очередь, замораживает объект, одновременно запрещая добавление новых свойств и изменение существующих свойств. Что соответствует применению Object.preventExtensions() к объекту и writable:false к его свойствам.

Обратим внимание, что метод поверхностный, у замороженного объекта остаётся возможность изменять вложенные объекты. На MDN есть пример глубокой заморозки, метод deepFreeze(), позволяющий сделать полностью иммутабельный объект. При этом, невозможно сделать иммутабельными Date, Map или Set.

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

Методы Object.seal(), Object.freeze() и Object.preventExtensions() возвращают ссылку на тот же объект, что им был передан:

        
          
          const foo = {}const bar = Object.freeze(foo)foo === bar // true
          const foo = {}
const bar = Object.freeze(foo)
foo === bar // true

        
        
          
        
      
        
          
          const frozen = Object.freeze({ foo: 'bar' })
          const frozen = Object.freeze({ foo: 'bar' })

        
        
          
        
      

🛠 Для объявления нескольких свойств, воспользуйтесь статическим методом Object.defineProperties():

        
          
          const laptop = {}Object.defineProperties(laptop, {  os: {    value: 'MacOS',    enumerable: true  },  age: {    value: 10,    enumerable: false  }})const result = Object.keys(laptop)console.log(result)// ['os']
          const laptop = {}

Object.defineProperties(laptop, {
  os: {
    value: 'MacOS',
    enumerable: true
  },
  age: {
    value: 10,
    enumerable: false
  }
})

const result = Object.keys(laptop)

console.log(result)
// ['os']

        
        
          
        
      

🛠 Получение значений дескрипторов для конкретного свойства объекта:

        
          
          const source = {    name: 'Doka',    sections: ['HTML', 'CSS', 'JS', 'Tools', 'Recipes'],    themes: ['light']}const nameDescriptors = Object.getOwnPropertyDescriptor(source, 'name')console.log(nameDescriptors)//{//  'value':'Doka',//  'writable':true,//  'enumerable':true,//  'configurable':true//}
          const source = {
    name: 'Doka',
    sections: ['HTML', 'CSS', 'JS', 'Tools', 'Recipes'],
    themes: ['light']
}

const nameDescriptors = Object.getOwnPropertyDescriptor(source, 'name')

console.log(nameDescriptors)
//{
//  'value':'Doka',
//  'writable':true,
//  'enumerable':true,
//  'configurable':true
//}

        
        
          
        
      

🛠 Получение значений дескрипторов для всех свойств объекта:

        
          
          const allPropertyDescriptors = Object.getOwnPropertyDescriptors(source)console.log(allPropertyDescriptors)
          const allPropertyDescriptors = Object.getOwnPropertyDescriptors(source)

console.log(allPropertyDescriptors)

        
        
          
        
      

Получим следующий ответ:

        
          
          {  name: {    value: 'Doka',    writable: true,    enumerable: true,    configurable: true,  },  sections: {    value: ['HTML', 'CSS', 'JS', 'Tools', 'Recipes'],    writable: true,    enumerable: true,    configurable: true,  },  themes: {    value: ['light'],    writable: true,    enumerable: true,    configurable: true,  },}
          {
  name: {
    value: 'Doka',
    writable: true,
    enumerable: true,
    configurable: true,
  },
  sections: {
    value: ['HTML', 'CSS', 'JS', 'Tools', 'Recipes'],
    writable: true,
    enumerable: true,
    configurable: true,
  },
  themes: {
    value: ['light'],
    writable: true,
    enumerable: true,
    configurable: true,
  },
}

        
        
          
        
      

Если передан пустой объект без свойств, то получим пустой объект:

        
          
          const user = {}const userDescriptors = Object.getOwnPropertyDescriptors(user)console.log(userDescriptors)// {}
          const user = {}
const userDescriptors = Object.getOwnPropertyDescriptors(user)

console.log(userDescriptors)
// {}

        
        
          
        
      

🛠 Object.isFrozen() определяет, был ли объект заморожен. Возвращает true, если добавление/удаление/изменение свойств запрещено, и для всех текущих свойств установлено configurable: false, writable: false.