Skip to content

Latest commit

 

History

History
863 lines (630 loc) · 73 KB

File metadata and controls

863 lines (630 loc) · 73 KB

Вы не знаете JS: this и прототипы объектов

Глава 3: Объекты

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

Синтаксис

Объекты создаются двумя способами: декларативно (литерально) и с помощью конструктора.

Литеральный синтаксис для объекта выглядит так:

var myObj = {
key: value
// ...
};

Конструкторная форма выглядит так:

var myObj = new Object();
myObj.key = value;

Конструкторная и литеральная формы в результате дают одинаковые объекты. Единственное отличие в том, что в литеральной форме вы можете добавлять сразу несколько пар ключ-значение, в то время как с конструктором вам нужно добавлять свойства по одному.

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

Тип

Объекты -- это основные элементы из которых построена большая часть JS. В JS есть шесть основных типов (в спецификации называются «языковые типы»):

  • string
  • number
  • boolean
  • null
  • undefined
  • object

Обратите внимание, что простые примитивы (string, number, boolean, null, and undefined) сами по себе не являются объектами. null иногда упоминается как объект, но это заблуждение произошло из ошибки в языке, которая приводит к тому, что typeof null ошибочно возвращает "object". По факту, null это самостоятельный примитивный тип.

Есть распространённое заблуждение, что «в JS всё является объектом». Это совсем не так.

Однако, есть несколько специальных подтипов, которые мы можем называть сложными примитивами.

function -- это подтип объекта (технически, «вызываемый объект»). Говорят, что функции в JS это объекты «первого класса», поскольку в основном они являются обычными объектами и с ними можно работать как с любым другим объектом.

Массивы -- это тоже форма объекта с расширенным поведением. Содержимое массивов организовано более структурировано, чем у обычных объектов.

Встроенные Объекты

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

  • String
  • Number
  • Boolean
  • Object
  • Function
  • Array
  • Date
  • RegExp
  • Error

Эти подтипы выглядят как настоящие типы или даже классы, если вы полагаетесь на сходство с другими языками вроде класса String в Java.

Но в JS это, на самом деле, встроенные функции. Каждая из этих встроенных функций может быть использована как конструктор (ага, вызов функции с оператором new -- смотрите Главу 2), а результатом будет новый сконструированный объект указанного подтипа. Например:

var strPrimitive = "I am a string";
typeof strPrimitive;	// "string"
strPrimitive instanceof String;	// false
var strObject = new String( "I am a string" );
typeof strObject; // "object"
strObject instanceof String;	// true
// проверим подтип объекта
Object.prototype.toString.call( strObject );	// [object String]

В следующей главе мы подробно рассмотрим как именно работает Object.prototype.toString.... Вкратце: мы можем проверить внутренний подтип, заимствовав базовый стандартный метод toString(). Как видите, он показывает, что этот strObject -- на самом деле объект, созданный конструктором String.

Значение примитива «I am a string» это не объект, а литерал примитива, и его значение иммутабельно. Чтобы выполнять над ним операции вроде проверки длины, доступа к содержимому символов и т. д. требуется объект String.

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

Рассмотрим:

var strPrimitive = "I am a string";
console.log( strPrimitive.length );	// 13
console.log( strPrimitive.charAt( 3 ) );	// "m"

В обоих случаях мы вызываем свойство или метод строчного примитива и движок автоматически преобразует его к объекту String, так что свойство/метод работают.

Такое же преобразование происходит между численным литеральным примитивом 42 и обёрткой объекта new Number(42) при использовании методов вроде 42.359.toFixed(2). То же самое и с объектом Boolean из примитива "boolean".

null и undefined не имеют формы Объекта, только значения их примитивов. Для примера, значения Date могут быть созданы только с помощью их конструкторной формы объекта, так как у них нет соответствующей литеральной формы.

Object, Array, Function, и RegExp (регулярные выражения) -- это объекты, не зависимо от того, используется литеральная или конструкторная форма. Конструкторная форма в некоторых случаях предлагает больше опций для создания, чем литеральная. Поскольку объекты создаются в любом случае, простая литеральная форма почти универсальна. Используйте конструкторную форму только если вам нужны дополнительные опции.

Объекты Error редко создаются в коде в явном виде. Обычно они создаются автоматически, когда появляются исключения. Они могут быть созданы с помощью конструкторной формы new Error(..), но обычно в этом нет необходимости.

Содержимое

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

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

Рассмотрим:

var myObject = {
    a: 2
};
myObject.a;	    // 2
myObject["a"];	// 2

Чтобы получить значение по адресу a в myObject мы должны использовать либо оператор . либо оператор []. Синтаксис .a обычно описывают как доступ к «свойству», а синтаксис ["a"] называют доступ по «ключу». В реальности они оба обращаются по одному адресу и выведут одно и то же значение 2, так что эти термины взаимозаменяемы. Далее мы будем использовать наиболее общий термин «доступ к свойству».

Основное различие между двумя синтаксисами в том, что оператор . требует, чтобы после него шло название свойства, совместимое с Идентификатором, в то время как синтаксис [".."] может принять в качестве имени свойства любую строку, совместимую с UTF-8/unicode.

Также, поскольку синтаксис [".."] использует значение строки для указания адреса, можно программно сгенерировать значение этой строки, например, так:

var wantA = true;
var myObject = {
    a: 2
};
var idx;
if (wantA) {
    idx = "a";
}
// позже
console.log( myObject[idx] ); // 2

В объектах названия свойств всегда являются строкой. Если вы используете в качестве свойств любые другие значения кроме string (примитив), они будут сконвертированы в строку. Это относится и к числам, которые обычно используют как индексы массивов. Так что будьте осторожны и не путайте использование чисел в объектах и массивах.

var myObject = { };
myObject[true] = "foo";
myObject[3] = "bar";
myObject[myObject] = "baz";
myObject["true"];	            // "foo"
myObject["3"];	                // "bar"
myObject["[object Object]"];	// "baz"

Вычисляемые имена свойств

Описанный выше синтаксис доступа к свойствам вида myObject[..] полезен, когда необходимо использовать результат выражения в качестве ключа, вроде myObject[prefix + name]. Но он не сильно помогает при объявлении объектов через объектно-литеральный синтаксис.

ES6 добавляет вычисляемые имена свойств, где можно указать выражение, обрамленное [ ], в качестве пары ключ-значение при литеральном объявлении объекта:

var prefix = "foo";
var myObject = {
    [prefix + "bar"]: "hello",
    [prefix + "baz"]: "world"
};
myObject["foobar"]; // hello
myObject["foobaz"]; // world

Наиболее распространённым, вероятно, будет использование вычисляемых имен свойств для Symbol в ES6, которые не будут подробно рассматриваться в этой книге. Вкратце, это новый примитивный тип данных, значение которого непрозрачно и неопределимо (технически, это значение -- string). Вы будете сильно озадачены, работая с действительным значением Symbol (которое теоретически может быть разным в разных движках JS), поэтому вы будете использовать имя Символа, вроде Symbol.Something (просто выдуманное имя!):

var myObject = {
    [Symbol.Something]: "hello world"
};

Свойство против Метода

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

Интересно, что спецификация делает такое же различие.

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

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

Каждый раз, когда вы запрашиваете свойство объекта, это обращение к свойству, вне зависимости от типа значения, которое вы получаете. Если вдруг вы получите в результате обращения к этому свойству функцию, она не превратится волшебным образом в «метод». Нет ничего особенного в том, что функция выводится при обращении к свойству (кроме возможной неявной привязки this, как было описано ранее).

Например:

function foo() {
    console.log( "foo" );
}
var someFoo = foo;	// переменная указывает на `foo`
var myObject = {
    someFoo: foo
};
foo;	            // function foo(){..}
someFoo;	        // function foo(){..}
myObject.someFoo;	// function foo(){..}

someFoo и myObject.someFoo это лишь две разных ссылки на одну функцию, и ни одна из них не подразумевает, что функция является особенной или «принадлежит» какому-то другому объекту.

Возможно, кто-то возразит, что функция становится методом не в момент объявления, а в процессе выполнения конкретного вызова, в зависимости от того, как она была вызвана и точки ее вызова (в контексте ссылки на объект или нет -- подробнее смотрите в Главе 2). Даже такая интерпретация выглядит притянутой за уши.

Возможно, самым безопасным выводом будет такой: «функция» и «метод» взаимозаменяемы в JavaScript.

Примечание: ES6 добавляет указатель super, который обычно используется вместе с class (см. Приложение А). То, как работает super (статическая привязка вместо поздней привязки в виде this) прибавляет весомости идее, что функция, которую привязывает super больше похожа на «метод», чем на «функцию». Но опять же, это лишь тонкие нюансы семантики (и механики).

Даже если вы объявляете функциональное выражение как часть литерала объекта, эта функция не становится волшебным образом более привязанной к объекту -- это по-прежнему лишь несколько указателей на ту же функцию.

var myObject = {
    foo: function foo() {
    console.log( "foo" );
}
};
var someFoo = myObject.foo;
someFoo;	    // function foo(){..}
myObject.foo;	// function foo(){..}

Примечание: В Главе 6 мы рассмотрим сокращенный синтаксис ES6 для foo: function foo(){ .. } в нашем литерале объекта.

Массивы

Массивы тоже используют для доступа форму [ ], но, как упоминалось ранее, имеют более структурированную организацию того, как хранятся значения (хотя всё еще без ограничений типов хранимых значений). Массивы предполагают числовую индексацию. Это означает, что значения хранятся в местах, обычно называемых индексами, с неотрицательными целыми числами, вроде 0 и 42.

var myArray = [ "foo", 42, "bar" ];
myArray.length;	// 3
myArray[0];	    // "foo"
myArray[2];	    // "bar"

Массивы -- это объекты, поэтому даже если каждый индекс является положительным целым числом, вы можете также добавить свойства массива:

var myArray = [ "foo", 42, "bar" ];
myArray.baz = "baz";
myArray.length;	// 3
myArray.baz;	// "baz"

Обратите внимание, что добавление именованных свойств (не зависимо от синтаксиса . или [ ]) не влияет на выводимый результат свойства length.

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

Осторожно: Если вы попытаетесь добавить свойство к массиву, но имя свойства выглядит как число, оно добавится в виде числового индекса (таким образом изменится содержимое массива):

var myArray = [ "foo", 42, "bar" ];
myArray["3"] = "baz";
myArray.length;	// 4
myArray[3];	    // "baz"

Дублирование Объектов

Один из наиболее распространенных вопросов для разработчиков JavaScript, которые только знакомятся с языком, это копирование объекта. Казалось бы, должен быть простой встроенный метод copy(), верно? Оказывается, всё немного сложнее, потому что изначально не до конца понятно каким должен быть алгоритм для создания дубликата.

Для примера рассмотрим этот объект:

function anotherFunction() { /*..*/ }
var anotherObject = {
    c: true
};
var anotherArray = [];
var myObject = {
    a: 2,
    b: anotherObject,	// ссылка, а не копия!
    c: anotherArray,	// еще одна ссылка!
    d: anotherFunction
};
anotherArray.push( anotherObject, myObject );

Как же на самом деле должна выглядеть копия объекта myObject?

Во-первых, мы должны решить будет это поверхностная или глубокая копия? Результатом поверхностного копирования в новом объекте будет свойство a в виде копии значения 2, но свойства b, c, и d -- лишь в виде ссылки на те же места, что и в оригинальном массиве. Глубокая копия продублирует не только myObject, но и anotherObject и anotherArray. Но тогда у нас будет проблема с тем, что anotherArray содержит ссылки на anotherObject и myObject, так что их тоже нужно скопировать вместо того чтобы сохранять ссылку. Теперь у нас есть проблема бесконечного дублирования из-за зацикленности ссылок.

Должны ли мы выявлять цикличные ссылки и просто прерывать цикличный обход (оставляя глубокие элементы не до конца продублированными)? Может просто вывести ошибку? Или что-то среднее?

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

Так как же нам решить все эти каверзные вопросы? Различные фреймворки имеют свои собственные интерпретации и решения. Но какое из них (если такое имеется) должен принять JS в качестве стандартного? Долгое время не было четкого ответа.

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

var newObj = JSON.parse( JSON.stringify( someObj ) );

Конечно, для этого вам нужно убедиться, что ваш объект безопасен для JSON. В некоторых ситуациях это элементарно. В других этого недостаточно.

В то же время, поверхностное копирование достаточно понятно и имеет меньше проблем, поэтому в ES6 для этой задачи есть Object.assign(..). Object.assign(..) принимает целевой объект в качестве первого параметра, а также один или более исходных объектов в качестве последующих параметров. Он проходит по всем перечисляемым (см. ниже), собственным ключам (существующим непосредственно) в исходном объекте(тах) и копирует их (только через присваивание =) в целевой объект. Кроме того, удобно, что он возвращает целевой объект, как показано ниже:

var newObj = Object.assign( {}, myObject );
newObj.a;	                    // 2
newObj.b === anotherObject;	    // true
newObj.c === anotherArray;	    // true
newObj.d === anotherFunction;	// true

Примечание: В следующем разделе мы опишем «дескрипторы свойств» (характеристики свойств) и покажем использование Object.defineProperty(..). Как бы то ни было, дублирование, которое имеет место в Object.assign(..) это чистое присваивание в стиле =, так что любые особенные характеристики свойств (вроде writable) исходного объекта не сохраняются в целевом объекте.

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

Вплоть до ES5 язык JavaScript не давал вашему коду напрямую проверить или описать различия между характеристиками свойств: например, узнать доступно ли свойство только для чтения или нет.

Но в ES5 все свойства описываются с помощью дескриптора свойств.

Рассмотрим такой код:

var myObject = {
    a: 2
};
Object.getOwnPropertyDescriptor( myObject, "a" );
// {
// value: 2,
// writable: true,
// enumerable: true,
// configurable: true
// }

Как видно, дескриптор свойства (называемый «дескриптором данных», поскольку он хранит только значение данных) для нашего обычного свойства a это больше чем просто его value, равное 2.

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

Например:

