О чем нам говорит удаление свойств объектов в JavaScript

Источник: «What Removing Object Properties Tells Us About JavaScript»
Удаление свойств объекта в JavaScript — не самая интересная задача, но существует множество способов её решения, каждый из которых раскрывает фундаментальный аспект работы JavaScript. В этой статье Juan Diego Rodríguez рассматривает каждый из способов.

Группе участников предлагается выполнить следующее задание:

Сделать object1 похожим на object2.

let object1 = {
a: "hello",
b: "world",
c: "!!!",
};

let object2 = {
a: "hello",
b: "world",
};

Казалось бы, все просто, верно? Достаточно удалить свойство c, чтобы оно соответствовало object2. Удивительно, но каждый участник описал своё решение:

Было дано очень много ответов, и все они кажутся правильными. Так какой же из них "правильный"? Давайте разберём каждый подход.

Участник A: "Я установил в c значение undefined"

В JavaScript обращение к несуществующему свойству возвращает значение undefined.

const movie = {
name: "Up",
};

console.log(movie.premiere); // undefined

Легко подумать, что установка свойства в значение undefined удаляет его из объекта. Но если мы попытаемся это сделать, то заметим небольшую, но важную деталь:

const movie = {
name: "Up",
premiere: 2009,
};

movie.premiere = undefined;

console.log(movie);

Вот вывод, который мы получаем в ответ:

{name: 'up', premiere: undefined}

Как видно, premiere по-прежнему существует внутри объекта, даже если оно undefined. Такой подход не удаляет свойство, а изменяет его значение. Мы можем убедиться в этом, используя метод hasOwnProperty():

const propertyExists = movie.hasOwnProperty("premiere");

console.log(propertyExists); // true

Но почему тогда в нашем первом примере обращение к object.premiere возвращает значение undefined, если свойство не существует в объекте? Не должно ли оно выдавать ошибку, как при обращении к несуществующей переменной?

console.log(iDontExist);

// Uncaught ReferenceError: iDontExist is not defined

Ответ заключается в том, как ведёт себя ReferenceError и что такое ссылка вообще.

Ссылка — это разрешённая привязка имени, которая указывает, где хранится значение. Она состоит из трёх компонентов: базового значения, имени ссылки и флага строгой ссылки.

Для ссылки user.name базовым значением является объект user, а ссылающимся именем — строка name, причём флаг строгой ссылки равен false, если код не находится в strict mode.

Переменные ведут себя по-другому. У них нет родительского объекта, поэтому их базовым значением является запись окружения, т.е. уникальное базовое значение, присваиваемое каждый раз при выполнении кода.

Если мы попытаемся получить доступ к чему-либо, не имеющему базового значения, JavaScript выдаёт ошибку ReferenceError. Однако если базовое значение найдено, но имя ссылки не указывает на существующее значение, JavaScript просто присвоит ему значение undefined.

Тип Undefined имеет ровно одно значение, называемое undefined. Любая переменная, которой не было присвоено значение, имеет значение undefined.

ECMAScript Specification

Мы могли бы посвятить целую статью разбору undefined!

Участник B: "Я использовал оператор delete"

Единственное назначение оператора delete — удалить свойство из объекта, возвращая true в случае успешного удаления элемента.

const dog = {
breed: "bulldog",
fur: "white",
};

delete dog.fur;

console.log(dog); // {breed: 'bulldog'}

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

const movies = ["Interstellar", "Top Gun", "The Martian", "Speed"];

delete movies[2];

console.log(movies); // ['Interstellar', 'Top Gun', empty, 'Speed']

console.log(movies.length); // 4

Во-вторых, представим себе следующий вложенный объект:

const user = {
name: "John",
birthday: {day: 14, month: 2},
};

Если попытаться удалить свойство birthday с помощью оператора delete, то все будет работать нормально, но существует распространённое заблуждение, что при этом освобождается память, выделенная под объект.

В приведённом примере birthday — это свойство, содержащее вложенный объект. Объекты в JavaScript отличаются от примитивных значений (например, чисел, строк и логических значений) тем, как они хранятся в памяти. Они хранятся и копируются "по ссылке", в то время как примитивные значения копируются независимо, как целое значение.

Возьмём, к примеру, такое примитивное значение, как строка:

let movie = "Home Alone";
let bestSeller = movie;

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

movie = "Terminator";

console.log(movie); // "Terminator"

console.log(bestSeller); // "Home Alone"

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

let movie = {title: "Home Alone"};
let bestSeller = movie;

bestSeller.title = "Terminator";

