Неизменяемый JavaScript: как это делается с ES6 и выше

в 12:44, , рубрики: ES6, es7, javascript, web-разработка, Блог компании Издательский дом «Питер», Программирование

Здравствуйте, уважаемые читатели. Сегодня мы хотели бы предложить вам перевод статьи о неизменяемости в современном JavaScript. Подробнее о различных возможностях ES6 рекомендуем почитать в вышедшей у нас замечательной книге Кайла Симпсона "ES6 и не только".

Писать неизменяемый код Javascript – правильно. Существует ряд потрясающих библиотек, например, Immutable.js, которые могли бы для этого пригодиться. Но можно ли сегодня обойтись без библиотек – писать на «ванильном» JavaScript нового поколения?

Если коротко — да. В ES6 и ES.Next есть ряд потрясающих возможностей, позволяющих добиться неизменяемого поведения без какой-либо возни. В этой статье я расскажу, как ими пользоваться – это интересно!

ES.Next – это следующ(ая/ие) верси(я/и) EcmaScript. Новые релизы EcmaScript выходят ежегодно и содержат возможности, которыми можно пользоваться уже сегодня при помощи транспилятора, например, Babel.

Проблема

Для начала определимся, почему неизменяемость так важна? Ну, если изменять данные, то может получиться сложночитаемый код, подверженный ошибкам. Если речь идет о примитивных значениях (например, числах и строках), писать «неизменяемый» код совсем просто – ведь сами эти значения изменяться не могут. Переменные, содержащие примитивные типы, всегда указывают на конкретное значение. Если передать его другой переменной, то другая переменная получит копию этого значения.

С объектами (и массивами) другая история: они передаются по ссылке. Это означает, что, если передать объект другой переменной, то обе они будут ссылаться на один и тот же объект. Если же вы впоследствии измените объект, принадлежащий любой из них, то изменения отразятся на обеих переменных. Пример:

const person = {
  name: 'John',
  age: 28
}
const newPerson = person
newPerson.age = 30
console.log(newPerson === person) // истина
console.log(person) // { name: 'John', age: 30 }

Видите, что происходит? Изменив newObj, мы автоматически поменяем и старую переменную obj. Все потому, что они ссылаются на один и тот же объект. В большинстве случаев такое поведение нежелательно, и писать код таким образом плохо. Посмотрим, как можно решить эту проблему.

Неизменяемый JavaScript: как это делается с ES6 и выше - 1

Обеспечиваем неизменяемость

А что если не передавать объект и не изменять его, а вместо этого создавать совершенно новый объект:

const person = {
  name: 'John',
  age: 28
}
const newPerson = Object.assign({}, person, {
  age: 30
})
console.log(newPerson === person) // ложь
console.log(person) // { name: 'John', age: 28 }
console.log(newPerson) // { name: 'John', age: 30 }

Object.assign – это возможность ES6, позволяющая принимать объекты в качестве параметров. Она объединяет все передаваемые ей объекты с первым. Возможно, вы удивились: а почему первый параметр – это пустой объект {}? Если бы первым шел параметр ‘person’, то мы по-прежнему изменяли бы person. Если бы у нас было написано { age: 30 }, то мы бы опять перезаписали 30 значением 28, так как оно шло бы позже. Наше решение работает — person сохранилось без изменений, так как мы поступили с ним как с неизменяемым!

Хотите без лишних хлопот опробовать эти примеры? Открывайте JSBin. В левой панели щелкните Javascript и замените его на ES6/Babel. Все, уже можете писать на ES6 :).

Однако, на самом деле в EcmaScript есть специальный синтаксис, еще сильнее упрощающий такие задачи. Он называется object spread, использовать его можно при помощи транспилятора Babel. Смотрите:

const person = {
  name: 'John',
  age: 28
}
const newPerson = {
  ...person,
  age: 30
}
console.log(newPerson === person) // ложь
console.log(newPerson) // { name: 'John', age: 30 }

Тот же результат, только теперь Код еще чище. Сначала оператор ‘spread’ (...) копирует все свойства из person в новый объект. Затем мы определяем новое свойство ‘age’, которым перезаписываем старое. Соблюдайте порядок: если бы age: 30 было определено выше person, то затем оно было бы перезаписано age: 28.

А если нужно убрать элемент? Нет, удалять мы его не будем, ведь при этом объект вновь бы изменился. Такой прием немного сложнее, и мы могли бы поступить, например, вот так:

const person = {
  name: 'John',
  password: '123',
  age: 28
}
const newPerson = Object.keys(person).reduce((obj, key) => {
  if (key !== property) {
    return { ...obj, [key]: person[key] }
  }
  return obj
}, {})

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

Массивы

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

const characters = [ 'Obi-Wan', 'Vader' ]
const newCharacters = characters
newCharacters.push('Luke')
console.log(characters === newCharacters) // истина :-(

Та же проблема, что и с объектами. Нам решительно не удалось создать новый массив, мы просто изменили старый. К счастью, в ES6 есть оператор spread для массива! Вот как его использовать:

const characters = [ 'Obi-Wan', 'Vader' ]
const newCharacters = [ ...characters, 'Luke' ]
console.log(characters === newCharacters) // false
console.log(characters) // [ 'Obi-Wan', 'Vader' ]
console.log(newCharacters) // [ 'Obi-Wan', 'Vader', 'Luke' ]

Как же просто! Мы создали новый массив, в котором содержатся старые символы плюс ‘Luke’, а старый массив не тронули.

Рассмотрим, как делать с массивами другие операции, не изменяя исходного массива:

const characters = [ 'Obi-Wan', 'Vader', 'Luke' ]
// Удаляем Вейдера
const withoutVader = characters.filter(char => char !== 'Vader')
console.log(withoutVader) // [ 'Obi-Wan', 'Luke' ]
// Меняем Вейдера на Энекина
const backInTime = characters.map(char => char === 'Vader' ? 'Anakin' : char)
console.log(backInTime) // [ 'Obi-Wan', 'Anakin', 'Luke' ]
// Все символы в верхнем регистре
const shoutOut = characters.map(char => char.toUpperCase())
console.log(shoutOut) // [ 'OBI-WAN', 'VADER', 'LUKE' ]
// Объединяем два множества символов
const otherCharacters = [ 'Yoda', 'Finn' ]
const moreCharacters = [ ...characters, ...otherCharacters ]
console.log(moreCharacters) // [ 'Obi-Wan', 'Vader', 'Luke', 'Yoda', 'Finn' ]

Видите, какие приятные «функциональные» операторы? Действующий в ES6 синтаксис стрелочных функций их только красит. Каждый раз при запуске такой функции такая функция возвращает новый массив, одно исключение – древний метод сортировки:

const characters = [ 'Obi-Wan', 'Vader', 'Luke' ]
const sortedCharacters = characters.sort()
console.log(sortedCharacters === characters) // истина :-(
console.log(characters) // [ 'Luke', 'Obi-Wan', 'Vader' ]

Да, знаю. Я считаю, что push и sort должны действовать точно как map, filter и concat, возвращать новые массивы. Но они этого не делают, и если что-то поменять, то, вероятно, можно сломать Интернет. Если вам требуется сортировка, то, пожалуй, можно воспользоваться slice, чтобы все получилось:

const characters = [ 'Obi-Wan', 'Vader', 'Luke' ]
const sortedCharacters = characters.slice().sort()
console.log(sortedCharacters === characters) // false :-D
console.log(sortedCharacters) // [ 'Luke', 'Obi-Wan', 'Vader' ]
console.log(characters) // [ 'Obi-Wan', 'Vader', 'Luke' ]

Остается ощущение, что slice() – немного «хак», но он работает.
Как видите, неизменяемость легко достигается при помощи самого обычного современного JavaScript! В конце концов, важнее всего – здравый смысл и понимание, что именно делает ваш код. Если программировать неосторожно, JavaScript может быть непредсказуем.

Замечание о производительности

Что насчет производительности? Ведь создавать новые объекты – напрасная трата времени и памяти? Да, действительно, возникают лишние издержки. Но этот недостаток с лихвой компенсируют приобретаемые преимущества.

Одна из наиболее сложных операций в JavaScript – это отслеживание изменений объекта. Решения вроде Object.observe(object, callback) довольно тяжеловесны. Однако, если держать состояние неизменяемым, то можно обойтись oldObject === newObject и таким образом проверять, не изменился ли объект. Такая операция не так сильно нагружает CPU.

Второе важное достоинство – улучшается качество кода. Когда нужно гарантировать неизменяемость состояния, приходится лучше продумывать структуру всего приложения. Вы программируете «функциональнее», весь код проще отслеживать, а гнусные баги в нем заводятся реже. Куда ни кинь – всюду вин, верно?

Для справки

» Таблица совместимости ES6:
» Таблица совместимости ES.Next:

Автор: Издательский дом «Питер»

Источник

Поделиться

* - обязательные к заполнению поля