Головна / Статті / Уникнення помилок бездіяльного стану через зміни посилань у JavaScript

Уникнення помилок бездіяльного стану через зміни посилань у JavaScript

Дізнайтеся, чому зміна об’єктів та масивів через посилання порушує процес перерендерингу в React, чому операція spread копіює дані лише поверхнево, та як безпечно створювати глибокі клони стану.

1565 слів

Користувач відкриває модальне вікно налаштувань облікового запису у вашому продукті SaaS, змінює назву робочого простору з „Acme Marketing“ на „Acme Global“, а потім передумує та натискає „Скасувати“.

Модальне вікно зникає. Але назва робочого простору, яка відображається у верхній панелі навігації, тепер також показується як „Acme Global“.

Користувач перезавантажує сторінку, здивований. Назва повертається до „Acme Marketing“.

При детальному аналізі виявляється, що стан форми зберігався у локальному стані компонента, і натискання „Скасувати“ жодним чином не відправляло запит до мережі. То як зміна, яка так і не була збережена, потрапила до глобального заголовка?

Причиною є два на перший погляд прості рядки коду:

// In the modal component
const formState = currentUser.workspace;
formState.name = newName; // Direct object reference mutation!

Це класичний та легко помилково ігнорований спосіб збою в додатках на JavaScript: випадкова мутація через спільні посилання.

Оскільки об’єкти та масиви в JavaScript обробляються за посиланням, а не за значенням, зміна об’єкта всередині одного компонента може тихо змінити ці самі базові дані всюди, де вони використовуються в додатку — обійшовши систему керування станом, порушивши спосіб, яким React вирішує, що перерендерувати, та залишивши вас з помилкою без очевидної причини.

1. Рівність за посиланням: як JavaScript бачить ваші дані

Щоб зрозуміти, чому мутації спричиняють стільки проблем, корисно зрозуміти, як двигун JavaScript насправді зберігає ваші значення в пам’яті.

Примітивні значення — числа, рядки, логічні значення, null, undefined — копіюються за значенням:

let a = 10;
let b = a;
b = 20;console.log(a); // 10 (unchanged)

Об’єкти, масиви та функції працюють по-різному: вони зберігаються за посиланням. Змінна, яка містить об’єкт, не містить його даних безпосередньо — вона містить вказівник на адресу пам’яті, де насправді знаходяться ці дані:

const userA = {
  name: "Sarah",
  role: "Admin"
};
const userB = userA; // userB points to the EXACT same memory address!userB.name = "Alex";console.log(userA.name); // "Alex" — userA was mutated!

Сучасні фреймворки користувацького інтерфейсу, такі як React, сильно покладаються на поверхневі перевірки рівності (з використанням Object.is або ===) для визначення того, чи справді компоненту потрібно перерендеруватися.

Тож якщо ви змінюєте існуючий об’єкт безпосередньо та потім передаєте його назад до setState:

// BAD: Mutating existing state directly
const [user, setUser] = useState({
  name: "Sarah",
  age: 30
});
function updateAge() {
  user.age = 31; // Direct mutation
  setUser(user); // Passes the SAME memory reference!
}

React порівнює посилання на попередній стан із новим. Оскільки це буквально той самий об’єкт у пам’яті, React вирішує, що нічого не змінилося, і повністю пропускає перерендерування.

Основні дані були оновлені, але екран залишається у своєму старому стані.

2. Ілюзія оператора розповсюдження

Щоб уникнути прямої зміни даних, багато розробників використовують оператор розповсюдження (...) для об’єктів. Це корисний інструмент, але поширеним хибним уявленням є те, що він створює повну глибоку копію.

Це не так.

Оператор розповсюдження копіює лише верхній рівень об’єкта. Будь-які вкладені об’єкти чи масиви все одно діляться посиланням із оригіналом.

Розгляньмо типовий об’єкт налаштувань, який можна знайти в додатку типу SaaS:

const defaultSettings = {
  theme: "dark",
  notifications: {
    email: true,
    sms: false
  }
};
// Shallow copy using spread
const userSettings = { ...defaultSettings };// Changing a nested property
userSettings.notifications.email = false;// Disaster: defaultSettings was also mutated!
console.log(defaultSettings.notifications.email); // false!

Оскільки notifications сам по собі є об’єктом, як userSettings.notifications, так і defaultSettings.notifications все ще вказують на ту саму ділянку пам’яті.

Якщо defaultSettings є константою на рівні модуля, якою користуються всі, зміна налаштувань одного користувача може тихо пошкодити стандартну конфігурацію, яка використовується всюди іншде в додатку.

3. Підводні камені методів масивів

У JavaScript є кілька вбудованих методів масивів, які змінюють саме той масив, на якому вони викликаються, замість того щоб повертати новий.

Якщо передати масив, отриманий з props або спільного стану, у один із цих методів, виникнуть небажані побічні ефекти:

// METHODS THAT MUTATE IN PLACE (Dangerous with state)
array.sort();     // Mutates original array!
array.reverse();  // Mutates original array!
array.splice();   // Mutates original array!
array.push();     // Mutates original array!
array.pop();      // Mutates original array!

Уявіть компонент таблиці, який відображає список транзакцій:

// BAD: Direct prop mutation during render
function TransactionTable({
  transactions
}: {
  transactions: Transaction[];
}) {
  // transactions.sort() permanently reorders the array in parent state!
  const sorted = transactions.sort(
    (a, b) => b.amount - a.amount
  );
  return (
    <table>
      {sorted.map((tx) => (
        <tr key={tx.id}>
          <td>{tx.amount}</td>
        </tr>
      ))}
    </table>
  );
}

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

