Rozdział 9. Modularność

15 stycznia 2013
1 gwiadka2 gwiazdki3 gwiazdki4 gwiazdki5 gwiazdek

Tematem tego rozdziału jest organizacja programów. W małych programach kwestia ta praktycznie nie występuje. Ale z czasem niektóre aplikacje rozrastają się do tego poziomu, że trudno jest zapanować nad ich strukturą i zrozumieć ich działanie. Dość szybko kod programu może zacząć przypominać spaghetti, czyli bezkształtną masę, w której wydaje się, że wszystko jest ze sobą wzajemnie powiązane.

Tworząc strukturę programu wykonuje się dwie czynności. Dzieli się go na mniejsze części zwane modułami, z których każdy pełni jakąś rolę, i określa się relacje między między nimi.

W rozdziale 8 podczas pracy nad terrarium użyliśmy kilku funkcji utworzonych jeszcze w rozdziale 6. Ponadto w rozdziale tym zostały zdefiniowane pewne pojęcia które nie mają nic wspólnego z terrariami, takie jak metoda clone i typ Dictionary. Wszystko to zostało wrzucone do środowiska, jak do worka. Program ten można by było podzielić na moduły następująco:

  • moduł FunctionalTools zawierałby funkcje z rozdziału 6 i nie byłby zależny od żadnego innego.
  • Moduł ObjectTools zawierałby takie składniki, jak clone i create i byłby zależny od modułu FunctionalTools.
  • Moduł Dictionary, zawierający typ słownikowy byłby zależny od modułu FunctionalTools.
  • Moduł Terrarium byłby zależny od modułów ObjectTools i Dictionary.

Gdy jeden moduł jest zależny od innego, używa jego funkcji lub zmiennych i może działać tylko, gdy ten moduł jest załadowany.

Należy uważać, aby te zależności nie tworzyły błędnego koła. Oprócz tego, że sprawiają trudności natury praktycznej (jeśli moduły A i B są wzajemnie zależne, to który powinien zostać załadowany pierwszy?), to dodatkowo zamazują relacje między modułami i mogą powodować, że powstanie modularna wersja wspominanego kodu spaghetti.


Większość nowoczesnych języków programowania ma wbudowany jakiś system modularyzacji, ale nie JavaScript. Po raz kolejny musimy sami coś wymyślić. Najprostszym sposobem wydaje się umieszczenie każdego modułu w osobnym pliku. Dzięki temu wyraźnie widać, jaki kod zawiera każdy moduł.

Przeglądarki wczytują pliki JavaScript dołączane do stron internetowych za pomocą elementu HTML script z atrybutem src. Pliki zawierające kod JavaScript najczęściej mają rozszerzenie .js. W konsoli ładowanie plików jest realizowane przez funkcję load.

load("FunctionalTools.js");

Czasami wpisanie poleceń wczytania plików w niewłaściwej kolejności powoduje błędy. Jeśli jakiś moduł próbuje utworzyć obiekt Dictionary, ale moduł Dictionary jeszcze nie został naładowany, nie uda się znaleźć konstruktora i operacja nie zostanie wykonana.

Można pomyśleć, że problem ten jest łatwy do rozwiązania. Wystarczy na początku pliku modułu umieścić kilka wywołań funkcji load, aby załadować wszystkie moduły, które są mu potrzebne. Niestety przeglądarki internetowe działają w taki sposób, że wywołanie funkcji load nie powoduje natychmiastowego załadowania pliku. Plik jest wczytywany dopiero po zakończeniu wykonywania bieżącego pliku. Zwykle wtedy jest już za późno.

W większości przypadków można sobie poradzić zarządzając zależnościami ręcznie: wpisując elementy script w kodzie HTML strony we właściwej kolejności.


Zarządzanie zależnościami można częściowo zautomatyzować na dwa sposoby. Pierwszy polega na utworzeniu osobnego pliku z informacjami o zależnościach między modułami. Plik ten może być ładowany pierwszy, a znajdujące się w nim informacje mogą być wykorzystane do określenia kolejności ładowania pozostałych plików. Drugi sposób polega na zrezygnowaniu z elementu script (funkcja load wewnętrznie go tworzy i dodaje) i pobieraniu zawartości pliku bezpośrednio (zobacz rozdział 14), a następnie wykonywaniu jej za pomocą funkcji eval. W ten sposób skrypty są ładowane natychmiast, co ułatwia pracę.

