Дескрипторы, геттеры и сеттеры свойств

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

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

Дескрипторы в примерах

Основной метод для управления свойствами – Object.defineProperty.

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

Синтаксис:

Object.defineProperty(obj, prop, descriptor)

Аргументы:

obj
Объект, в котором объявляется свойство.
prop
Имя свойства, которое нужно объявить или модифицировать.
descriptor
Дескриптор – объект, который описывает поведение свойства.

В нём могут быть следующие поля:

  • value – значение свойства, по умолчанию undefined
  • writable – значение свойства можно менять, если true. По умолчанию false.
  • configurable – если true, то свойство можно удалять, а также менять его в дальнейшем при помощи новых вызовов defineProperty. По умолчанию false.
  • enumerable – если true, то свойство просматривается в цикле for..in и методе Object.keys(). По умолчанию false.
  • get – функция, которая возвращает значение свойства. По умолчанию undefined.
  • set – функция, которая записывает значение свойства. По умолчанию undefined.

Чтобы избежать конфликта, запрещено одновременно указывать значение value и функции get/set. Либо значение, либо функции для его чтения-записи, одно из двух. Также запрещено и не имеет смысла указывать writable при наличии get/set-функций.

Далее мы подробно разберём эти свойства на примерах.

Обычное свойство

Два таких вызова работают одинаково:

var user = {};

// 1. простое присваивание
user.name = "Вася";

// 2. указание значения через дескриптор
Object.defineProperty(user, "name", { value: "Вася", configurable: true, writable: true, enumerable: true });

Оба вызова выше добавляют в объект user обычное (удаляемое, изменяемое, перечисляемое) свойство.

Свойство-константа

Для того, чтобы сделать свойство неизменяемым, изменим его флаги writable и configurable:

"use strict";

var user = {};

Object.defineProperty(user, "name", {
  value: "Вася",
  writable: false, // запретить присвоение "user.name="
  configurable: false // запретить удаление "delete user.name"
});

// Теперь попытаемся изменить это свойство.

// в strict mode присвоение "user.name=" вызовет ошибку
user.name = "Петя";

Заметим, что без use strict операция записи «молча» не сработает. Лишь если установлен режим use strict, то дополнительно сгенерируется ошибка.

Свойство, скрытое для for…in

Встроенный метод toString, как и большинство встроенных методов, не участвует в цикле for..in. Это удобно, так как обычно такое свойство является «служебным».

К сожалению, свойство toString, объявленное обычным способом, будет видно в цикле for..in, например:

var user = {
  name: "Вася",
  toString: function() { return this.name; }
};

for(var key in user) alert(key);  // name, toString

Мы бы хотели, чтобы поведение нашего метода toString было таким же, как и стандартного.

Object.defineProperty может исключить toString из списка итерации, поставив ему флаг enumerable: false. По стандарту, у встроенного toString этот флаг уже стоит.

var user = {
  name: "Вася",
  toString: function() { return this.name; }
};

// помечаем toString как не подлежащий перебору в for..in
Object.defineProperty(user, "toString", {enumerable: false});

for(var key in user) alert(key);  // name

Обратим внимание, вызов defineProperty не перезаписал свойство, а просто модифицировал настройки у существующего toString.

Свойство-функция

Дескриптор позволяет задать свойство, которое на самом деле работает как функция. Для этого в нём нужно указать эту функцию в get.

Например, у объекта user есть обычные свойства: имя firstName и фамилия surname.

Создадим свойство fullName, которое на самом деле является функцией:

var user = {
  firstName: "Вася",
  surname: "Петров"
}

Object.defineProperty(user, "fullName", {
  get: function() {
    return this.firstName + ' ' + this.surname;
  }
});

alert(user.fullName); // Вася Петров

Обратим внимание, снаружи fullName – это обычное свойство user.fullName. Но дескриптор указывает, что на самом деле его значение возвращается функцией.

Также можно указать функцию, которая используется для записи значения, при помощи дескриптора set.

Например, добавим возможность присвоения user.fullName к примеру выше:

var user = {
  firstName: "Вася",
  surname: "Петров"
}

Object.defineProperty(user, "fullName", {

  get: function() {
    return this.firstName + ' ' + this.surname;
  },

  set: function(value) {
      var split = value.split(' ');
      this.firstName = split[0];
      this.surname = split[1];
    }
});

user.fullName = "Петя Иванов";
alert( user.firstName ); // Петя
alert( user.surname ); // Иванов

Указание get/set в литералах

Если мы создаём объект при помощи синтаксиса { ... }, то задать свойства-функции можно прямо в его определении.

Для этого используется особый синтаксис: get свойство или set свойство.

Например, ниже объявлен геттер-сеттер fullName:

var user = {
  firstName: "Вася",
  surname: "Петров",

  get fullName() {
    return this.firstName + ' ' + this.surname;
  },

  set fullName(value) {
    var split = value.split(' ');
    this.firstName = split[0];
    this.surname = split[1];
  }
};

alert( user.fullName ); // Вася Петров (из геттера)

user.fullName = "Петя Иванов";
alert( user.firstName ); // Петя  (поставил сеттер)
alert( user.surname ); // Иванов (поставил сеттер)

Да здравствуют get/set!

Казалось бы, зачем нам назначать get/set для свойства через всякие хитрые вызовы, когда можно сделать просто функции с самого начала? Например, getFullName, setFullName

Конечно, в ряде случаев свойства выглядят короче, такое решение просто может быть красивым. Но основной бонус – это гибкость, возможность получить контроль над свойством в любой момент!

