ES6 bez tajemnic to cykl artykułów poświęconych nowym składnikom języka JavaScript, które pojawiły się w 6. edycji standardu ECMAScript, w skrócie ES6.
Oto czym się będziemy dziś zajmować:
var obj = new Proxy({}, {
get: function (target, key, receiver) {
console.log(`pobieram ${key}!`);
return Reflect.get(target, key, receiver);
},
set: function (target, key, value, receiver) {
console.log(`ustawiam ${key}!`);
return Reflect.set(target, key, value, receiver);
}
});
Jak na pierwszy przykład jest to nieco skomplikowane, jednak wszystko objaśnię później. Na razie spójrzmy na utworzony przez nas obiekt:
> obj.count = 1;
ustawiam count!
> ++obj.count;
pobieram count!
ustawiam count!
2
Co tu się dzieje? W obiekcie tym przechwytujemy odwołania do własności obiektu. Przeciążamy operator kropkę – .
.
Jak to się dzieje
Najlepsza informatyczna sztuczka nosi nazwę wirtualizacji. To mająca ogólne zastosowanie technika, która pozwala robić fantastyczne rzeczy. Oto na czym polega.
- Weź dowolny obraz.
- Obrysuj jakiś jego fragment.
- Podmień część znajdującą się wewnątrz bądź na zewnątrz obrysu innym, zaskakującym obrazem. Obowiązuje tylko jedna zasada – zasada zgodności wstecznej. Podmieniany element musi zachowywać się tak, jak gdyby był tam cały czas. Nikt z zewnątrz nie może się zorientować, że zaszły jakieś zmiany.
Tego typu sztuczki są ci zapewne znane z klasycznych bazujących na technice komputerowej filmów takich jak Truman Show czy Matrix, w których bohater znajduje się wewnątrz obrysu, zaś otaczający go świat zostaje zmieniony przez łudząco normalną iluzję.
By spełnić wymóg zgodności wstecznej, zamiennik musi być sprytnie zaprojektowany. Cała sztuczka polega jednak przede wszystkim na odpowiednim obrysie.
Pisząc obrys mam na myśli granicę API. Interfejs. Interfejsy określają, jak fragmenty kodu mają ze sobą współpracować i czego mają od siebie oczekiwać. Zatem jeśli system wykorzystuje jakiś interfejs, to mamy już gotowy obrys. Wiesz, że możesz podmienić jedną część, wewnątrz lub na zewnątrz obrysu, nie zakłócając pracy drugiej.
Czas na wykazanie się kreatywnością przychodzi zaś wtedy, gdy interfejsu nie ma. Najlepsze sztuczki w historii oprogramowania polegały na ustaleniu granicy API tam, gdzie jej nie było i utworzeniu interfejsu dzięki kolosalnej pracy inżynierów.
Pamięć wirtualna, wirtualizacja sprzętowa, Docker, Valgrind, rr – w różnym stopniu wszystkie z tych projektów miały za zadanie wzbogacić istniejące systemy o nowe, wręcz zaskakujące interfejsy. W niektórych przypadkach trwało to całe lata, a zapewnienie prawidłowego funkcjonowania obrysu wymagało wprowadzenia do systemów operacyjnych nowych elementów funkcjonalności, a nawet nowego sprzętu.
Dobra wirtualizacja umożliwia zrozumienie rzeczy poddawanej wirtualizacji na nowo. By napisać do czegoś API, musisz to coś najpierw zrozumieć. Kiedy już zrozumiesz, będziesz mógł robić coś niezwykłego.
W ES6 wprowadzono obsługę wirtualizacji dla fundamentalnego składnika JavaScriptu: obiektu.
Czym jest obiekt
Poważnie. Zastanów się przez chwilę. Kiedy już będziesz gotowy, przewiń stronę, by poznać odpowiedź.
Dla mnie to pytanie jest zbyt trudne! Nigdy nie spotkałem się z w pełni satysfakcjonującą definicją.
Czy to dziwne? Definiowanie fundamentalnych pojęć zawsze nastręcza trudności – możesz w wolnej chwili sprawdzić kilka pierwszych definicji w Elementach Euklidesa. Twórcy specyfikacji ECMAScriptu są więc w dobrym towarzystwie. W opracowanym przez nich dokumencie znajdziemy bowiem niezbyt pomocną definicję obiektu jako „składnika typu o nazwie Object
”.
Dalsza część specyfikacji zawiera uzupełnienie: „obiekt stanowi kolekcję własności”. Brzmi nieźle. Jeśli potrzebujesz jakiejś definicji, ta na razie wystarczy. Zajmiemy się tym jeszcze później.
Powiedziałem, że by napisać do czegoś API, musisz to coś najpierw zrozumieć. Poniekąd więc obiecałem, że jeśli to wszystko przerobimy, to zaczniemy rozumieć obiekty w lepszym stopniu i będziemy mogli dokonywać niesamowitych rzeczy.
Podążmy zatem śladami komisji standaryzacyjnej ECMAScriptu i przekonajmy się, jak należy zdefiniować API – interfejs – obiektów JavaScript. Jakich metod potrzebujemy? Co robią obiekty?
To niejako zależy od obiektu. Elementy struktury DOM mogą robić jedne rzeczy, a obiekty AudioNode inne. Istnieje jednak kilka podstawowych cech wspólnych dla wszystkich obiektów:
- Obiekty mają własności. Można je pobierać, ustawiać, usuwać itd.
- Obiekty mają prototypy. Tak działa dziedziczenie w JS.
- Niektóre obiekty są funkcjami bądź konstruktorami. Można je wywołać.
Praktycznie we wszystkich operacjach, jakie programy JS wykonują przy pomocy obiektów wykorzystywane są własności, prototypy i funkcje. Nawet szczególny sposób działania obiektu Element
czy AudioNode
osiągalny jest poprzez wywołanie metod, które są po prostu dziedziczonymi własnościami funkcji.
Nie powinno być to zatem żadną niespodzianką, że definiując zestaw 14 metod wewnętrznych, wspólny interfejs wszystkich obiektów, komisja standaryzacyjna ECMAScriptu ostatecznie skupiła się na tych trzech podstawowych elementach.
Pełna lista tych metod znajduje się w tabeli 5 i 6 standardu ES6. Tutaj opisuję tylko wybrane z nich. Dziwaczny, podwójny nawias kwadratowy, [[ ]]
, zastosowałem po to, by podkreślić, że są to metody wewnętrzne, niewidoczne w zwykłym kodzie JS. W przeciwieństwie do zwykłych metod nie można ich wywoływać, usuwać ani nadpisywać.
obiekt.[[Get]](klucz, adresat)
– sprawdza wartość własności.Metoda wywoływana, gdy kod JS odwołuje się do własności poprzez
obiekt.własność
lubobiekt[klucz]
.obiekt to aktualnie szukany obiekt; adresat to obiekt, od którego rozpoczęliśmy szukanie danej własności. Czasami musimy wyszukać kilka obiektów. obiekt może być obiektem w łańcuchu prototypów adresata.
obiekt.[[Set]](klucz, wartość, adresat)
– dokonuje przypisania wartości do własności obiektu.Metoda wywoływana, gdy kod JS wykonuje przypisanie poprzez
obiekt.własność = wartość
lubobiekt[klucz] = wartość
.W przypisaniu takim jak
obiekt.własność += 2
, metoda[[Get]]
jest wywoływana jako pierwsza, w drugiej kolejności zaś metoda[[Set]]
. To samo dotyczy inkrementacji (++
) i dekrementacji (--
).obiekt.[[HasProperty]](klucz)
– sprawdza, czy dana własność istnieje.Metoda wywoływana, gdy kod JS sprawdza
klucz in obiekt
.obiekt.[[Enumerate]]()
– wymienia policzalne własności obiektu.Metoda wywoływana, gdy JS wykonuje pętlę
for (klucz in obiekt)...
.Metoda ta zwraca obiekt iteratora i w ten sposób pętla
for–in
otrzymuje nazwy własności obiektu.obiekt.[[GetPrototypeOf]]()
– zwraca prototyp obiektu.Metoda wywoływana, gdy kod JS odwołuje się do prototypu poprzez
obiekt.__proto__
lub metodęObject.getPrototypeOf(obiekt)
.obiektFunkcyjny.[[Call]](wartośćThis, argumenty)
– wywołuje funkcję.Metoda wywoływana, gdy JS wykonuje metodę
obiektFunkcyjny()
lubx.metoda()
.Metoda opcjonalna. Nie każdy obiekt jest funkcją.
obiektKonstruktora.[[Construct]](argumenty, nowyCel)
– uruchamia konstruktor.Metoda wywoływana, gdy JS wykonuje np. metodę
new Date(2890, 6, 2)
.Metoda opcjonalna. Nie każdy obiekt jest konstruktorem.
Argument
nowyCel
odgrywa rolę w tworzeniu podklasy. Powiemy o tym w jednym z kolejnych artykułów.
Może jesteś w stanie odgadnąć niektóre z pozostałych siedmiu metod.
W tych przypadkach, w których było to możliwe elementy składni bądź wbudowane funkcje związane z obiektami zostały określone w standardzie ES6 w ramach 14 metod wewnętrznych. ES6 jasno nakreśla granice możliwości obiektu. Obiekty pośredniczące pozwalają natomiast podmienić jego standardową treść dowolnym kodem JS.
Za chwilę powiemy o tym, jak przedefiniować wspomniane metody wewnętrzne. Pamiętaj, że chodzi tu o zmianę sposobu działania podstawowych elementów składniowych typu obiekt.własność
, wbudowanych funkcji takich jak Object.keys()
i innych.
Obiekty pośredniczące
ES6 definiuje nowy konstruktor globalny — Proxy
. Przyjmuje on dwa argumenty: obiekt docelowy (ang. target) oraz obiekt stanowiący procedurę obsługi (ang. handler). Oto prosty przykład:
var target = {}, handler = {};
var proxy = new Proxy(target, handler);
Zostawmy na chwilę procedurę obsługi i sprawdźmy, jaki jest związek pomiędzy obiektem pośredniczącym a obiektem docelowym.
Mogę opisać działanie obiektu pośredniczącego jednym zdaniem. Wszystkie wewnętrzne metody obiektu pośredniczącego są przekazywane obiektowi docelowemu. Jeśli zatem zostanie wykonane wywołanie proxy.[[Enumerate]]()
, to zwrócone zostanie po prostu target.[[Enumerate]]()
.
Przekonajmy się sami. Spowodujemy, by została wywołana metoda proxy.[[Set]]()
.
proxy.color = "pink";
Co się stało? Metoda proxy.[[Set]]()
powinna była wywołać target.[[Set]]()
, by utworzyć nową własność obiektu docelowego. Czy to się powiodło?
> target.color
"pink"
Owszem. To samo tyczy się pozostałych metod wewnętrznych. Obiekty pośredniczące w dużej mierze zachowują się identycznie jak ich obiekty docelowe.
Są jednak pewne granice tego odwzorowania. Przekonasz się, że proxy !== target
. Ponadto obiekt pośredniczący może czasami błędnie zgłosić błąd typu, który nie zostałby zgłoszony przez obiekt docelowy. Nawet jeśli obiektem docelowym pośrednika jest np. element DOM, to obiekt pośredniczący nie jest tak naprawdę elementem. Zatem wywołanie funkcji takiej jak document.body.appendChild(proxy)
zakończy się niepowodzeniem ze względu na błąd typu – TypeError
.
Procedury obsługi obiektów pośredniczących
Wróćmy teraz do obiektu procedury obsługi. Do tego właśnie przydają się obiekty pośredniczące.
Metody obiektu obsługi mogą przedefiniować dowolne metody wewnętrze obiektu Proxy
.
Jeśli chciałbyś przechwycić wszystkie próby nadpisania własności obiektu, możesz w tym celu zdefiniować metodę handler.set()
:
var target = {};
var handler = {
set: function (target, key, value, receiver) {
throw new Error("Proszę nie ustawiać własności tego obiektu.");
}
};
var proxy = new Proxy(target, handler);
> proxy.name = "angelina";
Error: Proszę nie ustawiać własności tego obiektu.
Dokumentacja wszystkich metod obsługi znajduje się na stronie MDN poświęconej obiektowi Proxy
. Jest ich 14 – tyle samo, co metod wewnętrznych zdefiniowanych w ES6.
Wszystkie metody obsługi są opcjonalne. Jeśli procedura obsługi nie przechwyci metody wewnętrznej, to zostaje ona przekazana obiektowi docelowemu, tak jak widzieliśmy w jednym z wcześniejszych przykładów.
Przykład — „niemożliwa” automatyczna populacja obiektów
Wiemy już wystarczająco dużo na temat obiektów pośredniczących, by spróbować wykorzystać je do czegoś naprawdę dziwnego – czegoś, co bez obiektów Proxy
jest niemożliwe.
Oto pierwsze ćwiczenie. Napisz funkcję Tree()
, która będzie wykonywać poniższy kod:
> var tree = Tree();
> tree
{ }
> tree.branch1.branch2.twig = "green";
> tree
{ branch1: { branch2: { twig: "green" } } }
> tree.branch1.branch3.twig = "yellow";
{ branch1: { branch2: { twig: "green" },
branch3: { twig: "yellow" }}}
Zwróć uwagę, że wszystkie obiekty pośrednie – branch1
, branch2
i branch3
– same magicznie powstają wtedy, gdy są potrzebne. Wygodnie, prawda? Ale jak to w ogóle możliwe?
Do tej pory nie dało się tego zrobić. Jednak dzięki obiektom pośredniczącym wystarczy zaledwie kilka linijek kodu. Musimy tylko dostać się do drzewa.[[Get]]()
. Jeśli chcesz sprawdzić swoje umiejętności, możesz spróbować samodzielnie zaimplementować taką funkcję przed przejściem do lektury dalszej części tekstu.
Oto moje rozwiązanie:
function Tree() {
return new Proxy({}, handler);
}
var handler = {
get: function (target, key, receiver) {
if (!(key in target)) {
target[key] = Tree(); // automatycznie utwórz poddrzewo
}
return Reflect.get(target, key, receiver);
}
};
Zwróć uwagę na końcowe wywołanie funkcji Reflect.get()
. Wygląda na to, że w metodach obsługi obiektów pośredniczących niezwykle ważne jest, by móc nakazać metodzie wykonanie domyślnego zadania, którym jest delegowanie działania do obiektu docelowego. ES6 definiuje zatem nowy obiekt Reflect
z 14 metodami, które można wykorzystać w tym celu.
Przykład — widok tylko do odczytu
Chyba błędnie zasugerowałem, że obiekty pośredniczące są łatwe w użytku. Przyjrzyjmy się jeszcze jednemu przykładowi, by to zweryfikować.
Tym razem nasze przypisanie będzie bardziej skomplikowane: musimy zaimplementować funkcję, readOnlyView(object)
, która przyjmuje dowolny obiekt i zwraca identycznie działający obiekt pośredniczący, z jedną różnicą – jest on niemodyfikowalny. Może się więc on zachowywać tak:
> var newMath = readOnlyView(Math);
> newMath.min(54, 40);
40
> newMath.max = Math.min;
Error: Nie można zmodyfikować widoku tylko do odczytu.
> delete newMath.sin;
Error: Nie można zmodyfikować widoku tylko do odczytu.
Jak to zaimplementować?
Pierwszym krokiem jest przechwycenie wszystkich metod wewnętrznych, które nieprzechwycone mogłyby zmodyfikować działanie obiektu docelowego. Jest takich metod pięć.
function NOPE() {
throw new Error("Nie można zmodyfikować widoku tylko do odczytu.");
}
var handler = {
// Przedefiniuj wszystkich pięć metod modyfikujących.
set: NOPE,
defineProperty: NOPE,
deleteProperty: NOPE,
preventExtensions: NOPE,
setPrototypeOf: NOPE
};
function readOnlyView(target) {
return new Proxy(target, handler);
}
Kod działa. Dzięki ustawieniu widoku tylko do odczytu zapobiegamy przypisaniu, definiowaniu własności itp.
Czy rozwiązanie to ma jakieś mankamenty?
Największym niedociągnięciem jest to, że metoda [[Get]]
, podobnie jak inne metody, wciąż może zwrócić modyfikowalne obiekty. Zatem nawet jeśli obiekt x
dostępny jest tylko do odczytu, to już poprzez odwołanie x.własność
można go zmodyfikować! To poważna luka.
By ją załatać, musimy dodać metodę handler.get()
:
var handler = {
...
// Opakuj pozostałe wyniki w widok tylko do odczytu.
get: function (target, key, receiver) {
// Rozpocznij od domyślnego działania.
var result = Reflect.get(target, key, receiver);
// Upewnij się, by nie zwrócić modyfikowalnego obiektu!
if (Object(result) === result) {
// result to obiekt
return readOnlyView(result);
}
// Wynik jest typu prostego, czyli z deifnicji jest niemodyfikowalny.
return result;
},
...
};
To jednak nie wystarczy. Podobny kod potrzebny jest także w przypadku innych metod, w tym getPrototypeOf
i getOwnPropertyDescriptor
.
Na tym nie koniec problemów. Kiedy metoda wywoływana jest za pośrednictwem tego typu obiektu pośredniczącego, wartość this
przekazywana metodzie sama będzie obiektem pośredniczącym. Jednak, jak już widzieliśmy wcześniej, wiele metod pomyślnie weryfikuje typ, który obiekt pośredniczący uznaje za błędny. W tym przypadku byłoby zatem lepiej zamienić obiekt docelowy na obiekt pośredniczący. Potrafisz wykombinować jak to zrobić?
Wniosek jest taki, że utworzenie dowolnego obiektu pośredniczącego nie nastręcza trudności, jednak utworzenie pośrednika, który działa intuicyjnie to dosyć trudne zadanie.
Różności
- Do czego tak naprawdę przydają się obiekty pośredniczące?
Obiekty te są bez wątpienia użyteczne wówczas, gdy chcemy zaobserwować bądź zapisać historię operacji dostępu do obiektu. Przydają się także w debugowaniu. Podczas testowania systemów szkieletowych można wykorzystać je do utworzenia makiet obiektów.
Sprawdzają się również wtedy, gdy potrzebujemy obiektu o nieco bardziej zaawansowanych możliwościach niż te, które mają zwykłe obiekty – np. takiego, który będzie praktycznie za nas wypełniał własności.
Wspominam o tym z wielką niechęcią, lecz jednym z najlepszych sposobów na to, by przekonać się co się dzieje w kodzie wykorzystującym obiekty pośredniczące jest…opakowanie obiektu obsługi pośrednika w inny obiekt pośredniczący, który przekaże do konsoli komunikat za każdym razem, gdy ktoś uzyskuje dostęp do metody obsługi.
Jak przekonaliśmy się na przykładzie metody
readOnlyView
, obiektami pośredniczącymi można się posłużyć, by ograniczyć dostęp do obiektu. W kodzie aplikacji takie zastosowanie spotyka się rzadko, jednak Firefox wykorzystuje obiekty pośredniczące wewnętrznie do implementacji granic bezpieczeństwa. Są one kluczowym składnikiem naszego modelu bezpieczeństwa. - Obiekty pośredniczące ♥ kolekcje WeakMap. W przykładzie z metodą
readOnlyView
tworzyliśmy nowy obiekt pośredniczący za każdym razem, gdy uzyskiwany był dostęp do obiektu. Moglibyśmy zaoszczędzić sporo pamięci, przechowując w pamięci podręcznej każdy obiekt pośredniczący utworzony w kolekcjiWeakMap
. Dzięki temu niezależnie od tego, ile razy obiekt zostałby przekazany do metodyreadOnlyView
, utworzony zostałby tylko jeden pośrednik.To jeden z powodów, dla których warto korzystać z obiektów pośredniczących w kolekcjach
WeakMap
. - Obiekty pośredniczące, które można anulować. ES6 definiuje także jeszcze jedną funkcję,
Proxy.revocable(target, handler)
, która podobnie jaknew Proxy(target, handler)
tworzy nowy obiekt pośredniczący, z jednym wyjątkiem – obiekt ten można anulować. (Proxy.revocable
zwraca obiekt z własnością.proxy
i metodą.revoke
). Gdy obiekt pośredniczący zostaje anulowany, to po prostu przestaje działać. Wszystkie jego metody wewnętrzne zgłaszają błędy. - Niezmienniki obiektów. W pewnych sytuacjach ES6 wymaga, by metody obsługi obiektów pośredniczących zgłaszały wyniki, które odpowiadają stanowi obiektu docelowego. W ten sposób język wymusza spełnienie zasad niemodyfikowalności wszystkich obiektów, nawet obiektów pośredniczących. Obiekt pośredniczący nie może na przykład udawać, że jest nierozszerzalny, o ile nie jest taki jego obiekt docelowy.
Dokładne zasady są zbyt skomplikowane, by zgłębiać je w tym artykule. Jeśli jednak zobaczysz błąd typu nieistniejąca własność nie może zostać zgłoszona przez proxy jako niekonfigurowalna, to właśnie one powodują jego wywołanie. Prawdopodobnie najlepszym rozwiązaniem tego problemu jest zmiana informacji, jakie obiekt pośredniczący raportuje na temat samego siebie. Innym sposobem jest modyfikowanie obiektu docelowego na bieżąco, tak by odzwierciedlał informacje raportowane przez pośrednika.
To czym jest w końcu ten obiekt?
Do tej pory ustaliliśmy, że: „obiekt stanowi kolekcję własności”.
Nie jestem w pełni przekonany co do tej definicji, nawet jeśli uznamy za oczywiste, że obiekty mają swoje własności i są wywoływalne. Biorąc pod uwagę jak ubogie mogą być definicje obiektów pośredniczących, wydaje mi się, że nazwanie obiektu „kolekcją” to zbyt wiele. Metody obsługi obiektu pośredniczącego mogą wykonywać dowolne operacje. Mogą zwracać różne wyniki.
Dzięki odkryciu potencjału obiektów, ustandaryzowaniu wspomnianych metod i dodaniu wirtualizacji, która dostępna jest dla wszystkich, komisja standaryzacyjna ECMAScriptu zwiększyła zakres możliwości obiektów.
Obecnie obiekty mogą być niemalże czymkolwiek.
Być może najszczerszą odpowiedź na pytanie „Czym jest obiekt?” stanowi 12 podstawowych metod wewnętrznych. Obiekt to taki element programu JS, który wykonuje operacje [[Get]]
, [[Set]]
itd.
Czy teraz rozumiemy obiekty lepiej? Nie jestem co do tego przekonany! Czy udało nam się zrobić coś niesamowitego? Zdecydowanie. Dokonaliśmy rzeczy, które do tej pory były w JS niemożliwe.
Czy mogę już teraz korzystać z obiektów pośredniczących?
Nie, a przynajmniej nie w sieci. Tylko przeglądarki Firefox i Microsoft Edge obsługują obiekty Proxy
, lecz nie ma dla nich wypełniacza.
Korzystanie z obiektów pośredniczących w środowisku Node.js oraz io.js wymaga zarówno ich domyślnego wyłączenia (opcja --harmony_proxies
), a także wypełniacza harmony-reflect, ponieważ silnik V8 implementuje starszą wersję specyfikacji obiektu Proxy
. (Poprzednia wersja niniejszego artykułu zawierała błędne informacje na ten temat. Podziękowania dla Mörre’a i Aarona Powella za sprostowanie mojej pomyłki w komentarzach).
Eksperymentuj więc śmiało z obiektami Proxy
! Możesz stworzyć wirtualny korytarz luster, który zdaje się zawierać tysiące kopii każdego obiektu i gdzie nic nie da się zdebugować! Teraz jest na to czas. Ryzyko, że twój nieprzemyślany kod wykorzystujący obiekty pośredniczące przedostanie się do prawdziwych projektów jest niewielkie…na razie.
Obiekty pośredniczące zostały po raz pierwszy zaimplementowane w 2010 r. przez Andreasa Gala, zaś przegląd kodu wykonał Blake Kaplan. Później komisja standaryzacyjna zdecydowała się zupełnie przeprojektować ten składnik funkcjonalności. Nowa specyfikacja obiektów pośredniczących została zaimplementowana przez Eddy’ego Bruela w 2012 r.
Ja zaimplementowałem obiekt Reflect
, a kod recenzował Jeff Walden. Można już z niego korzystać w Firefoksie Nightly za wyjątkiem metody Reflect.enumerate()
, która nie została jeszcze zaimplementowana.
W następnym artykule przyjrzymy się najbardziej kontrowersyjnemu składnikowi ES6. A któż opowie o nim lepiej niż osoba odpowiedzialna za jego implementację w Firefoksie? Odwiedzaj nas zatem często, by nie przegapić artykułu autorstwa inżyniera Mozilli Erica Fausta, który szczegółowo przedstawi klasy w ES6.