Funkcja eval (nazwa pochodzi od ang. słowa evaluate — oszacować) jest bardzo ciekawa. Przekazuje się jej wartość łańcuchową, a ona wykonuje ten łańcuch jako kod JavaScript.

eval("print("Jestem łańcuchem w łańcuchu!");");

Zapewne domyślasz się, że przy jej użyciu można zrobić wiele fajnych rzeczy. Kod może tworzyć inny kod, a następnie go wykonywać. Jednak większość problemów, które można wykonać pomysłowo wykorzystując funkcję eval można również wykonać przy użyciu funkcji anonimowych, które dodatkowo stwarzają mniejsze ryzyko wystąpienia dziwnych problemów.

Gdy funkcja eval jest wywoływana wewnątrz funkcji, wszystkie nowe zmienne stają się lokalne w tej funkcji. Gdyby zatem w jakiejś wersji funkcji load użyto funkcji eval, załadowanie modułu Dictionary spowodowałoby utworzenie konstruktora Dictionary wewnątrz funkcji load i zniknąłby on natychmiast po zakończeniu działania przez tę funkcję. Istnieją sposoby na obejście tego, ale są trochę niezgrabne.


Przyjrzymy się krótko pierwszej technice zarządzania zależnościami. Potrzebny jest w niej specjalny plik zawierający informacje o zależnościach, np.:

var dependencies =
  {"ObjectTools.js": ["FunctionalTools.js"],
   "Dictionary.js":  ["ObjectTools.js"],
   "TestModule.js":  ["FunctionalTools.js", "Dictionary.js"]};

W obiekcie dependencies znajduje się po jednej własności dla każdego pliku, który zależy od innych plików. Wartości tych własności są tablicami nazw plików. Zauważ, że nie mogliśmy tu użyć obiektu Dictionary, ponieważ nie mamy pewności, czy moduł Dictionary został już załadowany. Ponieważ wszystkie własności w tym obiekcie mają końcówkę .js, jest mało prawdopodobne, aby kolidowały z ukrytymi własnościami typu __proto__ czy hasOwnProperty.

Menedżer zależności musi wykonywać dwa działania. Po pierwsze pilnuje, aby pliki były ładowane we właściwej kolejności ładując zależności każdego pliku przed samym tym plikiem. Po drugie pilnuje, aby żaden plik nie został załadowany dwa razy. Wielokrotne wczytanie pliku może powodować problemy i jest stratą czasu.

var loadedFiles = {};

function require(file) {
  if (dependencies[file]) {
    var files = dependencies[file];
    for (var i = 0; i < files.length; i++)
      require(files[i]);
  }
  if (!loadedFiles[file]) {
    loadedFiles[file] = true;
    load(file);
  }
}

Teraz do ładowania plików wraz z zależnościami można używać funkcji require. Zwróć uwagę, jak funkcja ta rekurencyjnie wywołuje sama siebie, aby zająć się zależnościami (i ewentualnie zależnościami zależności).

require("TestModule.js");
test();

Gdy program jest budowany z zestawu małych modułów, to zazwyczaj używa się w nim dużej liczby niewielkich plików. W programowaniu sieciowym ładowanie dużej liczby plików JavaScript może spowolnić wczytywanie stron. Ale nie musi tak być. Testowy program można napisać jako zbiór małych plików, a przez opublikowaniem w internecie można je połączyć w jeden duży plik.


Podobnie jak typ obiektowy, moduł ma interfejs. W modułach będących prostymi zbiorami funkcji, jak FunctionalTools, interfejs zwykle składa się z wszystkich funkcji zdefiniowanych w module. W innych przypadkach na interfejs modułu składa się tylko niewielka część zdefiniowanych w nim funkcji. Na przykład nasz system zamieniający rękopis na format HTML opisany w rozdziale 6 wymaga w interfejsie tylko jednej funkcji — renderFile. (Podsystem tworzący kod HTML mógłby być osobnym modułem.)

W przypadku modułów zawierających definicję tylko jednego typu obiektowego, jak Dictionary, interfejs modułu jest tożsamy z interfejsem tego typu.


W języku JavaScript zmienne wszystkie najwyższego poziomu znajdują się w jednym miejscu. W przeglądarkach tym miejscem jest obiekt o nazwie window. Nazwa ta jest trochę dziwna. Lepsza byłaby environment (środowisko) albo top (najwyższy), ale ponieważ przeglądarki wiążą środowisko JavaScript z oknem (albo „ramką”), ktoś uznał, że wybór nazwy window jest uzasadniony.

