Strona główna / Artykuły / Unikanie błędów stanu milczącego spowodowanych mutacją referencji w JavaScript

Unikanie błędów stanu milczącego spowodowanych mutacją referencji w JavaScript

Dowiedz się, dlaczego modyfikacja obiektów i tablic przez referencję zakłóca ponowne renderowanie w React, dlaczego operacja spread kopiuje jedynie powierzchownie oraz jak bezpiecznie stworzyć głęboką kopię stanu.

1565 słów

Użytkownik otwiera okno ustawień konta w twoim produkcie SaaS, zmienia nazwę przestrzeni roboczej z „Acme Marketing” na „Acme Global”, a potem zmienia zdanie i naciska Anuluj.

Okno znika. Jednak nazwa przestrzeni roboczej pokazywana w górnej liście nawigacyjnej również jest teraz „Acme Global”.

Użytkownik przeładowuje stronę, zdezorientowany. Nazwa wraca do „Acme Marketing”.

Gdy sprawdzasz to dokładniej, okazuje się, że stan formularza znajdował się w lokalnym stanie komponentu, a kliknięcie Anuluj nigdy nie wysyłało żadnego żądania sieciowego. Jak więc zmiana, która nigdy nie została zapisana, mogła wpłynąć na globalny nagłówek?

Powodem jest dwie pozornie proste linijki kodu:

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

To klasyczny i łatwy do przeoczenia sposób awarii w aplikacjach JavaScript: przypadkowa mutacja poprzez wspólne referencje.

Ponieważ obiekty i tablice w JavaScript są obsługiwane poprzez referencje, a nie wartości, zmiana obiektu w jednym komponencie może bez żadnych objawów zmienić te same dane wszędzie indziej, gdzie są używane w aplikacji — omijając system zarządzania stanem, zakłócając sposób, w jaki React decyduje, co należy ponownie wyrenderować, i pozostawiając użytkownika w obliczu błędu bez oczywistej przyczyny.

1. Równość referencji: Jak JavaScript postrzega twoje dane

Aby zrozumieć, dlaczego mutacja powoduje tyle problemów, pomocne jest zrozumienie, w jaki sposób silnik JavaScript faktycznie przechowuje twoje wartości w pamięci.

Wartości prymitywne — liczby, łańcuchy, wartości logiczne, null, undefined — są kopiowane według wartości:

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

Obiekty, tablice i funkcje działają inaczej: są przechowywane poprzez referencję. Zmienna przechowująca obiekt nie zawiera bezpośrednio danych tego obiektu — przechowuje ona wskaźnik do miejsca w pamięci, gdzie te dane faktycznie się znajdują:

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!

Współczesne frameworki interfejsu użytkownika, takie jak React, w dużej mierze polegają na powierzchownych sprawdzaniach równości (używając Object.is lub ===) aby określić, czy komponent rzeczywiście wymaga ponownego renderowania.

Zatem jeśli zmienisz istniejący obiekt bezpośrednio, a następnie przekażesz go z powrotem do 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 porównuje poprzednią referencję stanu z nową. Ponieważ są to dosłownie ten sam obiekt w pamięci, React decyduje, że nic się nie zmieniło, i całkowicie pomija ponowne renderowanie.

Dane podstawowe zostały zaktualizowane, ale ekran pozostaje zamrożony w swoim starym stanie.

2. Iluzja operatora rozszerzania

Aby uniknąć bezpośredniej modyfikacji, wielu programistów korzysta z operatora rozszerzania (...) w obiektach. Jest to przydatne narzędzie, ale powszechnym błędnym przekonaniem jest to, że tworzy on pełną kopię głęboką.

To nie jest prawda.

Operator rozszerzania kopiuje jedynie najwyższy poziom obiektu. Wszystkie obiekty lub tablice zawarte wewnątrz są nadal udostępniane za pomocą referencji z oryginałem.

Weźmy typowy obiekt ustawień, jaki można znaleźć w aplikacji 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!

Ponieważ notifications sam w sobie jest obiektem, zarówno userSettings.notifications, jak i defaultSettings.notifications wskazują nadal na ten sam blok pamięci.

Jeśli defaultSettings jest stałą na poziomie modułu dostępną we wspólnym zasobie, edycja ustawień jednego użytkownika może potajemnie uszkodzić domyślną konfigurację używaną wszędzie indziej w aplikacji.

3. Miny na drodze metod tablicowych

JavaScript zawiera kilka wbudowanych metod tablicowych, które modyfikują tablicę, na której zostały wywołane, zamiast zwracać nową.

Gdy przekażesz tablicę pochodzącą z props lub wspólnego stanu do jednej z tych metod, pojawią się efekty uboczne, których nie chciałeś:

// 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!

Wyobraź sobie komponent tabeli renderujący listę transakcji:

// 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>
  );
}

Każde renderowanie TransactionTable potajemnie przestawia tablicę transakcji, która faktycznie należy do komponentu nadrzędnego.

Współczesne rozwiązanie: Metody tablicowe niezmieniające struktury

Nowsze wersje ECMAScript wprowadziły odpowiedniki tych metod, które nie zmieniają oryginału, zwracając zamiast tego nowy tablicę:

Metoda powodująca zmiany (unikaj) w porównaniu z jej wersją bez zmian (zalecane): arr.sort(fn) staje się arr.toSorted(fn), arr.reverse() staje się arr.toReversed(), arr.splice(start, count) staje się arr.toSpliced(start, count), a arr[index] = val staje się arr.with(index, val).

Zamiast modyfikować tablicę transactions bezpośrednio:

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

4. Nowoczesne kopiowanie głębokie: structuredClone kontra sztuczki z JSON

Gdy twoja aplikacja naprawdę wymaga głębokiej, całkowicie niezależnej kopii zagnieżdżonego stanu, nadszedł czas, aby porzucić stary sposób rozwiązania JSON.parse(JSON.stringify(obj)).

To podejście oparte na JSON ma kilka poważnych wad:

  • Funkcje oraz wartości undefined są cicho pomijane.
  • Obiekty daty zamieniają się w zwykłe łańcuchy ISO zamiast pozostać instancjami typu Date.
  • Obiekty Map, Set, RegExp oraz ArrayBuffer są niszczone w trakcie tego procesu.
  • Odwołania cyrkularne powodują natychmiastowe wywołanie błędu.

Standard: structuredClone()

Każdy obecny przeglądarka oraz środowisko Node.js posiada wbudowaną obsługę funkcji 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

Wywołanie structuredClone na obiekcie originalProject tworzy rzeczywiście oddzielną kopię: modyfikacja zestawu tagów tej kopii lub zmiana nazwy zagnieżdżonego współpracownika nie ma żadnego wpływu na obiekt źródłowy, ponieważ cała struktura zagnieżdżona została skopiowana, a nie tylko odwoływana.

5. Kiedy niezmienność staje się problemem wydajności

Niezmienność zapewnia przewidywalność logiki interfejsu użytkownika, ale stosowanie głębokiego klonowania przy każdej aktualizacji może negatywnie wpłynąć na wydajność, jeśli nie postępujesz ostrożnie.

Pomyśl o tabeli danych zawierającej 50 000 wierszy lub wykresie opartym na canvas, który wykonywa obliczenia z częstotliwością 60 klatek na sekundę. Głębokie klonowanie całej struktury — czy to za pomocą structuredClone, czy w inny sposób — przy każdej interakcji powoduje napływ śmieci, które musi usunąć kolektor, co sprawia, że przeglądarka zaczyna pracować nierównomiernie ze względu na ciągłe przydzielanie i zwalnianie dużych ilości pamięci.

Zrównoważone podejście

  1. Klonuj tylko poziom, który zmieniasz: jeśli aktualizacja dotyczy tylko user.name, wystarczy płytkie klonowanie najwyższego poziomu:
{ ...user, name: "New Name" }
  1. Korzystaj z bibliotek do współdzielenia struktury, gdy nesting jest głęboki: w przypadku drzew stanu o wielu warstwach narzędzia takie jak Immer umożliwiają uniknięcie pełnych kopii głębokich. Immer opiera się na obiektach JavaScript Proxy, aby sklonować tylko te gałęzie, które faktycznie zostały zmienione, dzięki czemu każda nienaruszona gałąź nadal wskazuje na swoją oryginalną referencję.
import { produce } from "immer";
// Clean, intuitive mutation syntax with zero reference pollution
const nextState = produce(currentState, (draft) => {
  draft.users[0].preferences.theme = "dark";
});

To zapewnia syntaksę w stylu mutacji, która jest łatwa do odczytania, podczas gdy Immer w tle tworzy nowy, poprawnie zaktualizowany obiekt stanu, bez przypadkowego współdzielenia referencji.

Podsumowanie i zasady niezmienności

Aby uniknąć fałszywych błędów i ukrytej korupcji stanu w twojej bazie kodu JavaScript:

  1. Nie modyfikuj bezpośrednio właściwości ani stanu: traktuj wszystkie dane pochodzące z zewnątrz twojego lokalnego zakresu jako tylko do odczytu.
  • Pamiętaj, że kopiowanie powierzchowne jest niewystarczające: { ...obj } oraz [ ...arr ] kopiują tylko poziom najwyższy; zagnieżdżone obiekty i tablice pozostają wspólnymi referencjami.
  • Najlepiej używać metod tablic to...: korzystaj z toSorted(), toReversed() i toSpliced() zamiast ich wersji mutujących, takich jak sort().
  • Dla prawdziwych głębokich kopii użyj structuredClone: przestań polegać na JSON.parse(JSON.stringify()) przy pracy z złożonymi, zagnieżdżonymi danymi.
  • Korzystaj ze współdzielenia strukturalnego: w przypadku głęboko zagnieżdżonego stanu Immer umożliwia czyste aktualizowanie danych bez konieczności kopiowania wszystkiego.
  • Szanowanie zasady równości referencyjnej oraz przestrzeganie zasad niezmienności eliminuje całą kategorię błędów w produkcji – takich, które w przeciwnym razie skutkowałyby godzinami kłopotliwego debugowania.

    Literatura pokrewna

  • Jak przeglądarka rysuje i gdzie mieści się React — Dowiedz się, jak krytyczna ścieżka renderowania, proces synchronizacji, Fiber oraz planer współpracują ze sobą, aby przekształcić aktualizacje React w piksele na ekranie.
  • Wydajność frontendu: od niewidzialnych błędów w przeglądaniu kodu do metryk produktu — Dowiedz się, dlaczego samo przeprowadzenie przeglądu kodu nie wystarcza, które z Core Web Vitals są naprawdę istotne oraz jak mierzyć i naprawiać problemy z wydajnością React w praktyce.