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.
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
undefinedsą 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
- Klonuj tylko poziom, który zmieniasz: jeśli aktualizacja dotyczy tylko
user.name, wystarczy płytkie klonowanie najwyższego poziomu:
{ ...user, name: "New Name" }
- 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:
- 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.
{ ...obj } oraz [ ...arr ] kopiują tylko poziom najwyższy; zagnieżdżone obiekty i tablice pozostają wspólnymi referencjami.to...: korzystaj z toSorted(), toReversed() i toSpliced() zamiast ich wersji mutujących, takich jak sort().structuredClone: przestań polegać na JSON.parse(JSON.stringify()) przy pracy z złożonymi, zagnieżdżonymi danymi.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
- Zrozumienie custom hooks w React: Użytkowanie logiki bez dzielenia się stanem — Dowiedz się, czym są custom hooks w React, jak wyodrębniają i udostępniają logikę związana ze stanem pomiędzy komponentami oraz jakich błędów należy unikać podczas ich tworzenia.
- Dziewięć częstych wzorców, które powodują niepotrzebne przeredrawiania w React — Wyjaśnia dziewięć codziennych wzorców związanych ze stanem i efektami w React, które potajemnie rozszerzają zakres przeredrawiania, oraz jak przebudować komponenty, aby aktualizacje były lokalne.