Pull to refresh

Продвинутое использование объектов в JavaScript

Reading time 15 min
Views 50K
Original author: Bjorn Tipling
Этот пост выходит за рамки повседневного использования объектов в JavaScript. Основы работы с объектами по большей части так же просты, как использование JSON-нотации. Тем не менее, JavaScript дает возможность использовать тонкий инструментарий, с помощью которого можно создавать объекты некоторыми интересными и полезными способами и который теперь доступен в последних версиях современных браузеров.

Последние два вопроса, которые будут затронуты — Proxy и Symbol относятся к спецификации ECMAScript 6, реализованы частично и внедрены только в некоторых из современных браузеров.

Геттеры и сеттеры


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

/**
 * @param {string} prefix
 * @constructor
 */
function Product(prefix) {
  /**
   * @private
   * @type {string}
   */
  this.prefix_ = prefix;
  /**
   * @private
   * @type {string}
   */
  this.type_ = "";
}

/**
 * @param {string} newType
 */
Product.prototype.setType = function (newType) {
  this.type_ = newType;
};

/**
 * @return {string}
 */
Product.prototype.type = function () {
  return this.prefix_ + ": " + this.type_;
}

var product = new Product("fruit");
product.setType("apple");
console.log(product.type());  //logs fruit: apple

jsfiddle

Используя геттер можно упростить этот код.

/**
 * @param {string} prefix
 * @constructor
 */
function Product(prefix) {
  /**
   * @private
   * @type {number}
   */
  this.prefix_ = prefix;
  /**
   * @private
   * @type {string}
   */
  this.type_ = "";
}

/**
 * @param {string} newType
 */
Product.prototype = {
    /**
     * @return {string}
     */
    get type () {
      return this.prefix_ + ": " + this.type_;
    },
    /**
     * @param {string}
     */
    set type (newType) {
      this.type_ = newType;
    }
};

var product = new Product("fruit");

product.type = "apple";
console.log(product.type); //logs "fruit: apple"

console.log(product.type = "orange");  //logs "orange"
console.log(product.type); //logs "fruit: orange"

jsfiddle

Код остается немного избыточным, а синтаксис — немного непривычным, однако преимущества применения get и set становятся более явными во время их прямого использования. Я для себя нашел, что:

product.type = "apple";
console.log(product.type);

гораздо более читаемо, чем:

product.setType("apple");
console.log(product.type());

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

console.log(product.type = "orange");  //logs "orange"
console.log(product.type); //logs "fruit: orange"

Обратите внимание, что сначала в консоль выводится “orange” и только потом “fruit: orange”. Геттер не выполняется в то время как возвращается устанавливаемое значение, так что при такой форме сокращенной записи можно наткнуться на неприятности. Возвращаемые при помощи set значения игнорируются. Добавление return this.type; к set не решает этой проблемы. Обычно это решается повторным использованием заданного значения, но могут возникнуть проблемы со свойством, имеющим геттер.

defineProperty


Синтаксис get propertyname () работает с литералами объектов и в предыдущем примере я назначил литерал объекта Product.prototype. В этом нет ничего плохого, но использование литералов вроде этого усложняет цепочку вызова прототипов для реализации наследования. Существует возможность определения геттеров и сеттеров в прототипе без использования литералов — при помощи defineProperty

/**
 * @param {string} prefix
 * @constructor
 */
function Product(prefix) {
  /**
   * @private
   * @type {number}
   */
  this.prefix_ = prefix;
  /**
   * @private
   * @type {string}
   */
  this.type_ = "";
}

/**
 * @param {string} newType
 */
Object.defineProperty(Product.prototype, "type", {
  /**
   * @return {string}
     */
  get: function () {
      return this.prefix_ + ": " + this.type_;
  },
  /**
   * @param {string}
  */
  set: function (newType) {
    this.type_ = newType;
  }
});

jsfiddle

Поведение этого кода такое же как и в предыдущем примере. Вместо добавления геттеров и сеттеров, предпочтение отдается defineProperty. Третьим аргументом в defineProperty передается дескриптор и в дополнение к set и get он дает возможность настроить доступность и установить значение. При помощи defineProperty можно создать нечто вроде константы — свойства, которое никогда не будет удалено или переопределено.

var obj = {
    foo: "bar",
};


//A normal object property
console.log(obj.foo); //logs "bar"

obj.foo = "foobar";
console.log(obj.foo); //logs "foobar"

delete obj.foo;
console.log(obj.foo); //logs undefined


Object.defineProperty(obj, "foo", {
    value: "bar",
});

console.log(obj.foo); //logs "bar", we were able to modify foo

obj.foo = "foobar";
console.log(obj.foo); //logs "bar", write failed silently

delete obj.foo;
console.log(obj.foo); //logs bar, delete failed silently

jsfiddle

Результат:

bar
foobar
undefined
bar 
bar
bar

Две последние попытки переопределить foo.bar в примере завершились неудачей (пусть и не были прерваны сообщением об ошибке), так как это поведение defineProperty по умолчанию — запрещать изменения. Чтобы изменить такое поведение, можно использовать ключи configurable и writable. Если вы используете строгий режим, ошибки будут брошены, так как являются обычными ошибками JavaScript.

var obj = {};

Object.defineProperty(obj, "foo", {
    value: "bar",
    configurable: true,
    writable: true,
});

console.log(obj.foo); //logs "bar"
obj.foo = "foobar";
console.log(obj.foo); //logs "foobar"
delete obj.foo;
console.log(obj.foo); //logs undefined

jsfiddle

Ключ configurable позволяет предотвратить удаление свойства из объекта. Кроме того, он дает возможность предотвратить последующее изменение свойства при помощи другого вызова defineProperty. Ключ writable дает возможность записать в свойство или изменять его значение.

Если configurable установлен в false (как и есть по умолчанию), попытки вызова defineProperty во второй раз приведут к тому, что будет брошена ошибка.

var obj = {};

Object.defineProperty(obj, "foo", {
    value: "bar",
});


Object.defineProperty(obj, "foo", {
    value: "foobar",
});

// Uncaught TypeError: Cannot redefine property: foo 

jsfiddle

Если configurable установлен в true, то можно изменять свойство в будущем. Это можно использовать для того, чтобы изменять значение незаписываемого свойства.

var obj = {};

Object.defineProperty(obj, "foo", {
    value: "bar",
    configurable: true,
});

obj.foo = "foobar";

console.log(obj.foo); // logs "bar", write failed

Object.defineProperty(obj, "foo", {
    value: "foobar",
    configurable: true,
});

console.log(obj.foo); // logs "foobar"

jsfiddle

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

var i, inventory;

inventory = {
    "apples": 10,
    "oranges": 13,
};

Object.defineProperty(inventory, "strawberries", {
    value: 3,
});

for (i in inventory) {
    console.log(i, inventory[i]);
}

jsfiddle

apples 10 
oranges 13

Чтобы позволить это, необходимо использовать свойство enumerable

var i, inventory;

inventory = {
    "apples": 10,
    "oranges": 13,
};

Object.defineProperty(inventory, "strawberries", {
    value: 3,
    enumerable: true,
});

for (i in inventory) {
    console.log(i, inventory[i]);
}

jsfiddle

apples 10
oranges 13
strawberries 3

Для проверки того, появится ли свойство в цикле for in можно использовать isPropertyEnumerable

var i, inventory;

inventory = {
    "apples": 10,
    "oranges": 13,
};

Object.defineProperty(inventory, "strawberries", {
    value: 3,
});

console.log(inventory.propertyIsEnumerable("apples")); //console logs true
console.log(inventory.propertyIsEnumerable("strawberries")); //console logs false

jsfiddle

Вызов propertyIsEnumerable также вернет false для свойств, определенных выше по цепочке прототипов, или для свойств, не определенных любым другим способом для этого объекта, что, впрочем, очевидно.
И ещё несколько слов напоследок об использовании defineProperty: будет ошибкой совмещать методы доступа set и get с writable: true или комбинировать их с value. Определение свойства при помощи числа приведет это число к строке, как было бы при любых других обстоятельствах. Вы также можете использовать defineProperty чтобы определить value как функцию.

defineProperties



Существует также и defineProperties. Этот метод позволяет определить несколько свойств за один раз. Мне попадался на глаза jsperf, сравнивающий использование defineProperties с defineProperty и, по крайней мере в Хроме, особой разницы в том, какой из методов использовать, не было.

var foo = {}

Object.defineProperties(foo, {
    bar: {
        value: "foo",
        writable: true,
    },
    foo: {
        value: function() {
           console.log(this.bar);
        }
    },
});

foo.bar = "foobar";
foo.foo();  //logs "foobar"

jsfiddle

Object.create



Object.create это альтернатива new, дающему возможность создать объект с определенным прототипом. Эта функция принимает два аргумента: первый это прототип, из которого вы хотите создать объект, а второй — тот же дескриптор, который используется при вызове Object.defineProperties

var prototypeDef = {
    protoBar: "protoBar",
    protoLog: function () {
        console.log(this.protoBar);
    }
};
var propertiesDef = {
    instanceBar: {
        value: "instanceBar"
    },
    instanceLog: {
        value: function () {
            console.log(this.instanceBar);
        }
    }
}

var foo = Object.create(prototypeDef, propertiesDef);
foo.protoLog(); //logs "protoBar"
foo.instanceLog(); //logs "instanceBar"

jsfiddle

Свойства. описанные при помощи дескриптора, перезаписывают соответствующие свойства прототипа:

var prototypeDef = {
    bar: "protoBar",
};
var propertiesDef = {
    bar: {
        value: "instanceBar",
    },
    log: {
        value: function () {
            console.log(this.bar);
        }
    }
}

var foo = Object.create(prototypeDef, propertiesDef);
foo.log(); //logs "instanceBar"

jsfiddle

Использование не примитивного типа, например Array или Object в качестве значений определяемых свойств может быть ошибкой, так как эти свойства расшарятся со всеми созданными экземплярами.

var prototypeDef = {
    protoArray: [],
};
var propertiesDef = {
    propertyArray: {
        value: [],
    }
}

var foo = Object.create(prototypeDef, propertiesDef);
var bar = Object.create(prototypeDef, propertiesDef);

foo.protoArray.push("foobar");
console.log(bar.protoArray); //logs ["foobar"] 
foo.propertyArray.push("foobar");
console.log(bar.propertyArray); //also logs ["foobar"] 

jsfiddle

Этого можно избежать, инициализировав propertyArray со значением null, после чего добавить необходимый массив, или сделать что-нибудь хипстерское, например использовать геттер:

var prototypeDef = {
    protoArray: [],
};
var propertiesDef = {
    propertyArrayValue_: {
        value: null,
        writable: true
    },
    propertyArray: {
        get: function () {
            if (!this.propertyArrayValue_) {
                this.propertyArrayValue_ = [];
            }
            return this.propertyArrayValue_;
        }
    }
}

var foo = Object.create(prototypeDef, propertiesDef);
var bar = Object.create(prototypeDef, propertiesDef);

foo.protoArray.push("foobar");
console.log(bar.protoArray); //logs ["foobar"] 
foo.propertyArray.push("foobar");
console.log(bar.propertyArray); //logs [] 

jsfiddle

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

Предыдущий пример демонстрирует необходимость помнить о том, что выражения, переданные любому значению в дескрипторе Object.create выполняются в момент определения дескриптора. Это — причина, по которой массивы становились общими для всех экземпляров класса. Я также рекомендую никогда не рассчитывать на фиксированный порядок, когда несколько свойств определяются вместе. Если это действительно необходимо — определить одно свойство раньше других — лучше использовать для него Object.defineProperty в этом случае.

Так как Object.create не вызывает функцию-конструктор, отпадает возможность использовать instanceof для проверки идентичности объектов. Вместо этого можно использовать isPrototypeOf, который сверяется со свойством prototype объекта. Это будет MyFunction.prototype в случае конструктора, или объект, переданный первым аргументом в Object.create

function Foo() {
}

var prototypeDef = {
    protoArray: [],
};
var propertiesDef = {
    propertyArrayValue_: {
        value: null,
        writable: true
    },
    propertyArray: {
        get: function () {
            if (!this.propertyArrayValue_) {
                this.propertyArrayValue_ = [];
            }
            return this.propertyArrayValue_;
        }
    }
}

var foo1 = new Foo();

//old way using instanceof works with constructors
console.log(foo1 instanceof Foo); //logs true

//You check against the prototype object, not the constructor function
console.log(Foo.prototype.isPrototypeOf(foo1)); //true

var foo2 = Object.create(prototypeDef, propertiesDef);

//can't use instanceof with Object.create, test against prototype object...
//...given as first agument to Object.create
console.log(prototypeDef.isPrototypeOf(foo2)); //true

jsfiddle

isPrototypeOf спускается по цепочке прототипов и возвращает true, если любой из них соответствует тому объекту, с которым происходит сравнение.

var foo1Proto = {
    foo: "foo",
};

var foo2Proto = Object.create(foo1Proto);
foo2Proto.bar = "bar";

var foo = Object.create(foo2Proto);

console.log(foo.foo, foo.bar); //logs "foo bar"
console.log(foo1Proto.isPrototypeOf(foo)); // logs true
console.log(foo2Proto.isPrototypeOf(foo)); // logs true

jsfiddle

«Пломбирование» объектов, «заморозка» и предотвращение возможности расширения



