JavaScript и ужасы мутаций

перевод
ru_vds 19 января в 11:42 16,6k
Оригинал: Zell Liew
Мутация — это изменение. Изменение формы или изменение сути. То, что подвержено мутациям, может меняться. Для того чтобы лучше осознать природу мутации — подумайте о героях фильма «Люди Икс». Они могли внезапно получать потрясающие возможности. Однако проблема заключается в том, что неизвестно, когда именно эти возможности проявятся. Представьте себе, что ваш товарищ ни с того ни с сего посинел и оброс шерстью. Страшновато, правда? В JavaScript существуют те же проблемы. Если ваш код подвержен мутациям, это значит, что вы можете, совершенно неожиданно, что-то изменить и поломать.



Объекты в 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-приложениях, вызванными мутациями объектов?

Проголосовать:
+15
Сохранить: