JavaScript címkéhez tartozó bejegyzések

jQuery Command Manager Widget II.

Mint korábban ígértem, továbbfejlesztjük az előző kis jQuery widget-et, mégpedig úgy, hogy bármilyen elem bármilyen eseményeihez köthető legyen, sőt javascript kifejezéseket is kitudjunk értékelni. Tehát a cél a következő HTML markup életképessé tétele:

<ul id="menu">
    <li>
        <!-- Egyszerű string command. -->
        <a role="button" href="#" data-command='selectItem'>
            <span>Select</span>
        </a>
    </li>
    <li>
        <!-- A "$value" automatikusan behelyettesítésre kerül a "select" aktuális értével -->
        <!-- ill. megadunk 2 eseményt "click" és "change", amikor eltüzelődjön a command. -->
        <select data-command='{"name":"selectItem","params":"$value","events":"click change"}'>
            <option>Item 1</option>
            <option>Item 2</option>
        </select>
    </li>
    <li>
        <!-- A JavaScript kifejezés kiértékelésre kerül a "select" kontextusába. -->
        <!-- (ha nem adunk meg eseményt, akkor "select"-hez auto a "change" lesz attacholva) -->
        <select data-command='{"name":"selectItem","params":"$(this).val()","eval":true}'>
            <option>Item 1</option>
            <option>Item 2</option>
        </select>
    </li>
</ul>
<script type="text/javascript">

    $(function () {
        $('#menu').commands({
            source: {
                //
                // A "selectItem" command, amit bekötöttünk.
                //
                selectItem: function (options) {
                    alert(JSON.stringify(options));
                }
            }
        })
        //
        // Rögtön elsütjük az első "select" parancsát
        // (a jQuery selector ilyenkor a kontextusban marad).
        //
        .commands('trigger''select[data-command]:first');
    });

</script>

Ahhoz, hogy mindezt életre hívjuk a következőképp kell módosítanunk a korábbi kódunkat:

(function ($, undefined) {

    $.widget('ui.commands', {

        options: {
            source: null
        },

        //
        //  A widget konstruktora, ameny egyszer hívodik meg / DOM element.
        //  Van egy _init() metódus is, a különbség, hogy az mindig eltüzelődik
        //  amikor az adott elemen megpróbáljuk inicializálni többször a widgetet.
        //  Tulajdonképp az is használható lenne a lentebb majd említett parse() helyett.
        //
        _create: function () {

            this._attachEvents();
        },

        //
        //  Hozzáadjuk az eseménykezelőket
        //  a DOM elemekhez, amik "this.element"
        //  kontextusába esnek.
        //
        _attachEvents: function () {

            //
            //  A jQuery widgetek konvencionálisan
            //  "self" elnevezést használnak a widget-re,
            //  ha már szükség van rá egy loopban például.
            //  A loop-on belül pedig $this -t az aktuális elemre.
            //
            var self = this;

            $('[data-command]'this.element).each(function () {
                //
                //  Megrpóbáljuk kihalászni az eseményeket
                //  a data-command-ból. Ha nincs megadva explicit,
                //  akkor mi magunk választunk egy event handler-t
                //  a DOM elem típusa alapján.
                //
                var events =
                    self._parseEventsByElement(this) ||
                    self._createEventsByElement(this);

                if (events) {
                    for (var i = 0; i < events.length; i++) {
                        //
                        //  A widget minden eseményét a ".commands" névtérbe
                        //  kötjük (pl. "click.commands"). A jQuery "bind"
                        //  erre ad lehetőséget nekünk, így együtt tudjuk
                        //  kezelni az összes widget eseményünket amikor unbindoljuk.
                        //  A $.proxy még mindig kell a kontextus átadás miatt.
                        //
                        $(this).bind(
                            events[i] + '.commands',
                            $.proxy(self._commandEvent, self)
                        );
                    }
                }
            });
        },

        //
        //  Eltávolítunk minden ".commands" névteres eseménykezelőt
        //  ami a "this.element" kontextusán belül taláható.
        //
        _detachEvents: function () {

            $('[data-command]'this.element).unbind('.commands');
        },

        //
        //  Parsoljuk az explicit megadott eseményeket, amiket
        //  egy szóköz választ el egymástól.
        //
        _parseEventsByElement: function (element) {

            var events = $(element).data('command').events;
            if (!events) {
                return null;
            }

            return events.split(' ');
        },

        //
        //  Magunk definiálunk eseményeket a megadott DOM elemhez.
        //
        _createEventsByElement: function (element) {

            var events;

            switch (element.nodeName) {
                case 'INPUT':
                    {
                        var type = element.getAttribute('type');
                        events = (type === 'submit' || type === 'button' ? 'click' : 'change');
                        break;
                    }

                case 'SELECT':
                case 'TEXTAREA':
                    {
                        events = 'change';
                        break;
                    }
                default:
                    {
                        events = 'click';
                    }
            }

            return events.split(' ');
        },

        //
        //  Az eseménykezelő, ami a kötött eseményekre eltüzelődik.
        //
        _commandEvent: function (event) {

            event.preventDefault();
            this.trigger(event.currentTarget);
        },

        //
        //  Eltávolít és újra hozzáad minden eseménykezelőt.
        //  A "live" megszűnt, de így orvosolható ha szükséges.
        //
        parse: function () {

            this._detachEvents();
            this._attachEvents();
        },

        //
        //  Eltüzeli a kötött parancsot a megadott elemen.
        //  A "target" nemcsak DOM elem lehet, hanem
        //  lehet akár egy jQuery selector is, a lényeg, hogy
        //  az "this.element" kontextusába működjön.
        //
        trigger: function (target) {

            var element = $(target, this.element);
            if (!element.length) {
                return;
            }

            var command = element.data('command');
            if (!command) {
                return;
            }

            //
            //  Végrehajtjuk az elementen megadott parancsot.
            //  Ha a params === '$value', akkor kinyerjük
            //  az element értékét ( $(...).val() ) és azt passzoljuk
            //  mint paramétert, ha szükség van kiértékelésre (JS expression),
            //  akkor a "target" element kontextusába futtatjuk a kódot.
            //
            this.execute.call(this, {
                name: command.name || command,
                params: command.params ?
                            command.params === '$value' ?
                                element.val() :
                                command.eval ?
                                    function () { return eval(command.params) } .call(target) :
                                    command.params :
                            null
            });
        },

        //
        //  Végrehajtunk egy parancsot, ahol az
        //  options.name a parancs neve, az optons.params
        //  a (kiértékelt) paramétereket tartalmazza.
        //
        execute: function (options) {

            var source, method;
            if ((source = this.options.source) &&
                $.isFunction(method = source[options.name])) {
                options.params ?
                    method.call(source, options.params) :
                    method.call(source);
            }
        },

        //
        //  Felszabadítjuk az erőforrásokat.
        //  Az összes ".commands" eseménykezelőt eltávolítjuk
        //  a kontextuson belül.
        //
        destroy: function () {

            this._detachEvents();

            $.Widget.prototype.destroy.call(this);
        }
    });

})(jQuery);

Ezt még kiegészíthetjük billentyűzet kezeléssel, gyakorlatilag bármivel amit a data-command attribútumban megtudunk adni. A lényegét tekintve a dolognak az, hogy így már egy sokkal rugalmasabb, bármire attacholható command managert kapunk. A command-ok nagy előnye a sima event handlerek írogatásával szemben az, hogy egyrészt nincs szükség gányolásra, szépen elválik a view a logikától, viszonylag könnyen karbantartható, könnyedén tudok egy meglévő parancsot definiálni egy másik elemhez is, mint ahogy azt a fenti példában is láthattuk.

Ez a módszer szépen keverhető a jQuery template és datalink megoldásokkal.

jQuery Command Manager Widget

Már az idejét se tudom mikor írtam utoljára, azóta elmélyedtem egy kicsit az ASP.NET MVC 3, HTML 5, JavaScript, ill. jQuery lelki világába, úgyhogy most inkább ezen a pályán, nem pedig a WPF / Silverlight vonalon mozgok. Megpróbálok bemutatni a jQuery-ből pár hasznos dolgot, főként olyan veteránoknak, akik még most se mozdultak ki a WPF és SL világból.

