Разбираем на примерах как избежать мутаций в JavaScript

Разбираем на примерах как избежать мутаций в JavaScript

Мутация в JavaScript – это изменение объекта или массива без создания новой переменной и переприсваивания значения. Например, вот так:

Оригинальный объект puppy мутировал: мы изменили значение поля age .

Казалось бы – ничего страшного. Но такие маленькие изменения могут приводить к большим проблемам.

Когда мы вызываем функцию с названием printSortedArray , то обычно не думаем о том, что она что-то сделает с полученными данными. Но здесь встроенный метод массива sort() изменяет оригинальный массив. Так как массивы в JavaScript передаются по ссылке, то последующие операции будут иметь дело с обновленным, отсортированным порядком элементов.

Подобные ошибки трудно заметить, ведь операции выполняются нормально – только с результатом что-то не так. Функция рассчитывает на один аргумент, а получает «мутанта» – результат работы другой функции.

Решением являются иммутабельные (неизменяемые) структуры данных. Эта концепция предусматривает создание нового объекта для каждого обновления.

Если у вас есть 9 -месячный puppy , который внезапно подрос, придется записать его в новую переменную grownUpPuppy .

К сожалению, иммутабельность из коробки в JavaScript не поддерживается. Существующие решения – это более или менее кривые костыли. Но если вы будете максимально избегать мутаций в коде, код станет понятнее и надежнее.

Проблема

Распространенная мутация в JavaScript – изменение объекта:

В этом примере мы создаем объект с тремя полями, поле settings опционально. Для добавления объекта мы мутируем исходный объект example – добавляем новое свойство. Чтобы понять, как выглядит в итоге объект example со всеми возможными вариациями, нужно просмотреть всю функцию. Было бы удобнее видеть его целиком в одном месте.

Решение

В большинстве кейсов отсутствующее поле в объекте заменимо полем со значением undefined .

В примере также присутствует конструкция try-catch , из которой в случае ошибки возвращается объект с совершенно другой структурой и единственным полем error . Это особый случай – объекты абсолютно разные, нет необходимости их объединять.

Чтобы очистить код, вынесем вычисление settings в отдельную функцию:

Теперь проще понять и что делает фрагмент, и форму возвращаемого объекта. Благодаря рефакторингу мы избавились от мутаций и уменьшили вложенность.

Далеко не все методы в JavaScript возвращают новый массив или объект. Многие мутируют оригинальное значение прямо на месте. Например, push() – один из самых часто используемых.

Проблема

Посмотрим на этот код:

Здесь описаны два пути определения строк таблицы: массив с постоянными значениями и функция, возвращающая строки для опциональных данных. Внутри последней происходит мутация оригинального массива с помощью метода .push() .

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

Решение

Одна из полезных техник рефакторинга – замена императивного кода, полного циклов и условий, на декларативный. Давайте объединим все возможные ряды в единый декларативный массив:

Данные будут выведены в случае, если метод isVisible вернет значение true .

Код стал читаемее и удобнее для поддержки:

  • Всего один путь определения строки таблицы – не нужно решать, какой метод использовать.
  • Все данные в одном месте.
  • Легко редактировать строки, изменяя функцию isVisible .

Проблема

Вот другой пример:

На первый взгляд, этот код не так уж плох. Он конвертирует объект в массив prompts путем добавления новых свойств. Но если взглянуть поближе, мы найдем еще одну мутацию внутри блока if – изменение объекта defaults . И вот это – уже большая проблема, которую сложно обнаружить.

Решение

Код выполняет две задачи внутри одного цикла:

  • конвертация объекта task.parameters в массив promts ;
  • обновление объекта defaults значениями из task.parameters .

Для улучшения читаемости следует разделить операции:

Другие мутирующие методы массивов, которые следует использовать с осторожностью:

Избегайте мутаций аргументов функции

Так как объекты и массивы в JavaScript передаются по ссылке, их изменение внутри функции приводит к неожиданным эффектам в глобальной области видимости.

В этом фрагменте объект person изменяется внутри функции mutate .

Проблема

Подобные мутации могут быть и преднамеренными, и случайными. И то, и то приводит к проблемам:

  • Ухудшается читаемость кода. Функция не возвращает значение, а изменяет один из входящих параметров, становится непонятно, как ее использовать.
  • Ошибки, вызванные случайными изменениями, сложно заметить и отследить.

Этот код конвертирует набор числовых переменных в массив messageProps со следующей структурой:

Проблема в том, что функция addIfGreateThanZero вызывает мутации массива, который мы ей передаем. Это изменение преднамеренное, оно необходимо для работы функции. Однако это не самое лучшее решение – можно создать более понятный и удобный интерфейс.

Решение

Давайте перепишем функцию, чтобы она возвращала новый массив:

Но от этой функции можно полностью отказаться:

Этот код проще для понимания: в нем нет повторов и сразу понятен формат результата. Функция getMessageProps преобразует список значений в массив определенного формата, а затем отфильтровывает элементы с нулевым значением поля count .

Можно еще немного упростить:

Но это приводит к менее очевидному интерфейсу и не позволяет использовать автокомплит в редакторе кода.

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

Вместо цепочки .map() + .filter() можно использовать встроенный метод массивов .reduce() :

Однако код с reduce выглядит менее очевидным и труднее читается, поэтому стоило бы остановиться на предыдущем шаге рефакторинга.

