Розбираємо на прикладах: як уникнути мутацій в JavaScript

  • 15 апреля, 18:42
  • 3976
  • 1

З цього докладного керівництва з численними прикладами коду  JavaScript ви дізнаєтеся, що таке мутації об'єктів, чому їх слід уникати і як це зробити.

Мутація в JavaScript - це зміна об'єкта або масиву без створення нової змінної і переприсвоєння значення. Наприклад, ось так:   

const puppy = {
  name: 'Dessi',
  age: 9
};
puppy.age = 10;

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

Розбираємо на прикладах: як уникнути мутацій в JavaScript

Проблеми з мутаціями

Здавалося б - нічого страшного. Але такі маленькі зміни можуть призводити до великих проблем.   

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.

Код став читабельніший і зручніший для підтримки:

  1. Всього один шлях визначення рядка таблиці - не потрібно вирішувати, який метод використовувати.
  2. Всі дані в одному місці.
  3. Легко редагувати рядки, змінюючи функцію 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. І ось це - вже велика проблема, яку складно виявити.

Рішення

Код виконує два завдання всередині одного циклу:

  1. конвертація об'єкта task.parameters в масив promts;
  2. оновлення об'єкта 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]
}));

***

Інші мутуючі методи масивів, які слід використовувати з обережністю:

  1. .copyWithin ()
  2. .fill ()
  3. .pop ()
  4. .push ()
  5. .reverse ()
  6. .shift ()
  7. .sort ()
  8. .splice ()
  9. .unshift ()

Уникайте мутацій аргументів функції

Так як об'єкти і масиви в JavaScript передаються по посиланню, їх зміна всередині функції призводить до несподіваних ефектів в глобальному контексті.

const mutate = object => {
  object.secret = 'Loves pizza';
};

const person = { name: 'Chuck Norris' };
mutate(person);
// -> { name: 'Chuck Norris', secret: 'Loves pizza' }

У цьому фрагменті об'єкт person змінюється всередині функції mutate.

Проблема

Подібні мутації можуть бути і навмисними, і випадковими. І те, і те призводить до проблем:

  1. Погіршується читабельність коду. Функція не повертає значення, а змінює один з вхідних параметрів, стає незрозуміло, як її використовувати.
  2. Помилки, викликані випадковими змінами, складно помітити і відстежити.

Розглянемо приклад:

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 [...counts].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 іммутабельності з коробки, можна спростити собі життя двома простими способами:

  1. Уникати мутацій.
  2. Спростити оновлення об'єктів.

Відстеження мутацій

Лінтинг

Один із способів відслідковувати мутації - використання лінтера коду. У 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 [...counts].sort();
}

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

В плагіні eslint-plugin-functional є правило, яке вимагає всюди додавати 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. Робота функції не впливає на зовнішню область видимості: вона не використовує глобальні змінні і не змінює свої аргументи.

Краще писати простий і чистий код з мутаціями, ніж складний і заплутаний без них! Якщо ви використовуєте мутації, постарайтеся ізолювати їх в маленькі чисті функції зі зрозумілими іменами.

Інструкція

  1. Імперативний код з мутаціями складніше читати і підтримувати, ніж чистий декларативний код. 
  2. Зберігайте повну форму об'єкта в одному місці і тримайте її максимально чистою.
  3. Відокремлюйте логіку «ЩО» від логіки «ЯК».
  4. Зміни вхідних параметрів функції призводить до непомітних, але дуже неприємних помилок, які важко дебажити.
  5. Ланцюжок методів .map()+ .filter() в більшості випадків виглядає зрозуміліше, ніж один метод .reduce().
  6. Якщо вам дуже хочеться щось мутувати, робіть це максимально явно. Намагайтеся ізолювати подібні операції в функції.
  7. Використовуйте техніки для автоматичного запобігання мутацій. 

Джерело перекладу


1 комментарий
Сортировка:
Добавить комментарий
stamper@ukr.net
stamper@ukr.net 2020, 21 апреля, 17:57
0

хотелось бы увидеть объяснение, почему это плохо