Интересные подробности об объектах JavaScript

Объекты в 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 при сохранении нестроковых ключей, а объекты во всех других случаях. 

Заключение

Надеюсь, что в статье вы нашли для себя что-то новое об объектах.

Благодарю за чтение!

Читайте также:

Читайте нас в Telegram, VK и Яндекс.Дзен


Перевод статьи Ramki Pitchala: A Deep Dive Into JavaScript Objects

Предыдущая статьяДекораторы в Python за три минуты
Следующая статьяЧто должен знать хороший фронтенд-разработчик