Про мутацию данных в JavaScript

Мутация означает изменение формы или природы. Что-то, что является мутабельным - может быть изменено, и аналогично, что является иммутабельным - не может быть изменено. Чтобы представить мутацию, подумайте о Людях Икс. В Людях Икс люди могут внезапно обрести силу. Проблема в том, что вы не знаете, когда эти силы появятся. Представьте, что ваш друг вдруг посинел или оброс мехом, это было бы довольно страшно, не так ли?

В 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. Затем вы хотите изменить имя 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

Когда вы присваиваете newEgg = egg, newEgg указывает на тот же объект, что и egg. Ввиду того, что egg и newEgg - это одно и то же, то при изменении newEgg, egg изменяется автоматически.

console.log(egg === newEgg); // true

К сожалению, большую часть времени вы не хотите, чтобы объект egg менялся вместе с newEgg, так как это приводит к тому, что ваш код ломается, когда вы меньше всего этого ожидаете. Так как же тогда предотвратить мутацию объектов в JavaScript? Для начала, чтобы разобраться, как предотвратить мутацию объектов, вы должны знать, что является иммутабельным в JavaScript.

Примитивные типы в JavaScript являются иммутабельными

В 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, даже если объект создан с помощью const, 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 }

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

Но Object.assign копирует ссылки на объекты

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

Рассмотрим это утверждение на примере.

Предположим, вы покупаете новую звуковую систему. Система позволяет задать состояние питания, задать громкость, размер басов и прочие опции.

const defaultSettings = {
  power: true,
  soundSettings: {
    volume: 50,
    bass: 20,
    // прочие опции
  }
};

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

const loudPreset = {
  soundSettings: {
    volume: 100
  }
};

Потом приглашаешь друзей на вечеринку. Чтобы сохранить существующие заготовки, вы пытаетесь объединить громкую заготовку с заготовкой по умолчанию.

const partyPreset = Object.assign({}, defaultSettings, loudPreset);

Однако partyPreset звучит странно. Громкость достаточно высокая, однако басов нет. Когда вы начинаете изучать partyPreset, вы удивляетесь, что в нём нет басов!

console.log(partyPreset);
// {
//   power: true,
//   soundSettings: {
//     volume: 100
//   }
// }

Это происходит потому, что JavaScript копирует ссылку на объект soundSettings. Так как и defaultSettings, и loudPreset имеют объект soundSettings, тот, который будет передан позже, будет скопирован в новый объект.

Если вы измените partyPreset, loudPreset будет мутирован соответственно - доказательство того, что ссылка на soundSettings была скопирована.

partyPreset.soundSettings.bass = 50;

console.log(loudPreset);
// {
//   soundSettings: {
//     volume: 100,
//     bass: 50
//   }
// }

Так как Object.assign выполняет высокоуровневое слияние, вам нужно использовать другой метод для слияния объектов, которые содержат вложенные свойства (т.е. объекты внутри объектов).

И тут к нам приходит простая библиотека assignment.

Assignment

Assignment это небольшая библиотека, которая является отличным материалом для более глубокого изучения JavaScript.

Эта библиотека поможет вам осуществить глубокое слияние объектов, не беспокоясь об их мутации. Помимо имени метода, синтаксис совпадает с Object.assign.

// Perform a deep merge with assignment
const partyPreset = assignment({}, defaultSettings, loudPreset);

console.log(partyPreset);
// {
//   power: true,
//   soundSettings: {
//     volume: 100,
//     bass: 20
//   }
// }

Assignment копирует значения всех вложенных объектов, что исключает возможность к мутации данных.

Теперь, если вы попытаетесь изменить любое из свойств partyPreset.soundSettings, то увидите, что loudPreset остался таким же, как и раньше.

partyPreset.soundSettings.bass = 50;

// loudPreset не будет мутирован
console.log(loudPreset);
// {
//   soundSettings {
//     volume: 100
//   }
// }

Assignment - это всего лишь одна из многих библиотек, которые помогают выполнить глубокое слияние объектов. Другие библиотеки, в том числе lodash.merge и merge-options, также могут помочь вам в этом. Экспериментируйте и выбирайте любую из этих библиотек, которая придёт по душе.

Нужно ли всегда использовать Assignment вместо 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 в ваш код в соответствии с инструкциями на npm).
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;

Резюме

В этой статье мы разобрали, как работает Object.assign и Object.freeze в JavaScript, а так же, как бороться с мутабельностью данных в плоских и рекурсивных свойствах.

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

Чтобы предотвратить мутацию объектов, вы можете использовать такие библиотеки, как ImmutableJS и Mori.js, или использовать Object.assign и Object.freeze.

Обратите внимание, что Object.assign и Object.freeze могут предотвращать первоуровневые свойства от мутации. Если вам нужно предотвратить мутацию объектов нескольких уровне вложенности, вам понадобятся такие библиотеки, как assignment и deep-freeze.