Добавление произвольных свойств случайным объектам и экземплярам класса только потому, что есть такая возможность, код, как минимум, лучше не делает. На node.js и в современных браузерах, в добавок к возможности ограничения изменений отдельных свойств при помощи defineProperty, существует возможность ограничить изменения и объекту в целом. Object.preventExtensions, Object.seal и Object.freeze — каждый из этих методов налагает более строгие ограничения на изменения в объекте. В строгом режиме нарушение ограничений, налагаемых этими методами, приведет к тому, что будет брошена ошибка, иначе же ошибки произойдут, но «тихо».

Метод Object.preventExtensions предотвращает добавление новых свойств в объект. Он не помешает ни изменить открытые для записи свойства, ни удалить те, которые являются настраиваемыми. Кроме того, Object.preventExtensions также не лишает возможности использовать вызов Object.defineProperty для того, чтобы изменять существующие свойства.

var obj = {
    foo: "foo",
};

obj.bar = "bar";
console.log(obj); // logs Object {foo: "foo", bar: "bar"} 

Object.preventExtensions(obj);

delete obj.bar;
console.log(obj); // logs Object {foo: "foo"} 

obj.bar = "bar";
console.log(obj); // still logs Object {foo: "foo"} 

obj.foo = "foobar"
console.log(obj); // logs {foo: "foobar"} can still change values

jsfiddle

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

Object.seal идет дальше. чем Object.preventExtensions. В дополнение к запрету на добавление новых свойств к объекту, этот метод также ограничивает возможности дальнейшей настройки и удаления существующих свойств. Как только объект был «опломбирован», вы больше не можете изменять существующие свойства при помощи defineProperty. Как было упомянуто выше, нарушение этих запретов в строгом режиме приведет к тому, что будет брошена ошибка.

"use strict"; 

var obj = {};

Object.defineProperty(obj, "foo", {
    value: "foo"
});

Object.seal(obj);

//Uncaught TypeError: Cannot redefine property: foo 
Object.defineProperty(obj, "foo", {
    value: "bar"
});

jsfiddle

Вы также не можете удалять свойства даже если они были изначально настраиваемыми. Остается возможность только изменять значения свойств.

"use strict"; 

var obj = {};

Object.defineProperty(obj, "foo", {
    value: "foo",
    writable: true,
    configurable: true,
});

Object.seal(obj);

console.log(obj.foo); //logs "foo"
obj.foo = "bar";
console.log(obj.foo); //logs "bar"
delete obj.foo; //TypeError, cannot delete

jsfiddle

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

"use strict"; 

var obj = {
    foo: "foo1"
};

Object.freeze(obj);

//All of the following will fail, and result in errors in strict mode
obj.foo = "foo2"; //cannot change values
obj.bar = "bar"; //cannot add a property
delete obj.bar; //cannot delete a property
//cannot call defineProperty on a frozen object
Object.defineProperty(obj, "foo", {
    value: "foo2"
});

jsfiddle

Методы позволяющие проверить является ли объект «замороженным», «опломбированным» или защищенным от расширения следующие:
Object.isFrozen, Object.isSealed и Object.isExtensible

valueOf и toString



Можно использовать valueOf и toString для настройки поведения объекта в контексте, когда JavaScript ожидает получить примитивное значение.

Вот пример использования toString:

function Foo (stuff) {
    this.stuff = stuff;
}

Foo.prototype.toString = function () {
    return this.stuff;
}


var f = new Foo("foo");
console.log(f + "bar"); //logs "foobar"

jsfiddle

И valueOf:

function Foo (stuff) {
    this.stuff = stuff;
}

Foo.prototype.valueOf = function () {
    return this.stuff.length;
}

var f = new Foo("foo");
console.log(1 + f); //logs 4 (length of "foo" + 1);

jsfiddle

Соединив использование этих двух методов можно получить неожиданный результат:

function Foo (stuff) {
    this.stuff = stuff;
}

Foo.prototype.valueOf = function () {
    return this.stuff.length;
}

Foo.prototype.toString = function () {
    return this.stuff;
}

var f = new Foo("foo");
console.log(f + "bar"); //logs "3bar" instead of "foobar"
console.log(1 + f); //logs 4 (length of "foo" + 1);

jsfiddle

Правильный способ использовать toString это сделать объект хэшируемым:

function Foo (stuff) {
    this.stuff = stuff;
}

Foo.prototype.toString = function () {
    return this.stuff;
}

var f = new Foo("foo");

var obj = {};
obj[f] = true;
console.log(obj); //logs {foo: true}