Например, в начале разработки мы используем обычные свойства, например у User будет имя name и возраст age:

function User(name, age) {
  this.name = name;
  this.age = age;
}

var pete = new User("Петя", 25);

alert( pete.age ); // 25

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

…Но рано или поздно могут произойти изменения. Например, в User может стать более целесообразно вместо возраста age хранить дату рождения birthday:

function User(name, birthday) {
  this.name = name;
  this.birthday = birthday;
}

var pete = new User("Петя", new Date(1987, 6, 1));

Что теперь делать со старым кодом, который выводит свойство age?

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

Добавление get-функции age позволяет обойти проблему легко и непринуждённо:

function User(name, birthday) {
  this.name = name;
  this.birthday = birthday;

  // age будет высчитывать возраст по birthday
  Object.defineProperty(this, "age", {
    get: function() {
      var todayYear = new Date().getFullYear();
      return todayYear - this.birthday.getFullYear();
    }
  });
}

var pete = new User("Петя", new Date(1987, 6, 1));

alert( pete.birthday ); // и дата рождения доступна
alert( pete.age );      // и возраст

Заметим, что pete.age снаружи как было свойством, так и осталось. То есть, переписывать внешний код на вызов функции pete.age() не нужно.

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

Другие методы работы со свойствами

Object.defineProperties(obj, descriptors)

Позволяет объявить несколько свойств сразу:

var user = {}

Object.defineProperties(user, {
  firstName: {
    value: "Петя"
  },

  surname: {
    value: "Иванов"
  },

  fullName: {
    get: function() {
      return this.firstName + ' ' + this.surname;
    }
  }
});

alert( user.fullName ); // Петя Иванов
Object.keys(obj), Object.getOwnPropertyNames(obj)

Возвращают массив – список свойств объекта.

Object.keys возвращает только enumerable-свойства.

Object.getOwnPropertyNames – возвращает все:

var obj = {
  a: 1,
  b: 2,
  internal: 3
};

Object.defineProperty(obj, "internal", {
  enumerable: false
});

alert( Object.keys(obj) ); // a,b
alert( Object.getOwnPropertyNames(obj) ); // a, internal, b
Object.getOwnPropertyDescriptor(obj, prop)

Возвращает дескриптор для свойства obj[prop].

Полученный дескриптор можно изменить и использовать defineProperty для сохранения изменений, например:

var obj = {
  test: 5
};
var descriptor = Object.getOwnPropertyDescriptor(obj, 'test');

// заменим value на геттер, для этого...
delete descriptor.value; // ..нужно убрать value/writable
delete descriptor.writable;
descriptor.get = function() { // и поставить get
  alert( "Preved :)" );
};

// поставим новое свойство вместо старого

// если не удалить - defineProperty объединит старый дескриптор с новым
delete obj.test;

Object.defineProperty(obj, 'test', descriptor);

obj.test; // Preved :)

…И несколько методов, которые используются очень редко:

Object.preventExtensions(obj)
Запрещает добавление свойств в объект.
Object.seal(obj)
Запрещает добавление и удаление свойств, все текущие свойства делает configurable: false.
Object.freeze(obj)
Запрещает добавление, удаление и изменение свойств, все текущие свойства делает configurable: false, writable: false.
Object.isExtensible(obj)
Возвращает false, если добавление свойств объекта было запрещено вызовом метода Object.preventExtensions.
Object.isSealed(obj)
Возвращает true, если добавление и удаление свойств объекта запрещено, и все текущие свойства являются configurable: false.
Object.isFrozen(obj)
Возвращает true, если добавление, удаление и изменение свойств объекта запрещено, и все текущие свойства являются configurable: false, writable: false.

Задачи

важность: 5

Вам попал в руки код объекта User, который хранит имя и фамилию в свойстве this.fullName:

function User(fullName) {
  this.fullName = fullName;
}

var vasya = new User("Василий Попкин");

Имя и фамилия всегда разделяются пробелом.

Сделайте, чтобы были доступны свойства firstName и lastName, причём не только на чтение, но и на запись, вот так:

var vasya = new User("Василий Попкин");

// чтение firstName/lastName
alert( vasya.firstName ); // Василий
alert( vasya.lastName ); // Попкин

// запись в lastName
vasya.lastName = 'Сидоров';

alert( vasya.fullName ); // Василий Сидоров

Важно: в этой задаче fullName должно остаться свойством, а firstName/lastName – реализованы через get/set. Лишнее дублирование здесь ни к чему.

function User(fullName) {
  this.fullName = fullName;

  Object.defineProperties(this, {

    firstName: {

      get: function() {
        return this.fullName.split(' ')[0];
      },

      set: function(newFirstName) {
        this.fullName = newFirstName + ' ' + this.lastName;
      }

    },

    lastName: {

      get: function() {
        return this.fullName.split(' ')[1];
      },

      set: function(newLastName) {
        this.fullName = this.firstName + ' ' + newLastName;
      }

    }

  });
}

var vasya = new User("Василий Попкин");

// чтение firstName/lastName
alert( vasya.firstName ); // Василий
alert( vasya.lastName ); // Попкин

// запись в lastName
vasya.lastName = 'Сидоров';

alert( vasya.fullName ); // Василий Сидоров
Карта учебника

Комментарии

перед тем как писать…
  • Приветствуются комментарии, содержащие дополнения и вопросы по статье, и ответы на них.
  • Для одной строки кода используйте тег <code>, для нескольких строк кода — тег <pre>, если больше 10 строк — ссылку на песочницу (plnkr, JSBin, codepen…)
  • Если что-то непонятно в статье — пишите, что именно и с какого места.