Разбираем на примерах: как избежать мутаций в JavaScript
Из этого подробного руководства с многочисленными примерами кода на JavaScript вы узнаете, что такое мутации объектов, почему их следует избегать и как это сделать.
Мутация в JavaScript – это изменение объекта или массива без создания новой переменной и переприсваивания значения. Например, вот так:
const puppy = { name: 'Dessi', age: 9 }; puppy.age = 10;
Оригинальный объект puppy
мутировал: мы изменили значение поля age
.
Проблемы с мутациями
Казалось бы – ничего страшного. Но такие маленькие изменения могут приводить к большим проблемам.
function printSortedArray(array) { array.sort(); for (const item of array) { console.log(item); } }
Когда мы вызываем функцию с названием printSortedArray
, то обычно не думаем о том, что она что-то сделает с полученными данными. Но здесь встроенный метод массива sort()
изменяет оригинальный массив. Так как массивы в JavaScript передаются по ссылке, то последующие операции будут иметь дело с обновленным, отсортированным порядком элементов.
Подобные ошибки трудно заметить, ведь операции выполняются нормально – только с результатом что-то не так. Функция рассчитывает на один аргумент, а получает «мутанта» – результат работы другой функции.
Решением являются иммутабельные (неизменяемые) структуры данных. Эта концепция предусматривает создание нового объекта для каждого обновления.
Если у вас есть 9
-месячный puppy
, который внезапно подрос, придется записать его в новую переменную grownUpPuppy
.
К сожалению, иммутабельность из коробки в JavaScript не поддерживается. Существующие решения – это более или менее кривые костыли. Но если вы будете максимально избегать мутаций в коде, код станет понятнее и надежнее.
const
в JavaScript защищает от изменения переменную, но не ее значение! Вы не сможете присвоить этой переменной другой объект, но вполне можете изменить поля оригинального объекта. Избегайте мутирующих операций
Проблема
Распространенная мутация в JavaScript – изменение объекта:
function parseExample(content, lang, modifiers) { const example = { content, lang }; if (modifiers) { if (hasStringModifiers(modifiers)) { example.settings = modifiers .split(' ') .reduce((obj, modifier) => { obj[modifier] = true; return obj; }, {}); } else { try { example.settings = JSON.parse(modifiers); } catch (err) { return { error: `Cannot parse modifiers` }; } } } return example; }
В этом примере мы создаем объект с тремя полями, поле settings
опционально. Для добавления объекта мы мутируем исходный объект example
– добавляем новое свойство. Чтобы понять, как выглядит в итоге объект example
со всеми возможными вариациями, нужно просмотреть всю функцию. Было бы удобнее видеть его целиком в одном месте.
Решение
В большинстве кейсов отсутствующее поле в объекте заменимо полем со значением undefined
.
В примере также присутствует конструкция try-catch
, из которой в случае ошибки возвращается объект с совершенно другой структурой и единственным полем error
. Это особый случай – объекты абсолютно разные, нет необходимости их объединять.
Чтобы очистить код, вынесем вычисление settings
в отдельную функцию:
function getSettings(modifiers) { if (!modifiers) { return undefined; } if (hasStringModifiers(modifiers)) { return modifiers.split(' ').reduce((obj, modifier) => { obj[modifier] = true; return obj; }, {}); } return JSON.parse(modifiers); } function parseExample(content, lang, modifiers) { try { return { content, lang, settings: getSettings(modifiers) }; } catch (err) { return { error: `Cannot parse modifiers` }; } }
Теперь проще понять и что делает фрагмент, и форму возвращаемого объекта. Благодаря рефакторингу мы избавились от мутаций и уменьшили вложенность.
Будьте осторожны с мутирующими методами массивов
Далеко не все методы в JavaScript возвращают новый массив или объект. Многие мутируют оригинальное значение прямо на месте. Например, push()
– один из самых часто используемых.
Проблема
Посмотрим на этот код:
const generateOptionalRows = () => { const rows = []; if (product1.colors.length + product2.colors.length > 0) { rows.push({ row: 'Colors', product1: <ProductOptions options={product1.colors} />, product2: <ProductOptions options={product2.colors} /> }); } if (product1.sizes.length + product2.sizes.length > 0) { rows.push({ row: 'Sizes', product1: <ProductOptions options={product1.sizes} />, product2: <ProductOptions options={product2.sizes} /> }); } return rows; }; const rows = [ { row: 'Name', product1: <Text>{product1.name}</Text>, product2: <Text>{product2.name}</Text> }, // More rows... ...generateOptionalRows() ];
Здесь описаны два пути определения строк таблицы: массив с постоянными значениями и функция, возвращающая строки для опциональных данных. Внутри последней происходит мутация оригинального массива с помощью метода .push()
.
Сама по себе мутация – не такая уж большая проблема. Но где мутации, там и другие подводные камни. Проблема этого фрагмента – императивное построение массива и различные способы обработки постоянных и опциональных строк.
Решение
Одна из полезных техник рефакторинга – замена императивного кода, полного циклов и условий, на декларативный. Давайте объединим все возможные ряды в единый декларативный массив:
const rows = [ { row: 'Name', product1: <Text>{product1.name}</Text>, product2: <Text>{product2.name}</Text> }, // More rows... { row: 'Colors', product1: <ProductOptions options={product1.colors} />, product2: <ProductOptions options={product2.colors} />, isVisible: (product1, product2) => (product1.colors.length > 0 || product2.colors.length) > 0 }, { row: 'Sizes', product1: <ProductOptions options={product1.sizes} />, product2: <ProductOptions options={product2.sizes} />, isVisible: (product1, product2) => (product1.sizes.length > 0 || product2.sizes.length) > 0 } ]; const visibleRows = rows.filter(row => { if (typeof row.isVisible === 'function') { return row.isVisible(product1, product2); } return true; });
Данные будут выведены в случае, если метод isVisible
вернет значение true
.
Код стал читаемее и удобнее для поддержки:
- Всего один путь определения строки таблицы – не нужно решать, какой метод использовать.
- Все данные в одном месте.
- Легко редактировать строки, изменяя функцию
isVisible
.
Проблема
Вот другой пример:
const defaults = { ...options }; const prompts = []; const parameters = Object.entries(task.parameters); for (const [name, prompt] of parameters) { const hasInitial = typeof prompt.initial !== 'undefined'; const hasDefault = typeof defaults[name] !== 'undefined'; if (hasInitial && !hasDefault) { defaults[name] = prompt.initial; } prompts.push({ ...prompt, name, initial: defaults[name] }); }
На первый взгляд, этот код не так уж плох. Он конвертирует объект в массив prompts
путем добавления новых свойств. Но если взглянуть поближе, мы найдем еще одну мутацию внутри блока if
– изменение объекта defaults
. И вот это – уже большая проблема, которую сложно обнаружить.
Решение
Код выполняет две задачи внутри одного цикла:
- конвертация объекта
task.parameters
в массивpromts
; - обновление объекта
defaults
значениями изtask.parameters
.
Для улучшения читаемости следует разделить операции:
const parameters = Object.entries(task.parameters); const defaults = parameters.reduce( (acc, [name, prompt]) => ({ ...acc, [name]: prompt.initial !== undefined ? prompt.initial : options[name] }), {} ); const prompts = parameters.map(([name, prompt]) => ({ ...prompt, name, initial: defaults[name] }));
Другие мутирующие методы массивов, которые следует использовать с осторожностью:
Избегайте мутаций аргументов функции
Так как объекты и массивы в JavaScript передаются по ссылке, их изменение внутри функции приводит к неожиданным эффектам в глобальной области видимости.
const mutate = object => { object.secret = 'Loves pizza'; }; const person = { name: 'Chuck Norris' }; mutate(person); // -> { name: 'Chuck Norris', secret: 'Loves pizza' }
В этом фрагменте объект person
изменяется внутри функции mutate
.
Проблема
Подобные мутации могут быть и преднамеренными, и случайными. И то, и то приводит к проблемам:
- Ухудшается читаемость кода. Функция не возвращает значение, а изменяет один из входящих параметров, становится непонятно, как ее использовать.
- Ошибки, вызванные случайными изменениями, сложно заметить и отследить.
Рассмотрим пример:
const addIfGreaterThanZero = (list, count, message) => { if (count > 0) { list.push({ id: message, count }); } }; const getMessageProps = ( adults, children, infants, youths, seniors ) => { const messageProps = []; addIfGreaterThanZero(messageProps, adults, 'ADULTS'); addIfGreaterThanZero(messageProps, children, 'CHILDREN'); addIfGreaterThanZero(messageProps, infants, 'INFANTS'); addIfGreaterThanZero(messageProps, youths, 'YOUTHS'); addIfGreaterThanZero(messageProps, seniors, 'SENIORS'); return messageProps; };
Этот код конвертирует набор числовых переменных в массив messageProps
со следующей структурой:
[ { id: 'ADULTS', count: 7 }, { id: 'SENIORS', count: 2 } ];
Проблема в том, что функция addIfGreateThanZero
вызывает мутации массива, который мы ей передаем. Это изменение преднамеренное, оно необходимо для работы функции. Однако это не самое лучшее решение – можно создать более понятный и удобный интерфейс.
Решение
Давайте перепишем функцию, чтобы она возвращала новый массив:
const addIfGreaterThanZero = (list, count, message) => { if (count > 0) { return [ ...list, { id: message, count } ]; } return list; };
Но от этой функции можно полностью отказаться:
const MESSAGE_IDS = [ 'ADULTS', 'CHILDREN', 'INFANTS', 'YOUTHS', 'SENIORS' ]; const getMessageProps = ( adults, children, infants, youths, seniors ) => { return [adults, children, infants, youths, seniors] .map((count, index) => ({ id: MESSAGE_IDS[index], count })) .filter(({ count }) => count > 0); };
Этот код проще для понимания: в нем нет повторов и сразу понятен формат результата. Функция getMessageProps
преобразует список значений в массив определенного формата, а затем отфильтровывает элементы с нулевым значением поля count
.
Можно еще немного упростить:
const MESSAGE_IDS = [ 'ADULTS', 'CHILDREN', 'INFANTS', 'YOUTHS', 'SENIORS' ]; const getMessageProps = (...counts) => { return counts .map((count, index) => ({ id: MESSAGE_IDS[index], count })) .filter(({ count }) => count > 0); };
Но это приводит к менее очевидному интерфейсу и не позволяет использовать автокомплит в редакторе кода.
Кроме того, создается ложное впечатление, что функция принимает любое количество аргументов и в любом порядке. Но это не так.
Вместо цепочки .map()
+ .filter()
можно использовать встроенный метод массивов .reduce()
:
const MESSAGE_IDS = [ 'ADULTS', 'CHILDREN', 'INFANTS', 'YOUTHS', 'SENIORS' ]; const getMessageProps = (...counts) => { return counts.reduce((acc, count, index) => { if (count > 0) { acc.push({ id: MESSAGE_IDS[index], count }); } return acc; }, []); };
Однако код с reduce
выглядит менее очевидным и труднее читается, поэтому стоило бы остановиться на предыдущем шаге рефакторинга.
Похоже, что единственная веская причина для мутации входящих параметров внутри функции – это оптимизация производительности. Если вы работаете с огромным объемом данных, то создание нового объекта/массива каждый раз – довольно затратная операция. Но как и с любой другой оптимизацией – не спешите, убедитесь, что проблема действительно существует. Не жертвуйте чистотой и ясностью кода.
Если вам нужны мутации, сделайте их явными
Проблема
Иногда мутаций не избежать, например, из-за неудачного API языка. Один из самых популярных примеров – метод массивов .sort()
.
const counts = [6, 3, 2]; const puppies = counts.sort().map(n => `${n} puppies`);
Этот фрагмент кода создает ошибочное впечатление, что массив counts
не изменяется, а просто создается новый массив puppies
, внутри которого и происходит сортировка значений. Однако метод .sort()
сортирует массив на месте – вызывает мутацию. Если разработчик не понимает этой особенности, в программе могут возникнуть ошибки, которые будет сложно отследить.
Решение
Лучше сделать мутацию явной:
const counts = [6, 3, 2]; const sortedCounts = [...counts].sort(); const puppies = sortedCounts.map(n => `${n} puppies`);
Создается неглубокая копия массива counts
, у которой и вызывается метод sort
. Исходный массив, таким образом, остается неизменным.
Другой вариант – обернуть встроенные мутирующие операции кастомной функцией и использовать ее:
function sort(array) { return [...array].sort(); } const counts = [6, 3, 2]; const puppies = sort(counts).map(n => `${n} puppies`);
Также вы можете применять сторонние библиотеки, например, функцию sortBy библиотеки Lodash:
const counts = [6, 3, 2]; const puppies = _.sortBy(counts).map(n => `${n} puppies`);
Обновление объектов
В современном JavaScript появились новые возможности, упрощающие реализацию иммутабельности – спасибо spread-синтаксису. До его появления нам приходилось писать что-то такое:
const prev = { coffee: 1 }; const next = Object.assign({}, prev, { pizza: 42 }); // -> { coffee: 1, pizza: 42 }
Обратите внимание на пустой объект, передаваемый в качестве первого аргумента методу Object.assign()
. Это исходное значение, которое и будет подвергаться мутациям (цель метода assign
). Таким образом, этот метод и изменяет свой параметр, и возвращает его – крайне неудачный API языка.
Теперь можно писать проще:
const prev = { coffee: 1 }; const next = { ...prev, pizza: 42 };
Суть та же, но гораздо менее многословно и без странного поведения.
А до введения стандарта ECMAScript 2015, который подарил нам Object.assign, избежать мутаций было и вовсе почти невозможно.
В документации библиотеки Redux есть замечательная страница Immutable Update Patterns, которая описывает концепцию обновления массивов и объектов без мутаций. Эта информация полезна, даже если вы не используете Redux.
Подводные камни методов обновления
Как бы ни был хорош spread-синтаксис, он тоже быстро становится громоздким:
function addDrink(meals, drink) { return { ...meals, lunch: { ...meals.lunch, drinks: [...meals.lunch.drinks, drink] } }; }
Чтобы изменить глубоко вложенных полей, приходится разворачивать каждый уровень объекта, иначе мы потеряем данные:
function addDrink(meals, drink) { return { ...meals, lunch: { drinks: [drink] } }; }
В этом фрагменте кода мы сохраняем только первый уровень свойств исходного объекта, а свойства lunch
и drinks
полностью переписываются.
И spread
, и Object.assign
осуществляют неглубокое клонирование – копируются только свойства первого уровня вложенности. Так что они не защищают от мутаций вложенных объектов или массивов.
Если вам приходится часто обновлять какую-то структуру данных, лучше сохранять для нее минимальный уровень вложенности.
Пока мы ждем появления в JavaScript иммутабельности из коробки, можно упростить себе жизнь двумя простыми способами:
- Избегать мутаций.
- Упростить обновление объектов.
Отслеживание мутаций
Линтинг
Один из способов отслеживать мутации – использование линтера кода. У ESLint есть несколько плагинов, которые занимаются именно этим. Например, eslint-plugin-better-mutation запрещает любые мутации, кроме локальных переменных внутри функций. Это отличная идея, которая позволяет предотвратить множество ошибок снаружи функции, но оставит большую гибкость внутри. Однако этот плагин часто ломается – даже в простых случаях вроде мутации в коллбэке метода .forEach()
.
ReadOnly
Другой способ – пометить все объекты и массивы как доступные только для чтения, если вы используете TypeScript или Flow.
Вот пример использования модификатора readonly
в TypeScript:
interface Point { readonly x: number; readonly y: number; }
Использование служебного типа Readonly
:
type Point = Readonly<{ readonly x: number; readonly y: number; }>;
То же самое для массивов:
function sort(array: readonly any[]) { return [...array].sort(); }
Модификатор readonly
, и тип Readonly
защищают от изменений только первый уровень свойств, так что их нужно отдельно добавлять к вложенным структурам.
В плагине eslint-plugin-functional есть правило, которое требует везде добавлять read-only типы. Его использование удобнее, чем их ручная расстановка. К сожалению, поддерживаются только модификаторы.
Еще удобнее был бы TypeScript флаг для дефолтной установки read-only типов с возможностью отката.
Заморозка
Чтобы сделать объекты доступными только для чтения во время выполнения, можно использовать метод Object.freeze
. Он также работает только на один уровень вглубь. Для «заморозки» вложенных объектов используйте библиотеку вроде deep-freeze.
Упрощение изменений
Для достижения наилучшего результата следует сочетать технику предотвращения мутаций с упрощением обновления объектов.
Самый популярный инструмент для этого – библиотека Immutable.js:
import { Map } from 'immutable'; const map1 = Map({ food: 'pizza', drink: 'coffee' }); const map2 = map1.set('drink', 'vodka'); // -> Map({ food: 'pizza', drink: 'vodka' })
Используйте ее, если вас не раздражает необходимость изучить новый API, а также постоянно преобразовывать обычные массивы и объекты в объекты Immutable.js и обратно.
Другой вариант – библиотека Immer. Она позволяет работать с объектом привычными методами, но перехватывает все операции и вместо мутации создает новый объект.
import produce from 'immer'; const map1 = { food: 'pizza', drink: 'coffee' }; const map2 = produce(map1, draftState => { draftState.drink = 'vodka'; }); // -> { food: 'pizza', drink: 'vodka' }
Immer также замораживает полученный объект в процессе выполнения.
Иногда в мутациях нет ничего плохого
В некоторых (редких) случаях императивный код с мутациями не так уж и плох, и переписывание в декларативном стиле не сделает его лучше. Рассмотрим пример:
const getDateRange = (startDate, endDate) => { const dateArray = []; let currentDate = startDate; while (currentDate <= endDate) { dateArray.push(currentDate); currentDate = addDays(currentDate, 1); } return dateArray; };
Здесь мы создаем массив дат в заданном диапазоне. У вас есть идеи, как можно переписать этот код без императивного цикла, переприсваивания и мутаций?
В целом этот код имеет право на существование:
- Все «плохие» операции изолированы внутри маленькой функции.
- Понятное название функции само по себе описывает, что она делает.
- Работа функции не влияет на внешнюю область видимости: она не использует глобальные переменные и не изменяет свои аргументы.
Если вы используете мутации, постарайтесь изолировать их в маленькие чистые функции с понятными именами.
Руководство к действию
- Императивный код с мутациями сложнее читать и поддерживать, чем чистый декларативный код. Поэтому – рефакторьте!
- Сохраняйте полную форму объекта в одном месте и держите ее максимально чистой.
- Отделяйте логику «ЧТО» от логики «КАК».
- Изменения входных параметров функции приводит к незаметным, но очень неприятным ошибкам, которые трудно дебажить.
- Цепочка методов
.map()
+.filter()
в большинстве случаев выглядит понятнее, чем один метод.reduce()
. - Если вам очень хочется что-то мутировать, делайте это максимально явно. Старайтесь изолировать подобные операции в функции.
- Используйте техники для автоматического предотвращения мутаций.