Przejdź do treści

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 przez obj.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ś czyta obj.field. To ona zwraca wartość, którą widzi czytający.
  • set - funkcja wywoływana, gdy ktoś zapisuje obj.field = newValue. Dostaje newValue jako 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ę w for...in, Object.keys, spread ({...obj}), JSON.stringify.
  • configurable - czy można później zmienić atrybuty pola albo w ogóle usunąć je przez delete.

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: x

Dla 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:

  1. Computed properties - wartość zależna od innych pól (jak fullName powyżej).
  2. 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:

  1. Każde pole obiektu ma pod spodem 4 flagi: writable, enumerable, configurable plus value lub para get/set. Domyślnie (przy obj.x = 5) wszystkie trzy flagi to true.
  2. Object.defineProperty domyślnie ustawia flagi na false. Jeśli używasz go i chcesz „normalnego” pola, dopisz writable: true, enumerable: true, configurable: true.
  3. 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.

← Wróć do notatek