Объекты в JavaScript и мутация
В JavaScript-объекты можно добавлять свойства. Когда это делают после создания экземпляра объекта, объект необратимо изменяется. Он мутирует, как один из персонажей «Людей Икс».
В следующем примере константа
egg
, объект, мутирует после того, как к ней добавляют свойство isBroken
. Такие объекты (вроде egg
) мы называем мутабельными (то есть, имеющими возможность мутировать, изменяться).const egg = { name: "Humpty Dumpty" };
egg.isBroken = false;
console.log(egg);
// {
// name: "Humpty Dumpty",
// isBroken: false
// }
Мутации — вполне обычное явление в JavaScript. Столкнуться с ними можно буквально всегда и везде.
Об опасности мутаций
Предположим, создана константа с именем
newEgg
, в которую записан объект egg
. Затем понадобилось изменить свойство name
у newEgg
:const egg = { name: "Humpty Dumpty" };
const newEgg = egg;
newEgg.name = "Errr ... Not Humpty Dumpty";
Когда мы меняем
newEgg
(подвергаем объект мутации), автоматически меняется и egg
. Вы знали об этом?console.log(egg);
// {
// name: "Errr ... Not Humpty Dumpty"
// }
Вышеприведённый пример иллюстрирует опасность мутаций. Она сводится к тому, что когда вы меняете что-то в коде, то нечто, находящееся где-то в другом месте, тоже может поменяться, причём так, что вы об этом и знать не будете. В результате — ошибки, которые сложно находить и исправлять.
Все эти странности являются следствием того, что объекты в JavaScript передаются по ссылке.
Объекты в JavaScript и ссылки на них
Для того чтобы осознать смысл утверждения «объекты передаются по ссылке», сначала нужно понять то, что у каждого объекта в JavaScript есть уникальный идентификатор. Когда вы назначаете объект переменной, вы связываете переменную с идентификатором этого объекта (то есть, переменная теперь ссылается на объект) вместо того, чтобы записывать в переменную значение объекта, копировать его. Именно поэтому, сравнивая два разных объекта, даже содержащих одни и те же значения (или не содержащих их вовсе), мы получаем
false
.console.log({} === {}); // false
Когда, в примере выше, константа
egg
была присвоена константе newEgg
, в newEgg
была записана ссылка на тот же объект, на который ссылалась константа egg
. Так как egg
и newEgg
ссылаются на один и тот же объект, то, когда меняется newEgg
, egg
меняется автоматически.console.log(egg === newEgg); // true
К сожалению, в ситуациях, схожих с описанной, обычно не нужно, чтобы то, что записано в одну переменную, менялось при воздействии на другую, так как это приводит к неправильному поведению кода, которое проявляется тогда, когда этого ждут меньше всего. Итак, как же предотвратить мутации объектов? Прежде чем найти ответ на этот вопрос, хорошо бы сначала узнать, что в JS является иммутабельным, то есть — неизменным.
Иммутабельные примитивы
В JavaScript примитивы (речь идёт о типах данных
String
, Number
, Boolean
, Null
, Undefined
, и Symbol
) иммутабельны. То есть, нельзя изменить структуру примитива, нельзя добавить к нему свойства или методы. Например, при попытке добавить к примитиву новое свойство не произойдёт абсолютно ничего.const egg = "Humpty Dumpty";
egg.isBroken = false;
console.log(egg); // Humpty Dumpty
console.log(egg.isBroken); // undefined
Ключевое слово const и иммутабельность
Многие думают, что переменные (константы), объявленные с использованием ключевого слова
const
, иммутабельны. Однако, это не так.Использование ключевого слова
const
не делает то, что записано в константу, иммутабельным. Оно лишь не даёт назначить константе новое значение.const myName = "Zell";
myName = "Triceratops";
// ERROR
Когда, с использованием ключевого слова
const
, определяют объект, его внутреннюю структуру вполне можно менять. В примере с объектом egg
, даже хотя egg
— константа, созданная с использованием ключевого слова const
, от мутации это объект не защищает.const egg = { name: "Humpty Dumpty" };
egg.isBroken = false;
console.log(egg);
// {
// name: "Humpty Dumpty",
// isBroken: false
// }
Предотвращение мутаций объектов
Для того, чтобы предотвращать мутации объектов, можно, при работе с ними, использовать метод
Object.assign
, реализующий операцию создания новых объектов путём комбинирования существующих объектов с присвоением результирующему объекту их свойств.?Метод Object.assign
Конструкция
Object.assign
позволяет комбинировать два объекта (или большее число объектов), получая на выходе один новый объект. Пользоваться ей можно так:const newObject = Object.assign(object1, object2, object3, object4);
Константа
newObject
будет содержать свойства из всех объектов, переданных Object.assign
.const papayaBlender = { canBlendPapaya: true };
const mangoBlender = { canBlendMango: true };
const fruitBlender = Object.assign(papayaBlender, mangoBlender);
console.log(fruitBlender);
// {
// canBlendPapaya: true,
// canBlendMango: true
// }
Если обнаружены два конфликтующих свойства, свойство объекта, который расположен правее в списке аргументов
Object.assign
, перезаписывает свойство объекта, расположенного в списке левее.const smallCupWithEar = {
volume: 300,
hasEar: true
};
const largeCup = { volume: 500 };
// В этом случае свойство volume будет перезаписано, вместо 300 тут будет 500
const myIdealCup = Object.assign(smallCupWithEar, largeCup);
console.log(myIdealCup);
// {
// volume: 500,
// hasEar: true
// }
Однако, будьте внимательны! Когда вы комбинируете два объекта с помощью
Object.assign
, первый объект в списке аргументов подвержен мутациям. Другие — нет.console.log(smallCupWithEar);
// {
// volume: 500,
// hasEar: true
// }
console.log(largeCup);
// {
// volume: 500
// }
?Решение проблемы мутации при использовании Object.assign
В качестве первого объекта
Object.assign
можно передать новый объект для того, чтобы предотвратить мутацию существующих объектов. Однако, первый объект (пустой) всё ещё подвергается изменениям, но тут нет ничего страшного, так как мутация больше ничего важного не затрагивает.const smallCupWithEar = {
volume: 300,
hasEar: true
};
const largeCup = {
volume: 500
};
// Использование нового объекта в качестве первого аргумента
const myIdealCup = Object.assign({}, smallCupWithEar, largeCup);
Новый объект после выполнения этой операции можно менять как угодно. Это не затронет предыдущие объекты.
myIdealCup.picture = "Mickey Mouse";
console.log(myIdealCup);
// {
// volume: 500,
// hasEar: true,
// picture: "Mickey Mouse"
// }
// smallCupWithEar не мутирует
console.log(smallCupWithEar); // { volume: 300, hasEar: true }
// largeCup не мутирует
console.log(largeCup); // { volume: 500 }
?Object.assign и ссылки на объекты-свойства
Ещё одна проблема с
Object.assign
заключается в том, что он выполняет поверхностное слияние объектов (shallow merge) — он копирует свойства напрямую из одного объекта в другой. При этом он копирует и ссылки на объекты, являющиеся свойствами обрабатываемых объектов.Рассмотрим это на примере.
Предположим, вы купили новую звуковую систему. Вы можете управлять её питанием, устанавливать громкость, уровень баса и другие параметры. Вот как выглядит стандартная конфигурация системы.
const defaultSettings = {
power: true,
soundSettings: {
volume: 50,
bass: 20,
// другие параметры
}
};
Некоторые из ваших друзей любят громкую музыку, поэтому вы решили сделать предустановку, которая гарантированно поставит на уши весь дом.
const loudPreset = {
soundSettings: {
volume: 100
}
};
Затем вы приглашаете друзей на вечеринку. Для того чтобы привести систему в рабочее состояние и при этом воспользоваться и стандартными настройками, и теми, где громкость выкручена на максимум, вы пытаетесь скомбинировать
defaultSettings
и loudPreset
.const partyPreset = Object.assign({}, defaultSettings, loudPreset);
Однако, включив музыку, вы понимаете, что система с
partyPreset
звучит странно. Громкость хороша, но совсем нет басов. Когда вы исследуете partyPreset
, вы с удивлением обнаруживаете, что настроек баса тут нет!console.log(partyPreset);
// {
// power: true,
// soundSettings: {
// volume: 100
// }
// }
Это происходит из-за того, что JavaScript копирует объект-свойство
soundSettings
по ссылке. Так как и у defaultSettings
, и у loudPreset
есть объект soundSettings
, тот объект, который стоит правее в аргументах Object.assign
, оказывается скопированным в новый объект.Если вы измените
partyPreset
, loudPreset
мутирует соответствующим образом — как свидетельство того, что в него была скопирована ссылка на soundSettings
из loudPreset
.partyPreset.soundSettings.bass = 50;
console.log(loudPreset);
// {
// soundSettings: {
// volume: 100,
// bass: 50
// }
// }
Так как
Object.assign
выполняет поверхностное слияние объектов, в подобных ситуациях, когда новый объект является комбинацией объектов, содержащих объекты-свойства, нужно использовать что-то другое. Что? Например — библиотеку assignment
.?Библиотека assignment
Assignment — это маленькая библиотека, которую создал Николя Бевакуа из Pony Foo (ценного источника информации по JS). Она помогает выполнять глубокое слияние объектов (deep merge) и при этом не беспокоиться о мутациях. Использование
assignment
выглядит так же, как и работа с Object.assign
, за исключением того, что тут используется другое имя метода.// Выполнение глубокого слияния объектов с помощью assignment
const partyPreset = assignment({}, defaultSettings, loudPreset);
console.log(partyPreset);
// {
// power: true,
// soundSettings: {
// volume: 100,
// bass: 20
// }
// }
Библиотека выполняет копирование значений всех объектов, вложенных в другие объекты, в новый объект, что предохраняет существующие объекты от мутации.
Если вы попытаетесь теперь изменить любое свойство в
partyPreset.soundSettings
, вы обнаружите, что loudPreset
не меняется.partyPreset.soundSettings.bass = 50;
// loudPreset не мутирует
console.log(loudPreset);
// {
// soundSettings {
// volume: 100
// }
// }
Библиотека
assignment
— это лишь один из многих инструментов, позволяющих выполнять глубокое слияние объектов. Другие библиотеки, включая lodash.assign и merge-options, тоже могут вам в этом помочь. Можете спокойно выбрать ту, что вам больше понравится.Всегда ли необходимо использовать глубокое слияние вместо Object.assign?
Так как теперь вы знаете, как защитить объекты от мутаций, вы можете осмысленно использовать
Object.assign
. Нет ничего плохого в этом стандартном методе, если знать, как пользоваться им правильно.Однако, если вам нужно работать с объектами, которые имеют вложенные свойства, всегда старайтесь использовать глубокое слияние объектов вместо
Object.assign
.Обеспечение иммутабельности объектов
Хотя те методы, о которых мы говорили выше, могут помочь защитить объекты от мутаций, они не гарантируют иммутабельность созданных с их помощью объектов. Если вы сделаете ошибку и используете
Object.assign
при работе с объектом, имеющим вложенные свойства-объекты, позже у вас могут быть серьёзные неприятности.Для того чтобы от этого защититься, стоит обеспечить гарантию того, что объект не будет мутировать вообще. Для этого можно использовать библиотеку наподобие ImmutableJS. Эта библиотека выдаёт ошибку при попытке изменения обработанного с её помощью объекта.
Кроме того, можно использовать метод
Object.freeze
и библиотеку deep-freeze
. Эти два средства не выдают ошибок, но и не позволяют объектам мутировать.Метод Object.freeze и библиотека deep-freeze
Метод
Object.freeze
защищает собственные свойства объекта от изменений. const egg = {
name: "Humpty Dumpty",
isBroken: false
};
// "Заморозим" объект egg
Object.freeze(egg);
// Попытка изменения свойства потерпит неудачу без сообщений об ошибках
egg.isBroken = true;
console.log(egg); // { name: "Humpty Dumpty", isBroken: false }
Однако этот метод не поможет, если попытаться изменить объект, являющийся свойством «замороженного» объекта, вроде
defaultSettings.soundSettings.base
.const defaultSettings = {
power: true,
soundSettings: {
volume: 50,
bass: 20
}
};
Object.freeze(defaultSettings);
defaultSettings.soundSettings.bass = 100;
// Несмотря на это soundSettings мутирует
console.log(defaultSettings);
// {
// power: true,
// soundSettings: {
// volume: 50,
// bass: 100
// }
// }
Для предотвращения мутации объектов-свойств, можно использовать библиотеку deep-freeze, которая рекурсивно вызывает
Object.freeze
для всех свойств «замораживаемого» объекта, являющихся объектами.const defaultSettings = {
power: true,
soundSettings: {
volume: 50,
bass: 20
}
};
// Выполнение "глубокой заморозки" (после подключения библиотеки deep-freeze)
deepFreeze(defaultSettings);
// Попытка изменения вложенных свойств не удастся, сообщений об ошибках не возникнет
defaultSettings.soundSettings.bass = 100;
// soundSettings больше не мутирует
console.log(defaultSettings);
// {
// power: true,
// soundSettings: {
// volume: 50,
// bass: 20
// }
// }
О перезаписи значений и мутации
Не стоит путать запись в переменные и в свойства объектов новых значений с мутацией.
Когда в переменную записывают новое значение, фактически, изменяют то, на что она указывает. В следующем примере значение переменной
a
меняется с 11
на 100
.let a = 11;
a = 100;
При мутации же меняется сам объект. Ссылка на объект, записанная в переменную или константу, остаётся той же самой.
const egg = { name: "Humpty Dumpty" };
egg.isBroken = false;
Итоги
Мутации опасны потому, что они могут нарушить работу кода, причём, сделать это совершенно незаметно и непредсказуемо. Если даже вы подозреваете, что причина проблемы в мутации, поиск проблемного места — та ещё задачка. Поэтому лучший способ защитить код от неприятных неожиданностей — это обеспечить, с момента создания объектов, их защиту от мутаций.
Для того, чтобы защитить объекты от мутаций, можно использовать библиотеки вроде ImmutableJS и Mori.js, или применять стандартные методы JS
Object.assign
и Object.freeze
.Обратите внимание на то, что методы
Object.assign
и Object.freeze
могут защитить от изменений только собственные свойства объектов. Если нужно защитить от мутаций и свойства, которые сами являются объектами, понадобятся библиотеки вроде assignment или deep-freeze.Уважаемые читатели! Сталкивались ли вы с неожиданными ошибками в JS-приложениях, вызванными мутациями объектов?
Комментарии (35)
Mycolaos
19.01.2018 12:43Когда мы меняем newEgg (подвергаем объект мутации), автоматически меняется и egg. Вы знали об этом?
Я думал, каждый кто изучает джаваскрипт это узнает в первую очередь. Не?
dmitry_pacification
19.01.2018 13:37Что если использовать… (spread) оператор? В Redux рекомендуют изменять данные в редьюсерах с помощью Object.assign или этого оператора. Он иммутабельный или нет?
khmm12
19.01.2018 13:47Spread оператор это синтаксический сахар над Object.assign
const newEntity = { ...original, ...mutation }
Эквивалентно
const newEntity = Object.assign({}, original, mutation)
shadek
21.01.2018 17:21MDN c вами не согласен, все таки спектр применения Spread несколько шире.
developer.mozilla.org/ru/docs/Web/JavaScript/Reference/Operators/Spread_operator
Farxial2
19.01.2018 14:04Или можно просто помнить о том, что объекты в JavaScript передаются по ссылке.
Вообще, на мой взгляд, это лучший подход в данной области. Представьте, что будет, если веб-разработчики, в целях лучшей стабильности, будут копировать все объекты. Никто не говорит о том, что все будут следить за тем, что копировать, а что нет, т.к. проще получить «по шапке» за некорректную работу программы, чем за её прожорливость. А ведь это всего лишь сценарий на веб-странице, которых у пользователя может быть открыт не один десяток. W3C мог бы создать нативные методы глубокого копирования, если бы счёл нужным.
[UPD] Упс, я забыл о рекурсиях и объектах типа элементов DOM. > +2 сложностиmayorovp
19.01.2018 15:22Фразу "объекты в JavaScript передаются по ссылке" способен произнести только тот программист, который никогда не работал с другими языками, где и правда существует передача по ссылке...
Zenitchik
19.01.2018 15:52Вы про передачу аргументов по ссылке?
Да, согласен, в функцию даже ссылка на объект передаётся по значению (значением переменной объектного типа является ссылка).
А как правильно сформулировать процитированную мысль?mayorovp
19.01.2018 16:07«Объекты относятся к ссылочному типу данных» или как-то так. Ну или «свойства объектов-параметров доступны по ссылке».
Lain_13
19.01.2018 17:20Боже, эта любовь к зависимостям…
deepFreeze:
const deepFreeze = o => { Object.freeze(o); return Object.getOwnPropertyNames(o).every( p => o.hasOwnProperty(p) && o[p] instanceof Object && !Object.isFrozen(o[p]) ? deepFreeze(o[p]) : true ); };
Можно ещё сразу же: deepFreeze(deepFreeze);
mayorovp
19.01.2018 18:58Зачем вы проверяете hasOwnProperty?
Кстати, проверка
o[p] instanceof Object
слишком опасная. Можно случайно вмешаться во внутреннюю структуру сложного класса и все поломать. Лучше проверять наObject.getPrototypeOf(o[p]) === Object.prototype
.
Кстати,
o[p]
может оказаться вычисляемым свойством. Лучше получать дескриптор черезObject.getOwnPropertyDescriptor
и проверять его value.Lain_13
19.01.2018 21:03Зачем вы проверяете hasOwnProperty?
А и правда, в сочетании с getOwnPropertyNames оно явно лишнее.
А вот про instanceof можно поподробнее? Впрочем, на сколько я понимаю проверять прототип тоже не самая здравая идея:
http://perfectionkills.com/instanceof-considered-harmful-or-how-to-write-a-robust-isarray/
Если в том примере с xArray добавить Object.getPrototypeOf(arr) === Array.prototype, то тоже будет false. Тогда уже лучшеo !== null && typeof o === 'object'
, наверное?
Т.е. полный варианту будет выглядеть как-то так:
const deepFreeze = o => { Object.freeze(o); Object.getOwnPropertyNames(o).forEach( (p, i, o) => { let d = Object.getOwnPropertyDescriptor(o, p); if (d && d.value !== null && typeof d.value === 'object' && !Object.isFrozen(d.value)) deepFreeze(d.value); } ); return o; };
mayorovp
19.01.2018 21:05Попробуйте применить ваш код к объекту Date или какому-нибудь HTMLElement… Или к модели mobx, компоненту React или еще чему-нибудь подобному.
Идея строгой проверки прототипа — убедиться, что на входе лежит именно литерал объекта, а не что-то более сложное.Lain_13
19.01.2018 22:05Если учесть, что с прототипами легко намухлевать:
medium.com/javascript-scene/common-misconceptions-about-inheritance-in-javascript-d5d9bab29b0a
То лучше сразу сказать, что идея универсального deepFreeze заранее обречена на провал.
k12th
19.01.2018 18:29Камон, если бы не React, который несмотря на название, совсем не реактивный, про иммутабельность никто бы и не вспомнил. И даже React с Mobx не нуждается в иммутабельных структурах.
Не мутации страшны, а неконтролируемые и неотслеживаемые мутации. vuex предупреждает разработчика — «не меняй состояние вручную», а в redux можно запросто это сделать и полдня потом дебажить, «какая сволочь стреляла».
Eika
19.01.2018 19:06Еще есть Object.seal.
Он такой же как freeze, только позволяет изменения существующим ключам.
MikailBag
19.01.2018 20:58Для полноты картины отмечу, что персистентные структуры данных сильно сложнее в написании и отладке, чем свои "рядовые" "братья". В лучшем случае они работают медленнее лишь в константу раз. Но проигрыш в скорости (например я не слышал о персистентном списке работающем за O(1)) может быть на больших объемах данных может быть очень серьезным.
Таким образом, не стоит пихать такие структуры туда, где идет много вычислений.mayorovp
19.01.2018 21:08Вот список как раз — классика персистентных структур.
{ head: ..., tail: ...}
За O(1) у него работают операции "добавить элемент в начало" и "удалить первый элемент". Итерация по списку делается удалением всех элементов.
MikailBag
19.01.2018 21:24Вы привели пример персистентного стека, и он действительно работает за O(1).
Я же имею в виду обычный список типа массива, т.е. чтение/запись в произвольные места.
Лучшее, что мне известно в этой области — персистентные деревья, с запросами за O(log N) и большой константой.
А плюшки, которые дает персистентность (много версий струкутры с возможностью одновременной работы с ними), в типичном веб-приложении вряд ли нужны (например в Redux хранилище единственно, т.о. версия нужна одна).
vintage
20.01.2018 09:28Object.freeze, Object.assign, ImmutableJS и прочие такие штуки серьёзно так замедляют работу приложения. Я бы рекомендовал воспользоваться TypeScript, который не позволит вам динамически изменить сигнатуру объекта или изменить readonly свойство. При этом ещё на стадии написания кода, а не в рантайме и соответственно без замедления исполнения.
Объекты можно условно разделить на два типа: значения и контейнеры. Беда JS в том, что, например, один и тот же Array выступает и в роли контейнера (push, pop, ...) и в качестве значения (map, filter, ...).
- Никаких опасностей мутыций вы не продемонстрировали. Только описали азы языка и назвали их "опасными мутантами". Кстати, тот самый синий и волосатый монстр, о котором вы говорили, — весьма душевный человек и проницательный собеседник :-)
abyrkov
20.01.2018 15:35-11. Решать проблемы JS перейдя на TS это так же как решать проблемы C++ перейдя на Java. Не всегда это оправданно
Zenitchik
20.01.2018 15:43Согласен. Лучше просто знать язык и писать нормально. Хорошему программисту мутабельность не мешала никогда. Не хочешь изменять — не изменяй.
k12th
20.01.2018 18:27Java не компилируется в C++.
abyrkov
20.01.2018 20:33А это разве влияет на мое утверждение, что переход ради решения одной (зачастую несущественной) проблемы конкретным путем, не всегда целесообразно переходить на другой язык?
abyrkov
20.01.2018 22:17Очень интересно узнать, за что минусуют то? Я же не предлагаю писать все и всия на JS, а просто не писать на TS там, где он избыточен
PYXRU
20.01.2018 18:58На этой вашей мутабельность держиться большинство фраемворков(angular), тот же Vue биндит реактивные свойства через замыкание по сути вообще костыль для избежания рекурсии, и таких приемов очень много. Проблема кажется более надуманная, поскольку большинство знают что не примитивные типы передаются по ссылке. Если для вас это действительно проблема Object.defineProperty или typescript вам в помощь.
rockon404
22.01.2018 13:29Странно, что в статье не упомянули spread оператор:
const obj = { foo: 'bar' }; const objCopy = { ...obj };
Он код с его использованием понятен и лаконичен. Пусть он и является синтаксическим сахаром над Object.assign.
vasIvas
Хватит уже жути нагонять. Если убрать мутации и отменить ссылки на объекты, создавать приложения будет в сотни раз сложнее.
zxxc
Такой подход в целом и общем делает код медленнее и часто может приводить к неожиданным последствиям в будущем
Избегая такого код разработка станет дешевле в долгосрочной перспективе
vasIvas
Нужно добавить что такой код пишут только те, кому ураган, на память, в голове инородный предмет оставил. Ну или совсем новичок в программировании.
vanxant
Или авторы плагинов
vasIvas
Тогда стоит добавить что авторов плагинов для плохо продуманных библиотек.
Зачем делать объекты динамическими, когда можно создать основу конфигурации в виде класса, наделив его базовым состояние, а авторам плагинов позволить его расширять?
vanxant
Думать сейчас некогда.
Херак херак и в продакшнВезде скрам cd аджайл