Talán első körben készítsünk egy saját command manager szerűséget, ami a WPF / SL parancsokhoz hasonlóan közvetíti a kéréseket a kötésbe szereplő forrás objektum és a DOM események között. Ehhez a jQuery widget frameworkot fogjuk használni. Inkább mutatom a scriptet és magyarázom. Alapvetően így néz ki egy nagyon alap widget jQuery-ül kikommentezve:

(function ($, undefined) {

    //
    //  Regisztrálunk egy saját "commands" nevű widget-et.
    //  A jQuery.ui = $.ui az a terület (scope) 
    //  ahol a widget konstruktora tárolásra kerül.
    //  Minden egyéb adminisztratív műveletet 
    //  a widget factory elvégez nekünk.
    //
    $.widget('ui.commands', {

        // 
        //  Egy widget rendelkezik egy inicializációs 
        //  "options" paraméter objektummal. A különlegessége
        //  ennek az "options" objektumnak, hogy automatikusan
        //  kiterjesztésre és felülírásra kerül az egyéni értékekkel
        //  (lásd: jQuery.extend(...) függvény).
        //
        //  Tehát amikor pl. létrehozunk egy ilyen widgetet:
        //      $('#id').commands({ static: false });
        //  felülírjuk az alább megadott default true értéket:
        // 
        options: {
            static: true// Statikus vagy dinamikus kötés (def.: statikus).
            source: null // A forrás objektum (def.: null).
        },

        // 
        //  Ez itt a widget konstruktora, mely automatikusan meghívásra kerül
        //  a widget factory által. Konvenció, hogy a privát tagokat egy
        //  aláhúzással kezdjük: "_create".
        //
        _create: function () {

            //
            //  Itt pl. adhatunk egyéni CSS osztályt a widget
            //  által wrapelt DOM objektumnak, eseménykezelőket
            //  aggathatunk a DOM objektumra ... Magát az DOM elemet a 
            //  "this.element"-en keresztül érjük el.
            //
            this._attachEvents();
        },

        //
        //  Egy saját privát metódus, ami attacholja a szükséges
        //  eseménykezelőket a DOM objektumokhoz.
        //
        _attachEvents: function () {

            //
            //  Ha statikus módot választottunk akkor
            //  minden "data-command" attribútummal
            //  rendelkező DOM elemre, mely a becsomagolt
            //  elem kontextusába esik sima "bind"-al
            //  attacholunk egy click event handler-t.
            //
            if (this.options.static) {
                //
                //  A jQuery selector első paramétere az attribútum szűrés,
                //  második paramétere a kontextus, ami azt a csomópontot jelöli
                //  a DOM-ban, ahonnan a keresés indítandó. Jelen esetben a wrapelt
                //  DOM elemet tekintjük kiindulási pontnak: $('*[data-command]', this.element)
                //  Ezt egyébként mindig érdemes meghatároznunk amikor jQuery
                //  selectorokat használunk, mivel szűkítjük csomópontok számát gyorsabbá téve a keresést.
                //
                //  A jQuery.proxy függvény azt a célt szolgálja, hogy
                //  tetszőleges kontextusba tudjuk futtatni az eseménykezelőt.
                //  Az eseménykezelő neve: "_click", lentebb látható.
                //  Azért működik benne a "this.invoke(...)", mert a "this"
                //  a jQuery.proxy(this, ...) hívás miatt ugyanezt az objektumot jelenti,
                //  nem pedig a klikkelt elemet. De persze az továbbra is elérhető az
                //  event paraméter alatt: event.target, event.currentTarget, stb.,
                //  de így nincs szükség változó elfogásra (variable capture),
                //  ami könnyen vezethet memory leak-hez ha nem bánunk vele körültekintően.
                //
                $('*[data-command]'this.element)
                    .bind('click', $.proxy(this'_click'));
            }
            else {
                //
                //  Ha dinamikus a kötés, akkor használjuk a "live" függvényt.
                //  Annyi a különbség a "bind"-hoz képest, hogy az újonnan,
                //  későbbiekben dinamikusan beszúrt új DOM elemekre is automatikusan
                //  ráaggatja a click event handler-t, de nyilván erőforrásigényesebb.
                //  Ha előre lerendereltettük az egész html tartalmat és nem kezdünk el
                //  gombokat beszúrogatni scriptel, akkor felesleg ez az overhead.
                //
                $('*[data-command]'this.element)
                    .live('click', $.proxy(this'_click'));
            }
        },

        //
        //  Egy saját privát metódus, ami detacholja az összes
        //  eseménykezelőt a DOM objektumokból.
        //
        _detachEvents: function () {

            if (this.options.static) {
                //
                //  Az "unbind" a "bind" párja.
                //
                $('*[data-command]'this.element)
                    .unbind('click'this._click);
            }
            else {
                //
                //  A "die" a "live" párja.
                //
                $('*[data-command]'this.element)
                    .die('click'this._click);
            }
        },

        //
        //  Kezeljük azt az eseményt, amikor a felhasználó egy "data-command"
        //  attribútummal ellátott elemre klikkel és dispécseljük a parancsot
        //  a forrás objektum megfelelő metódusába paraméterekkel együtt.
        //
        _click: function (event) {

            //
            //  Először is megnézzük, hogy az event.currentTarget
            //  rendelkezik-e ezzel az attribútummal.
            //  Megjegyzendő: Nem az "event.target"-et használtam,
            //  mert pl.: <a role="button" href="#"><span>Tovább</span></a>
            //  esetében a span-re való klikkelés a SPAN-t jelenti target-ként.
            //  Ez egyébként nekünk csak jó, mert az esemény buborékozás
            //  miatt együtt tudjuk kezelni a kettőt, de ilyenkor a currentTarget-be
            //  van az aki nekük kell (azért hangsúlyozom, hogy nehogy jQuery.parent()-et hívjunk ezért!):
            //
            var command = $(event.currentTarget).data('command');
            if (!command) {
                return true// Nincs command attrib, mehet Isten hírével amerre lát!
            }

            //
            //  Megakadályozzunk az alapértelmezett esemény eltüzelését.
            //  Ha pl. a href="http://www.devportal.hu" lenne, akkor
            //  ez nem fogja hagyni, hogy elnavigáljunk.
            //
            event.preventDefault();

            //
            //  Ha a parancs egy sima string, akkor azt vesszünk
            //  parancs névként, ha JSON, akkor pedig a name lesz a
            //  parancs neve és a params a paraméterei a parancsnak.
            //
            if (typeof (command) == 'string') {
                this.invoke(
                    command // A neve a parancsnak.
                );
            }
            else {
                this.invoke(
                    command.name, // A neve a parancsnak.
                    command.params, // A paraméterei a parancsnak.
                    command.eval // Kezelje a paramétert mint JS kifejezést.
                );
            }

            return false;
        },

        //
        //  Ha érvénytelen a parancs neve.
        //
        _invalidCommand: function (command) {

            //
            //  Ide jöhet valami egyéni hibakezelő megoldás,
            //  kivétel dobás, jQuery dialógus ablak, logolás, stb.
            //
            throw new Error("Invalid command: " + command);
        },

        //
        //  Itt pedig következik ami diszpécseli a parancsot
        //  és a paramétert a forrás objektumba. Látható, itt már
        //  nincs aláhúzás, úgyhogy ez "public", manulisan is meghívható.
        //
        invoke: function (command, params, eval) {

            if (!command) {
                this._invalidCommand(command);
                return this;
            }

            //
            //  Megnézzük, hogy a forrás objektumon van-e
            //  ilyen tag és ha van, akkor az ténylegesen
            //  egy függvény ($.isFunction(...))
            //
            var source = this.options.source;
            var func = source[command];
            if (!$.isFunction(func)) {
                this._invalidCommand(command);
                return this;
            }

            //
            //  Meghívjuk a függvényt átpasszolva a paramétereket ha vannak,
            //  viszont a "this"-t, azaz a kontextust itt is rendeznünk kell,
            //  hogy a hívott függvényen belül a forrás objektumot lássa
            //  az abba lévő kód a "this" kulcsszón keresztül, amit pedig
            //  a javascript függvény prototípuson definiált call(...)-al tudunk átpasszolni.
            //
            if (params) {
                func.call(source, eval ? window.eval(params) : params);
            }
            else {
                func.call(source);
            }

            //
            //  Ahhoz, hogy láncolhatóak
            //  legyenek a hívások (nem kötelező).
            //
            return this;
        },

        //
        //  A widget destruktora.
        //  Ide kerülnek az erőforrások felszabadításával kapcsolatos kódok.
        //
        destroy: function () {

            //
            //  Detacholjuk az eseményekezelőket, illetve
            //  meghívjuk az "örökölt" destruktort, ami
            //  auto eltávolít minden extra hozzáadott információt a DOM-ból
            //  amit a factory hozzátett. Nyilván a csomagolt elemet nem távolítja el.
            //  Ide jöhetnek például még a CSS osztályok eltávolításaival kapcsolatos dolgok is.
            //
            this._detachEvents();

            $.Widget.prototype.destroy.call(this);
        }
    });

})(jQuery);