Сучасне рішення: методи масивів без змін

Останні версії ECMAScript ввели немутуючі аналоги цих методів, кожен з яких повертає новий масив замість того, щоб змінювати оригінал:

Мутуючий метод (уникати) проти його немутуючої заміни (використовувати): arr.sort(fn) стає arr.toSorted(fn), arr.reverse() стає arr.toReversed(), arr.splice(start, count) стає arr.toSpliced(start, count), а arr[index] = val стає arr.with(index, val).

Замість того, щоб мутувати масив transactions на місці:

// GOOD: Leaves the original transactions array pristine
const sorted = transactions.toSorted(
  (a, b) => b.amount - a.amount
);

4. Сучасне глибоке копіювання: structuredClone проти трюків з JSON

Коли ваше застосування дійсно потребує глибокої, повністю незалежної копії вкладених даних, настав час відмовитися від старого способу JSON.parse(JSON.stringify(obj)).

Цей трюк, заснований на JSON, має кілька серйозних недоліків:

  • Функції та значення типу undefined потайки видаляються.
  • Об’єкти типу Date перетворюються на звичайні рядки у форматі ISO, замість того щоб залишатися екземплярами Date.
  • Об’єкти Map, Set, RegExp та ArrayBuffer знищуються під час цього процесу.
  • Циклічні посилання спричиняють пряму помилку.

Стандарт: structuredClone()

Усі сучасні браузери та середовища виконання Node.js мають вбудовану підтримку structuredClone():

const originalProject = {
  id: "proj_123",
  metadata: {
    createdAt: new Date(),
    tags: new Set(["frontend", "ui"])
  },
  collaborators: [
    { name: "Sarah" }
  ]
};
// Creates a complete, true deep copy
const clonedProject = structuredClone(originalProject);clonedProject.metadata.tags.add("react");
clonedProject.collaborators[0].name = "Alex";// Original remains completely untouched
console.log(
  originalProject.metadata.tags.has("react")
); // falseconsole.log(
  originalProject.collaborators[0].name
); // "Sarah"console.log(
  originalProject.metadata.createdAt instanceof Date
); // true

Виклик structuredClone на об’єкті originalProject створює справді окрему копію: зміна набору тегів клону чи оновлення імені вкладеного співробітника не має жодного впливу на вихідний об’єкт, оскільки кожна вкладена структура була скопійована, а не лише посилана.

5. Коли незмінність стає проблемою продуктивності

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

Уявіть собі таблицю даних із 50 000 рядків або графік, створений на основі canvas, який виконує обчислення з частотою 60 кадрів на секунду. Глибоке копіювання всієї структури — чи то за допомогою structuredClone, чи іншим способом — під час кожної взаємодії призводить до накопичення великої кількості сміття, яке потрібно очищувати, і браузер починає працювати з перешкодами через постійне виділення та звільнення великих об’ємів пам’яті.

Збалансований підхід

  1. Копіюйте лише поверхнево той рівень, який змінюється: якщо зміни стосуються лише user.name, достатньо поверхневого копіювання верхнього рівня:
{ ...user, name: "New Name" }
  1. Використовуйте бібліотеки для структурного спільного використання, коли ієрархія глибока: для дерев стану з багатьма рівнями інструменти на кшталт Immer дозволяють уникнути повних глибоких копій. Immer використовує об’єкти JavaScript Proxy для клонування лише тих гілок, які були фактично змінені, тож кожна незмінена гілка продовжує посилатися на свій первинний об’єкт.
import { produce } from "immer";
// Clean, intuitive mutation syntax with zero reference pollution
const nextState = produce(currentState, (draft) => {
  draft.users[0].preferences.theme = "dark";
});

Це забезпечує синтаксис у стилі мутацій, який зручно читати, тоді як Immer у фоновому режимі створює новий, правильно оновлений об’єкт стану без випадкового спільного використання посилань.

Підсумок та правила незмінності

Щоб уникнути „привидних“ помилок та прихованого пошкодження стану у вашому коді на JavaScript:

  1. Не мутуйте пропси чи стан безпосередньо: розглядайте будь-які дані ззовні вашого локального області видимості як лише для читання.
  • Пам’ятайте, що копіювання за принципом «широкого поширення» є поверхневим: { ...obj } та [ ...arr ] копіюють лише верхній рівень; вкладені об’єкти та масиви залишаються спільними посиланнями.
  • Використовуйте методи масивів типу to...: звертайтеся до toSorted(), toReversed() та toSpliced() замість їхніх версій, які змінюють дані, наприклад sort().
  • Для справжніх глибоких копій використовуйте structuredClone: не покладайтесь на JSON.parse(JSON.stringify()) при роботі з складними вкладеними даними.
  • Скористайтеся принципом структурного спільного використання: для глибоко вкладених даних Immer дозволяє оновлювати їх без проблем, не оплачуючи витрат на копіювання всього.
  • Дотримання принципу рівності посилань та суворе дотримання незмінності усуває цілу категорію багів у продакшені — саме тих, які інакше призводили б до годин складного відлову проблем.

    Пов’язана література

  • Як браузер малює елементи та яке місце займає React — Дізнайтеся, як критичний шлях відображення, процес узгодження даних, технологія Fiber та планувальник співпрацюють для перетворення оновлень React на пікселі на екрані.
  • Продуктивність фронтенду: від недоліків перевірки коду до показників продукту — Дізнайтеся, чому достатньо лише перевірки коду недостатньо, які саме Core Web Vitals мають значення, та як вимірювати та вирішувати проблеми з продуктивністю React у реальних умовах.