Podstawy#
Interfejsy i obiekty#
Przy charakterystyce Web IDL dosyć szczegółowo opisałem składnię tego języka, czasami pojawiło się także praktyczne odzwierciedlenie w języku ECMAScript. Dla osób, które nie do końca zrozumiały temat, zamieszczam króciutkie wyjaśnienie najważniejszych rzeczy.
Interfejsy Web IDL to tekstowe opisy stanów i zachowań dla obiektów, które będą implementowały te interfejsy. Sama implementacja interfejsów będzie silnie uzależniona od stosowanego języka skryptowego w środowisku uruchomieniowym, chociaż można przyjąć, że w przypadku przeglądarki będziemy mieli do czynienia tylko z ECMAScriptem. Kluczowym pojęciem będzie dziedziczenie. Interfejsy mogą po sobie dziedziczyć, czyli interfejs który dziedziczy będzie miał dostęp do operacji i atrybutów interfejsu z którego dziedziczy. Nie ma potrzeby definiowania tych samych poleceń w wielu interfejsach, wystarczy przenieść je do osobnego interfejsu, z którego będą mogły dziedziczyć pozostałe interfejsy. Dziedziczenie interfejsów Web IDL musi zostać odwzorowane w danej implementacji DOM.
Dziedziczenie w ECMAScript#
Zacznijmy od ogólnego przypadku, czyli dziedziczenia przez rdzenne obiekty ECMAScript, mam tu na myśli obiekty wbudowane w rdzeń języka (np. Object
, Array
, Function
itd.), jak i nasze własne. W przypadku ECMAScript dziedziczenie odbywa się przy wykorzystaniu obiektów prototypowych.
Prototyp to szczególny obiekt, w którym umieszczane są konkretne metody i właściwości, wspólne dla każdej kolejnej instancji obiektu. Prototyp jest częścią funkcji (obiektu funkcyjnego), można się do niego odwołać przez właściwość prototype
dostępną dla każdej funkcji. Każda funkcja posiada prototyp, nawet jeśli nie utworzymy go samodzielnie, w takim przypadku prototypem będzie pusty obiekt. Funkcje wywoływane jako konstruktor tworzą nowe instancje obiektów, które będą miały ukryte połączenie z obiektem umieszczonym pod właściwością prototype
konstruktora.
Utwórzmy pustą funkcję Car()
i przeanalizujmy kilka konkretnych poleceń, oto przykład:
<script>
function Car(){ // Nowa funkcja (obiekt funkcyjny)
}
document.write(Car); // function Car(){ // Nowa funkcja (obiekt funkcyjny) }
document.write("<br>");
document.write(Car.toString()); // function Car(){ // Nowa funkcja (obiekt funkcyjny) }>
document.write("<br>");
document.write(typeof Car); // function
document.write("<br><br>");
document.write(Car.constructor); // function Function() { [native code] }
document.write("<br>");
document.write(Car.constructor.prototype == Car.__proto__); // true - odwołanie do prototypu konstruktora z poziomu instancji (dwa sposoby)
document.write("<br>");
document.write(Car.__proto__.__proto__); // [object Object]
document.write("<br><br>");
document.write(Car.prototype); // [object Object]
document.write("<br>");
document.write(Car.prototype.__proto__.constructor); // function Object() { [native code] }
document.write("<br>");
document.write(Car.prototype.__proto__ == Object.prototype); // true - prototyp najwyższego rzędu
document.write("<br>");
document.write(Car.prototype.__proto__.__proto__); // null- brak kolejnych prototypów
</script>
Nasz pierwszy przykład można scharakteryzować następująco:
- Deklarujemy nową funkcję
Car()
. Funkcja nie ma żadnych metod i właściwości zdefiniowanych przez nas samych. Nie ma także żadnego kodu do wykonania, czyli poleceń umieszczonych w ciele funkcji (między nawiasami klamrowymi). - Funkcja
Car()
jest typu funkcyjnego, określanego jakofunction
. - Funckcja
Car()
jest obiektem, który utworzony został za pomocą konstruktoraFunction()
(czyli innej funkcji wywołanej za pomocą operatoranew
). Mówimy, że funkcjaCar()
jest instancją funkcjiFunction()
. - Konstruktor
Function()
posiada prototyp, w którym umieszczane są wszystkie wspólne metody i właściwości dla nowych instancji tego konstruktora. Dlatego nowa funkcjaCar()
ma dostęp do metod i właściwości z łańcucha prototypu. - Prototyp konstruktora
Function()
to obiekt, który również został utworzony za pomocą innego konstruktora, w tym przypadkuObject()
. KonstruktorObject()
posiada prototyp, w którym umieszczane są wszystkie wspólne metody i właściwości dla nowych instancji tego konstruktora. Jest to prototyp najwyższego rzędu, który już nie dziedziczy z niczego innego. - Metoda
toString()
wywołana na funkcjiCar()
tak naprawdę pochodzi z prototypu wObject()
. Metoda najpierw wyszukiwana jest bezpośrednio wCar()
, jeśli jej nie ma przeszukiwany jest prototyp wFunction()
, jeśli wciąż jej nie ma przeszukany zostanie ostatni prototyp wObject()
.
Z poziomu dowolnej instancji (obiektu) można odwołać się do jej prototypu (obiektu) z którego dziedziczy, czyli do obiektu zawartego we właściwości prototype
konstruktora który utworzył dany obiekt (instancję), za pomocą poleceń:
instancja.constructor.prototype
- przejście przez konstruktor. Niestety, właściwośćconstructor
może zostać w każdej chwili zmieniona, dlatego nigdy nie należy jej ufać w 100%.instancja.__proto__
- jest to ukryte bezpośrednie połączenie do prototypu, z pominięciem konstruktora. Niestety, właściwość jest niestandaryzowana i nie musi być udostępniana przez implementacje ECMAScript, chociaż większość nowoczesnych środowisk daje nam taki dostęp.
Do prototypu można przejść bezpośrednio z konstruktora za pomocą polecenia constructor.prototype
, chociaż koniecznym będzie znajomość dokładnej nazwy konstruktora, co w praktyce może być problematyczne.
W przypadku obiektów funkcyjnych nie należy mylić ze sobą dwóch rzeczy, czyli prototypu po którym dziedziczy sama funkcja, oraz właściwości prototype
wskazującej na obiekt, z którym będą powiązane każde nowe instancje tworzone za pomocą tej funkcji.
Relację między instancją, konstruktorem oraz prototypem najwygodniej zobrazować za pomocą graficznego trójkąta.
Rysunek. Relacja między instancją, konstruktorem a prototypem
Najwyższy czas utworzyć parę nowych obiektów za pomocą naszej funkcji Car()
. Żeby to zrobić, funkcja musi zostać wywołana w roli konstruktora, czyli za pomocą operatora new
. Dla urozmaicenia dodajmy do konstruktora kilka nowych poleceń.
<script>
function Car(){ // Nowa funkcja (obiekt funkcyjny)
this.test1 = "Właściwość test1 dostępna bezpośrednio w instancji.";
this.test2 = function(){
document.write("<br>Metoda test2 dostępna bezpośrednio w instancji.")
};
}
Car.prototype = {
"rodzaj": "Właściwość rodzaj dostępna z prototypu Car.prototype",
"stop" : function(){
document.write("<br>Metoda stop dostępna z prototypu Car.prototype");
}
};
var newCar1 = new Car();
var newCar2 = new Car();
document.write(newCar1.test1); // Właściwość test1 dostępna bezpośrednio w instancji.
document.write("<br>");
document.write(newCar2.test1); // Właściwość test1 dostępna bezpośrednio w instancji.
newCar1.test2(); // Metoda test2 dostępna bezpośrednio w instancji.
newCar2.test2(); // Metoda test2 dostępna bezpośrednio w instancji.
document.write("<br><br>");
document.write(newCar1.rodzaj); // Właściwość rodzaj dostępna z prototypu Car.prototype
document.write("<br>");
document.write(newCar2.rodzaj); // Właściwość rodzaj dostępna z prototypu Car.prototype
newCar1.stop(); // Metoda stop dostępna z prototypu Car.prototype
newCar2.stop(); // Metoda stop dostępna z prototypu Car.prototype
Car.prototype.go = function(){
document.write("<br>Metoda go dostępna z prototypu Car.prototype");
}
document.write("<br>");
newCar1.go(); // Metoda go dostępna z prototypu Car.prototype
newCar2.go(); // Metoda go dostępna z prototypu Car.prototype
</script>
Nasz kolejny przykład można scharakteryzować następująco:
- Do konstruktora
Car()
dodaliśmy właściwośćtest1
oraz metodętest2()
, które będą dodawane bezpośrednio do każdej instancjiCar()
. - Do prototypu konstruktora
Car()
dodaliśmy właściwośćrodzaj
oraz metodęstop()
, które będą współdzielone przez każdą instancjęCar()
. - Utworzyliśmy dwie nowe instancje
Car()
, czyli obiektynewCar1
oraznewCar2
. - Każda nowa instancja ma swoje własne polecenia
test1
itest2()
, oraz wspólne poleceniarodzaj
istop
. - Rozszerzyliśmy prototyp konstruktora
Car()
poprzez dodanie metodygo()
. Metoda będzie dostępna dla każdej instancjiCar()
, nawet tych, które utworzone zostały przed rozszerzeniem prototypu. - Prototyp konstruktora
Car()
jest obiektem, ma swój własny prototyp z którego dziedziczy, dlatego instancjeCar()
będą miały dostęp do wszystkich poleceń z całego łańcucha dziedziczenia.
Dziedziczenie jest ogólnym mechanizmem, z którego korzystają wszystkie obiekty. Ostatecznie każdy obiekt będzie dziedziczył z prototypu najwyższego rzędu #, czyli z Object.prototype
. Zachowanie takie wynika z wymogów stawianych przez specyfikację ECMAScript. Chociaż w przypadku ECMAScript 5 można już samodzielnie tworzyć obiekty "czyste", czyli obiekty nie dziedziczące po żadnym prototypie, wciąż jednak wszystkie wbudowane obiekty ECMAScript oraz obiekty gospodarza będą dziedziczyć po Object.prototype
.
Największą siłą prototypów jest możliwość ich swobodnego rozszerzania. Możemy dodawać do prototypów nowe właściwości i metody, możemy także modyfikować wbudowane polecenia poprzez ich całkowite nadpisanie (jeśli polecenia te nie mają ustawionych flag blokujących). Każda zmiana będzie miała wpływ na już istniejące obiekty, dziedziczące po danym prototypie. Dotyczy to wszystkich obiektów, nawet obiektów DOM.
Mechanizm dziedziczenia w ECMAScript na pierwszy rzut oka wydaje się skomplikowany. Nie jest to kurs JS dlatego nie będę opisywał wszystkiego dokładnie. Polecam przestudiowanie ogólnodostępnych materiałów w Internecie lub zakup odpowiedniej literatury. Pamiętajmy, że dziedziczenie jest sposobem na ponowne wykorzystanie pewnych fragmentów kodu. Język JS jest na tyle elastyczny, że zapewnia alternatywne mechanizmy, np. bezpośrednie kopiowanie metod i właściwości z jednego obiektu do drugiego, co przez wielu programistów uważane jest za łatwiejsze i wygodniejsze w użyciu.
Obiekty DOM#
W przypadku obiektów DOM przeglądarki działają na tej samej zasadzie, jak opisałem to nieco wyżej, czyli wykorzystywane jest dziedziczenie przez prototypy. Można powiedzieć, że przeglądarka zawiera obiekty prototypowe, które implementują interfejsy Web IDL (czyli to, co jest w nich wyszczególnione). Kiedy tworzony jest nowy węzeł DOM będzie on dziedziczył z konkretnego prototypu. Z racji tego, że prototyp także jest obiektem, będzie on dziedziczyć z innego prototypu, który z kolei będzie dziedziczyć z kolejnego prototypu, itd. W ten sposób tworzy się łańcuch dziedziczenia.
Oto prosty przykład z definicjami interfejsów Web IDL oraz odwzorowanie ich w ECMAScript:
// Definicja IDL
interface A {
void f();
void g();
};
interface B : A {
void f();
void g(DOMString x);
};
// Odwzorowanie w ECMAScript
[Object.prototype: the Object prototype object]
↑
[A.prototype: interface prototype object for A]
↑
[B.prototype: interface prototype object for B]
↑
[instanceOfB]
Obiekt B.prototype
implementuje interface B
, dziedziczy także z obiektu A.prototype
, który to implementuje interface A
. Każde utworzenie nowej instancji B
- instanceOfB
- będzie dziedziczyło z B.prototype
(włączając w to kolejne łańcuchy dziedziczenia dla B.prototype
).
Funkcje B
, A
i Object
to też obiekty (typu funkcyjnego), dlatego same będą dziedziczyły z jakiegoś powiązanego prototypu. Jeśli wywołane zostaną w roli konstruktora, to zwrócą nowy obiekt nazywany instancją danej funkcji.
Ciekawostką może być dziedziczenie A.prototype
z Object.prototype
, chociaż w definicji fragmentu IDL nie widać żeby interface A
po czymś jeszcze dziedziczył. Takie są wymagania specyfikacji ECMASCript, ale zachowanie to wyjaśniłem w poprzednim punkcie.
Ogólny opis zależności interfejsów Web IDL i obiektów ECMAScript zamieszczam ze względu na często pojawiające się pytania, np. dlaczego następujące polecenie nie działa:
document.getElementById("id1").getElementById("id2");
Pierwsze wyjaśnienie można oprzeć na logice. Biorąc pod uwagę fakt, że identyfikator jest czymś unikatowym w dokumencie - nie może się powtarzać - wystarczy od razu wyszukać element z identyfikatorem id2
:
document.getElementById("id2");
Drugie wyjaśnienie jest już związane bezpośrednio z definicjami IDL. Polecenie document.getElementById()
zwróci nam węzeł typu Element
(implementujący interfejs Element). Węzeł ten nie ma w definicji IDL zadeklarowanej operacji getElementById()
, nie dziedziczy tej operacji z żadnego innego interfejsu, dlatego metoda nie może być wywołana. W przypadku węzła document
(implementującego interfejs Document) jest inaczej. Węzeł ten ma zdefiniowaną metodę getElementById()
we fragmencie IDL, ale jest ona dostępna tylko dla tego rodzaju węzła.
Na dzień dzisiejszy DOM4 rozszerza zakres stosowania metody getElementById()
na interfejsy DocumentFragment oraz Element. Chociaż powyższy przykład mógł stracić już na aktualności, to wciąż pozwala zrozumieć pewne mechanizmy występujące w DOM.
Ta sama uwaga będzie dotyczyła np. metody getElementsByName()
. Jest ona zdefiniowana jedynie dla interfejsu Document w specyfikacji HTML5. Dlatego poniższe wywołanie będzie błędne:
document.getElementsByTagName("div")[0].getElementsByName("fotki");
Trzeba dokładnie analizować konkretne interfejsy oraz ich łańcuchy dziedziczenia, ponieważ od tego zależy dostęp do poszczególnych operacji i atrybutów w implementacji.
Takie niuanse będą w szczególności dziwić osoby początkujące, sam kiedyś miałem z tym problem. Podstawowa znajomość specyfikacji DOM oraz Web IDL pozwala samodzielnie ustalić przyczyny niejasnego zachowania.
Modyfikacja prototypów DOM#
Obiekty DOM są specyficzne. Przez bardzo długi okres czasu przeglądarki internetowe umożliwiały nam dostęp do węzłów, zezwalały na ich tworzenie, ale nie było sposobu na zmianę ich prototypów DOM. Nie wiem jak sytuacja wyglądała w pozostałych programach, ale w przypadku IE możliwość zmiany prototypów DOM wprowadzono dopiero w wersji 8.
Prototypy DOM implementujące interfejsy Web IDL są automatycznie budowane przez przeglądarki. Z racji tego, że DOM jest najczęściej dowiązywany do ECMAScriptu, podlega takim samym zasadom. Prototypy DOM obsługują identyczny mechanizm dziedziczenia ECMAScript, dlatego też można swobodnie manewrować prototypami, czyli bezpośrednio wpływać na dziedziczenie węzłów. Możliwość modyfikacji prototypów DOM nie jest wymagana przez żadną specyfikację, ale większość nowoczesnych przeglądarek na to zezwala.
Twórcy przeglądarek implementują dziedziczenie DOM w taki sposób, żeby na wierzchu całość odpowiadała temu, co już dobrze znamy, czyli mechanizmom ECMAScript. Z drugiej strony, szczegóły implementacji w każdym programie będą inne, na skutek czego mogą wystąpić minimalne różnice w udostępnianych obiektach. W razie czego można sięgnąć do artykułu "JavaScript-DOM Prototypes in Mozilla", by zobaczyć od kuchni, jak zostało to wprowadzone w Firefoksie.
Zacznijmy najpierw od najniższego szczebla, czyli samych instancji. Każdy węzeł DOM będzie instancją jakiegoś konstruktora (obiektu funkcyjnego). Tworząc nowy węzeł będziemy mieli do czynienia właśnie z instancją. Żeby ustalić z jakiego interfejsu korzysta dany węzeł wystarczy przekazać go do jakiejś metody tekstowej, np. Window.alert()
lub Document.write()
. Można też wywołać właściwość constructor
lub odziedziczoną metodę Object.toString()
bezpośrednio na węźle:
<script>
var whatInterface = document.toString();
document.write(document); >// [object HTMLDocument]
document.write("<br>");
document.write(document.constructor); >// [object HTMLDocument]
document.write("<br>");
document.write(whatInterface); >// [object HTMLDocument]
document.write("<br>");
document.write(document.firstChild); >// [object HTMLHtmlElement]
document.write("<br><br>");
document.write(document.createTextNode('')); >// [object Text]
document.write("<br>");
document.write(document.createComment('')); >// [object Comment]
document.write("<br>");
document.write(document.createElement('P')); >// [object HTMLParagraphElement]
</script>
Można przyjąć, że konwersja węzła na tekst będzie zwracała łańcuch znakowy o następującej składni:
[object Interfejs]
gdzie poszczególne człony oznaczają:
object
- typ danych reprezentujący węzeł DOM. Węzeł w implementacji ECMAScript jest obiektem, dlatego zwracany jest typ obiektowyobject
.Interfejs
- nazwa interfejsu który implementuje nasz węzeł. Nazwa będzie identyczna z nazwą podawaną w specyfikacji DOM (z uwzględnieniem wielkości znaków).
Z każdego węzła drzewa DOM można się odwołać do jego prototypu na dwa sposoby:
node.constructor.prototype
- przejście przez konstruktor. Niestety, właściwośćconstructor
może zostać w każdej chwili zmieniona, dlatego nigdy nie należy jej ufać w 100%.node.__proto__
- jest to ukryte bezpośrednie połączenie do prototypu, z pominięciem konstruktora. Niestety, właściwość jest niestandaryzowana i nie musi być udostępniana przez implementacje ECMAScript, chociaż większość nowoczesnych środowisk daje nam taki dostęp.
Prosty przykład:
<script>
document.write(document.constructor.prototype);
document.write("<br>");
document.write(document.__proto__);
document.write("<br>");
document.write(document.constructor.prototype == document.__proto__); // true - ten sam prototyp
</script>
W powyższym przykładzie łańcuch znakowy będący wynikiem zamiany prototypu na tekst może być inny w każdej przeglądarce. Najtrafniejsze opisy będzie miała Opera. Nie jest to istotne, ważne że mamy odwołanie do właściwego prototypu.
Nie są to jedyne sposoby dostępu do prototypu węzła. Obecnie każda nowoczesna przeglądarka udostępnia globalne właściwości, których nazwy są zgodne z nazwami interfejsów DOM. Można powiedzieć, że są to odwołania do funkcji (konstruktorów węzłów), które zawierają właściwość prototype
, pod którą udostępniane są obiekty prototypowe DOM. Z technicznego punktu widzenia nie są to funkcje, nie można ich wykonywać czy wywoływać jako konstruktorów, ale za ich pomocą można przejść do prototypu i go zmienić - jest to jedyne ich przeznaczenie.
W poniższym przykładzie modyfikujemy (rozszerzamy) interfejs Text trzema sposobami, tak żeby wszystkie węzły tekstowe miały dostęp do nowych poleceń:
<script>
document.write(Text); // bezpośredni dostęp do konstruktora z przestrzeni globalnej
Text.prototype.test1 = "Nowa właściwość test1 ustawiona za pomocą Text.prototype.";
document.createTextNode("").constructor.prototype.test2 = "Nowa właściwość test2 ustawiona za pomocą constructor.prototype.";
document.createTextNode("").__proto__.test3 = "Nowa właściwość test3 ustawiona za pomocą __proto__.";
document.write("<br><br>");
document.write(document.createTextNode("").test1); // Nowa właściwość test1 ustawiona za pomocą Text.prototype.
document.write("<br>");
document.write(document.createTextNode("").test2); // Nowa właściwość test2 ustawiona za pomocą constructor.prototype.
document.write("<br>");
document.write(document.createTextNode("").test3); // Nowa właściwość test3 ustawiona za pomocą __proto__.
</script>
W podobny sposób można rozszerzać każdy inny prototyp DOM implementujący konkretny interfejs DOM. Dotyczy to nawet tych interfejsów, które nie mają swojego bezpośredniego odpowiednika węzłowego, np. Node czy CharacterData. Samo rozszerzanie prototypów nie stwarza problemów, ale zmiana/nadpisanie domyślnego polecenia DOM nie zawsze będzie możliwe. Przykładowo Node.prototype.nodeName = "test"
nie zmieni nam działania właściwości nodeName
dostępnej dla każdego węzła. Można przypuszczać, że wszystkie wbudowane polecenia mają ustawiane flagi, które zezwalają bądź nie zezwalają na ich zmianę.
Bardziej życiowym przykładem może być rozszerzenie pewnych interfejsów o metodę getElementsByClassName()
, która nie będzie dostępna w starszych przeglądarkach:
<script>
function getElementsByClassName(classList){
// Kod funkcji
}
if (typeof HTMLDocument.prototype.getElementsByClassName != "function"){
HTMLDocument.prototype.getElementsByClassName = getElementsByClassName;
}
if (typeof Element.prototype.getElementsByClassName != "function"){
Element.prototype.getElementsByClassName = getElementsByClassName;
}
</script>
Należy zachować szczególną ostrożność przy wykorzystywaniu konkretnych nazw interfejsów DOM. Specyfikacja DOM4 wprowadza sporo zmian, redukuje wiele interfejsów, podobnie zachowuje się HTML5. Przeglądarki internetowe muszą mieć trochę czasu na dostosowanie się do aktualnych wymagań. Najlepiej osobiście zgłaszać każdą wyłapaną nieścisłość ze specyfikacjami. Dzięki temu zmiany w poszczególnych programach będą postępowały szybciej.
Zalecenia#
Modyfikacja obiektów prototypowych wbudowanych funkcji konstruujących to wygodny i elastyczny sposób na dodawanie nowych funkcjonalności. Czasem jednak okazuje się zbyt potężny.
Rozszerzanie prototypów obiektów wbudowanych takich jak Object
, Array
, Function
czy prototypów DOM jest kuszące, ale w praktyce znacząco utrudnia konserwację kodu, bo staje się on mniej przewidywalny. Inni programiści korzystający z utworzonego kodu zapewne będą oczekiwać jednolicie działających obiektów wbudowanych bez żadnych dodatków.
Co więcej, właściwości dodane do prototypu mogą pojawić się w pętlach, które nie zostały zabezpieczone testem wykorzystującym metodę Object.hasOwnProperty()
, co może prowadzić do dodatkowych konsternacji.
Z podanych powodów, lepiej nie modyfikować wbudowanych prototypów. Wyjątek od tej reguły stanowią sytuacje, w których spełnione zostaną wszystkie poniższe warunki:
- Oczekuje się, że wszystkie przyszłe wersje ECMAScript, DOM lub HTML5 wprowadzą określoną funkcjonalność jako metodę wbudowaną, a jej implementacje będą działały identycznie. Przykładowo, można zaimplementować metody opisywane w specyfikacji ECMAScript 5 w sytuacji, gdy oczekuje się na ich implementację w przeglądarkach. W ten sposób po prostu przygotowujemy się do wykorzystania dostępnych wkrótce metod wbudowanych.
- Sprawdzi się, czy tworzona metoda lub właściwość już nie istnieje - być może została dodana przez inną wykorzystywaną na stronie bibliotekę lub też została udostępniona przez przeglądarkę jako część nowszego interpretera JavaScript.
- Jasno i wyraźnie poinformuje się cały zespół o wprowadzeniu takiej metody lub właściwości.
Niektórzy programiści polubili mechanizm prototypów tak bardzo, że utworzyli bibliotekę o tej samej nazwie, czyli Prototype, opartą głównie na rozszerzaniu prototypów ECMAScript.