Похоже, что единственная веская причина для мутации входящих параметров внутри функции – это оптимизация производительности. Если вы работаете с огромным объемом данных, то создание нового объекта/массива каждый раз – довольно затратная операция. Но как и с любой другой оптимизацией – не спешите, убедитесь, что проблема действительно существует. Не жертвуйте чистотой и ясностью кода.

Проблема

Иногда мутаций не избежать, например, из-за неудачного API языка. Один из самых популярных примеров – метод массивов .sort() .

Этот фрагмент кода создает ошибочное впечатление, что массив counts не изменяется, а просто создается новый массив puppies , внутри которого и происходит сортировка значений. Однако метод .sort() сортирует массив на месте – вызывает мутацию. Если разработчик не понимает этой особенности, в программе могут возникнуть ошибки, которые будет сложно отследить.

Решение

Лучше сделать мутацию явной:

Создается неглубокая копия массива counts , у которой и вызывается метод sort . Исходный массив, таким образом, остается неизменным.

Другой вариант – обернуть встроенные мутирующие операции кастомной функцией и использовать ее:

Также вы можете применять сторонние библиотеки, например, функцию sortBy библиотеки Lodash:

Обновление объектов

В современном JavaScript появились новые возможности, упрощающие реализацию иммутабельности – спасибо spread-синтаксису. До его появления нам приходилось писать что-то такое:

Обратите внимание на пустой объект, передаваемый в качестве первого аргумента методу Object.assign() . Это исходное значение, которое и будет подвергаться мутациям (цель метода assign ). Таким образом, этот метод и изменяет свой параметр, и возвращает его – крайне неудачный API языка.

Теперь можно писать проще:

Суть та же, но гораздо менее многословно и без странного поведения.

А до введения стандарта ECMAScript 2015, который подарил нам Object.assign, избежать мутаций было и вовсе почти невозможно.

В документации библиотеки Redux есть замечательная страница Immutable Update Patterns, которая описывает концепцию обновления массивов и объектов без мутаций. Эта информация полезна, даже если вы не используете Redux.

Подводные камни методов обновления

Как бы ни был хорош spread-синтаксис, он тоже быстро становится громоздким:

Чтобы изменить глубоко вложенных полей, приходится разворачивать каждый уровень объекта, иначе мы потеряем данные:

В этом фрагменте кода мы сохраняем только первый уровень свойств исходного объекта, а свойства lunch и drinks полностью переписываются.

И spread , и Object.assign осуществляют неглубокое клонирование – копируются только свойства первого уровня вложенности. Так что они не защищают от мутаций вложенных объектов или массивов.

Если вам приходится часто обновлять какую-то структуру данных, лучше сохранять для нее минимальный уровень вложенности.

Пока мы ждем появления в JavaScript иммутабельности из коробки, можно упростить себе жизнь двумя простыми способами:

  • Избегать мутаций.
  • Упростить обновление объектов.

Линтинг

Один из способов отслеживать мутации – использование линтера кода. У ESLint есть несколько плагинов, которые занимаются именно этим. Например, eslint-plugin-better-mutation запрещает любые мутации, кроме локальных переменных внутри функций. Это отличная идея, которая позволяет предотвратить множество ошибок снаружи функции, но оставит большую гибкость внутри. Однако этот плагин часто ломается – даже в простых случаях вроде мутации в коллбэке метода .forEach() .

ReadOnly

Другой способ – пометить все объекты и массивы как доступные только для чтения, если вы используете TypeScript или Flow.

Вот пример использования модификатора readonly в TypeScript:

Использование служебного типа Readonly :

То же самое для массивов:

Модификатор readonly , и тип Readonly защищают от изменений только первый уровень свойств, так что их нужно отдельно добавлять к вложенным структурам.

В плагине eslint-plugin-functional есть правило, которое требует везде добавлять read-only типы. Его использование удобнее, чем их ручная расстановка. К сожалению, поддерживаются только модификаторы.

Заморозка

Чтобы сделать объекты доступными только для чтения во время выполнения, можно использовать метод Object.freeze . Он также работает только на один уровень вглубь. Для «заморозки» вложенных объектов используйте библиотеку вроде deep-freeze.

Упрощение изменений

Для достижения наилучшего результата следует сочетать технику предотвращения мутаций с упрощением обновления объектов.

Самый популярный инструмент для этого – библиотека Immutable.js:

Используйте ее, если вас не раздражает необходимость изучить новый API, а также постоянно преобразовывать обычные массивы и объекты в объекты Immutable.js и обратно.

Другой вариант – библиотека Immer. Она позволяет работать с объектом привычными методами, но перехватывает все операции и вместо мутации создает новый объект.

Immer также замораживает полученный объект в процессе выполнения.

В некоторых (редких) случаях императивный код с мутациями не так уж и плох, и переписывание в декларативном стиле не сделает его лучше. Рассмотрим пример:

Здесь мы создаем массив дат в заданном диапазоне. У вас есть идеи, как можно переписать этот код без императивного цикла, переприсваивания и мутаций?

В целом этот код имеет право на существование:

  • Все «плохие» операции изолированы внутри маленькой функции.
  • Понятное название функции само по себе описывает, что она делает.
  • Работа функции не влияет на внешнюю область видимости: она не использует глобальные переменные и не изменяет свои аргументы.

Если вы используете мутации, постарайтесь изолировать их в маленькие чистые функции с понятными именами.