Szczypta dynamiki

Od pewnego czasu pracuję nad nową wersją Canoe. Jednym z ważniejszych punktów na mojej liście rzeczy do zrobienia było napisanie zgrabnego instalatora, obsługiwanego w całości z poziomu przeglądarki. Po zaprogramowaniu mechanizmów tworzących bazę danych przyszedł czas na napisanie interface'u. Stworzenie szablonu z formularzem i wzbogacenie go o odpowiedni arkusz stylów nie było dla mnie niczym nowym. Założenia jakie przyjąłem wymagały jednak również dodania dynamiki do procesu instalacji. Jednym z pytań jakie stawia się użytkownikowi jest pytanie o rodzaj i szczegóły (takie jak login i hasło) dotyczące bazy danych. Problem jednak w tym, że różne rodzaje baz wymagają podania różnych zestawów parametrów. Nie chcąc czynić całego procesu dwustopniowym, postanowiłem włożyć odrobinę wysiłku w przygotowanie JavaScriptu zapewniającego wymaganą funkcjonalność. W tym artykule przedstawiłem proces pisania tego właśnie skryptu, linijka po linijce opisując wykorzystane konstrukcje języka. Moja uwaga skupia się również na analizie błędów typowych dla początkującego programisty JavaScriptu. Jeżeli chcesz zrozumieć podstawy tworzenia dynamicznych stron, ten artykuł jest dla Ciebie.

Zanim przejdziesz dalej, obejrzyj jak wygląda efekt działania skryptu. Gdy już się nim nacieszysz, wróć do artykułu, by dowiedzieć się w jaki sposób to wszystko działa.

Narzędzia

Jako platformę testową wykorzystałem przeglądarkę Firefox w wersji 1.5 z zainstalowaną wtyczką FireBug, stanowiącą nieocenioną pomoc przy śledzeniu błędów w skryptach (i nie tylko, bo równie dobrze ułatwia poprawianie arkuszy stylów).

Przygotowanie dokumentu

Mając już gotowy dokument HTML rozejrzałem się wpierw za metodami pozwalającymi na dołączenie do niego skryptów. Po przeczytaniu artykułu wspominającego o problemach wynikających z osadzania JavaScriptu bezpośrednio w dokumentach HTML, zdecydowałem oszczędzić sobie kłopotu i użyć jedynej słusznej metody umieszczenia skryptu w oddzielnym pliku. W szablonie strony pomiędzy tagami <head> dodałem linię:

<script type="text/javascript" src="/engine.js" />

i utworzyłem nowy plik engine.js, który wkrótce zapełni się treścią skryptu. Wiedząc, że jednym z filarów manipulacji treścią dokumentu jest document.getElementById, oznaczyłem listę typów baz danych unikatowym identyfikatorem engine:

<select id="engine" name="engine" size="1">
    <option value="mysql" selected="selected">
        MySQL
    </option>

    <option value="sqlite">
        SQLite
    </option>
</select>

Zdarzenia

Nie czekając ani chwili dłużej zapisałem pierwsze dwie linie skryptu. W pierwszej za pomocą wspomnianej funkcji znajduję element o identyfikatorze engine, w drugiej wyświetlam ostrzeżenie, o treści będącej identyfikatorem obiektu element (czyli po prostu engine):

element = document.getElementById("engine");
alert(element.id);

Z nadzieją w sercu wcisnąłem Ctrl+R, nadaremnie jednak. Firebug powiadomił mnie o błędzie:

element has no properties
element nie posiada atrybutów

