Pytanie o property descriptor w JavaScript jest jednym z pytań z mojego e-booka 106 Pytań Rekrutacyjnych Junior JavaScript Developer. Jestem świadom, że jest to jedno z trudniejszych pytań z tego e-booka i że nie każdy Junior JavaScript Developer jest świadom istnienia i sposobu działania tego mechanizmu. Z pewnością property descriptor nie jest zagadnieniem, którego zrozumienie jest konieczne do tworzenia podstawowych programów. Jednakże prędzej czy później natkniesz się na przypadek, gdzie znajomość property descriptorów będzie konieczna.
W tym wpisie przedstawię Ci, czym jest property descriptor, z czego się składa i do czego służy. Dowiesz się też, jak możesz wykorzystać wiedzę z tego artykułu w praktyce.
Czym są property descriptors?
Elementem składowym obiektów są właściwości (properties). Każda z właściwości obiektu ma przypisany zestaw opisujących jej cech. Ów zestaw cech nazywany jest deskryptorem (property descriptor). Aby podejrzeć, jakie aspekty można opisać deskryptorem, można wykorzystać metodę Object.getOwnPropertyDescriptor( obj, 'property_name' )
.
const obj = { name: 'John' };
console.log( Object.getOwnPropertyDescriptor( obj, 'name' ) );
// {
// "value": "John",
// "writable": true,
// "enumerable": true,
// "configurable": true
// }
Rezultatem wywołania tej metody jest zwrócenie obiektu z czterema właściwościami: value
, writable
, enumerable
i configurable
. Właściwość value
jest najbardziej oczywista i przechowuje wartość opisywanej właściwości. W przypadku typów referencyjnych jest to referencja. Można to potwierdzić prostym testem:
const obj = { name: 'John', meta: { foo: 'bar' } };
console.log( Object.getOwnPropertyDescriptor( obj, 'meta' ).value === obj.meta ); // true
Możliwe jest również zdefiniowanie akcesorów dla właściwości obiektów. W celu implementacji metod get
i set
można wykorzystać metodę Object.defineProperty( obj, 'property_name', descriptors )
.
const pineapple = {
price: 0
};
let price = 10;
Object.defineProperty( pineapple, 'price', {
get() {
return price;
},
set( val ) {
if ( val < 0 ) {
throw new Error( 'Price cannot be lower than zero!' );
}
price = val;
}
} );
console.log( pineapple.price ); // 10
pineapple.price = -2 // Uncaught Error: Price cannot be lower than zero!
Na co uważać?
Definiując gettery i settery w przedstawiony sposób można nadziać się na kilka pułapek. Pierwszą z nich jest próba jednoczesnego zdefiniowania gettera oraz value
dla danej właściwości. Skutkować to będzie błędem Uncaught TypeError: Invalid property descriptor. Cannot both specify accessors and a value or writable attribute
.
Inny ciekawy błąd wystąpi przy próbie bezpośredniego przypisywania wartości do przekazanego obiektu w setterze. W powyższym przykładzie wartość przypisywaną w setterze przechowuje zmienna pośrednicząca price
. Intuicja podpowiada, że właściwość price
mógłby być przypisywany bezpośrednio, tj. pineapple.price = val
. Niestety taka implementacja settera będzie skutkować jego ponownym wywołaniem w momencie przypisania wartości. Skutkować będzie to zapętlonym w nieskończoność wykonaniem settera, a po chwili informacją o przekroczeniu rozmiaru stosu wywołań: Uncaught RangeError: Maximum call stack size exceeded
.
Również próba zdefiniowania własnych deskryptorów zakończy się niepowodzeniem.
const pineapple = {};
Object.defineProperty( pineapple, 'price', {
value: 10,
foo: 'bar'
} );
console.log( Object.getOwnPropertyDescriptor( pineapple, 'price' ) );
// {
// "value": 10,
// "writable": false,
// "enumerable": false,
// "configurable": false
// }
console.log( Object.getOwnPropertyDescriptor( pineapple, 'price' ).foo ); //undefined
Należy również zwrócić uwagę na domyślne wartości, jakie przypisywane są w deskryptorze w zależności od sposobu definiowania właściwości. W większości przypadków właściwości writable
, enumerable
, configurable
będą równe true
. W przypadku definiowania wartości właściwości poprzez ustawienie wartości value
z wykorzystaniem Object.defineProperty()
wszystkie wymienione będą równe false
. Co ciekawe, tyczy się to tylko definiowania poprzez value
. Korzystając z gettera (nawet zwracającego statyczną wartość), wymienione właściwości będą równe true
.
Należy również pamiętać, że przy kopiowaniu obiektów, property descriptory nie są kopiowane.
Enumerable
Właściwość enumerable
definiuje czy właściwość ma być uwzględniania podczas enumeracji. Przykładami mechanizmów korzystających z enumeracji są:
- wykorzystanie pętli
for...in
; - pozyskanie kluczy obiektu za pomocą
Object.keys()
; - wykorzystanie spread operatora.
Jednocześnie należy wspomnieć, że właściwość obiektu, która nie jest enumerowalna, w dalszym ciągu jest widoczna i dostępna poprzez jej bezpośrednie wykorzystanie.
const pineapple = {};
Object.defineProperty( pineapple, 'price', { value: 10, enumerable: true } );
Object.defineProperty( pineapple, 'secretPrice', { value: 6, enumerable: false } );
console.log( 'KEYS:', Object.keys( pineapple ) ); // ['price']
for ( const property in pineapple ) {
console.log( 'LOOP:', property, pineapple[ property ] ); // price 10
}
const grapes = { ...pineapple };
console.log( 'COPY:', pineapple, grapes ); // {price: 10, secretPrice: 6} {price: 10}
console.log( 'EXPLICIT USE:', pineapple.secretPrice ); // 6
Przykładem właściwości, dla której ustawienie wartości enumerable
na false
ma sens, są wszelkiego rodzaju identyfikatory. W przypadku kopiowania obiektu kopiowanie identyfikatorów zwykle nie ma sensu. Również wyłączenie enumeracji dla właściwości zawierających dane wrażliwe może mieć dużo sensu.
Writable i configurable
Ustawienie writable
na false
pozwala zapobiec nadpisaniu właściwości. W dalszym ciągu możliwe będzie usunięcie danej właściwości i modyfikacja jej deskryptora. Warto zaznaczyć, że próba nadpisania wartości nie będzie skutkowała rzuceniem błędu, chyba że skrypt działa w trybie strict
. W takim przypadku rzucony zostanie błąd Cannot assign to read only property 'price' of object #<Object>
.
const pineapple = { price: 10 };
Object.defineProperty( pineapple, 'price', { writable: false } );
pineapple.price = 50;
console.log( pineapple.price ); // 10
delete pineapple.price;
console.log( pineapple.price ); // undefined
Z kolei configurable
ustawione na false
pozwala zapobiec usunięciu właściwości z obiektu. Dodatkowo dalsza modyfikacja deskryptora nie będzie możliwa. Próba redefinicji deskryptora skutkować będzie błędem Uncaught TypeError: Cannot redefine property
. Próba usunięcia wartości nie będzie skutkowała rzuceniem błędu, chyba że skrypt uruchomiony jest w trybie strict
. W trybie strict
taka operacja będzie skutkować błędem Cannot delete property 'price' of #<Object>
.
Object.defineProperty( pineapple, 'price', { configurable: false } );
pineapple.price = 50;
console.log( pineapple.price ); // 50
delete pineapple.price;
console.log( pineapple.price ); // 50
Object.defineProperty( pineapple, 'price', { configurable: true } ); // Uncaught TypeError: Cannot redefine property: price
Metody freeze i seal
Metody freeze()
i seal()
są statycznymi metodami klasy Object
i pozwalają na ograniczenie możliwości modyfikacji obiektów.
Metoda Object.freeze()
powoduje ustawienie dla wszystkich właściwości obiektu deskryptorów z writable
i configurable
równymi false
. Dodatkowo nie jest możliwe dalsze rozszerzanie obiektu, podobnie jak ma to miejsce po wywołaniu Object.preventExtensions()
. Próby rozszerzenia obiektu o nowe właściwości nie będą powodowały błędów. Włączenie trybu strict
spowoduje, że próba modyfikacji zamrożonego obiektu będzie powodowała błąd Cannot add property property_name, object is not extensible
. Podobny efekt da próba modyfikacji zamrożonej tablicy, np. wykorzystując metody push()
i unshift()
. Rezultatem będzie błąd o treści Cannot add property 1, object is not extensible
. Dla tablic takie zachowanie występuje niezależnie od tego, czy skrypt uruchamiany jest w trybie strict
, czy nie.
Metoda Object.seal()
od Object.freeze()
różni się tym, że właściwości obiektu, na którym wywołano metodę Object.seal()
, wciąż są modyfikowalne. Podobnie jak w przypadku Object.freeze()
nie jest możliwe rozszerzanie obiektu, a wszystkie właściwości nie są configurable
.
Z powyższymi metodami powiązane jest zjawisko shallow freeze. Oznacza to, że jeśli obiekt zawiera cechy będące obiektami, to zamrożeniu nie ulegają właściwości wewnątrz zagnieżdżonego obiektu. W celu zamrożenia zagnieżdżonych obiektów konieczne jest przygotowanie metody pozwalające dokonać tzw. deep freeze. Aby tego dokonać, można przygotować rekurencyjną funkcję, która przeiteruje po wszystkich poziomach zagnieżdżenia obiektu i dokona na nich zamrożenia.
Przykładem, gdzie zamrożenie obiektu ma sporo sensu, są np. obiekty zawierające konfigurację dla klientów usług czy aplikacji. Zwykle nie ulegają one modyfikacji, więc ich zamrożenie wydaje się mieć sporo sensu. Można się dzięki temu zabezpieczyć przed ich przypadkową modyfikacją.
Innym przypadkiem zastosowania, jaki widzę dla tych metod, jest usprawnienie procesu debugowania. Załóżmy, że mamy do zdebugowania sytuację, gdy w nieznanym miejscu w kodzie modyfikowany jest obiekt, który nie powinien być modyfikowany. Zamrożenie obiektu pozwoli wykryć moment modyfikacji, ponieważ rzucony zostanie wtedy błąd.
Podsumowanie
Jestem ogromnie ciekaw, czy udało Ci się dowiedzieć czegoś nowego. Daj znać w komentarzu, jakie widzisz zastosowania do metod i mechanizmów opisanych w tym artykule. Zajrzyj też w linki poniżej by dowiedzieć się nieco więcej o poruszonym temacie.
Zapisz się na mailing i odbierz e-booka
Odbierz darmowy egzemplarz e-booka 106 Pytań Rekrutacyjnych Junior JavaScript Developer i realnie zwiększ swoje szanse na rozmowie rekrutacyjnej! Będziesz też otrzymywać wartościowe treści i powiadomienia o nowych wpisach na skrzynkę e-mail.