show(window);
show(window.print == print);
show(window.window.window.window.window);

Jak wykazałem w trzecim wierszu powyższego kodu, nazwa window jest jedynie własnością obiektu środowiska wskazującą na siebie.


Gdy w środowisku znajduje się dużo kodu, używanych jest wiele nazw zmiennych najwyższego poziomu. Gdy ilość kodu będzie na tyle duża, że nie będziesz w stanie wszystkiego zapamiętać, to pojawi się ryzyko, że w końcu przez przypadek użyjesz jakiejś nazwy po raz drugi. To spowoduje awarię w miejscu, w którym używana była oryginalna wartość. Sytuacja, w której liczba zmiennych najwyższego rzędu jest bardzo duża nazywa się zaśmieceniem przestrzeni nazw. W JavaScripcie jest to bardzo niebezpieczne, ponieważ język ten nie ostrzega, gdy redefiniowana jest istniejąca zmienna.

Nie da się tego problemu rozwiązać całkowicie, ale można zredukować ryzyko starając się nie zaśmiecać środowiska. Przede wszystkim w modułach wszystkie zmienne nie należące do zewnętrznego interfejsu nie powinny być najwyższego poziomu.


Brak możliwości definiowania wewnętrznych funkcji i zmiennych w modułach oczywiście stanowi utrudnienie. Na szczęście można to obejść stosując pewną sztuczkę. Cały kod modułu pisze się w funkcji, a na koniec wszystkie zmienne należące do interfejsu modułu dodaje się do obiektu window. Dzięki temu, że zostały utworzone w tej samej funkcji, wszystkie funkcje modułu „widzą” się wzajemnie, ale kod znajdujący się na zewnątrz modułu ich nie widzi.

function buildMonthNameModule() {
  var names = ["Styczeń", "Luty", "Marzec", "Kwiecień",
               "Maj", "Czerwiec", "Lipiec", "Sierpień", "Wrzesień",
               "Październik", "Listopad", "Grudzień"];
  function getMonthName(number) {
    return names[number];
  }
  function getMonthNumber(name) {
    for (var number = 0; number < names.length; number++) {
      if (names[number] == name)
        return number;
    }
  }

  window.getMonthName = getMonthName;
  window.getMonthNumber = getMonthNumber;
}
buildMonthNameModule();

show(getMonthName(11));

Jest to bardzo prosty moduł zamieniający nazwy miesięcy na numery miesięcy (np. do użytku w obiekcie Date, w którym styczeń to 0). Zauważ jednak, że buildMonthNameModule nadal jest zmienną najwyższego poziomu nie będącą częścią interfejsu modułu. Ponadto nazwy funkcji interfejsu musimy powtarzać trzy razy. Ech.


Pierwszy problem można rozwiązać czyniąc funkcję modułu anonimową i wywołując ją bezpośrednio. W tym celu wartość funkcji musimy umieścić w klamrze, ponieważ jeśli tego nie zrobimy, dla JavaScriptu będzie to definicja zwykłej funkcji, która nie może być wywoływana bezpośrednio.

Drugi problem można rozwiązać przy użyciu funkcji pomocniczej provide, której można podać obiekt zawierający wartości, które muszą zostać wyeksportowane do obiektu window.

function provide(values) {
  forEachIn(values, function(name, value) {
    window[name] = value;
  });
}

Korzystając z tego możemy napisać taki moduł:

(function() {
  var names = ["Niedziela", "Poniedziałek", "Wtorek", "Środa",
               "Czwartek", "Piątek", "Sobota"];
  provide({
    getDayName: function(number) {
      return names[number];
    },
    getDayNumber: function(name) {
      for (var number = 0; number < names.length; number++) {
        if (names[number] == name)
          return number;
      }
    }
  });
})();

show(getDayNumber("Środa"));

Nie polecam pisania modułów w taki sposób od samego początku. Podczas pracy łatwiej jest stosować prostą technikę, jaką stosowaliśmy do tej pory i wszystko wrzucać na najwyższy poziom. Dzięki temu można sprawdzać i testować wewnętrzne wartości modułu w przeglądarce. Gdy moduł zostanie ukończony, nietrudno będzie zapakować go w funkcję.


