Zrozumienie proxy w JavaScript: pułapki, Reflect i wzorce reaktywne
Dowiedz się, jak obiekty Proxy i Reflect w JavaScript umożliwiają przechwytywanie dostępu do właściwości w celu zwiększenia skuteczności walidacji, tworzenia właściwości wirtualnych oraz wykorzystania frameworków reaktywnych.
Za każdym razem, gdy piszesz obj.property lub przypisujesz do niego wartość za pomocą obj.property = value, polegasz na operacjach, które JavaScript wykonywa w tle, bez wbudowanego sposobu na obserwowanie lub modyfikowanie tego, co dzieje się pod spodem. Obiekt Proxy całkowicie eliminuje to założenie. Pozwala on na otoczenie obiektu docelowego i przechwytywanie tych podstawowych operacji – czytania, zapisu, usuwania – zanim wejdą one w życie. Ten mechanizm przechwytywania okazuje się być prawdziwym silnikiem wielu zjawisk, które wyglądają jak „magia” w nowoczesnych bibliotekach i narzędziach JavaScript.
Otoczenie, które może przechwytywać wszystko
Proxy znajduje się pomiędzy twoim kodem a obiektem, który otacza, a zbiór funkcji przechwytywających decyduje o tym, co faktycznie dzieje się przy każdym rodzaju operacji wykonywanej na nim.
const config = { retries: 3, timeout: 5000 };
const guardedConfig = new Proxy(config, {
set(target, key, value) {
if (key === "retries" && (typeof value !== "number" || value < 0)) {
throw new TypeError("retries must be a non-negative number");
}
target[key] = value;
return true;
},
});
guardedConfig.retries = 5; // works fine
guardedConfig.retries = -1; // throws TypeError, caught before it ever reaches the object
Nie ma nic szczególnego w zapisie guardedConfig.retries = 5, co odróżniałoby go od zwykłego przypisywania właściwości. To właśnie jest celem takiego projektu: interwencja pozostaje niewidoczna w miejscu wywołania. Można dodawać warunki walidacji, logowanie lub inne efekty uboczne do tego, co wygląda jak zwykły dostęp do właściwości, bez konieczności wprowadzania zmian w kodzie, który faktycznie używa tego obiektu.
Mechanizm stojący za frameworkami reaktywnymi
function reactive(target, onChange) {
return new Proxy(target, {
get(obj, key) {
return obj[key];
},
set(obj, key, value) {
const changed = obj[key] !== value;
obj[key] = value;
if (changed) onChange(key, value);
return true;
},
});
}
const state = reactive({ count: 0 }, (key, value) => {
console.log(`${key} changed to ${value}, re-rendering...`);
});
state.count = 1; // "count changed to 1, re-rendering..." — no explicit call needed
Zapis state.count = 1 wygląda jak zupełnie zwykła operacja przypisania, ponieważ pod względem składniowym faktycznie nią jest. To, co nadaje jej znaczenie, to „pułapka set”, która przekształca tę zwyczajną linię kodu w zdarzenie, na które reszta systemu może zareagować. To właśnie stanowi prawdziwe podstawy, na których opierają się frameworki reaktywne. Nie istnieje żaden specjalny typ danych „obserwowalnych”, który trzeba używać we wszystkich miejscach. Są to zwykłe obiekty otoczone Proxy, który cicho wykrywa każdą operację zapisu dokonaną w nich.
Wirtualne właściwości: wartości istniejące tylko wtedy, gdy o nie poprosisz
„Pułapka get” może swobodnie zwracać wartość, która w rzeczywistości nigdy nie została zapisana w docelowym obiekcie, co oznacza, że można udostępniać wartości obliczone lub pochodne tak, jakby były to zwykłe właściwości.
const product = { name: "Desk Lamp", priceCents: 3499 };
const productView = new Proxy(product, {
get(target, key) {
if (key === "priceDollars") {
return (target.priceCents / 100).toFixed(2);
}
return target[key];
},
});
console.log(productView.priceDollars); // "34.99" — computed on read, not stored anywhere
W obiekcie product nie ma żadnego pola priceDollars. Jest ono generowane dynamicznie za każdym razem, gdy jest dostępne, wewnątrz mechanizmu get. Różni się to znacząco od gettera napisanego za pomocą get wewnątrz klasy lub literala obiektu, ponieważ mechanizm Proxy może przechwytywać dostęp do kluczy, które w ogóle nie zostały wcześniej zadeklarowane. Jest to przydatne na przykład wtedy, gdy chcesz otoczyć odpowiedź API dodatkowymi obliczanymi polami, nie zmieniając oryginalnej treści.
Dlaczego Reflect często pojawia się razem z Proxy
Istnieje subtelność, która zaskakuje osoby po raz pierwszy piszące mechanizm przechwytywania, który musi korzystać z zachowania domyślnego. Proste podejście może łatwo prowadzić do niezauważalnych błędów.
const handler = {
get(target, key) {
console.log(`reading ${key}`);
return target[key]; // works, but loses some edge-case correctness
},
};
Dla prostych obiektów taki podejście zazwyczaj działa, ale bezpieczniejszym i poprawniejszym sposobem na przekierowanie operacji do jej domyślnego zachowania jest użycie Reflect. Dostarcza on funkcję dla każdej sytuacji krytycznej, która dokładnie odtwarza operację, zachowując właściwe powiązanie this oraz prawidłowo radząc sobie z trudniejszymi przypadkami, takimi jak dziedziczone gettery.
const handler = {
get(target, key, receiver) {
console.log(`reading ${key}`);
return Reflect.get(target, key, receiver);
},
};
Wywołanie Reflect.get(target, key, receiver) odtwarza dokładnie to, co zrobiłby zwykły dostęp do właściwości, włączając przypadki krawędziowe związane z prototypami oraz getterami, które zależą od this, sytuacje, w których target[key] może po cichu zwrócić błędny wynik. W praktycznym kodzie wykorzystującym Proxy konwencją jest niemal zawsze wywoływanie odpowiedniej metody Reflect bezpośrednio wewnątrz każdej „pułapki”, chyba że celowo zmienia się domyślne zachowanie, ponieważ to właśnie ta wersja gwarantuje zgodność z tym, co normalnie zrobiłby zwykły dostęp do właściwości.
Pułapki, które ograniczają zamiast rozszerzać
Pułapki nie służą tylko do dodawania zachowań, ale równie łatwo mogą je usunąć. Pułapka has kontroluje to, co zwraca operator in, a pułapka deleteProperty może całkowicie odmówić usunięcia.
const secureRecord = new Proxy(
{ id: 1, ssn: "123-45-6789" },
{
get(target, key) {
if (key === "ssn") throw new Error("Direct access to ssn is not allowed");
return Reflect.get(target, key);
},
deleteProperty() {
throw new Error("Deleting fields is not allowed on this record");
},
}
);
console.log(secureRecord.id); // 1
console.log(secureRecord.ssn); // throws
delete secureRecord.id; // throws
Tego typu ochrona różni się w istotny sposób od prywatnego pola klasy: może być zastosowana do dowolnego obiektu, bez konieczności wcześniejszego zdefiniowania tej klasy, i umożliwia określenie wąskich reguł dotyczących poszczególnych kluczy, których pojedyncze oznaczenie #private po prostu nie może wyrazić.
Jedna idea, wiele pułapek
Każda pułapka w rzeczywistości odpowiada na to samo pytanie: co powinno się stać w momencie, gdy kod próbuje wykonać tę codzienną operację na tym obiekcie? Odczytanie wartości, zapisanie jej, usunięcie, sprawdzenie, czy dana klucz istnieje – to wszystko to zazwyczaj niewidzialne, automatyczne działania, a Proxy sprawia, że każde z nich staje się momentem, który można zaobserwować lub przekierować. Systemy stanu reaktywnego, otoczki walidacji, warstwy kontroli dostępu, wirtualne pola obliczane – żaden z tych elementów nie jest niepowiązanym trikiem pochodzącym z innych źródeł. Wszystkie opierają się na identycznym mechanizmie podstawowym, po prostu skierowanym na inną pułapkę.
Literatura pokrewna
- Budowanie modelu mentalnego dla React: Reconciliation, State i Hooks — Poznaj logikę stojącą za podstawowymi koncepcjami React – reconciliation, komponenty, props, state i hooks – aby rozwijać intuicję zamiast zapamiętywać API.
- Diagnozowanie problemów z wydajnością React poza czasem odpowiedzi API — Dowiedz się, dlaczego szybkie API nie gwarantują szybkiego interfejsu użytkownika, oraz jak renderowanie, rozmiar pliku i organizacja plików w tle wpływają na rzeczywistą wydajność aplikacji React.