Мутация в JavaScript – это изменение объекта или массива без создания новой переменной и переприсваивания значения. Например, вот так:
Оригинальный объект puppy
мутировал: мы изменили значение поля age
.
Проблемы с мутациями
Казалось бы – ничего страшного. Но такие маленькие изменения могут приводить к большим проблемам.
Когда мы вызываем функцию с названием printSortedArray
, то обычно не думаем о том, что она что-то сделает с полученными данными. Но здесь встроенный метод массива sort()
изменяет оригинальный массив. Так как массивы в JavaScript передаются по ссылке, то последующие операции будут иметь дело с обновленным, отсортированным порядком элементов.
Подобные ошибки трудно заметить, ведь операции выполняются нормально – только с результатом что-то не так. Функция рассчитывает на один аргумент, а получает «мутанта» – результат работы другой функции.
Решением являются иммутабельные (неизменяемые) структуры данных. Эта концепция предусматривает создание нового объекта для каждого обновления.
Если у вас есть 9
-месячный puppy
, который внезапно подрос, придется записать его в новую переменную grownUpPuppy
.
К сожалению, иммутабельность из коробки в JavaScript не поддерживается. Существующие решения – это более или менее кривые костыли. Но если вы будете максимально избегать мутаций в коде, код станет понятнее и надежнее.
const
в 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 типы. Его использование удобнее, чем их ручная расстановка. К сожалению, поддерживаются только модификаторы.
Еще удобнее был бы TypeScript флаг для дефолтной установки read-only типов с возможностью отката.
Заморозка
Чтобы сделать объекты доступными только для чтения во время выполнения, можно использовать метод Object.freeze
. Он также работает только на один уровень вглубь. Для «заморозки» вложенных объектов используйте библиотеку вроде deep-freeze.
Упрощение изменений
Для достижения наилучшего результата следует сочетать технику предотвращения мутаций с упрощением обновления объектов.
Самый популярный инструмент для этого – библиотека Immutable.js:
Используйте ее, если вас не раздражает необходимость изучить новый API, а также постоянно преобразовывать обычные массивы и объекты в объекты Immutable.js и обратно.
Другой вариант – библиотека Immer. Она позволяет работать с объектом привычными методами, но перехватывает все операции и вместо мутации создает новый объект.
Immer также замораживает полученный объект в процессе выполнения.
Иногда в мутациях нет ничего плохого
В некоторых (редких) случаях императивный код с мутациями не так уж и плох, и переписывание в декларативном стиле не сделает его лучше. Рассмотрим пример:
Здесь мы создаем массив дат в заданном диапазоне. У вас есть идеи, как можно переписать этот код без императивного цикла, переприсваивания и мутаций?
В целом этот код имеет право на существование:
- Все «плохие» операции изолированы внутри маленькой функции.
- Понятное название функции само по себе описывает, что она делает.
- Работа функции не влияет на внешнюю область видимости: она не использует глобальные переменные и не изменяет свои аргументы.
Если вы используете мутации, постарайтесь изолировать их в маленькие чистые функции с понятными именами.
Руководство к действию
- Императивный код с мутациями сложнее читать и поддерживать, чем чистый декларативный код. Поэтому – рефакторьте!
- Сохраняйте полную форму объекта в одном месте и держите ее максимально чистой.
- Отделяйте логику «ЧТО» от логики «КАК».
- Изменения входных параметров функции приводит к незаметным, но очень неприятным ошибкам, которые трудно дебажить.
- Цепочка методов
.map()
+.filter()
в большинстве случаев выглядит понятнее, чем один метод.reduce()
. - Если вам очень хочется что-то мутировать, делайте это максимально явно. Старайтесь изолировать подобные операции в функции.
- Используйте техники для автоматического предотвращения мутаций.
Комментарии