var myObject = {};
Object.defineProperty( myObject, "a", {
    value: 2,
    writable: true,
    configurable: true,
    enumerable: true
} );
myObject.a; // 2

С помощью defineProperty(..) мы вручную добавили простое, обычное свойство a к объекту myObject в явном виде. Как бы то ни было, в общем случае вам не придется использовать ручной способ, пока вы не захотите изменить обычное поведение характеристик дескриптора.

Перезаписываемое

Возможность изменить значение свойства контролируется характеристикой writable.

Рассмотрим:

var myObject = {};
Object.defineProperty( myObject, "a", {
    value: 2,
    writable: false, // не перезаписываемо!
    configurable: true,
    enumerable: true
} );
myObject.a = 3;
myObject.a;         // 2

Как видите, наша попытка модифицировать value не удалась, при этом мы не получили никакого уведомления об этом. В строгом режиме strict mode мы получим ошибку:

"use strict";
var myObject = {};
Object.defineProperty( myObject, "a", {
    value: 2,
    writable: false, // не перезаписываемо!
    configurable: true,
    enumerable: true
} );
myObject.a = 3;     // TypeError

TypeError говорит о том, что мы не можем изменить неперезаписываемое свойство

Примечание: Мы скоро обсудим геттеры и сеттеры, но вкратце, вы можете заметить, что writable:false означает, что значение нельзя изменить. Это отчасти равносильно указанию NOOP-сеттера. На самом деле, чтобы действительно соответствовать writable:false ваш NOOP-сеттер при вызове должен выдавать TypeError.

Конфигурируемое

Пока свойство является конфигурируемым, мы можем изменять описание дескриптора, используя всё тот же инструмент defineProperty(..).

var myObject = {
    a: 2
};
myObject.a = 3;
myObject.a;	// 3
Object.defineProperty( myObject, "a", {
    value: 4,
    writable: true,
    configurable: false,	// не конфигурируемо!
    enumerable: true
} );
myObject.a;	// 4
myObject.a = 5;
myObject.a;	// 5
Object.defineProperty( myObject, "a", {
    value: 6,
    writable: true,
    configurable: true,
    enumerable: true
} );        // TypeError

Последний вызов defineProperty(..) приводит к ошибке TypeError, вне зависимости от strict mode, если вы пытаетесь изменить значение дескриптора неконфигурируемого свойства. Осторожно: как видите, изменение configurable на false необратимо и его нельзя отменить.

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

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

var myObject = {
    a: 2
};
myObject.a;     // 2
delete myObject.a;
myObject.a;     // undefined
Object.defineProperty( myObject, "a", {
    value: 2,
    writable: true,
    configurable: false,
    enumerable: true
} );
myObject.a;     // 2
delete myObject.a;
myObject.a;     // 2

Как видите, последний вызов delete не удался (без уведомления), потому что мы сделали свойство a неконфигурируемым.

delete используется только для удаления свойств объекта (которое может быть удалено) напрямую из указанного объекта. Если свойство объекта -- это последняя оставшаяся ссылка на некоторый объект/функцию и вы удаляете его, то ссылка удалится и теперь не имеющий ссылок объект/функция могут быть убраны сборщиком мусора.

Перечисляемое

Последнее свойство дескриптора, о котором мы расскажем (есть еще два других, с которыми мы будем иметь дело, когда обсудим геттеры/сеттеры), это enumerable.

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

Все нормальные свойства, заданные пользователем, по умолчанию являются enumerable, поскольку обычно это то, что вам нужно. Но если у вас есть особенное свойство, которое вы хотите спрятать от перечислений, установите для него enumerable:false.

Мы скоро продемонстрируем перечисляемость более подробно, так что возьмите ее себе на заметку.

Иммутабельность

Иногда требуется создать свойства или объекты, которые не могут быть изменены (случайно или преднамеренно). ES5 добавляет для работы с этим несколько различных способов с определенными тонкостями.

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

myImmutableObject.foo; // [1,2,3]
myImmutableObject.foo.push( 4 );
myImmutableObject.foo; // [1,2,3,4]

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

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

Константа объекта

Комбинируя writable:false и configurable:false вы по сути можете создать константу (не может быть изменена, переопределенна или удалена) в качестве свойства объекта, вроде:

var myObject = {};
Object.defineProperty( myObject, "FAVORITE_NUMBER", {
    value: 42,
    writable: false,
    configurable: false
} );

Запрет расширения

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

var myObject = {
    a: 2
};
Object.preventExtensions( myObject );
myObject.b = 3;
myObject.b; // undefined

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

Запечатывание

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

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

Заморозка

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

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

Вы можете «глубоко заморозить» объект, применив Object.freeze(..) к объекту и рекурсивно перебрать все объекты, на которые он ссылается (которые еще не были затронуты) применив к ним Object.freeze(..). Однако, будьте осторожны, поскольку это может затронуть другие (общие) объекты, которые вы не планировали менять.