console.log(movie); // {title: "Terminator"}

console.log(bestSeller); // {title: "Terminator"}

Как видите, теперь это объекты, и переназначение свойства bestSeller также изменяет результат movie. Под капотом JavaScript смотрит на реальный объект в памяти и выполняет изменение, а обе ссылки указывают на изменённый объект.

Зная, как ведут себя объекты "по ссылке", мы теперь можем понять, почему использование оператора delete не освобождает место в памяти.

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

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

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

Участник C: "Я удалил свойство через Proxy-объект"

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

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

const cat = {
breed: "siamese",
age: 3,
};

const handler = {
get(target, property) {
return `cat's ${property} is ${target[property]}`;
},
};

const catProxy = new Proxy(cat, handler);

console.log(catProxy.breed); // cat's breed is siamese

console.log(catProxy.age); // cat's age is 3

Здесь handler модифицирует операцию get, чтобы вернуть пользовательское значение.

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

const product = {
name: "vase",
price: 10,
};

const handler = {
deleteProperty(target, property) {
console.log(`Deleting property: ${property}`);
},
};

const productProxy = new Proxy(product, handler);

delete productProxy.name; // Deleting property: name

Имя свойства записывается в консоль, но при этом возникает ошибка:

Uncaught TypeError: 'deleteProperty' on proxy: trap returned falsish for property 'name'

Ошибка возникает из-за того, что handler не имеет возвращаемого значения. Это означает, что по умолчанию он принимает значение undefined. В строгом режиме, если оператор delete возвращает false, он выдаёт ошибку, а undefined, будучи ложным значением, вызывает такое поведение.

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

// ...

const handler = {
deleteProperty(target, property) {
console.log(`Deleting property: ${property}`);

return true;
},
};

const productProxy = new Proxy(product, handler);

delete productProxy.name; // Deleting property: name

console.log(productProxy); // {name: 'vase', price: 10}

Свойство не удалено!

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

Именно здесь вступает в игру Reflect.

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

Например, в нашем коде мы можем решить эту проблему, вернув внутри обработчика Reflect.deleteProperty() (т.е. Reflect-версию оператора delete).

const product = {
name: "vase",
price: 10,
};

const handler = {
deleteProperty(target, property) {
console.log(`Deleting property: ${property}`);

return Reflect.deleteProperty(target, property);
},
};

const productProxy = new Proxy(product, handler);

delete productProxy.name; // Deleting property: name

console.log(product); // {price: 10}

Следует отметить, что некоторые объекты, такие как Math, Date и JSON, имеют свойства, которые нельзя удалить с помощью оператора delete или любого другого метода. Это "неконфигурируемые" свойства объектов, то есть они не могут быть переназначены или удалены. Если попытаться использовать оператор delete для неконфигурируемого свойства, то он будет молча завершён и вернёт false или выдаст ошибку, если мы выполняем код в строгом режиме.

"use strict";

delete Math.PI;

Вывод:

Uncaught TypeError: Cannot delete property 'PI' of #<Object>

Если мы хотим избежать ошибок с оператором delete и неконфигурируемыми свойствами, то можно использовать метод Reflect.deleteProperty(), поскольку он не выдаёт ошибку при попытке удалить неконфигурируемое свойство даже в строгом режиме, так как происходит беззвучный сбой.

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

Участник D: "Я избежал мутации, использовав деструктуризацию объекта"

Деструктуризация объекта — это синтаксис присваивания, позволяющий извлекать свойства объекта в отдельные переменные. В левой части присваивания используется фигурная скобка ({}), указывающая, какое из свойств необходимо получить.

const movie = {
title: "Avatar",
genre: "science fiction",
};

const {title, genre} = movie;

console.log(title); // Avatar

console.log(genre); // science fiction

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

const animals = ["dog", "cat", "snake", "elephant"];

const [a, b] = animals;

console.log(a); // dog

console.log(b); // cat

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

Мы можем использовать деструктуризацию объекта для распаковки значений нашего объекта и синтаксис "spread" для сохранения только тех, которые нам нужны:

const car = {
type: "truck",
color: "black",
doors: 4
};

const {color, ...newCar} = car;

console.log(newCar); // {type: 'truck', doors: 4}

Таким образом, мы избегаем мутирования объектов и связанных с ним побочных эффектов!

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

Представьте, что у вас есть интернет-магазин с обширной базой данных товаров. У вас есть функция для их поиска. Разумеется, ей потребуются некоторые параметры, возможно, название и категория товара.

