Незмінний JavaScript: як це робиться з ES6 і вище

Здравствуйте, шановні читачі. Сьогодні ми хотіли б запропонувати вам переклад статті про незмінюваності в сучасному 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
. Все тому, що вони посилаються на один і той же об'єкт. У більшості випадків це небажано, і писати код таким чином погано. Подивимося, як можна вирішити цю проблему.


Забезпечуємо незмінюваність
А що якщо не передавати об'єкт і не змінювати його, а замість цього створювати абсолютно новий об'єкт:

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 s = [ 'Obi-Wan', 'Vader' ]
const newCharacters = characters
newCharacters.push('Luke')
console.log(characters === newCharacters) // істина :-(

Та ж проблема, що і з об'єктами. Нам рішуче не вдалося створити новий масив, ми просто змінили старий. На щастя, в ES6 є оператор spread для масиву! Ось як його використовувати:

const s = [ '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 s = [ '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 s = [ '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 s = [ '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:
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.