Są przypadki, w których moduł eksportuje tak dużo zmiennych, że umieszczenie ich wszystkich w środowisku najwyższego poziomu jest złym pomysłem. Wówczas można zrobić to samo, co robi standardowy obiekt Math, czyli zaprezentować cały moduł jako jeden obiekt, którego własności są eksportowanymi funkcjami i wartościami. Na przykład:

var HTML = {
  tag: function(name, content, properties) {
    return {name: name, properties: properties, content: content};
  },
  link: function(target, text) {
    return HTML.tag("a", [text], {href: target});
  }
  /* ... kolejne funkcje tworzące elementy HTML ... */
};

Gdyby zawartość modułu była potrzebna tak często, że ciągłe wpisywanie HTML byłoby uciążliwe, zawsze można by było go przenieść do najwyższego poziomu środowiska za pomocą funkcji provide.

provide(HTML);
show(link("http://download.oracle.com/docs/cd/E19957-01/816-6408-10/object.htm",
          "Tak działają obiekty."));

Można nawet połączyć technikę funkcyjną z obiektową umieszczając wewnętrzne zmienne modułu w funkcji i zwracając przez tę funkcję obiekt zawierający jego zewnętrzny interfejs.


Podobny problem z zaśmiecaniem przestrzeni nazw występuje przy dodawaniu metod do standardowych prototypów, jak Array i Object. Jeśli dwa moduły dodadzą metodę map do prototypu Array.prototype, możesz mieć problem. Jeśli wynik działania obu tych wersji metody map będzie taki sam, to może nic złego się nie stać, ale to będzie czysty przypadek.


Projektowanie interfejsu modułów i typów obiektowych jest jednym z subtelnych elementów sztuki programowania. Z jednej strony nie chcemy ujawniać zbyt wielu szczegółów, ponieważ tylko będą przeszkadzać podczas używania modułu. Z drugiej strony nie chcemy nadmiernie upraszczać i generalizować, ponieważ moglibyśmy uniemożliwić używanie modułu w skomplikowanych lub specjalnych sytuacjach.

Czasami dobrym rozwiązaniem jest utworzenie dwóch interfejsów. Jeden szczegółowy do zastosowań w skomplikowanych sprawach, a drugi uproszczony do użytku w pozostałych sytuacjach. Drugi interfejs można łatwo zbudować przy użyciu narzędzi udostępnianych przez pierwszy.

W innych przypadkach trzeba tylko wymyślić na czym oprzeć swój interfejs. Porównaj to z podejściami do dziedziczenia opisanymi w rozdziale 8. Stawiając w centrum prototypy zamiast konstruktorów udało się nam znacznie uprościć niektóre rzeczy.

Niestety najlepszym sposobem nauki projektowania poprawnych interfejsów jest używanie przez jakiś czas niepoprawnych. Gdy w końcu ma się ich dość, zaczyna się szukać sposobu na ich udoskonalenie i przy okazji sporo się uczy. Nie zakładaj, że słaby interfejs taki już po prostu musi być. Popraw go albo zapakuj w lepszy interfejs (przykład tego przedstawiłem w rozdziale 12).


Istnieją funkcje wymagające dużej ilości argumentów. Czasami jest to spowodowane tym, że są źle zaprojektowane i wystarczy je podzielić na kilka mniejszych funkcji. Jednak czasami nie ma innej możliwości. Zazwyczaj argumenty te mają jakieś sensowne wartości domyślne. Moglibyśmy np. napisać kolejną rozszerzoną wersję funkcji range.

function range(start, end, stepSize, length) {
  if (stepSize == undefined)
    stepSize = 1;
  if (end == undefined)
    end = start + stepSize * (length - 1);

  var result = [];
  for (; start <= end; start += stepSize)
    result.push(start);
  return result;
}

show(range(0, undefined, 4, 5));

Zapamiętanie, w którym miejscu powinien znajdować się każdy z argumentów może być trudne, nie mówiąc już o tym, jak denerwujące byłoby wpisywanie undefined jako drugi argument zawsze, gdy używany jest argument length. Przekazywanie argumentów do tej funkcji moglibyśmy uprościć pakując je w obiekt.

function defaultTo(object, values) {
  forEachIn(values, function(name, value) {
    if (!object.hasOwnProperty(name))
      object[name] = value;
  });
}