[[Get]]

Есть одна маленькая, но важная деталь, связанная с тем, как происходит доступ к свойствам.

Рассмотрим:

var myObject = {
    a: 2
};
myObject.a; // 2

myObject.a -- это запрос свойства, но он не просто ищет в myObject свойство с именем a, как может показаться.

Согласно спецификации, код выше выполняет операцию [[Get]] (что-то вроде вызова функции [[Get]]()) с объектом myObject. Стандартная встроенная операция [[Get]] проверяет объект на наличие запрашиваемого свойства и, если находит его, то возвращает соответствующее значение.

Однако, в алгоритме [[Get]] описано важное поведение для случая, когда она не находит запрошенное свойство. В главе 5 мы узнаем что происходит дальше (обход по цепочке [[Prototype]], если что).

Но один из важных результатов операции [[Get]] заключается в том, что, если она по какой-либо причине не может найти значение запрошенного свойства, то вернёт значение undefined.

var myObject = {
    a: 2
};
myObject.b; // undefined

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

var myObject = {
    a: undefined
};
myObject.a; // undefined
myObject.b; // undefined

С точки зрения значения, нет разницы между этими двумя вызовами -- они оба выдадут undefined. Однако внутри операции [[Get]], хоть это и не заметно на первый взгляд, потенциально выполняется немного больше «работы» для вывода myObject.b, чем для вывода myObject.a.

Проверяя лишь результаты вывода значения, вы не можете отличить когда существует свойство, явно содержащее значение undefined, а когда свойство не существует и undefined -- это значение, по умолчанию возвращаемое, если [[Get]] не может вернуть нечто определённое.

[[Put]]

Поскольку существует встроенная операция [[Get]] для получения значения свойства, очевидно, должна существовать и стандартная операция [[Put]].

Заманчиво думать, что назначение свойства объекту просто вызовет [[Put]], чтобы задать или создать это свойство для запрашиваемого объекта. Но ситуация сложнее, чем кажется.

Поведение [[Put]] при вызове зависит от нескольких факторов, включая (наиболее значимый): существует ли такое свойство у объекта или нет.

Если свойство существует, то алгоритм [[Put]] проверит примерно следующее:

  1. Является ли свойство дескриптором доступа (смотрите раздел «Геттеры и Сеттеры» ниже)? Если да, то вызовет сеттер, если он есть.
  2. Является ли свойство дескриптором данных с ключом writable равным false? Если да, то тихо завершится в нестрогом режиме [non-strict mode], или выдаст ошибку TypeError в строгом режиме [strict mode].
  3. Иначе, установит значение существующего свойства как обычно.

Если свойство запрашиваемого объекта еще не задано, то операция [[Put]] еще более сложная и запутанная. Мы вернемся к этому сценарию в Главе 5, когда обсудим [[Prototype]], чтобы внести больше ясности.

Геттеры и Сеттеры

Стандартные операции объектов [[Put]] и [[Get]] полностью контролируют, как значения задаются для существующих или новых свойств и, соответственно, запрашиваются из существующих свойств.

Примечание: При использовании будущих/расширенных возможностей языка можно переопределить стандартные операции [[Get]] или [[Put]] для всего объекта (а не только для свойства). Данная тема выходит за рамки обсуждения этой книги, но будет охвачена позже в серии «Вы не знаете JS».

ES5 представил способ переопределения части этих стандартных операций не на уровне объекта, а на уровне свойств, через использование геттеров и сеттеров. Геттеры -- это свойства, которые, на самом деле, вызывают скрытую функцию для получения значения. Сеттеры -- это свойства, которые, на самом деле, вызывают скрытую функцию для задания значения.

Когда вы задаете свойству геттер или сеттер, оно определяется как «дескриптор доступа» (в противовес «дескриптору данных»). Для дескрипторов доступа, характеристики дескриптора value и writable игнорируются, а вместо этого JS рассматривает характеристики свойства set и get (а также configurable и enumerable).

Рассмотрим:

var myObject = {
    // определяем геттер для `a`
    get a() {
        return 2;
    }
};
Object.defineProperty(
    myObject,	// цель
    "b",	    // имя свойства
    {	// дескриптор
        // определяем геттер для `b`
        get: function(){ return this.a * 2 },
        // убедимся что `b` будет отображаться как свойство объекта
        enumerable: true
    }
);
myObject.a; // 2
myObject.b; // 4

Как в объектно-литеральном синтаксисе с использованием get a() { .. }, так и с помощью явного определения через defineProperty(..) мы создали свойство объекта, которое на самом деле не содержит значение, но доступ к которому приводит к вызову функции-геттера, чьё возвращаемое значение и будет результатом обращения к свойству.

var myObject = {
    // определяем геттер для `a`
    get a() {
        return 2;
    }
};
myObject.a = 3;
myObject.a; // 2

Поскольку мы определили геттер для a, то если мы попытаемся установить значение a, операция не выдаст ошибки, а молча отбросит присваивание. Даже если бы тут был валидный сеттер, в нашем геттере жестко прописано вернуть только 2, так что операция присваивания будет спорной.

Чтобы сделать этот сценарий более разумным, свойства должны быть заданы с помощью сеттеров, которые переопределяют стандартную операцию [[Put]] (известную как присваивание) для каждого свойства, как вы того и ожидали. Скорее всего, вы захотите всегда объявлять и геттер, и сеттер (наличие только одного из них часто приводит к непредсказуемому/удивительному поведению):

var myObject = {
    // определим геттер для `a`
    get a() {
        return this._a_;
    },
    // определим сеттер для `a`
    set a(val) {
        this._a_ = val * 2;
    }
};
myObject.a = 2;
myObject.a; // 4

Примечание: В этом примере мы на самом деле сохраняем указанное значение присваивания 2 (операция [[Put]]) в другой переменной _a_. Имя _a_ здесь чисто для примера и не означает никакого особенного поведения -- это обычное свойство, как и любое другое.

Существование

Ранее мы показали, что запрос свойства вроде myObject.a может вывести значение undefined, как в случае, когда там явно задано undefined, так и в случае, когда свойство a вообще не существует. Если в обоих случаях значение одинаково, как же нам их различить?

Мы можем спросить есть ли у объекта свойство, не запрашивая значение свойства:

var myObject = {
    a: 2
};
("a" in myObject);	            // true
("b" in myObject);	            // false
myObject.hasOwnProperty( "a" );	// true
myObject.hasOwnProperty( "b" );	// false

Оператор in проверит находится ли свойство в объекте или существует ли оно уровнем выше в цепочке [[Prototype]] объекта (смотрите Главу 5). hasOwnProperty(..) наоборот проверяет есть ли свойство только у объекта myObject или нет и не опрашивает цепочку [[Prototype]]. Мы еще вернёмся к важным различиям между этими двумя операциями в Главе 5, когда исследуем [[Prototype]] более подробно.

Метод hasOwnProperty(..) доступен для всех нормальных объектов через делегирование Object.prototype (см. Главу 5). Но можно создать объект, который не привязан к Object.prototype (с помощьюObject.create(null) -- см. Главу 5). В этом случае, вызвать метод myObject.hasOwnProperty(..) не получится.

При таком сценарии более надежным способом выполнить подобную проверку будет Object.prototype.hasOwnProperty.call(myObject,"a"), который заимствует базовый метод hasOwnProperty и использует явную привязку this (см. Главу 2), чтобы применить его к нашему myObject.

Примечание: Оператор in выглядит так, будто он проверяет существование значения внутри контейнера, но, на самом деле, он проверяет существование имени свойства. Это отличие важно учитывать применительно к массивам, поскольку велик соблазн сделать проверку вроде 4 in [2, 4, 6], но она не будет вести себя так, как вы ожидали.

Перечисление

Ранее мы кратко объяснили идею «перечисляемости», когда рассматривали enumerable -- характеристику дескриптора свойства. Давайте вернемся и рассмотрим её более подробно.

var myObject = { };
Object.defineProperty(
    myObject,
    "a",
    // сделаем `a` перечисляемой, как обычно
    { enumerable: true, value: 2 }
);
Object.defineProperty(
myObject,
    "b",
    // сделаем `b` НЕперечисляемой
    { enumerable: false, value: 3 }
);
myObject.b;                     // 3
("b" in myObject);              // true
myObject.hasOwnProperty( "b" ); // true
// .......
for (var k in myObject) {
    console.log( k, myObject[k] );
}
// "a" 2

Вы заметите, что myObject.b по факту существует и имеет доступное значение, но оно не отображается в цикле for..in (хотя, внезапно, оно обнаружилось проверкой на существование оператором in). Всё потому, что по сути «перечислимое» означает «будет учтено, если пройти перебором по свойствам объекта»).

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

Еще один способ определить перечисляемые и неперечисляемые свойства:

var myObject = { };
Object.defineProperty(
    myObject,
    "a",
    // сделаем `a` перечисляемым, как обычно
    { enumerable: true, value: 2 }
);
Object.defineProperty(
    myObject,
    "b",
    // сделаем `b` неперечисляемым
    { enumerable: false, value: 3 }
);
myObject.propertyIsEnumerable( "a" );   // true
myObject.propertyIsEnumerable( "b" );   // false
Object.keys( myObject );                // ["a"]
Object.getOwnPropertyNames( myObject ); // ["a", "b"]

