Property descriptors i gettery/settery w JavaScripcie
Każda właściwość obiektu w JS ma pod spodem 4 flagi (writable, enumerable, configurable + value lub get/set). Rzadko o tym myślisz, dopóki coś dziwnego się nie wydarzy: przypisanie nie zmienia wartości, delete nie usuwa pola, for...in pomija właściwość. Notatka o tym, czym są property descriptors, jak działają gettery/settery i dlaczego Object.defineProperty ma inne domyślne wartości niż literal obiektu.
Spójrz na ten kod:
const config = {};
Object.defineProperty(config, "apiUrl", {
value: "https://api.example.com",
});
config.apiUrl = "https://malicious.com";
console.log(config.apiUrl); // "https://api.example.com" (!)Przypisanie nie zmieniło wartości. Bez błędu, bez ostrzeżenia. To nie błąd - to property descriptor z writable: false.
Pod każdym polem obiektu w JS silnik trzyma nie tylko wartość, ale też kilka flag, które decydują, co z polem wolno robić: czy można je nadpisać, czy pojawia się w for...in, czy da się je usunąć, czy ma getter i setter zamiast wartości. Te flagi to property descriptor - obiekt opisujący właściwość. Na co dzień nie zaglądasz do ich wnętrza, ale niektóre mechanizmy języka (np. Object.freeze, klasy, prywatne pola) opierają się na nim wprost.
#Cztery flagi (i dwa rodzaje descriptora)
Specyfikacja ECMA-262 definiuje dwa rodzaje descriptora - w zależności od tego, jak ma się zachowywać pole.
Data descriptor to zwykłe pole, które przechowuje wartość. Ma dwa atrybuty:
value- faktyczna wartość pola (np.5,"hello", jakaś tablica).writable- czy próba zmiany wartości przezobj.field = ...ma się powieść.
Accessor descriptor to pole, które zamiast statycznej wartości trzyma parę funkcji odpalanych automatycznie przy odczycie i zapisie:
get- funkcja wywoływana, gdy ktoś czytaobj.field. To ona zwraca wartość, którą widzi czytający.set- funkcja wywoływana, gdy ktoś zapisujeobj.field = newValue. DostajenewValuejako argument.
Każde pole ma descriptor jednego z dwóch typów - data albo accessor, nigdy oba naraz. Próba podania jednocześnie value i get kończy się TypeError.
Plus dwa atrybuty wspólne dla obu rodzajów:
enumerable- czy pole pokazuje się wfor...in,Object.keys, spread ({...obj}),JSON.stringify.configurable- czy można później zmienić atrybuty pola albo w ogóle usunąć je przezdelete.
Descriptor dowolnego istniejącego pola podejrzysz przez Object.getOwnPropertyDescriptor:
const obj = { name: "Adam" };
Object.getOwnPropertyDescriptor(obj, "name");
// { value: "Adam", writable: true, enumerable: true, configurable: true }#Pułapka 1: defineProperty ma inne domyślne wartości niż literal
Najczęstsza pułapka - i powód, dla którego przykład z hooka „nie działał”:
// obj.x = 5 lub { x: 5 }
{ value: 5, writable: true, enumerable: true, configurable: true }
// Object.defineProperty(obj, "x", { value: 5 })
{ value: 5, writable: false, enumerable: false, configurable: false }Pola bez writable: true nie da się nadpisać (w trybie non-strict zapis kończy się cicho, w strict leci TypeError). Bez enumerable: true pole nie pojawi się w Object.keys, for...in, spreadzie, JSON.stringify. Bez configurable: true nie da się go usunąć ani zmienić jego flag.
Jeśli używasz Object.defineProperty i chcesz „normalnego” pola, ustaw jawnie wszystkie trzy flagi na true.
#Pułapka 2: configurable: false jest jednokierunkowe
Gdy raz ustawisz configurable: false, nie cofniesz tego. Specyfikacja celowo czyni to nieodwracalnym - inaczej cała semantyka „zamrożonego” pola dałaby się obejść:
const obj = {};
Object.defineProperty(obj, "x", { value: 1, configurable: false });
Object.defineProperty(obj, "x", { configurable: true });
// TypeError: Cannot redefine property: xDla data descriptora z writable: true jest jeden wyjątek - wciąż wolno zmieniać value i przełączyć writable na false (ale nie z powrotem na true). Dla accessor descriptora albo writable: false - nic nie da się zmienić.
Object.freeze(obj) to w istocie skrót: ustawia writable: false i configurable: false na każdym własnym polu obiektu.
#Gettery i settery - kiedy faktycznie sięgać
Accessor descriptor (get / set) to szczególny przypadek. Składnia w object literal i w klasach jest na tyle naturalna, że często nie pamiętasz, że pod spodem jest descriptor:
const user = {
firstName: "Adam",
lastName: "Nowak",
get fullName() {
return `${this.firstName} ${this.lastName}`;
},
set fullName(value) {
[this.firstName, this.lastName] = value.split(" ");
},
};
user.fullName; // "Adam Nowak" - getter się odpalił
user.fullName = "Jan Kowalski"; // setter się odpaliłDwa najważniejsze zastosowania:
- Computed properties - wartość zależna od innych pól (jak
fullNamepowyżej). - Walidacja przy zapisie - setter sprawdza wartość przed przypisaniem (np. wiek musi być nieujemny, email musi pasować do regexu).
Klasyczna pułapka: nieskończona rekurencja w setterze. Setter wywoływany jest przy każdej próbie przypisania - włącznie z przypisaniem, które robisz wewnątrz settera:
const obj = {
set value(v) {
this.value = v; // ← wywołuje sam siebie. RangeError: Maximum call stack
},
};Rozwiązanie: trzymaj wewnętrzną wartość pod inną nazwą (_value) albo w prywatnym polu klasy (#value).
#Co z tego zapamiętać
Trzy rzeczy, które warto mieć w głowie:
- Każde pole obiektu ma pod spodem 4 flagi:
writable,enumerable,configurableplusvaluelub paraget/set. Domyślnie (przyobj.x = 5) wszystkie trzy flagi totrue. Object.definePropertydomyślnie ustawia flagi nafalse. Jeśli używasz go i chcesz „normalnego” pola, dopiszwritable: true, enumerable: true, configurable: true.- Gettery/settery to accessor descriptor. Sięgaj po nie dla computed properties i walidacji przy zapisie. Pamiętaj, że przypisanie do tego samego pola wewnątrz settera wywołuje go w nieskończoność.
W codziennym kodzie rzadko sięgasz po Object.defineProperty bezpośrednio. Ale wiesz już, dlaczego Object.freeze zamraża i dlaczego Symbol.iterator nie pokazuje się w Object.keys (bo enumerable: false) - to wszystko są zastosowania tego samego mechanizmu.