О чем нам говорит удаление свойств объектов в JavaScript
Группе участников предлагается выполнить следующее задание:
Сделать object1
похожим на object2
.
let object1 = {
a: "hello",
b: "world",
c: "!!!",
};
let object2 = {
a: "hello",
b: "world",
};
Казалось бы, все просто, верно? Достаточно удалить свойство c
, чтобы оно соответствовало object2
. Удивительно, но каждый участник описал своё решение:
- Участник A: "Я установил в
c
значение undefined". - Участник B: "Я использовал оператор
delete
". - Участник C: "Я удалил свойство через Proxy-объект".
- Участник D: "Я избежал мутации, использовав деструктуризацию объекта".
- Участник E: "Я использовал
JSON.stringify
иJSON.parse
". - Участник F: "В моей компании мы используем
Lodash
".
Было дано очень много ответов, и все они кажутся правильными. Так какой же из них "правильный"? Давайте разберём каждый подход.
Участник 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
.
Мы могли бы посвятить целую статью разбору 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
, который принимает два параметра:
target
: Объект, на основе которого мы хотим создать прокси.handle
: Объект, содержащий промежуточную логику для выполнения операций.
Внутри 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
имеет два случая:
category
—undefined
( или falsy);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 и мутация объектов. Это довольно много для такого, казалось бы, скучного и тривиального занятия!