01 апреля 2020

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

Frontend-разработчик в Foquz. https://www.cat-in-web.ru/
Из этого подробного руководства с многочисленными примерами кода на 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;
};

    

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

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

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

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

Руководство к действию

  1. Императивный код с мутациями сложнее читать и поддерживать, чем чистый декларативный код. Поэтому – рефакторьте!
  2. Сохраняйте полную форму объекта в одном месте и держите ее максимально чистой.
  3. Отделяйте логику «ЧТО» от логики «КАК».
  4. Изменения входных параметров функции приводит к незаметным, но очень неприятным ошибкам, которые трудно дебажить.
  5. Цепочка методов .map() + .filter() в большинстве случаев выглядит понятнее, чем один метод .reduce().
  6. Если вам очень хочется что-то мутировать, делайте это максимально явно. Старайтесь изолировать подобные операции в функции.
  7. Используйте техники для автоматического предотвращения мутаций.
Больше полезной информации вы можете найти на нашем телеграм-канале «Библиотека фронтендера».

А как вы боретесь с мутациями в JavaScript коде?

Источники

МЕРОПРИЯТИЯ

Комментарии

ВАКАНСИИ

Добавить вакансию
Golang разработчик (middle)
от 230000 RUB до 300000 RUB
Аналитик данных
Екатеринбург, по итогам собеседования

ЛУЧШИЕ СТАТЬИ ПО ТЕМЕ