propertyIsEnumerable(..) проверяет существует ли данное имя свойства непосредственно в объекте и установлено ли enumerable:true.

Object.keys(..) возвращает массив всех перечисляемых свойств, в то время как Object.getOwnPropertyNames(..) возвращает массив всех свойств -- перечисляемых или нет.

Отличия in от hasOwnProperty(..) в том, опрашивают ли они цепочку [[Prototype]] или нет. В то время как Object.keys(..) и Object.getOwnPropertyNames(..) проверяют только конкретный указанный объект.

Не существует (пока) встроенного способа получить список всех свойств, эквивалентного тому, как опрашивает оператор in (перебирая все свойства по всей цепочке [[Prototype]], как описано в Главе 5). Приблизительно, такой инструмент можно сделать, если рекурсивно перебирать цепочку [[Prototype]] объекта и на каждом уровне выбирать список из Object.keys(..) -- только перечисляемых свойств.

Итерация

Цикл for..in проходит по списку перечисляемых свойств объекта (включая его цепочку [[Prototype]]). Но что, если вместо этого вы хотите перебрать именно значения?

В массивах с числовыми индексами перебор значений обычно выполняется стандартным циклом for, вроде:

var myArray = [1, 2, 3];
for (var i = 0; i < myArray.length; i++) {
    console.log( myArray[i] );
}
// 1 2 3

Это не перебор значений, а перебор индексов, где вы используете индекс для получения значения, наподобие myArray[i].

ES5 добавил несколько вспомогательных итераторов для массивов, включая forEach(..), every(..), и some(..). Каждый из этих помощников принимает функцию обратного вызова для каждого элемента массива. Отличия только в том, как они реагируют на значение, возвращаемое этой функцией.

forEach(..) перебирает все значения массива и игнорирует любые значения, возвращаемые функцией обратного вызова. every(..) продолжает перебор до конца или пока функция не вернёт false (или «ложное» значение), в то время как some(..) продолжает до конца или пока функция не вернёт значение true (или «истинное» значение).

Эти специальные возвращаемые значения внутри every(..) и some(..) действуют наподобие инструкции break внутри обычного цикла, поскольку они прекращают перебор задолго до конца.

Если вы перебираете объект циклом for..in, вы также лишь косвенно запрашиваете значения, поскольку он, на самом деле, перебирает только перечисляемые свойства объекта, заставляя вас обращаться к свойствам вручную для получения значений.

Примечание: В противовес перебору индексов массива в числовой последовательности (циклы for или другие итераторы), порядок перебора свойств объекта не гарантирован и может различаться в разных движках JS. Не полагайтесь на любую наблюдаемую последовательность для всего, что требует постоянства окружения, поскольку любое наблюдаемое ненадежно.

Но что если вместо индексов массива (или свойств объекта) вы хотите перебрать значения напрямую? К счастью, ES6 добавляет синтаксис цикла for..of для перебора массивов (и объектов, если объект определяет свой собственный итератор).

var myArray = [ 1, 2, 3 ];
for (var v of myArray) {
    console.log( v );
}
// 1
// 2
// 3

Цикл for..of запрашивает объект-итератор (из стандартной встроенной функции, на языке спецификации известной как @@iterator) у перебираемой сущности, а затем перебирает возвращаемые значения, вызывая метод next() объекта-итератора для каждой итерации цикла.

Массивы имеют встроенный @@iterator, поэтому for..of легко работает с ними, как показано выше. Давайте переберем массив вручную, используя встроенный @@iterator, чтобы посмотреть как он работает:

var myArray = [ 1, 2, 3 ];
var it = myArray[Symbol.iterator]();
it.next(); // { value:1, done:false }
it.next(); // { value:2, done:false }
it.next(); // { value:3, done:false }
it.next(); // { done:true }

Примечание: В @@iterator мы получаем внутреннее свойство объекта, используя Symbol из ES6: Symbol.iterator. Мы уже упоминали семантику Symbol ранее в этой главе (см. «Вычисляемые имена свойств»), здесь применяются те же рассуждения. Как правило, вы захотите обращаться к таким особенным свойствам через имя Symbol, а не через специальное значение, которое оно может содержать. Не смотря на подтекст в названии, @@iterator является не объектом-итератором, а функцией, возвращающей объект-итератор -- маленькая, но важная деталь.

Как показывает фрагмент выше, значение, которое возвращает вызов next() итератора, -- это объект вида { value: .. , done: .. }, где value -- это значение текущей итерации, а done -- это boolean, показывающее, остались ли элементы для перебора.

Обратите внимание, что значение 3 вернулось вместе с done:false, что на первый взгляд может показаться странным. Вам нужно вызвать next() четвертый раз (что автоматически делает for..of из предыдущего фрагмента) чтобы получить done:true и понять, что вы действительно закончили перебор. Причина такого костыля находится за рамками текущего обсуждения, но она исходит из семантики генерирующих функций стандарта ES6.