Dlaczego interpreter twierdzi, że obiekt zwrócony przez getElementById nie posiada żadnych atrybutów? Zaglądając do dokumentacji można dowiedzieć się, że funkcja ta może zwrócić jedną z dwóch wartości: albo to będzie obiekt opisujący element o zadanym identyfikatorze, albo null. Z tych dwóch tylko null nie posiada atrybutów, co oznacza, że w pierwszej linijce skryptu element przyjmuje właśnie wartość null. To zaś świadczy o tym, że element o podanym identyfikatorze w dokumencie nie został znaleziony. Dlaczego tak jest, skoro umieściliśmy w dokumencie element <select> o atrybucie id="engine"? Przyczyną jest sposób działania przeglądarki, która wczytuje i interpretuje elementy dokumentu jeden po drugim, przez co skrypt zostaje wykonany jeszcze zanim reszta dokumentu, a w tym i element select, zostaną wczytane1! Mało eleganckim rozwiązaniem (i niezgodnym ze standardem) byłoby przeniesienie elementu <script> na koniec dokumentu. Istnieje jednak w zupełności poprawna metoda na osiągnięcie naszego celu, jakim jest opóźnienie wykonania naszego kodu do momentu wczytania całego dokumentu. Z pomocą przychodzą nam zdarzenia (ang. events). Z każdym ze zdarzeń możemy związać jedną lub więcej funkcji, które zostaną wywołane przez interpreter JavaScriptu w momencie wystąpienia danego zdarzenia. Interesującym nas zdarzeniem, pod który podłączymy swój kod, jest load; występuje ono zaraz po wczytaniu dokumentu. Do rejestracji zdarzeń służy metoda addEventListener2. Ostatnią rzeczą, jaka jest nam potrzebna jest sposób definiowania funkcji, który jest w istocie bardzo prosty:

function nazwa_funkcji (argumenty)
{
    instrukcje;
}

We wnętrzu funkcji zmienne definiuje się przy pomocy słowa kluczowego var:

var nazwa_zmiennej = początkowa_wartość;

Jeżeli chcemy, by funkcja zwracała wartość, oznaczamy to poprzez return. Punktów wyjścia z funkcji (a więc i dyrektyw return) może być kilka. Jeżeli wykonywanie funkcji dotrze do nawiasu końcowego, funkcja zwróci wartość null (ang. nic). Przykładowo, jeżeli chcemy zwrócić sumę dwóch liczb podanych jako argumenty, możemy napisać:

function sumuj (a, b)
{
    return a + b;
}

Uzbrojony w taką wiedzę, byłem w stanie napisać wreszcie działającą wersję skryptu:

function init()
{
    var element = document.getElementById("engine");
    alert(element.id);
}

window.addEventListener("load", init, true);

Szkielet skryptu

Po przełamaniu pierwszych lodów należałoby wreszcie zastanowić się nad logiką całego skryptu. Nie jest ona szczególnie skomplikowana: dla każdej z opcji chcę wyświetlić inny zestaw pól tekstowych do wypełnienia. Dla bazy danych SQLite będzie to pojedyncze pole do wpisania pełnej ścieżki do pliku bazy danych, zaś dla silnika MySQL pola będą już cztery, odpowiadające kolejno za nazwę hosta, nazwę bazy danych, jak i za nazwę i hasło użytkownika. Gdy użytkownik wybierze z listy silnik bazy danych, którego chciałby użyć, wchodzi do gry nasz skrypt i dynamicznie dostosowuje listę pól wyświetlanych na stronie. Całość można ująć następująco:

  1. po wczytaniu strony:
    1. utwórz listę pól dla każdego z silników
    2. zarejestruj obsługę zdarzenia "zmiana opcji" dla elementu o identyfikatorze "engine"
  2. przy zmianie silnika:
    1. znajdź element, do którego podłączymy listę opcji
    2. podłącz do niego listę pól odpowiednią dla wybranego silnika

Na podstawie tych punktów przygotowałem zarys kodu. Punkt 1a rozpisany jest w liniach 18-27. Nazwy sqlite_elements i mysql_elements wiążę z tablicami, której elementami są akapity zwrócone przez funkcję create_input_p. Warto zwrócić tutaj uwagę na prostotę definiowania tablic: wystarczy elementy rozdzielone przecinkiem umieścić pomiędzy nawiasami kwadratowymi. Definicję tablic umieściłem na poziomie globalnym skryptu, a nie we wnętrzu funkcji init(), bo przy ich konstrukcji nie będziemy korzystać z elementów dokumentu, przez co możemy tę konstrukcję przeprowadzić od razu, nie czekając na wczytanie całej strony. Punkt 1b realizuje wnętrze funkcji init() (linie 29-34), znajdując element select i wyznaczając do obsługi zdarzenia zmiany jego wartości ("change" - ang. zmiana) funkcję change() (definicja w liniach 13-16). Punkty 2a i 2b zaimplementowane zostaną właśnie we wnętrzu funkcji change().

  1. /*
  2. * Stwórz akapit z polami do wypełnienia
  3. */
  4. function create_input_p(label_text, input_name)
  5. {
  6.     /* ... */
  7. }
  8.  
  9. /*
  10. * Ustaw odpowiedni akapit na podstawie
  11. * wartości elemetu "select"
  12. */
  13. function change()
  14. {
  15.     /* ... */
  16. }
  17.  
  18. sqlite_elements = [
  19.     create_input_p("Path: ", "db_path"),
  20. ]
  21.  
  22. mysql_elements = [
  23.     create_input_p("Host: ", "db_host"),
  24.     create_input_p("Database name: ", "db_name"),
  25.     create_input_p("Username: ", "db_username"),
  26.     create_input_p("Password: ", "db_password"),
  27. ]
  28.  
  29. function init()
  30. {
  31.     var select = document.getElementById("engine");
  32.  
  33.     select.addEventListener("change", change, true);
  34. }
  35.  
  36. window.addEventListener("load", init, true);

Konstrukcja pól tekstowych

Zaimplementujemy teraz funkcję create_input_p, która zwracać ma akapit zawierający w sobie obiekt pola formularza. Mówiąc konkretnie, ma generować kod HTML przypominający ten poniżej:

<p>
    <label for="input_name">label_text</label>
    <input type="text" value=""
        id="input_name" name="input_name" />

</p>

Nie będziemy jednak operować na znacznikach HTML, tylko na obiektach JavaScriptu. Wartości label_text i input_name będą argumentami naszej funkcji. Pierwszy jest treścią etykiety pola, drugi jego identyfikatorem. Zanim zaczniemy pisać kod, musimy zapoznać się jeszcze z funkcjami, które umożliwią nam stworzenie i połączenie elementów, takich jak akapit, czy pole tekstowe.

Funkcja document.createElement(nazwa) zwraca obiekt reprezentujący element o podanej nazwie. Stąd linia div = document.createElement("div") utworzy element odpowiadający w języku HTML tagowi <div>. Tak utworzonemu elementowi można nadać atrybuty, służy do tego metoda setAttribute(atrybut, wartość). Jeżeli więc na uprzednio utworzonym obiekcie div wywołamy tę metodę w następujący sposób: div.setAttribute("class", "content"), to uzyskamy wynik przekładający się na kod: <div class="content">.

Jako, że cały dokument HTML można zapisać w postaci drzewa, w którym każdy element może mieć wiele potomków i dokładnie jednego rodzica, musi istnieć prosty sposób łączenia elementów ze sobą. Metodą, służącą do tego celu jest rodzic.appendChild(obiekt), która czyni obiekt jednym z potomków rodzica. Przykładowo, jeżeli fragment struktury dokumentu przedstawia się następująco:

<ul id="lista">
    <li>Element pierwszy listy</li>
    <li>Element drugi listy</li>
</ul>

to po wykonaniu tego kodu Javascript:

li = document.createElement("li");
ul = document.getElementById("lista");
ul.appendChild(li);

uzyskamy zmieniony dokument, w którym element ul posiada troje potomków:

<ul id="lista">
    <li>Element pierwszy listy</li>
    <li>Element drugi listy</li>
    <li></li>
</ul>

Tworzenie elementów i nadawanie im atrybutów to podstawy, jednak aby manipulować treścią potrzebujemy jeszcze sposobu na stworzenie obiektu tekstowego. Efekt ten uzyskujemy poprzez wywołanie metody document.createTextNode(tekst), której argument tekst wypełniamy własną treścią. Obiekt zwrócony przez tę funkcję możemy podłączyć do innych obiektów zwyczajnie za pomocą appendChild. W ten sposób bardzo łatwo uzupełnić poprzedni przykładowy skrypt, nadając nowemu elementowi listy treść "Element trzeci listy":

li_tekst = document.createTextNode("Element trzeci listy");

li = document.createElement("li");
li.appendChild(li_tekst);

ul = document.getElementById("lista");
ul.appendChild(li);

W tej chwili wiemy już wszystko, co potrzebne nam do stworzenia funkcji create_input_p. Przypomnijmy sobie efekt jaki chcemy osiągnąć:

<p>
    <label for="input_name">label_text</label>
    <input type="text" value=""
        id="input_name" name="input_name" />

</p>

Potrzebujemy więc utworzyć tekstowy element o treści label_text i element label o atrybucie input_name, które następnie połączymy ze sobą (czyniąc element tekstowy potomkiem elementu label). Dalej stworzymy element input o atrybutach type, value, id i name. Na końcu utworzymy element akapitu p i podłączymy do niego elementy label i input. W efekcie uzyskamy drzewo o postaci:

Drzewo DOM

Kod funkcji create_input_p poniżej:

function create_input_p(label_text, input_name)
{
    var label_t = document.createTextNode(label_text);

    var label = document.createElement("label");
    label.setAttribute("for", input_name);
    label.appendChild(label_t);

    var input = document.createElement("input");
    input.setAttribute("type", "text");
    input.setAttribute("value", "");
    input.setAttribute("id", input_name);
    input.setAttribute("name", input_name);

    var paragraph = document.createElement("p");

    paragraph.appendChild(label);
    paragraph.appendChild(input);

    return paragraph;
}

Podłączanie pól do formularza

Drugą białą plamą, jaką zostawiliśmy w skrypcie na etapie projektowania jest funkcja change(). Jej działanie ma ograniczyć się do odnalezienia wartości elementu select i na jej podstawie dołączenie odpowiedniego akapitu do treści strony. Dołączać będziemy do elementu fieldset, któremu w szablonie nadałem identyfikator dynamic_options:

<fieldset id="dynamic_options">

By rozróżnić wartości elementu select potrzebować będziemy instrukcji warunkowej. Jej postać jest bardzo prosta:

if (warunek) {
    instrukcje wykonywane gdy warunek był prawdą;
} else {
    instrukcje wykonywane gdy warunek nie był prawdą;
}

Wartości prawda i fałsz, idealne do wykorzystania w instrukcji warunkowej, zwracają m.in. wszystkie operatory porównujące: > (większe od), < (mniejsze od), >= (większe lub równe), < (mniejsze lub równe), == (równe) i != (różne). 1 != 2 jest zatem prawdą, zaś "napis" == "inny napis" jest fałszem. Elementy <select> i <fieldset> znajdujemy za pomocą getElementById, zaś elementy listy akapitów dołączamy wykorzystując appendChild, nie ma w tym nic nowego. Dołączanie moglibyśmy zaprogramować "na sztywno", w taki oto sposób:

function change()
{
    var select = document.getElementById("engine");
    var fieldset = document.getElementById("dynamic_options");

    if (select.value == "mysql") {
        fieldset.appendChild(mysql_elements[0]);
        fieldset.appendChild(mysql_elements[1]);
        fieldset.appendChild(mysql_elements[2]);
        fieldset.appendChild(mysql_elements[3]);
    } else if (select.value == "sqlite") {
        fieldset.appendChild(sqlite_elements[0]);
    }
}

Jednak o wiele lepiej wykorzystać pętlę, w kolejnych iteracjach wykorzystującą kolejne wartości zadanej tablicy. Długość tablicy pobrać możemy poprzez atrybut length. Iterując więc po indeksach tablicy od 0 do length-1 w kolejności otrzymujemy dostęp do wszystkich elementów tablicy. Elementy tablicy indeksuje się przy pomocy nawiasów kwadratowych. Dla tablicy o nazwie elementy pierwszy element uzyskujemy pisząc elementy[0], drugi: elementy[1], trzeci elementy[2], itd. W naszym kodzie skorzystamy z pętli for, której składnia jest identyczna do tej znanej z języków takich jak C, czy PHP:

for (instrukcje wstępne ; warunek ; instrukcje końcowe) {
    instrukcje wykonywane po sprawdzeniu warunku;
}

Model wykonania pętli jest następujący. Na początku wykonywane są instrukcje wstępne. Zazwyczaj w tym miejscu umieszcza się deklaracje zmiennych wykorzystywanych w trakcie wykonywania pętli. Ten krok, w przeciwności do pozostałych, wykonywany jest jedynie raz. Następnie sprawdzany jest warunek, na tych samych zasadach, co w instrukcji warunkowej if. Jeżeli warunek jest prawdziwy, kolejno wykonywane są wszystkie instrukcje zawarte w treści pętli pomiędzy nawiasami klamrowymi. Gdy wykonane zostaną wszystkie, sterowanie przekazywane jest do instrukcji końcowych. Po tym kroku możemy mówić o wykonaniu jednego przebiegu pętli. Dalej ponownie sprawdzany jest warunek i jeżeli wciąż jest prawdziwy, to sterowanie znów skacze do wnętrza pętli, po czym następuje wykonanie instrukcji końcowych. Te trzy kroki wykonywane są dopóki warunek jest spełniony. Wykorzystując pętlę możemy już funkcję change() skrócić do postaci:

function change()
{
    var select = document.getElementById("engine");
    var fieldset = document.getElementById("dynamic_options");

    if (select.value == "mysql") {
        for (var i=0; i < mysql_elements.length; i++) {
            fieldset.appendChild(mysql_elements[i]);
        }
    } else if (select.value == "sqlite") {
        for (var i=0; i < sqlite_elements.length; i++) {
            fieldset.appendChild(sqlite_elements[i]);
        }
    }
}

To, czego warto wystrzegać się podczas programowania, to powtórzenia. Funkcje są jednym z najłatwiejszych sposobów na uogólnienie powtarzającego się kodu i zamknięciu go w prostym w użyciu wywołaniu. W kodzie powyżej powtarzającym się schematem są dopiero co zaimplementowane pętle. Obie dołączają do elementu fieldset wartości odpowiednich list. Czynność tę można uogólnić, tworząc funkcję, która czyni podany element rodzicem elementów zadanej listy. Całość można zapisać w sześciu prostych liniach:

function insert_elements_after(node, elements)
{
    for (var i=0; i < elements.length; i++) {
        node.appendChild(elements[i]);
    }
}

W ten oto sposób sam kod funkcji change() uległ uproszeniu i nie zawiera już zbędnych powtórzeń:

function change()
{
    var select = document.getElementById("engine");
    var fieldset = document.getElementById("dynamic_options");

    if (select.value == "mysql") {
        insert_elements_after(fieldset, mysql_elements);
    } else if (select.value == "sqlite") {
        insert_elements_after(fieldset, sqlite_elements);
    }
}

Usuwanie elementów dokumentu

Łącząc napisany dotychczas kod w jedną całość uzyskaliśmy skrypt, który co prawda wykonuje się, ale jego działanie nie spełnia naszych oczekiwań. Bowiem już przy drugim wyborze opcji z listy baz danych okazuje się, że pola nie są zastępowane, tylko dopisywane do zestawu pól elementu <fieldset>. W naszym schemacie aplikacji zapomnieliśmy uwzględnić usuwania starych elementów. By jednak zapełnić tę lukę, musimy zapoznać się z jeszcze jedną funkcją służącą do manipulowania drzewem DOM. Jest to removeChild, która działa odwrotnie do appendChild, tzn. usuwa z listy dzieci danego węzła podany element. Podobnie, jak dla appendChild argumentem metody removeChild musi być obiekt usuwanego elementu, co wymusza na nas uprzednie jego znalezienie. Jako, że nie wszystkie dzieci elementu fieldset są manipulowane przez nasz skrypt (zostaje bowiem stały element select), musimy również w jednoznaczny sposób oznaczyć i następnie zidentyfikować elementy, na których operujemy. Przyjmijmy, że wszystkim elementom jakie dodajemy nadamy klasę "dynamic" i na jej podstawie będziemy elementy usuwać. Do pobrania wartości atrybutu służy metoda getAttribute. Jedyne, co nam jest potrzebne do szczęścia, to informacja o tym, w jaki sposób pobrać listę potomków danego węzła. Nie trzeba daleko szukać, by dowiedzieć się, że atrybut ten nazywa się childNodes. Kod znajdujący i usuwający elementy o klasie "dynamic" mógłby wyglądać następująco:

var fieldset = document.getElementById("dynamic_options");
var children = fieldset.childNodes;

for (var i=0; i < children.length; i++) {
    if (children[i].getAttribute("class") == "dynamic") {
        fieldset.removeChild(children[i]);
    }
}

Należy również uzupełnić definicję funkcji create_input_p o linię nadającą akapitowi klasę "dynamic":

paragraph.setAttribute("class", "dynamic");

Jeżeli wstawimy wcześniejszy fragment kodu do wnętrza funkcji change() i uruchomimy w przeglądarce, przekonamy się, że usuwa on tylko niektóre z elementów. Żeby zrozumieć, dlaczego tak się dzieje, musimy jeszcze raz przypomnieć sobie, co robi removeChild. Metoda ta usuwa określony węzeł z listy potomków. W ten sposób indeksy wszystkich następujących elementów zmniejszają się o jeden, zaś sama długość listy potomków skraca się również o jeden. W ten sposób omijamy średnio co drugi element, bo chociaż indeksy elementów się zmieniają, to zmienna i za każdym razem zwiększana jest o jeden. Problem, ogólnie ujmując, polega na tym, że modyfikujemy listę, po której iterujemy w pętli. Błąd ten naprawia się rozbijając całe zadanie na dwie pętle. W pierwszej tworzymy listę elementów do usunięcia, w drugiej te elementy usuwamy. Implementacja wygląda następująco:

var fieldset = document.getElementById("dynamic_options");
var children = fieldset.childNodes;
var to_remove = [];

for (var i=0; i < children.length; i++) {
    child = children[i];
    if (child.getAttribute("class") == "dynamic") {
        to_remove.push(child);
    }
}

for (var i=0; i < to_remove.length; i++) {
    fieldset.removeChild(to_remove[i]);
}

Oczywiście nie wypada tak dużego kawałku kodu wklejać bezpośrednio do treści funkcji change(). Stworzymy więc funkcję, która usuwa z listy dzieci podanego węzła wszystkie elementy o zadanej klasie:

function remove_children_of_class(node, class)
{
    var children = node.childNodes;
    var to_remove = [];

    for (var i=0; i < children.length; i++) {
        child = children[i];
        if (child.getAttribute("class") == class) {
            to_remove.push(child);
        }
    }

    for (var i=0; i < to_remove.length; i++) {
        node.removeChild(to_remove[i]);
    }
}

W definicji zaś funkcji change() wywołujemy tę funkcję w sposób następujący:

remove_children_of_class(fieldset, "dynamic");

Jak jednak można się domyślić, świat byłby zbyt piękny, gdyby kod zadziałał za pierwszym razem. Zostałem uraczony tajemniczym błędem:

child.getAttribute is not a function
child.getAttribute nie jest funkcją

Chociaż napisane jest, że getAttribute nie jest funkcją, należy rozumieć to jako informację, że metoda getAttribute dla obiektu child nie została znaleziona i dlatego wywołanie jej jest niepoprawne. Jeżeli obejrzymy sobie drzewko DOM elementu fieldset, na którym manipulujemy, z łatwością zauważymy, że jego potomkami są nie tylko inne elementy, takie jakie jak legend, czy p, ale również wartości tekstowe. Występują one ze względu na spacje oddzielające tagi w źródle strony HTML.

Drzewo DOM
By otworzyć w Firefoksie okno inspektora DOM, wciśnij Ctrl+Shift+i.

Obiekty tekstowe nie posiadają atrybutów, a w związku tym również metody getAttribute. Jeżeli dany obiekt nie posiada atrybutów, to z pewnością nie ma klasy równej "dynamic". Dlatego zwyczajnie możemy pominąć wszystkie elementy nie posiadające metody getAttribute. Istniejąca metoda lub atrybut w warunku zwraca wartość prawda, jeżeli zaś nie istnieje - fałsz. Możemy więc warunek rozszerzyć w następujący sposób:

if (child.getAttribute) {
    if (child.getAttribute("class") == class) {
        to_remove.push(child);
    }
}

Powyższe dwa zagnieżdżone warunki można połączyć w jeden za pomocą operatora sumy logicznej: &&. Warunek zewnętrzny trafia na lewą stronę operatora, warunek wewnętrzny na prawą:

if (child.getAttribute
        && child.getAttribute("class") == class) {
    to_remove.push(child);
}

Po tych poprawkach nareszcie możemy sobie pogratulować - skrypt działa poprawnie i dostosowuje treść formularza zależnie od wybranego silnika bazy danych. Jedyną zmianą funkcjonalną, jaką można by wprowadzić, to wymuszenie wywołania funkcji change() zaraz po załadowaniu strony, co przystosuje treść formularza do początkowej wartości elementu select. Ostatecznie funkcja init() wyglądać więc będzie następująco:

function init()
{
    var select = document.getElementById("engine");
 
    select.addEventListener("change", change, true);

    change();
}

Paskudne szczegóły

Niestety, ale na tym cała historia się nie kończy. Chociaż JavaScript jest językiem objętym standardem, to przeglądarki w różnym stopniu wytyczne standardów implementują. Czasami niektóre funkcje nie są dostępne i trzeba posiłkować się środkami zastępczymi. W innym przypadku obiekty zachowują się w odmienny sposób, niż oczekiwany. Dostosowanie skryptu do indywidualnych potrzeb różnych przeglądarek jest sztuką samą w sobie, zupełnie odrębną od pisania samego skryptu. Temat jest na tyle rozległy, że można by o nim napisać książkę, ograniczę się więc w tym miejscu jedynie do przedstawienia drobnych modyfikacji naszego skryptu, które to pozwolą mu działać nie tylko w przeglądarkach z silnikiem Gecko (a więc i używanym przez nas do testów Firefoksie), ale również pod Internet Explorerem i Operą. A dokładnie testować będziemy IE w wersjach 5.50 i 6.0, zaś Operę w wersji 8.52.