Használni pedig már nagyon egyszerű:

Először is kellenek hozzá a jquery.js, ill. a jquery.ui.js fájlok.
A fenti kódot pedig elhelyezhetjük egy külön .js fájlba.
A html fájlba például írhatjuk:

<ul id="context">
    <li><a role="button" href="#" data-command='edit'></li>
    <li><a role="button" href="#" data-command='{"name":"copy","params":{"count":8}}'></li>
    <li><a role="button" href="#" data-command='{"name":"expression","params":"Math.sqrt(16)","eval":true}'></li> </ul>
<script type="text/javascript">
//<![CDATA[
    $(function () {
        $('#context').commands({
            source: {
                edit: function () { },
                copy: function (options) {
                    for (var i = 0; i < options.count; i++) ...
                }
            }
        });
    });
//]]>
</script>

Mint látható, a root jelen esetben a ‘context’ id-el ellátott DOM element lesz, ezen belül pedig ráakaszkodunk az összes létező olyan elemre, aminek van ‘data-command’ attribútuma és az abban található paramétereknek megfelelően hozzákötjük a ‘source’ objektumunk megfelelő metódusaihoz. Mivel az ‘invoke’ nem privát, ezért az inicializáció után meghívhatjuk az egyes parancsokat pl. így is: $(‘#context’).commands(‘invoke’, ‘edit’); A destruktor ugyanígy hívható: $(‘#context’).commands(‘destroy’); és akkor detacholunk minden ‘_click’ event handlert a root elemen belül.

Később még tovább fejlesztjük ezt, ill. csinálunk ettől komolyabb dolgokat is, most csak illusztrálni szerettem volna egy példán keresztül, hogy nem az ördögtől való ez az egész, könnyedén összetudunk rakni magunk is akár olyan sikertörténettel bíró felhasználói felületeket, mint pl. a Facebook, és nem is kell nagyon megerőltetni magunkat. Én már például komplett Google mintán alapuló űrlap szerkesztőn keresztül, komplett css szerkesztőn, webszerkesztőn keresztül, facebook dropdrown gombokon keresztül, URL hashen alapuló státuszkezelésen keresztül (lsd. Facebook), ASP.NET MVC ajax hívás támogatáson keresztül, listák, lapozáson keresztül, minden szörnyűséget megírtam könnyedén jQuery segítségével magamnak.

Ui.: A fenti kódot kiegészítettem még egy eval-el, így a paraméterek kiértékelhetők, mint JavaScript kifejezések akár. Példa:

$('<div id="context"></div>')
.append(
    $('<a role="button" href="#" data-command=\'{"name":"edit","params":"Math.sqrt(16)","eval":true}\'></a>')
)
.commands({
    source: {
        edit: function (options) {
            alert(options);
        }
    }
})
.children('a[data-command]')
.trigger('click');