jQuery – uporaba live funkcij (event delegation)

December 27th, 2009 by Krof Drakula Leave a reply »

Če ste kdaj dinamično ustvarjali HTML kontrole, ste verjetno po klasičnem modelu pripenjali event handler funkcije na vsak element, ki ste ga ustvarili znotraj dokumenta. Če se vam je kdaj zdelo potratno iterirati po več elementih in na vsakega pripenjati funkcionalnost, se niste motili; obstajajo boljši načini. Pa si poglejmo, kako lahko takšne situacije razrešimo z bolj elegantno rešitvijo.

Za začetek si poglejmo en preprost primer, s katerim bomo upravljali; zamislimo si, da sestavljamo aplikacijo, pri kateri želimo imeti seznam vnosnih polj, ki vsak drži en email naslov. Uporabnik lahko po želji dodaja in odstranjuje polja, vsako polje pa mora ob izgubi fokusa zagnati validator, ki bo preveril, ali je vsebina polja pravilna.

Najprej bomo zastavili HTML strukturo, ki bo predstavljala našo kontrolo:

<div id="myEmailList" class="myControls TextBoxList">
  <ul>
    <li>
      <input type="text" name="email[]" />
      <button class="remove">Remove</button>
    </li>
  </ul>
  <div><button class="add">Add</button></div>
</div>

S to strukturo smo ustvarili ogrodje naše funkcionalnosti. Imamo seznam elementov, ki vsebujejo vnosno polje in gumb, ki odstrani trenutni element v seznamu, na koncu pa še gumb za dodajanje novega polja. S pomočjo class atributa na <button> elementih sem znotraj naše kontrole določil funkcionalnost gumbov; z uporabo class atributov za določanje funkcionalnosti sem tudi omogočil, da uporabnik uporabi katerikoli element namesto <button> elementa.

Na tej točki lahko sestavimo kodo, ki je tokrat ne bomo zapisali kot plugin, temveč kar kot kodo znotraj document.ready event handlerja:

(function($) {
  $(function() {
    // izberemo vse ustrezne kontrole in jim pripnemo
    // funkcionalnost
    $(".myControls.TextBoxList").each(function() {
      var $t = $(this);
 
      // iteriraj po vseh li elementih
      $t.find(">ul>li").each(function() {
 
        // vsakemu elementu pripni event handler za
        // "remove" event
        $(this).find(".remove").click(function() {
          $(this).parent().remove();
          return false;
        });
      });
 
      // dodaj event handler za "add" event
      $t.find(".add").click(function() {
 
        // ustvari kopijo zadnjega elementa
        var newElement = $t.find(">ul>li:last").clone();
        // nastavi vrednost polja na prazen string
        newElement.find("input").val("");
        // pripni na konec seznama
        newElement.appendTo($t.find(">ul"));
 
        // dodaj "remove" event handler
        newElement.click(function() {
          $(this).parent().remove();
          return false;
        });
 
      });
    });
  });
})(jQuery);

S tem smo ustvarili funkcionalnost naše kontrole, ki se znotraj strani avtomatsko izvede na podlagi pripetih razredov (torej elementi, ki imajo class=”myControls TextBoxList”).

Čeprav zadeva deluje, se moramo vseeno vprašati, ali je trenutna koda res optimalna glede na rešitev, ki jo iščemo. Analizirajmo torej potek dogodkov, ki se zgodijo ob inicializaciji same kontrole:

  1. Za vsako najdeno .myControls.TextBoxList iterira po seznamu vseh li elementov in vsakemu .remove elementu znotraj li elementa pripne novo funkcijo, ki se sproži ob click eventu na ta element.
  2. Za vsak .add element pripni novo funkcijo, ki se bo sprožila ob kliku na ta element.

Če povzamemo inicializacijo, vidimo, da v primeru enega li elementa pripnemo en event handler na <button> in enega na <button>. Če seveda v prvotni HTML strukturi dodamo dodatne li elemente z ustrezno strukturo, se količina novih event handlerjev izračuna po formuli n + 1.

To pa še ni vse; ko je kontrola na voljo uporabniku v brskalniku, lahko ta klikne na Add gumb, s čimer doda nov element v seznam in pripne spet novo funkcijo za click event handler.

Zakaj pa bi nas moralo število anonimnih funkcij skrbeti? Načeloma to ni problem, če imamo opravka z eno takšno kontrolo na strani. Zavedati pa se moramo, da ko podamo konstruktor funkcije neposredno .click() metodi, se vedno ustvari nova instanca funkcije, vsaka instanca pa terja nekaj spominskega prostora in CPU ciklov. Pri bolj naprednih straneh, kot je recimo Facebook, si takšnega naivnega pristopa ne moremo privoščiti, saj takšne strani velikokrat vsebujejo po več sto instanc kontrol.