Na pierwszy ogień idzie Opera. Po wczytaniu strony widać, że początkowe pola formularza zostały ustawione prawidłowo. Niestety zmiana opcji nie przynosi żadnych efektów, formularz ani drgnie. Co gorsza wbudowana w przeglądarkę konsola JavaScriptu nie wskazuje żadnych błędów. Ale nie jest jeszcze tak źle, bo wiemy, że funkcja change() działa dobrze, brak jedynie reakcji na zmianę. Prawdopodobnie więc problemem jest przechwycenie lub podłączenie się pod obsługę zdarzenia zmiany wartości elementu select. Z braku innych pomysłów spróbowałem więc przypisać funkcję do zdarzenia change starym (nie)dobrym sposobem:

select.onchange = change;

I zadziałało! Szybkie sprawdzenie, czy nie zepsuło to niczego w obsłudze skryptu pod Firefoksem (na szczęście nie) i możemy przejść do czarnej owcy przeglądarek, Internet Explorera. Dawkując sobie poziom rozrywki zaczniemy od wersji nowszej, czyli szóstej. Na początek IE nie podoba się coś w funkcji remove_children_of_class(). Tutaj słowo ostrzeżenia: IE ma własne zdanie dotyczące numeracji linii i znaków w plikach skryptu, należy więc podanymi miejscami wystąpienia błędu kierować się jedynie orientacyjnie. W tym przypadku punktem spornym okazuje się nazwa zmiennej class w definicji funkcji. Wystarczy zamienić wszystkie wystąpienia nazwy class w nagłówku i treści funkcji na coś innego (np. z hiszpańskiego na el_class) i IE zostawi nas w spokoju. Oczywiście tylko dla tego błędu, bo to nie koniec naszych zmagań. Do poprawki bowiem trafia metoda addEventListener(). W IE nie jest dostępna metoda o takiej nazwie, jest za to jej odpowiednik attachEvent. Przyjmuje on nazwy zdarzeń z przedrostkiem "on", więc zamiast zdarzenia "load" należy użyć "onload". Metoda jest na tyle automatyczna, że można spokojnie jej wykonanie powierzyć odpowiedniej funkcji:

function addEvent(obj, event, handler)
{
    if (obj.addEventListener) {
        obj.addEventListener(event, handler, true);
    } else if (obj.attachEvent) {
        obj.attachEvent("on"+event, handler);
    }
}

Jeszcze inny sposób rejestrowania funkcji obsługi zdarzeń można znaleźć w tym wpisie blogowym Simona Willisona. Mając już zdefiniowaną tak sprytną funkcję, możemy zamienić rejestrację funkcji init() na postać następującą:

addEvent(window, "load", init);

Obsługi zdarzenia "change" nie zmieniamy, by nie zepsuć kodu dla Opery. Jak więc widać, zostaliśmy zmuszeni użyć trzech różnych sposobów na funkcjonalnie tą samą czynność. Kod zaczyna wyglądać brzydko, choć jeszcze w granicach akceptowalności. Warto sporne miejsca dobrze skomentować. Co ciekawe, po tych dwóch drobnym zmianach, IE 6 miłosiernie decyduje się zaakceptować nasz skrypt do interpretacji.

Ostatnim krokiem było sprawdzenie działania skryptu pod IE 5.50. Jak można się domyślić, poprawki były nieuniknione (poczujcie się jak na studiach). Przeglądarka poinformowała mnie o błędzie w bardzo przejrzysty sposób, mianowicie wypisując Wiersz 41, znak 9: Pomyłka literowa. Rzecz prawdopodobnie dotyczyła funkcji insert_elements_after(). Nie tracąc nadziei postanowiłem prześledzić sytuację dodając tu i tam najpopularniejszą instrukcję do debugowania, tzn. alert(). I wreszcie alert(elements.length) mnie oświeciło: dla tablicy mysql_elements pokazało 5! Czemu tablica o czterech elementach ma długość pięć? A może rzeczywiście elementów jest pięć? Spojrzałem jeszcze raz na definicję tablic i bez przekonania usunąłem przecinek po ostatnim elemencie. W tych językach, które to umożliwiają, zawsze zostawiam separator listy po ostatnim elemencie, co ułatwia dodawanie nowych elementów i zmianę kolejności obecnych. I tym razem miałem nosa, po tej drobnej zmianie IE 5.50 obsłużyło kod mojego skryptu jak należy.