В то время, как массивы автоматически перебираются циклами for..of, обычные объекты не имеют встроенного @@iterator. Причины такого намеренного упущения намного сложнее чем то, что мы рассмотрим. В общих чертах, правильным было решение не добавлять реализацию, которая будет проблемной для будущих типов объектов.

Для перебора любого объекта можно определить свой собственный стандартный @@iterator. Например:

var myObject = {
    a: 2,
    b: 3
};
Object.defineProperty( myObject, Symbol.iterator, {
    enumerable: false,
    writable: false,
    configurable: true,
    value: function() {
        var o = this;
        var idx = 0;
        var ks = Object.keys( o );
        return {
            next: function() {
                return {
                    value: o[ks[idx++]],
                    done: (idx > ks.length)
                };
            }
        };
    }
} );
// перебираем `myObject` вручную
var it = myObject[Symbol.iterator]();
it.next(); // { value:2, done:false }
it.next(); // { value:3, done:false }
it.next(); // { value:undefined, done:true }
// перебираем `myObject` с помощью `for..of`
for (var v of myObject) {
    console.log( v );
}
// 2
// 3

Примечание: Мы использовали Object.defineProperty(..) чтобы задать свой @@iterator (в основном для того, чтобы сделать его неперечисляемым), но, используя Symbol как рассчитанное имя свойства (описанное ранее в этой главе), мы могли бы объявить его напрямую, вроде var myObject = { a:2, b:3, [Symbol.iterator]: function(){ /* .. */ } }.

Каждый раз, когда цикл for..of вызовет next() из итератора объекта myObject, внутренний указатель переместится и вернёт следующее значение из списка свойств объекта (см. примечание о порядке перебора свойств/значений объекта).

Мы продемонстрировали простой перебор «значение за значением», но вы, конечно, можете задать перебор произвольной сложности для своих структур данных так, как вам будет удобней. Самодельные итераторы вкупе с циклом for..of из ES6 -- мощный инструмент для работы с объектами, определяемыми пользователем.

Например, для списка объектов Pixel (со значениями координат x и y) можно упорядочить перебор в зависимости от линейного расстояния до начала координат (0,0) или отфильтровать точки, которые расположены «слишком далеко». Пока ваш итератор возвращает предполагаемое { value: .. }, возвращает значения при вызове next() и { done: true }, когда перебор завершен, цикл for..of стандарта ES6 сможет выполнить перебор.

Фактически, вы можете сгенерировать «бесконечные» итераторы, которые никогда не «завершатся» и всегда будут возвращать новое значение (вроде случайного числа, инкрементированного значения, уникального идентификатора и т.д.), хотя, скорее всего, вы не захотите использовать такие итераторы в неограниченном цикле for..of, поскольку он никогда не закончится и повесит вашу программу.

var randoms = {
    [Symbol.iterator]: function() {
        return {
            next: function() {
                return { value: Math.random() };
            }
        };
    }
};
var randoms_pool = [];
for (var n of randoms) {
    randoms_pool.push( n );
    // не продолжаем бесконечно!
    if (randoms_pool.length === 100) break;
}

Этот итератор будет генерировать случайные числа «вечно», поэтому мы позаботились о том, чтобы получить только 100 значений, и наша программа не зависла.

Обзор (TL;DR)

Объекты в JS имеют литеральную форму (вроде var a = { .. }) и конструкторную форму (вроде var a = new Array(..)). Литеральная форма почти всегда предпочтительнее, но конструкторная форма в некоторых случаях предлагает больше опций при создании.

Многие ошибочно заявляют, что «в JS всё является объектом», но это некорректно. Объекты -- это один из 6 (или 7, в зависимости от ваших взглядов) примитивных типов. Существуют подтипы объектов, в том числе function, а также подтипы со специальным поведением, наподобие [object Array], представляющего внутреннее обозначение такого подтипа объекта, как массив.

Объекты -- это коллекции ключ-значение. Значения могут быть получены через свойства, посредством синтаксиса .propName или ["propName"]. Вне зависимости от синтаксиса, движок вызывает встроенную стандартную операцию [[Get]][[Put]] для установки значений), которая не только ищет свойство непосредственно в объекте, но и перемещается по цепочке [[Prototype]] (см. Главу 5), если свойство не найдено.

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

Свойства не обязательно содержат значения -- они могут быть также «свойствами доступа» с геттерами/сеттерами. Они могут быть перечисляемыми или нет, что влияет на их появление в итерациях цикла, например for..in.

Вы также можете перебирать значения структур данных (массивов, объектов и т.п.) используя синтаксис ES6 for..of, который ищет встроенный или самодельный объект @@iterator, содержащий метод next() для перебора значений по одному.