Prvi korak k optimizaciji bi bil izolacija kode v instance funkcij, katere referenco potem podajamo v event handlerje posameznih elementov:

(function($) {
  $(function() {
    $(".myControls.TextBoxList").each(function() {
      var $t = $(this);
 
      // click handler za "remove" gumb
      var removeElement = function() {
        $(this).parent().remove();
        return false;
      };
 
      // click handler za "add" gumb"
      var addElement = function() {
        // kloniraj zadnji element v seznamu
        var newElement = $t.find(">ul>li:last").clone();
        // pripni na konec seznama
        newElement.appendTo($t.find(">ul"));
        // nastavi vrednost vnosnega polja na prazno vrednost
        newElement.find("input").val("");
        // pripni event handler za klik na remove gumb
        newElement.find(".remove").click(removeElement);
        return false;
      };
 
      // iteriraj po vseh li elementih
      $t.find(">ul>li").each(function() {
        // vsakemu elementu pripni event handler za
        // "remove" event
        $(this).find(".remove").click(removeElement);
      });
 
      // dodaj event handler za "add" event
      $t.find(".add").click(addElement);
    });
  });
})(jQuery);

S tem smo sicer uspeli zmanjšati količino instanc funkcij, ampak še vedno pa je prisotna duplikacija – funkcionalnost remove gumba moramo ob kloniranju seveda spet zvezati s click event handlerjem.

Nekateri med vami ste verjetno opazili, da sem uporabil .clone() funkcijo na li elementu. Če si ogledamo dokumentacijo za to funkcijo, opazimo, da lahko podamo en parameter – deepCopy. Ta v primeru true vrednosti pove jQuery, naj ob kloniranju skopira tudi vse event handlerje, pripete na elemente, ki jih kloniramo.

To bi sicer rešilo naš problem, ampak v tem primeru smo le delegirali prenos event handlerjev knjižnici.

Zakaj pa pravzaprav sploh obsesiram s tem, da vežem event handlerje neposredno na elemente? Problem se pojavi takrat, ko želimo dinamično spreminjati elemente v kontroli. Če bi odstranili add gumb in dodali novega, bi morali v tem primeru novemu elementu dodati click handler – ker pa je funkcija deklarirana znotraj closureja, ta ni na voljo zunanji kodi, kar pomeni, da bi morali izpostavljati metode za vezavo event handlerjev za nove gumbe.

Takšen scenarij se sicer zdi za lase privlečen, ampak če želimo ustvariti dekoratorje za takšne kontrole (tj. koda, ki jo lahko apliciramo na kontrolo in jo ozaljša z novo funkcionalnostjo in izgledom brez spreminjanja izvorne kode), ne da bi eksplicitno izpostavljali funkcije za event handlerje, moramo seveda imeti možnost uporabiti takšne tehnike. V tem primeru bomo uporabili lastnost propagiranja eventov v DOM modelu, ki se imenuje bubbling.

Ko izvedemo neko dejanje v DOM dokumentu, se vsak event najprej zgodi na elementu, kjer smo dejansko izvedli dejanje, potem pa se event prenaša po hierarhiji navzgor (torej v smeri proti html tagu), če seveda eventa eksplicitno ne prekličemo. V primeru, da želimo opazovati click event na celotnem dokumentu, to storimo z vezavo handlerja na document objektu:

(function($) {
  $(function() {
    $(document).click(function(event) {
      var target = $(event.target);
      alert(
        'Class = "' + target.attr("class") + '"\n' +
        'Id = "' + target.attr("id") + '"'
      );
      return false;
    });
  });
})(jQuery);

S pomočjo takšne funkcije sedaj lovimo vse click evente, ki se zgodijo na tem elementu in na vseh elementih, ki se nahajajo znotraj drevesne strukture tega elementa. Če želimo dostopati do objekta, ki je prvi sprožil click event, lahko iz event objekta, ki ga event handler funkcija dobi v prvem argumentu, prikličemo s pomočjo target atributa. Zgornji primer torej v alert oknu izpiše class in id izvornega objekta.

Takšen pristop se imenuje event delegation, ker delegiramo procesiranje eventa drugim elementom. Glede na objekt, ki je sprožil event, se nato odločimo, kaj narediti. V našem primeru to pomeni, da preverimo klaso elementa, na podlagi česar se nato odločimo, kaj narediti. Pa si poglejmo, kako takšna koda izgleda:

(function($) {
  $(function() {
    $(".myControls.TextBoxList").each(function() {
      var $t = $(this);
 
      // pripni click event handler na TextBoxList kontrolo
      $t.click(function(event) {
        // prikliči element, ki je sprožil event
        var target = $(event.target);
 
        // preveri klaso objekta, ki je sprožil
        // click event
        if(target.is(".remove")) {
 
          // uporabnik je kliknil remove gumb
          target.parent().remove();
          return false;
 
        } else if(target.is(".add")) {
 
          // uporabnik je kliknil add gumb,
          // kloniraj zadnji element v seznamu
          var newElement = $t.find(">ul>li:last").clone();
          // pripni na konec seznama
          newElement.appendTo($t.find(">ul"));
          // nastavi vrednost vnosnega polja na prazno vrednost
          newElement.find("input").val("");
          // pripni event handler za klik na remove gumb
          newElement.find(".remove").click(removeElement);
          return false;
        }
      });
  });
})(jQuery);

Kaj smo torej s tem pridobili? Sestavili smo event handler, ki je neodvisen od strukture same kontrole (z izjemo add funkcije, ki zahteva seznam li elementov znotraj ul seznama), na posamezne elemente pa še vedno lahko pripenjamo svoje event handlerje. Seveda se tukaj porodi vprašanje, zakaj je to boljše kot enostavno nalepljanje več event handlerjev na isti element; razlog je prilagodljivost, ki jo lahko s tem nadziramo. Če nalepimo click event handler na katerikoli add ali remove gumb, lahko na koncu funkcije vrnemo false in bomo s tem preklicali akcijo, ki jo izvede naš delegat na .TextBoxList kontroli. Če bi želeli isto doseči brez delegatov, bi morali najprej klicati .unbind(), da bi odstranili obstoječi event handler in nato pripeli lastnega, kar pa hitro zakomplicira sicer preprosto kodo. Poleg tega lahko tudi mimo funkcionalnosti našega plugina dodajamo li elemente in pripadajoče remove gumbe neposredno v kontroli, event delegat pa bo tudi za te elemente enako poskrbel kot tudi za vse ostale elemente v kontroli.

Seveda pa se zgodba tukaj ne konča. jQuery (od različice 1.3 naprej) ponuja za event delegation drugačen pristop, ki naredi kodo bolj podobno standardni jQuery kodi s pomočjo .live() funkcije:

(function($) {
  $(function() {
 
    // event handler za klik na add gumb
    $(".myControls.TextBoxList .add").live(
      "click", function(event) {
        // kloniraj zadnji element v seznamu
        var newElement = $t.find(">ul>li:last").clone();
        // pripni na konec seznama
        newElement.appendTo($t.find(">ul"));
        // nastavi vrednost vnosnega polja na prazno vrednost
        newElement.find("input").val("");
        // pripni event handler za klik na remove gumb
        newElement.find(".remove").click(removeElement);
        return false;
      }
    );
 
    // event handler za klik na remove gumb
    $(".myControls.TextBoxList .remove").live(
      "click", function(event) {
        var target = $(event.target);
        target.parent().remove();
        return false;
      }
    );
 
  });
})(jQuery);

Koda se razlikuje od zgornje zaradi omejitve v implementaciji v jQuery knjižnici, ampak pustimo zaenkrat in si oglejmo, kaj zgornja koda počne in kako se razlikuje od prejšnje. Namesto da bi iterirali po vsaki instanci .TextBoxList kontrole in na vsaki izvedli pripenjanje delegata, s pomočjo selektorja izberemo vse add in remove gumbe v vseh .TextBoxList kontrolah v dokumentu in jim zvežemo live click handler. S tem smo dosegli isto kot v prejšnjem primeru, a na bolj jasen način. .live() funkcija v tem primeru namesto nas izvede tisto if funkcijo, v kateri smo prej preverili, za kateri tip gumba je šlo; v primeru ujemanja izvede funkcijo, ki jo podamo kot drugi argument.

Zdaj pa se vrnimo k vprašanju na začetku prejšnjega odstavka – zakaj smo morali torej izvesti globalni selektor namesto iteriranja po kontrolah? Razlog se skriva v implementaciji live funkcije, ki zahteva selektor, na podlagi katerega filtrira evente. Vsak klic na live funkcijo mora slediti selektorju, raba funkcij, ki spreminjajo množico elementov, pa ni dovoljena. Klic na ostale funkcije (css(), attr() in podobno) pa so dovoljeni.

V splošnem je takšen sistem event delegatov primeren za praktično vsak primer uporabe, se pa včasih lahko zgodi, da je filtriranje elementov na podlagi CSS selektorja lahko dvoumno in zato neprimerno za uporabo. V takšnem primeru se moramo poslužiti prejšnjega primera, ki iterira po instancah kontrol in pripenja event delegate na kontejnerske elemente.

In s to mislijo zaključujem že peti članek v seriji. Naj bo to dovolj za letos. :)

Advertisement

Trackbacks /
Pingbacks

  1. Twitted by web2feed