Объекты в JavaScript являются основой. Так что более глубокое их изучение позволит писать вам более чистый и производительный код.
Примечание: эта статья подразумевает наличие у читателей базового знания объектов.
Приступим!
План статьи
- Объекты только для чтения.
- Глубокое и поверхностное копирование.
- Объекты как примитивы.
- Объекты против карт.
Объекты только для чтения
Представим, что хотим создать перечисление для представления сторон света:
const DIRECTIONS = {
NORTH: "north",
SOUTH: "south",
EAST: "east",
WEST: "west"
}
Однако свойства/ключи, существующие в DIRECTIONS
, могут изменяться. Кроме того, туда также могут добавляться новые. В больших базах кода это является опасным, поскольку возрастает шанс случайно изменить перечисления/объекты, предполагающие только чтение.
DIRECTIONS.NORTH = "south"; //ничто не мешает такому случиться
Вводим Object.freeze
:
Object.freeze(DIRECTIONS);
Object.freeze
устраняет возможность добавления новых свойств и редактирование/удаление имеющихся.
Object.freeze(DIRECTIONS);
/*Следующая инструкция либо молча провалится, либо выбросит ошибку TypeError*/
DIRECTIONS.NORTH = "south";
DIRECTIONS.UP = "up";
del DIRECTIONS.SOUTH;
Object.freeze
также ограничивает возможность изменять дескрипторы отдельных свойств объекта. Дескриптор свойства подобен его “настройкам” и состоит из четырех полей:
value
: фактическое значение свойства.enumerable
: определяет, появится ли свойство при переборе/перечислении их набора в объекте. Еслиenumerable
свойства будетtrue
, тогда оно отобразиться при переборе объекта цикломfor _ in
и будет включено вObject.keys()
.configurable
: определяет возможность удаления свойства из объекта или изменения его дескриптора.writable
:определяет возможность изменения значения свойства через присваивание.
const obj = {'a': 1, 'b':2};
console.log(Object.getOwnPropertyDescriptors(obj));
/*
Вывод дескрипторов свойств obj:
{
a: {value: 1, writable: true, enumerable: true, configurable: true},
b: {value: 2, writable: true, enumerable: true, configurable: true}
}
*/
Object.freeze
сохранит enumerable
как есть, но установит параметры свойств объекта configurable
и writable
на false
. В результате больше нельзя будет редактировать дескриптор свойства (мы не сможем изменять writable
, enumerable
или configurable
) и переопределять его значение.
Нюанс: Object.freeze
устанавливает эти ограничения только на свойства верхнего уровня.
const o = {a: 0, b: {c: 5}};
Object.freeze(o);
o.b.c = 10; // действительная инструкция
Перечисленные ограничения сработают для свойств a
и b
, но не для свойства c
. Мы сможем редактировать его значение, даже после заморозки (freeze
)объекта.
Для полной заморозки объекта можно рекурсивно вызвать Object.freeze
в его потомках.
Поверхностное и глубокое копирование
В отличие от примитивов, объекты JavaScript передаются по ссылке, которая является указателем на расположение объекта в памяти.
const myPet = {
name: "Doggie",
type: "Dog"
}
myPet
хранит ссылку на объект, с которым связана, а не сам объект.
const yourPet = myPet;
yourPet.name = "Cattie";
console.log(myPet);
/*
Here is the output of myPet:
{ name: 'Cattie', type: 'Dog' }
*/
Подобное присваивание ведет к копированию ссылок myPet
в yourPet
. В результате yourPet
и myPet
будут указывать на один объект.
По умолчанию, если отредактировать свойство в yourPet
, то это изменение отразиться и в myPet
, поскольку ссылаются эти переменные на один объект.
Когда у нас две переменных, ссылающихся на один объект, можно назвать их поверхностными копиями.
Как видно выше, один из способов их создания — это присваивание.
const obj = {"a": 0};
const anotherObj = obj; // поверхностная копия
Можно выполнить проверку на поверхностность с помощью Object.js
. Object.js
уточняет, имеют ли обе переменные ссылку на один объект.
const obj = {"a": 0};
const anotherObj = obj;
Object.is(obj, anotherObj); // возвращает true
Так! А почему следующий код возвращает false
?
const histo1 = {"a": 0};
const histo2 = {"a": 0};
Object.is(histo1, histo2); // возвращает false
Несмотря на то, что содержимое объектов histo1
и histo2
одинаково, это не значит, что они ссылаются на один объект в памяти. В результате histo1
и histo2
не являются поверхностными копиями, так как их ссылки указывают на разные объекты, которые просто имеют одинаковое содержимое.
В этом случае histo1
и histo2
выступают глубокими копиями.
Для создания таких копий есть два варианта.
Глубокое копирование через JSON
const me = {"name": "Ramki"};
const you = JSON.parse(JSON.stringify(me));
Идея в том, чтобы преобразовать объект в строку с помощью JSON.stringify
, а затем распарсить эту строку посредством JSON.parse
, получив таким образом закодированный объект. Главное ограничение здесь в том, что объекты со свойствами, являющимися функциями, не будут скопированы должным образом, поскольку JSON.stringify
не может кодировать функции (JSON.Stringify
).
Глубокое копирование через Lodash
// in ECMAScript: import * as cloneDeep from "lodash.clonedeep"
const cloneDeep = require("lodash.clonedeep");
const obj1 = {a: 1, b: {c: 4}};
// obj2 является глубокой копией obj1
const obj2 = cloneDeep(obj1);
console.log(obj2)
//obj2: { a: 1, b: { c: 4 } }
Object.is(obj1, obj2) // returns false
Можно импортировать из Lodash cloneDeep
. Это метод, который рекурсивно клонирует свойства передаваемого ему объекта. Возвращаемый объект при этом будет глубокой копией. Основной недостаток в необходимости установки внешней библиотеки, что увеличивает общий размер приложения.
В ситуациях, когда значения объекта совместимы с JSON, будет проще использовать подход с JSON.stringify
. В противном случае подойдет уже lodash.cloneDeep
.
Объекты как примитивы
Несмотря на то, что привязанные к объекту переменные хранят его ссылку, можно получить примитивные значения объектов переопределением Object.prototype.valueOf
.
Object.prototype.valueOf
это функция, возвращающая примитивное значение объекта. По умолчанию она возвращает сам объект, но можно ее переопределить на возвращение чего-нибудь другого.
const result = 1 + new Number(14);
console.log(result);
// result: 15
Number
является численным объектом-оберткой. Интересно, что когда мы прибавляем к объекту примитив 1
(new Number (14)
), то по-прежнему получаем верный результат 15
.
При прибавлении 1
к new Number(14)
JavaScript автоматически преобразует new Number(14)
в его примитивное значение, 14
. Это значение получается из Number.prototype.valueOf()
, которая переопределяется для предоставления численного значения, которое хранит объект Number
.
Еще один пример. Предположим, что у нас есть объект StringBuilder
для конкатенации строк, и мы хотим использовать его так:
function StringBuilder(){
this.strings = [];
this.add = (s) => {
this.strings.push(s)
}
this.concat = () => {
return this.strings.join("");
}
}
const builder = new StringBuilder();
builder.add("Hi");
builder.add(" Bye");
console.log(builder.concat()) // "Hi Bye"
const builder = new StringBuilder();
builder.add("B");
builder.add("C");
const result = "A" + builder;
//result: нам нужна "ABC", но получается "A[object Object]"
Чтобы result
получился равен "ABC"
, можно переопределить StringBuilder.prototype.valueOf
, вызвав конкатенацию builder.strings
.
StringBuilder.prototype.valueOf = function() {
return this.concat();
}
При каждом преобразовании объекта StringBuilder
в примитив, этот примитив будет конкатенацией всех добавленных в объект строк.
Можно переопределить Object.prototype.valueOf
, чтобы предоставлять заданные примитивные значения для объектов при их преобразовании в примитивы.
Объекты и карты
Хоть в JavaScript и есть класс Map
, многие зачастую используют для отображения ключей и значений объекты.
Вариация известной задачи two-Sum с использованием объектов в качестве карт:
// подсчитывает все пары в arr, которые суммируются в target
function twoSumCount(arr, target){
let count = 0;
let map = {};
for(const num of arr){
if((target - num) in map){
count += map[target - num];
}
if(!(num in map)){
map[num] = 0;
}
map[num]++;
}
return count;
}
twoSumCount([2,2,2], 4); //3
twoSumCount
с использованием Map
:
// подсчитывает все пары в arr, которые суммируются в target
function twoSumCountMap(arr, target){
let count = 0;
let map = new Map();
for(const num of arr){
if(map.has(target - num)){
count += map.get(target - num);
}
if(!map.has(num)){
map.set(num, 0)
}
map.set(num, map.get(num) + 1);
}
return count;
}
twoSumCountMap([2,2,2], 4); //3
Их применение выглядит схоже, но есть пара отличий.
Ключи
Ключ объекта может быть либо строкой, либо Symbol
.
Обождите-ка. Разве только что в twoSumCount
мы не использовали в качестве ключей числа?
const obj = {};
obj[1] = "Something";
console.log(Object.keys(obj));
//output: ["1"]
Применяемые нами целочисленные ключи будут автоматически преобразовываться в строки. Это может стать проблемой, если нам нужно использовать в качестве ключей другие объекты.
obj = {};
obj[{}] = {};
console.log(Object.keys(obj));
//output: ['[object Object]']
А вот при использовании Map
типы ключей не будут ограничены до строк или Symbol
. В этом случае в их качестве можно будет задействовать объекты, функции, числа и т.д.
const map = new Map();
const obj = {'a': 1};
const func = () => {}
map.set(obj, 1);
map.set(func, 2);
map.set(3, 3);
console.log(map.keys());
//output: [Map Iterator] { { a: 1 }, [Function: func], 3 }
Помимо этого, в объектах при инициализации могут содержаться предустановленные ключи.
const map = {};
console.log(map['toString']); //output: [Function: toString]
Несмотря на то, что obj
пуст, он все равно наследует от Object.prototype
. В результате ключи вроде toString
и valueOf
существуют при инициализации. Чтобы удалить эти предустановленные ключи лучше всего использовать объекты, которые ни от чего не наследуют.
const map = Object.create(null);
console.log(map['toString']);//output: undefined
Применение Object.create(null)
гарантирует, что в объекте при инициализации не будет ключей от прототипного наследования, что снизит вероятность коллизий.
Производительность
Согласно документации Mozilla, Map
работает быстрее объектов в сценариях, где происходят частые добавления и удаления. И все же я решил протестировать производительность этих структур сам.
Тест разделен на четыре части: добавление, получение, перечисление и удаление миллиона ключей. REPL-ссылка на код приведена ниже:
KlutzyAlarmingLanservers
A Node.js repl by ramapitchalareplit.com
На удивление при выполнении в REPL тест выиграли объекты, а не карты.
Node Version: 12.22.1Map: Adding Keys: 676.662ms
Map: Getting Keys: 437.161ms
Map: Enumeration: 4580.738ms
Map: Deleting Keys: 699.071ms
=============================
Object: Adding Keys: 135.423ms
Object: Getting Keys: 92.645ms
Object: Enumeration: 4123.763ms
Object: Deleting Keys: 266.606ms
Вот результаты в Node v.14.16.0 на компьютере Razor Stealth:
Node Version: 14.16.0Map: Adding Keys: 163.153ms
Map: Getting Keys: 130.77ms
Map: Enumeration: 53.908ms
Map: Deleting Keys: 212.994ms
=============================
Object: Adding Keys: 28.134ms
Object: Getting Keys: 9.936ms
Object: Enumeration: 157.712ms
Object: Deleting Keys: 61.353ms
Согласно моему бенчмарку, Object
победил Map
во всех задачах, кроме перечисления. Исходя из этого, лучше всего будет использовать Map
при сохранении нестроковых ключей, а объекты во всех других случаях.
Заключение
Надеюсь, что в статье вы нашли для себя что-то новое об объектах.
Благодарю за чтение!
Читайте также:
- notebookJS: JavaScript и D3 в Jupyter Notebook
- Наскучил JavaScript? Достойная альтернатива - Mint
- Пишем фронтенд-компоненты на ванильном JS
Читайте нас в Telegram, VK и Яндекс.Дзен
Перевод статьи Ramki Pitchala: A Deep Dive Into JavaScript Objects