function range(args) {
  defaultTo(args, {start: 0, stepSize: 1});
  if (args.end == undefined)
    args.end = args.start + args.stepSize * (args.length - 1);

  var result = [];
  for (; args.start <= args.end; args.start += args.stepSize)
    result.push(args.start);
  return result;
}

show(range({stepSize: 4, length: 5}));

Funkcja defaultTo służy do dodawania domyślnych wartości do obiektów. Kopiuje własności swojego drugiego argumentu do swojego pierwszego argumentu pomijając te, które już mają wartości.


Moduł lub grupa modułów, której można użyć w więcej niż jednym programie nazywa się biblioteką. Dla wielu języków programowania dostępne są ogromne biblioteki kodu wysokiej jakości. Dzięki temu programiści nie muszą za każdym razem pisać wszystkiego od początku i mogą szybciej pracować. Także dla języka JavaScript liczba bibliotek jest całkiem pokaźna.

I wydaje się, że wciąż rośnie. Istnieją biblioteki zawierające takie podstawowe narzędzia, jak map czy clone. W innych językach tak przydatne konstrukcje są dostępne standardowo, ale w JavaScripcie trzeba je budować samodzielnie albo używać bibliotek, jeśli chce się ich używać. Z bibliotek warto korzystać z wielu powodów: pozwalają zaoszczędzić na pracy, a zawarty w nich kod jest o wiele lepiej przetestowany niż to, co sami napiszemy.

Do najpopularniejszych bibliotek JavaScript zaliczają się prototype, mootools, jQuery i MochiKit. Dostępne są też bardziej rozbudowane „szkielety” zawierające znacznie więcej niż tylko zestaw podstawowych narzędzi. W tej kategorii do najpopularniejszych należą YUI (własność Yahoo) i Dojo. Wszystkie wymienione biblioteki są darmowe i dostępne do pobrania w internecie. Moja ulubiona to MochiKit, ale to tylko kwestia gustu. Gdy staniesz się poważnym programistą JavaScript, przejrzyj dokumentacje tych bibliotek, aby mieć ogólne rozeznanie jak działają i do czego można je wykorzystać.

Fakt że podstawowy zestaw narzędzi jest praktycznie niezbędny do napisania jakiegokolwiek większego programu w JavaScripcie w połączeniu z faktem, że istnieje tak dużo różnych zestawów narzędzi sprawia, że programiści mają twardy orzech do zgryzienia. Muszą bowiem wybrać czy ich biblioteka ma zależeć od jakiejś innej biblioteki czy też wolą wszystko napisać od początku sami. Wybór pierwszej opcji utrudnia używanie biblioteki osobom używającym innego zestawu narzędzi, a wybór drugiej sprawia, że w kodzie biblioteki będzie dużo kodu, który nie jest niezbędny. Ten dylemat może być jedną z przyczyn, dla których wciąż jest względnie mało dobrych i powszechnie używanych bibliotek JavaScript. Niewykluczone że przyszłe zmiany w standardzie ECMAScript i przeglądarkach wiele z tych zestawów narzędzi uczynią niepotrzebnymi i częściowo problem zostanie rozwiązany.

Autor: Marijn Haverbeke

Źródło: http://eloquentjavascript.net/1st_edition/chapter9.html

Tłumaczenie: Łukasz Piwko

Treść tej strony dostępna jest na zasadach licencji CC BY 3.0

2 komentarze

  1. Bardzo ciekawy artykuł i strona również przednia, konkretna i nie zatrzymująca się na etapie zmienne, petle, instrukcje warunkowe.

    @up
    Polecam równie biblioteke LABjs korzystałem z niej w kilku ‚domowych’ projektach i spisuje się rewelacyjnie.
    Nie wiem czy można podrzucać linki, ale to nie jest strona konkurencyjna, więc dla zainteresowanych: http://labjs.com/documentation.php

    Odpowiedz
  2. Do zarządzania zależnościami polecam bibliotekę autoloadjs którą ostatnio znalazłem na githubie.

    https://github.com/szagi3891/autoloadjs

    Ogólnie działa ona tak : autoloadjs([‚modul’], callbacl);
    callback jest wywoływany w momencie gdy został załadowany moduł. Bardzo upraszcza życie ta biblioteka.

    Odpowiedz

Dyskusja

Twój adres email nie zostanie opublikowany. Pola, których wypełnienie jest wymagane, są oznaczone symbolem *