jsfiddle

getOwnPropertyNames и keys



Для того, чтобы получить все свойства объекта, можно использовать Object.getOwnPropertyNames. Если вы знакомы с python, то он, в общем, аналогичен методу keys словаря, хотя метод Object.keys также существует. Основная разница между Object.keys и Object.getOwnPropertyNames в том, что последний также возвращает «неперечисляемые» свойства, те, которые не будут учитываться при работе цикла for in.

var obj = {
    foo: "foo",
};

Object.defineProperty(obj, "bar", {
    value: "bar"
});

console.log(Object.getOwnPropertyNames(obj)); //logs ["foo", "bar"]
console.log(Object.keys(obj));  //logs ["foo"]

jsfiddle

Symbol



Symbol это специальный новый примитив, определенный в ECMAScrpt 6 harmony, и он будет доступен в следующей итерации JavaScript. Его уже сейчас можно попробовать в Chrome Canary и Firefox Nightly и следующие примеры на jsfiddle будут работать только в этих браузерах, по крайней мере на время написания этого поста, в августе 2014.

Symbol могут быть использованы как способ создать и ссылаться на свойства объекта
var obj = {};

var foo = Symbol("foo");

obj[foo] = "foobar";

console.log(obj[foo]); //logs "foobar"

jsfiddle

Symbol уникален и является неизменным

//console logs false, symbols are unique:
console.log(Symbol("foo") === Symbol("foo"));

jsfiddle

Symbol можно использовать вместе с Object.defineProperty:

var obj = {};

var foo = Symbol("foo");

Object.defineProperty(obj, foo, {
    value: "foobar",
});

console.log(obj[foo]); //logs "foobar"

jsfiddle

Свойства, определенные при помощи Symbol не будут итерироваться в цикле for in, однако вызов hasOwnProperty сработает нормально:

var obj = {};

var foo = Symbol("foo");

Object.defineProperty(obj, foo, {
    value: "foobar",
});

console.log(obj.hasOwnProperty(foo)); //logs true

jsfiddle

Symbol не попадет в массив, возвращаемый функцией Object.getOwnPropertyNames, но зато есть метод Object. getOwnPropertySymbols

var obj = {};

var foo = Symbol("foo");

Object.defineProperty(obj, foo, {
    value: "foobar",
});

//console logs []
console.log(Object.getOwnPropertyNames(obj));

//console logs [Symbol(foo)]
console.log(Object.getOwnPropertySymbols(obj));

jsfiddle

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

Proxy



Ещё одно нововведение в ECMAScript 6 это Proxy. Состоянием на август 2014 года прокси работают только в Firefox. Следующий пример с jsfiddle будет работать только в Firefox и, фактически, я тестировал его в Firefox beta, который был у меня установлен.

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

var obj = {
    foo: "foo",
};
var handler = {
    get: function (target, name) {
        if (target.hasOwnProperty(name)) {
            return target[name];
        }
        return "foobar";
    },
};
var p = new Proxy(obj, handler);
console.log(p.foo); //logs "foo"
console.log(p.bar);  //logs "foobar"
console.log(p.asdf); //logs "foobar"

jsfiddle

В этом примере мы проксируем объект obj. Мы создаем объект handler, который будет обрабатывать взаимодействие с создаваемым объектом. Метод обработчика get довольно прост. Он принимает объект и имя свойства, к которому осуществляется доступ. Эту информацию можно возвращать когда угодно, но в нашем примере возвращается фактическое значение, если ключ есть и «foobar», если его нет. Я вижу огромное поле возможностей и интересных способов использования прокси, один из которых немного похож на switch, такой, как в Scala.

Ещё одна область применения для прокси это тестирование. Кроме get есть ещё и другие обработчики: set, has, прочие. Когда прокси получат поддержку получше, я не задумываясь уделю им целый пост в своем блоге. Советую посмотреть документацию MDN по прокси и обратить внимание на приведенные примеры.
Кроме прочего есть ещё и отличный с доклад с jsconf о прокси, который я очень рекомендую: видео | слайды

Существует много способов использовать объекты в JavaScript более глубоко, чем просто хранилище случайных данных. Уже сейчас доступны мощные способы определения свойств, а в будущем нас ждет, как вы можете убедиться, подумав о том, как прокси может изменить способ написания кода на JavaScript, ещё много интересного. Если у вас есть какие-либо уточнения или замечания, дайте пожалуйста мне знать об этом, вот мой твиттер: @bjorntipling.
Tags:
Hubs:
+52
Comments 29
Comments Comments 29

Articles