Jak widać problem przenośności nie jest taki prosty i oczywisty. Na szczęście wraz ze wzrostem znaczenia standardów sieciowych i coraz większą powszechnością nowoczesnych technologii webowych sytuacja zdaje się poprawiać. Cieszy również zwiększone zainteresowanie kwestiami dostępności i używalności, co naturalnie prowadzi do ujednolicenia wsparcia popularnych technologii w przeglądarkach. Obecny rozwój dynamicznych stron internetowych zawdzięczamy nie tylko potężniejszym przeglądarkom, ale również istnieniu JavaScriptowych bibliotek (takich jak behaviour, czy prototype), które kwestie przenośności kodu pomiędzy przeglądarkami biorą na siebie, pozwalając programiście skupić się wyłącznie na tworzeniu aplikacji. Mam nadzieję, że kiedyś doczekam czasów, gdy będzie można publikować dynamiczną (i tą statyczną również) treść w sieci opierając się wyłącznie na jasnych wytycznych standardów. Czego sobie i czytelnikom życzę.

Literatura

Ostateczna wersja skryptu

Kod źródłowy, jako część projektu Canoe, objęty jest licencją BSD.

/*
 * IE have attachEvent,
 * Opera and Gecko addEventListener
 */

function addEvent(obj, event, handler)
{
    if (obj.addEventListener) {
        obj.addEventListener(event, handler, true);
    } else if (obj.attachEvent) {
        obj.attachEvent("on"+event, handler);
    }
}

function create_input_p(label_text, input_name)
{
    var label_t = document.createTextNode(label_text);

    var label = document.createElement("label");
    label.setAttribute("for", input_name);
    label.appendChild(label_t);

    var input = document.createElement("input");
    input.setAttribute("type", "text");
    input.setAttribute("value", "");
    input.setAttribute("id", input_name);
    input.setAttribute("name", input_name);

    var paragraph = document.createElement("p");
    paragraph.setAttribute("class", "dynamic");

    paragraph.appendChild(label);
    paragraph.appendChild(input);

    return paragraph;
}

function insert_elements_after(node, elements)
{
    for (var i=0; i < elements.length; i++) {
        node.appendChild(elements[i]);
    }
}

/*
 * IE doesn't want to work if variable is called "class"
 */

function remove_children_of_class(node, el_class)
{
    var children = node.childNodes;
    var to_remove = [];

    for (var i=0; i < children.length; i++) {
        child = children[i];
        if (child.getAttribute
                && child.getAttribute("class") == el_class) {
            to_remove.push(child);
        }
    }
    for (var i=0; i < to_remove.length; i++) {
        node.removeChild(to_remove[i]);
    }
}

function change()
{
    var select = document.getElementById("engine");
    var fieldset = document.getElementById("dynamic_options");

    remove_children_of_class(fieldset, "dynamic");

    if (select.value == "mysql") {
        insert_elements_after(fieldset, mysql_elements);
    } else if (select.value == "sqlite") {
        insert_elements_after(fieldset, sqlite_elements);
    }
}

sqlite_elements = [
    create_input_p("Path: ", "db_path")
]

mysql_elements = [
    create_input_p("Host: ", "db_host"),
    create_input_p("Database name: ", "db_name"),
    create_input_p("Username: ", "db_username"),
    create_input_p("Password: ", "db_password")
]

function init()
{
    var select = document.getElementById("engine");

    /*
     * Opera doesn't handle
     * select.addEventListener("change", change)
     * for some reason
     */

    select.onchange = change;

    change();
}

addEvent(window, "load", init);

Przypisy

1 Biblioteka obsługi zdarzeń wydana przez Yahoo pozwala podłączać funkcje do obiektów, które jeszcze nie zostały wczytane .

2 Szczegóły dotyczące argumentów funkcji addEventListener i działania obsługi wyjątków można znaleźć w dokumentacji DOM, a w skróconej formie również na stronach DevMo.

Autor: Michał Kwiatkowski
Data ostatniej modyfikacji: 7 marca 2006
Licencja: Creative Commons Attribution-ShareAlike 2.5

Technorati tags:

redditSkomentuj ten artykuł.