const find = (product, category) => {
const options = {
limit: 10,
product,
category,
};

console.log(options);

// Поиск в базе данных...
};

В данном примере для выполнения запроса пользователь должен указать name товара, а category — необязательно. Таким образом, мы можем вызвать функцию так::

find("bedsheets");

А поскольку category не указана, она возвращается как undefined, что приводит к следующему результату:

{limit: 10, product: 'beds', category: undefined}

В данном случае мы не должны использовать параметры по умолчанию, поскольку мы не ищем одну конкретную категорию.

Обратите внимание, что база данных может ошибочно предположить, что мы запрашиваем товары в категории под названием undefined! Это привело бы к пустому результату, что является непредвиденным побочным эффектом. Несмотря на то, что многие базы данных сами отфильтровывают undefined свойство, было бы лучше обеззараживать параметры перед выполнением запроса. Отличным способом динамического удаления undefined свойства является деструкция объекта с помощью оператора AND (&&).

Вместо того чтобы записать options следующим образом:

const options = {
limit: 10,
product,
category,
};

…вместо этого мы можем сделать вот что:

const options = {
limit: 10,
product,
...(category && {category}),
};

Может показаться, что это сложное выражение, но после понимания каждой части оно превращается в простую однострочную фразу. Мы используем преимущества оператора &&.

Оператор AND чаще всего используется в условных операторах,

If A and B are true, then do this.

Но по своей сути он оценивает два выражения слева направо, возвращая выражение слева, если оно ложно, и выражение справа, если они оба истинны. Таким образом, в нашем предыдущем примере оператор AND имеет два случая:

  1. categoryundefined ( или falsy);
  2. category определена.

В первом случае, когда оператор falsy, он возвращает выражение слева — category. Если мы вставим category внутрь остальной части объекта, то он оценивается так:

const options = {
limit: 10,

product,

...category,
};

И если мы попытаемся деструктурировать любое falsy-значение внутри объекта, то оно будет деструктурировано в ничто:

const options = {
limit: 10,
product,
};

А поскольку category определена, она деструктурируется в обычное свойство:

const options = {
limit: 10,
product,
category,
};

Сложив все это вместе, мы получим следующую функцию betterFind():

const betterFind = (product, category) => {
const options = {
limit: 10,
product,
...(category && {category}),
};

console.log(options);

// Поиск в базе данных...
};

betterFind("sofas");

А если мы не указываем какую-либо category, то она просто не появляется в конечном объекте options.

{limit: 10, product: 'sofas'}

Участник E: "Я использовал JSON.stringify и JSON.parse"

К моему удивлению, существует способ удалить свойство, переназначив его на undefined. Следующий код делает именно это:

let monitor = {
size: 24,
screen: "OLED",
};

monitor.screen = undefined;

monitor = JSON.parse(JSON.stringify(monitor));

console.log(monitor); // {size: 24}

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

Несмотря на то, что JSON напрямую заимствован из JavaScript, он отличается тем, что имеет строго типизированный синтаксис. В нем не допускаются функции и undefined значения, поэтому использование JSON.stringify() опустит все недействительные значения при преобразовании, в результате чего мы получим текст JSON без undefined свойств. После этого мы можем разобрать текст JSON и вернуть его в объект JavaScript с помощью метода JSON.parse().

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

Участник F: "В моей компании мы используем Lodash"

Стоит отметить, что такие библиотеки, как Lodash.js, Underscore.js или Ramda, также предоставляют методы для удаления — или pick() — свойств из объекта. Мы не будем рассматривать различные примеры для каждой библиотеки, поскольку их документация и так прекрасно справляется с этой задачей.

Заключение

Вернёмся к нашему первоначальному сценарию: кто из участников прав?

Ответ: Все! Ну, кроме первого участника. Установка свойства в undefined — это просто не тот подход, который мы хотим рассматривать для удаления свойства из объекта, учитывая все остальные способы, которыми мы можем воспользоваться.

Как и во многих других областях разработки, наиболее "правильный" подход зависит от конкретной ситуации. Но интересно то, что за каждым подходом скрывается урок о самой природе JavaScript. Понимание всех способов удаления свойства в JavaScript может научить нас фундаментальным аспектам программирования и JavaScript, таким как управление памятью, сборка мусора, прокси, JSON и мутация объектов. Это довольно много для такого, казалось бы, скучного и тривиального занятия!

Дополнительные материалы

Предыдущая Статья

Новое в Symfony 6.4: DatePoint

Следующая Статья

Новое в Symfony 6.4: Упрощённый выход из системы