Главная / Статьи / Избегание ошибок бесшумного состояния из-за мутации ссылки в 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] = valarr.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 строками или график, построенный на канвасе, который выполняет вычисления со скоростью 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 в реальных условиях.