Ez a fejezet abba nyújt betekintést, hogy JavaScriptben milyen lehetőségek vannak a kód modularizáltabbá tételére.
Ahhoz, hogy egy alkalmazás áttekinthető, karbantartható legyen, valamilyen módon tagolni szükséges. A tagolás módja függ az alkalmazás méretétől. Másképpen kell hozzáfogni egy kis alkalmazásnál, és megint más módszereket kell alkalmazni több ezer sor írásakor. Sokszor nem lehet előre megtervezni a tagolás mikéntjét, hanem a kód növekedésével kell a megfelelő kódszervezési technikákat alkalmazni. Érdemes a fejlesztéseinkben ezt a folyamatosságot alkalmazni, mert így egyrészt nem köti gúzsba a kezünket egy elején hozott döntés, másrészt a cél határozza meg az eszközt, és nem az eszköz határozza meg a kódolási stílusunkat.
A függvények elsődleges feladata, hogy egy adott feladatot elvégző kódrészletet strukturálisan elválasszanak a többi kódtól, ráadásul a paraméterezésen keresztül az adott feladat megfelelőképpen általánosítható.
Kis méretű alkalmazások strukturálására elegendőek a függvények. A függvények alkotják végeredményében az alkalmazás építőelemeit, az általuk képviselt részfeladatok megfelelő sorrendben történő végrehajtásával áll össze a teljes feladat megoldása.
A kódot nagyon sokféleképpen lehet függvényekre osztani. Az alábbiakban felsorolunk pár olyan irányelvet, amely segít ennek a feladatnak az elvégzésében:
A függvények adatokkal dolgoznak. Az osztályok alkalmazásával lehetővé válik, hogy egy adott funkcionalitás elvégzéséhez szükséges adatokat és az azokat feldolgozó függvényeket elkülönítsük a többitől. A klasszikus objektum-orientált nyelvekben az osztályok ennél többet is elvégeznek. A publikus adatokon és metódusokon keresztül képes definiálni egy objektum külvilág felé mutatott arcát, ez az objektum interfésze, a megvalósítás belügyeit viszont képes elrejteni a külvilág elől (egységbe zárás).
Hasonló feladat elvégzésére szolgálnak, de koncepcionálisan más csoportba tartoznak a modulok. A modul egy adott funkcionalitást megvalósító független egység, amely egy alkalmazás egészének egy részét alkotja. A modulok újrahasznosítható szoftver komponensek, amelyek egy alkalmazás építőelemeit alkotják. A modulok nagy előnye, hogy definiálják az interfészüket, amelyen keresztül az adott funkcionalitás elérhető. Így a megvalósítás részletei rejtve maradnak, és később az interfész módosítása nélkül is alakíthatók. Meghatározott interfészek mentén sokkal jobb dolgozni, mint egy adott implementációhoz igazodni.
A modularitás alapelvei szerint egy modulnak a következő feltételeket kell teljesítenie:
Egy modul esetében az egyik legfontosabb dolog az interfésze, amit a külvilág felé felhasználásra biztosít. Tulajdonképpen egy szerződés a program többi komponense felé arról, amit megvalósít. Az interfészek tervezésére tehát különösen oda kell figyelni. Ezzel kapcsolatban is jól meghatározható néhány alapelv.
A legtöbb programozási nyelvnek van saját modulkezelő rendszere. Ilyenek pl. C++-ban a függvénykönyvtárak, amelyeket az #include-dal emelünk be, vagy a Pascalbeli unit-ok, amiket a uses kulcsszó emel be, vagy a Javabeli import kulcsszó tartozhat még ide.
Végül utolsó megjegyzésként megemlíthető, hogy a modul hasonló viselkedést mutat az osztállyal annyiban, hogy az osztály is egy adott funkcionalitáshoz tartozó működést foglalja egységbe, működtetéséhez pedig megfelelő publikus interfészt biztosít. A modulok ezért sokszor egy-egy osztályt tartalmaznak. De a modulok annyiban többek, hogy általában fájlszintű szeparációt is biztosítanak, azaz egy modul egy fájl, és meghatározható a többi modultól való függőségük is.
JavaScript nem bővelkedik a kódszervezést segítő nyelvi elemekben. JavaScriptben nincsenek osztályok, csak objektumok, és az objektumok tulajdonságainak hozzáférhetőségét sem lehet beállítani. Ugyancsak nincsen külön modulkezelő rendszere. JavaScriptben a kódszervezésre két nyelvi elem, az objektumok és a függvények szolgálnak, ezek viszont vannak annyira rugalmasak, hogy velük a fentebbi koncepciók jól megvalósíthatóak.
A kód alapvető strukturálására természetesen JavaScriptben is a függvények szolgálnak. Függvények írása már csak azért is elkerülhetetlen, mert az aszinkron környezet jellegéből adódóan függvényekbe kell ágyaznunk bizonyos események bekövetkeztekor lefutó logikát. Az callback és eseménykezelő függvényeken kívül azonban a kód többi részét is érdemes a tiszta kód szabályainak megfelelően minél több részre tagolni.
var elems = [], push = function (e) { elems.push(e); }, pop = function () { return elems.pop(); }, top = function () { return elems[size()-1]; }, size = function () { return elems.length; };
A kódban definiált adatok és függvények hamar ellephetik a globális névteret, ráadásul semmilyen nyelvi elem sem biztosítja az adott funkcionalitáshoz tartozó adatok és függvények összetartozását. Az összetartozó adatokat és metódusokat objektumliterállal foghatjuk össze. Ekkor az objektum neve meghatározza a funkcionalitást, és rajta keresztül elérhetők a megfelelő metódusok és adatok.
var stack = { elems: [], push: function (e) { this.elems.push(e); return this; }, pop: function () { return this.elems.pop(); }, top: function () { return this.elems[this.size()-1]; }, size: function () { return this.elems.length; } }; stack.push(10).push(20); ok( stack.top() === 20, 'Az utoljára betett elem van a tetején'); ok( stack.size() === 2, 'A mérete is megfelelő'); ok( stack.elems.join(',') === '10,20', 'Az elemtömb kívülről is elérhető.');
A fenti példából is jól látható, hogy az objektumliterállal nem rejthetőek el az implementációs részletek. Egy másik probléma pedig az, hogy nincs lehetőség a modul létrehozásakor inicializáló kódot futtatni, csak úgy, ha egy külön függvénybe tesszük ezt a kódot (pl. init()), és a modul létrehozása után lefuttatjuk.
Az objektumliterálos megközelítés egyik továbbfejlesztett változata az objektumok névterekbe szervezése. A JavaScript nyelvben nincsenek beépített névterek, de ezek jól szimulálhatóak egymásba ágyazott objektumokkal. Így a globális névtérbe felvett objektumok sokasága helyett tetszőleges objektum-, vagy modulhierarchia építhető fel. Ezzel az is elérhető, hogy alkalmazásunk csak egy globális objektummal bővítse a névteret, és minden egyéb funkcionalitást ez alá szervezzünk.
var myApp = myApp || ; myApp.dataStructures = myApp.dataStructures || ; myApp.dataStructures.stack = { elems: [], push: function (e) { /* ... */ }, pop: function () { /* ... */ }, top: function () { /* ... */ }, size: function () { /* ... */ } };
A fenti kódrészletben látható, hogy a felülírás elkerülése végett mindig ellenőrizzük, hogy az adott objektum létezik-e, s ha nem, akkor létrehozzuk. Névterek létrehozására bevezethetünk egy namespace() segédfüggvényt, mely nagyobb mélységek létrehozásában segíthet minket.
var MyApp = MyApp || ; MyApp.namespace = function (ns) { var parts = ns.split('.'), parent = MyApp, i; //Ha MyApp-pal kezdődik, akkor kihagyható if (parts[0] === "MyApp") { parts = parts.slice(1); } for (i = 0; i < parts.length; i += 1) { //Nem létező tulajdonság létrehozása if (typeof parent[parts[i]] === "undefined") { parent[parts[i]] = ; } parent = parent[parts[i]]; } return parent; };
Ezzel a fenti névtér létrehozása a következőképpen alakul:
var stack = MyApp.namespace('MyApp.dataStructures.stack'); stack = { /* ... */ }; //vagy MyApp.namespace('dataStructures.stack') = { /* ... */ };
Az objektumliterálos kódszervezés hiányát, miszerint az implementáció részletei is publikusak, a modul minta próbálja orvosolni. JavaScriptben csak a függvények adnak hatókört, így ha azt szeretnénk, hogy a modulunk kódja elkülönüljön és külön hatókörben fusson le, akkor a funkcionalitást függvénybe kell csomagolni. Ráadásul a függvény closure-t is definiál, amivel pedig a függvény törzsében lévő kódrészleteket elrejthetjük a külvilág elől.
A modul minta tehát mindig egy függvényből áll. A függvényen belül tetszőleges változókat és függvényeket deklarálhatunk. A modul interfészét a függvény visszatérési értéke határozza meg, ami általában egy objektum.
var module = (function () { //Inicializáló kód //Rejtett változók és függvények //Visszatérés egy objektummal return { //Publikus interfész }; })();
Verem példánk a modul mintával az alább látható. Lényeges különbség az objektumliterálos formával szemben, hogy a privát tagokra a this nélkül, míg a publikus tagokra a this kulcsszón keresztül kell hivatkozni.
var stack = (function () { var elems = []; return { push: function (e) { elems.push(e); return this; }, pop: function () { return elems.pop(); }, top: function () { return elems[this.size()-1]; }, size: function () { return elems.length; } }; })(); stack.push(10).push(20); ok( stack.top() === 20, 'Az utoljára betett elem van a tetején'); ok( stack.size() === 2, 'A mérete is megfelelő'); ok( stack.elems === undefined, 'Az implementáció rejtve van');
A modul minta osztályok emulálására is használatos, nem véletlen az objektumgenerátoroknál megfigyelt mintákkal való egyezés. Ott is és ebben az esetben is a privát láthatóságot egy függvény biztosítja a closure-ével, a publikus interfészt pedig a visszatérő objektum adja.
A modul mintának számos változata létezik. Ezek közül nézünk meg néhányat.
Az eredeti mintában a függvény visszatérési értékét egy megadott névtér alá fűzték.
MyApp.namespace('dataStructures.stack') = (function () { /* ... */ })();
Globális változókat az önkioldó függvény paramétereként lehet megadni. Így akár ugyanannak a függvénykönyvtárnak több különböző verziója is a modul hatókörébe injektálható.
var module = (function (win, doc, $, undefined) { /* ... */ })(window, document, jQuery);
Egy modul funkcionalitását akár több fájlba is szétszedhetjük. Ekkor a modul paraméterként kapja meg a módosítandó modult, és kiegészíti azt. Ha a betöltés sorrendje nem biztosítható, akkor meg kell vizsgálni a paraméterként átadandó objektumot, hogy létezik-e, és ha nem, akkor egy üres objektumot létrehozni. Az alábbi példa azt is megmutatja, hogy hogyan lehet a kiterjesztés során felülírt metódusokat továbbra is használni.
var module = (function (module) { var old_method = module.method; /* ... */ return extendDeep(module, { //Kiegészítés, illetve felülírás method: function () { var old = old_method(); /* ... */ } }) })(module || );
Sokszor célszerű nem egy objektumot, hanem egy függvényt, sőt egy gyárfüggvényt visszaadni. Ez történhet úgy, hogy az önkioldó függvényt egyszerű függvényre cseréljük, de úgy is, hogy az önkioldó függvényen belül definiált függvényt adja vissza az önkioldó függvény (ld. a gyárfüggvények becsomagolását az objektumokról szóló fejezetben).
var module = function () { }; //vagy var module = (function () { var Constr = function () { /* ... */ }; return Constr; })();
A felfedő modul minta az eredeti modul mintának azt a hiányosságát orvosolja, hogy másképpen kell hivatkozni a privát és a publikus adattagokra és metódusokra. Ebben a mintában minden adatot és metódust a függvény rejtett részében deklarálunk, majd a visszatérési értékként megadott objektum publikus interfészét képviselő tulajdonságaihoz rendeljük hozzá a rejtett adattagokat és metódusokat. A hivatkozás így mindig a this nélkül történik. Verem példánk a felfedő modul mintával így néz ki.
var stack = (function () { var elems = [], push = function (e) { elems.push(e); return this; }, pop = function () { return elems.pop(); }, top = function () { return elems[size()-1]; }, size = function () { return elems.length; }; return { push: push, pop: pop, top: top, size: size }; })();
A modul minta egy másik nagy hátránya, hogy alkalmazásával mindig bővül a globális névtér. Erre megoldásként a névterezés szolgál, de ebben az esetben pedig a modul definíciójába bekerül az alkalmazás névtere, így egy másik alkalmazásba nem lehet a modult átírás nélkül újra felhasználni.
Az általános megoldást az jelenti, hogy egy előre megadott exports nevű objektumot bővítünk a modulon belül, és a modulon kívül adjuk át az exports konkrét értékét. Ezzel a modult mindenféle körülménytől (globális objektumtól, névterektől) függetlenítettük, és a felhasználási helye dönti el, hogy mihez adja hozzá a modult. Ezt a mintát alkalmazzák a modern modulkezelő könyvtárak.
(function (exports) { /* ... */ extendDeep(exports, { //Publikus interfész }) })(exports);
A homokozó minta az alkalmazásfüggetlen modul mintához hasonlóan a globális névtérszennyezést próbálja orvosolni (gyakorlatilag ugyanazzal a módszerrel, miszerint egy függvényben várja el a megvalósítást), de ezen túl biztosít számára közös eszközöket úgy, hogy közben nem interferál a modul más modul megoldásaival.
JavaScriptben a modulok egységes kezelésére tett kísérletek mára két kikristályosodott módszert eredményeztek: CommonJS és AMD. Mindkettő valamilyen módon visszavezethető az alkalmazásfüggetlen modul mintára, bár megoldásaik és felhasználási területük erőteljesen különböző. A két átmeneti megoldás mellé az utóbbi időszakban került a szabvány által előterjesztett modul minta, amely majd hosszútávon fogja beépített nyelvi elemekkel segíteni a modulkezelést.
Mindegyik modulkezelőnek két alapvető eleme van:
A Node.js megjelenése előtt már számos próbálkozás volt a JavaScript nyelv szerveroldali alkalmazására. Mindegyik próbálkozás kísérletet tett egy megfelelő modulrendszer kidolgozására. A különböző megközelítéseket a CommonJS szabvány próbálta közös nevezőre hozni. A CommonJS szabvány szerinti modul mintát a szerveroldali JavaScriptes környezetekben használják, és kiválóan alkalmas modulok szinkron betöltésére.
A CommonJS szabványnak igen egyszerű a szintaxisa. Minden modul külön fájlban helyezkedik, így nincs is szükség a modult körülvevő, hatókört biztosító függvényre. Minden fájl külön hatókörrel bír. A fájlon belül az exportálandó API-t az exports nevű objektumhoz kell fűzni.
//stack.js var elems = [], push = function (e) { /* ... */ }, pop = function () { /* ... */ }, top = function () { /* ... */ }, size = function () { /* ... */ }; extendDeep(exports, { push: push, pop: pop, top: top, size: size });
Egy modult használni a require kulcsszóval lehet. Ez tölti be szinkron módon a kívánt modult, visszatérési értéke az, amit az exports objektumhoz hozzáfűztünk.
//app.js var stack = require('./stack.js'); stack.push(10).push(20);
Kliensoldali környezetben CommonJS modulok a szinkron betöltésük miatt használhatatlanok. A szinkronitás egyrészt megállítaná a böngésző működését addig, amíg nem töltődne be az adott modul, ha pedig párhuzamosan töltenénk be a modulokat, akkor az egymástól való függőségük nem lenne megoldott. A böngészők környezetéhez igazított aszinkron modul kezelést az Aszinkron Modul Definíciós szabvány, röviden az AMD írja le.
Az AMD kulcseleme a modulok definiálásáért felelős define() függvény. Ennek első paramétere az opcionálisan megadható modulnév, amit általában nem adunk meg a modul nagyobb hordozhatósága érdekében (névtelen modulok). Második paramétere a modul függőségeit leíró tömb, amelyben az importálandó modulok nevei vannak felsorolva. Utolsó paramétere pedig egy függvény, mely a modul definícióját tartalmazza (ez a hatókört biztosító függvény). Ez a függvény explicit paraméterekként kapja meg a függőségi listában szereplő modulokat. A függvény interfészét visszatérési értékként kell megadni, akárcsak a klasszikus modul mintában.
define(modulnév, [modul1, modul2], function (modul1, modul2) { //Modul definíciója return { //Publikus API } });
Vermünket a következőképpen definiálhatjuk AMD-ben:
define('stack', [], function () { var elems = [], push = function (e) { /* ... */ }, pop = function () { /* ... */ }, top = function () { /* ... */ }, size = function () { /* ... */ }; return { push: push, pop: pop, top: top, size: size }; });
Az alkalmazást egy require() függvény által megadott blokkban lehet elindítani. Ennek első paramétere a függőségi listát tartalmazza, második paramétere pedig hasonló módon kapja meg ezeket paraméterként. A függvény törzsébe az alkalmazás kezdőlépései kerülnek.
require(['stack'], function (stack) { stack.push(10).push(20); });
Az AMD szabványt számos modulbetöltő függvénykönyvtár implementálta. Legnépszerűbbek:
Az EcmaScript szabvány 6-os verziója kész megoldást próbál adni a modulok egységes kezelésére, mely egyaránt használható a szerveren és a böngészőben. Modul definiálására a module kulcsszó használatos. Modulon belül az import kulcsszóval tudjuk a függőségeinket megadni, a publikus interfészt az export kulcsszóval kell jelezni.
Az alábbi példa Addy Osmani online könyvéből való:
module staff{ // specify (public) exports that can be consumed by // other modules export var baker = { bake: function( item ){ console.log( "Woo! I just baked " + item ); } } } module skills{ export var specialty = "baking"; export var experience = "5 years"; } module cakeFactory{ // specify dependencies import baker from staff; // import everything with wildcards import * from skills; export var oven = { makeCupcake: function( toppings ){ baker.bake( "cupcake", toppings ); }, makeMuffin: function( mSize ){ baker.bake( "muffin", size ); } } }
Hivatkozások:
A tananyag az ELTE - PPKE informatika tananyagfejlesztési projekt (TÁMOP-4.1.2.A/1-11/1-2011-0052) keretében valósult meg.
A tananyag elkészítéséhez az ELTESCORM keretrendszert használtuk.