Het bouwen van grote, onderhoudbare en testbare Knockout.js-applicaties

Knockout.js is een populair open source (MIT) MVVM JavaScript-framework, gemaakt door Steve Sandersen. De website biedt geweldige informatie en demo's over het bouwen van eenvoudige applicaties, maar doet dit helaas niet voor grotere applicaties. Laten we wat van die gaten opvullen!


AMD en Require.js

AMD is een JavaScript-module-indeling en een van de meest populaire (zoniet de meeste) frameworks is http://requirejs.org op https://twitter.com/jrburke. Het bestaat uit twee globale functies genaamd vereisen() en bepalen(), hoewel require.js ook een JavaScript-startbestand bevat, zoals main.js.

Er zijn in de eerste plaats twee smaken van require.js: een vanille require.js bestand en een bestand dat jQuery bevat (vereisen-jquery). Uiteraard wordt de laatste voornamelijk gebruikt in jQuery-enabled websites. Nadat u een van deze bestanden aan uw pagina heeft toegevoegd, kunt u de volgende code aan uw toevoegen main.js het dossier:

vereisen (["https://twitter.com/jrburkeapp"], functie (App) App.init ();)

De vereisen() functie wordt meestal gebruikt in de main.js bestand, maar u kunt het gebruiken om direct een module op te nemen. Het accepteert twee argumenten: een lijst met afhankelijkheden en een callback-functie.

De callback-functie wordt uitgevoerd als alle afhankelijkheden zijn voltooid en de argumenten die aan de callback-functie worden doorgegeven, de objecten zijn verplicht in de bovengenoemde reeks.

Het is belangrijk op te merken dat de afhankelijkheden asynchroon laden. Niet alle bibliotheken zijn AMD-compatibel, maar require.js biedt een mechanisme om die typen bibliotheken te vullen zodat ze kunnen worden geladen.

Voor deze code is een module vereist app, wat er als volgt uit zou kunnen zien:

define (["jQuery", "ko"], function ($, ko) var App = function () ; App.prototype.init = function () // INIT ALL TEH THINGS; retourneer nieuwe app ( ););

De bepalen() functie doel is om een ​​te definiëren module. Het accepteert drie argumenten: de naam van de module (wat is typisch niet inbegrepen), een lijst met afhankelijkheden en een callback-functie. De bepalen() Met de functie kunt u een toepassing scheiden in meerdere modules, elk met een specifieke functie. Dit bevordert de ontkoppeling en scheiding van punten van zorg, omdat elke module zijn eigen specifieke verantwoordelijkheden heeft.

Knockout.js en Require.js samen gebruiken

Knockout is AMD ready en definieert zichzelf als een anonieme module. Je hoeft het niet te vullen; voeg het gewoon toe aan je paden. De meeste voor AMD geschikte Knockout-plug-ins vermelden het als "knock-out" in plaats van "ko", maar u kunt elke waarde gebruiken:

require.config (paths: ko: "vendor / knockout-min", postal: "vendor / postal", onderstrepingsteken: "vendor / underscore-min", amplify: "vendor / amplify", shim: underscore: exports: "_", amplify: exports: "amplify", baseUrl: "/ js");

Deze code staat bovenaan main.js. De paden optie definieert een kaart van gemeenschappelijke modules die laden met een sleutelnaam in tegenstelling tot het gebruik van de volledige bestandsnaam.

De shim optie gebruikt een sleutel die is gedefinieerd in paden en kan twee speciale toetsen hebben genoemd export en deps. De export key definieert wat de geremde module retourneert, en deps definieert andere modules waarop de opgevulde module kan zijn gebaseerd. Het vulstuk van jQuery Validate ziet er bijvoorbeeld als volgt uit:

shim: // ... "jQuery-validate": deps: ["jquery"]

Single- vs Multi-Page Apps

Het is gebruikelijk om al het benodigde JavaScript op te nemen in een toepassing met één pagina. U kunt dus de configuratie en de initiële vereisten van een toepassing met één pagina definiëren in main.js zoals zo:

require.config (paths: ko: "vendor / knockout-min", postal: "vendor / postal", onderstrepingsteken: "vendor / underscore-min", amplify: "vendor / amplify", shim: ko: exports: "ko", onderstrepingsteken: exports: "_", amplify: exports: "amplify", baseUrl: "/ js"); vereisen (["https://twitter.com/jrburkeapp"], functie (App) App.init ();)

Mogelijk hebt u ook aparte pagina's nodig die niet alleen paginaspecifieke modules bevatten, maar ook een gemeenschappelijke reeks modules delen. James Burke heeft twee repositories die dit soort gedrag implementeren.

In de rest van dit artikel wordt ervan uitgegaan dat u een toepassing met meerdere pagina's maakt. Ik zal de naam wijzigen main.js naar common.js en neem het nodige op require.config in het bovenstaande voorbeeld in het bestand. Dit is puur voor semantiek.

Nu zal ik vereisen common.js in mijn bestanden, zoals dit:

   

De require.config functie wordt uitgevoerd, waarvoor het hoofdbestand voor de specifieke pagina vereist is. De pages / index hoofdbestand kan er als volgt uitzien:

vereisen (["app", "postal", "ko", "viewModels / indexViewModel"], functie (app, postal, ko, IndexViewModel) window.app = app; window.postal = postal; ko.applyBindings (new IndexViewModel ()););

Deze page / index module is nu verantwoordelijk voor het laden van alle benodigde code voor de index.html pagina. U kunt andere hoofdbestanden toevoegen aan de map met pagina's die ook verantwoordelijk zijn voor het laden van hun afhankelijke modules. Hiermee kunt u apps met meerdere pagina's in kleinere stukjes splitsen, terwijl u onnodige scriptinsluitsels vermijdt (zoals bijvoorbeeld de JavaScript-code voor index.html in de about.html pagina).


Voorbeeldapplicatie

Laten we een voorbeeldtoepassing schrijven met deze benadering. Het geeft een doorzoekbare lijst met biermerken weer en laat ons uw favorieten kiezen door op hun naam te klikken. Hier is de mapstructuur van de app:

Laten we eerst kijken index.htmlHTML-opmaak:

Pages

De structuur van onze applicatie maakt gebruik van meerdere "pagina's" of "hoofdlijnen" in a pagina's directory. Deze afzonderlijke pagina's zijn verantwoordelijk voor het initialiseren van elke pagina in de toepassing.

De ViewModels zijn verantwoordelijk voor het instellen van de Knockout-bindingen.

ViewModels

De ViewModels map is waar de hoofdlogica van Knockout.js leeft. Bijvoorbeeld de IndexViewModel ziet eruit als het volgende:

// https://github.com/jcreamer898/NetTutsKnockout/blob/master/lib/js/viewModels/indexViewModel.js define (["ko", "underscore", "postal", "models / beer", "models) / baseViewModel "," shared / bus "], functie (ko, _, postal, Beer, BaseViewModel, bus) var IndexViewModel = function () this.beers = []; this.search =" "; BaseViewModel.apply (this, arguments);; _.extend (IndexViewModel.prototype, BaseViewModel.prototype, initialize: function () // ..., filterBeers: function () / * ... * /, parseren: functie (bieren ) / * ... * /, setup Subscriptions: function () / * ... * /, addToFavorites: function () / * ... * /, removeFromFavorites: function () / * ... * /); return IndexViewModel;);

De IndexViewModel definieert een paar basisafhankelijkheden boven aan het bestand en erft het BaseViewModel om zijn leden te initialiseren als waarneembare objecten van knockout.js (we zullen dat binnenkort bespreken).

Vervolgens, in plaats van het definiëren van alle verschillende ViewModel-functies als instantieleden, underscore.js's uitbreiden() functie breidt het prototype van de IndexViewModel data type.

Overerving en een BaseModel

Overname is een vorm van hergebruik van code, waardoor u functionaliteit tussen vergelijkbare typen objecten opnieuw kunt gebruiken in plaats van die functionaliteit te herschrijven. Het is dus handig om een ​​basismodel te definiëren waarvan andere modellen kunnen erven. In ons geval is ons basismodel BaseViewModel:

var BaseViewModel = function (options) this._setup (options); this.initialize.call (dit, opties); ; _.extend (BaseViewModel.prototype, initialize: function () , _setup: function (options) var prop; options = options || ; for (prop in this) if (this.hasOwnProperty (prop) ) if (options [prop]) this [prop] = _.isArray (options [prop])? ko.observableArray (options [prop]): ko.observable (options [prop]); else this [ prop] = _.isArray (this [prop])? ko.observableArray (this [prop]): ko.observable (this [prop]);); terug BaseViewModel;

De BaseViewModel type definieert twee methoden op zijn prototype. De eerste is initialiseren (), die in de subtypen moet worden opgeheven. De tweede is _opstelling(), die het object voor gegevensbinding instelt.

De _opstelling methode lus over de eigenschappen van het object. Als de eigenschap een array is, wordt de eigenschap ingesteld als een observableArray. Er is iets anders dan een array gemaakt waarneembaar. Het controleert ook de oorspronkelijke waarden van de eigenschappen en gebruikt deze indien nodig als standaardwaarden. Dit is een kleine abstractie die het constant herhalen van het elimineert waarneembaar en observableArray functies.

De "deze"Probleem

Mensen die Knockout gebruiken, geven de voorkeur aan instantieleden boven prototypeleden vanwege de problemen met het behouden van de juiste waarde van deze. De deze keyword is een gecompliceerde functie van JavaScript, maar het is niet zo erg als het eenmaal volledig is gekraakt.

Van de MDN:

"In het algemeen is het object gebonden aan deze in de huidige scope wordt bepaald door hoe de huidige functie werd aangeroepen, deze kan niet worden ingesteld door toewijzing tijdens de uitvoering, en deze kan verschillen telkens wanneer de functie wordt aangeroepen. "

Het bereik verandert dus afhankelijk van HOE een functie wordt aangeroepen. Dit is duidelijk bewezen in jQuery:

var $ el = $ ("#mySuperButton"); $ el.on ("klik", functie () // hier, dit verwijst naar de knop);

Deze code stelt een eenvoudig in Klik gebeurtenishandler op een element. De callback is een anonieme functie en het doet niets totdat iemand op het element klikt. Wanneer dat gebeurt, is de reikwijdte van deze binnenkant van de functie verwijst naar het daadwerkelijke DOM-element. Houd rekening met het volgende voorbeeld in gedachten:

var someCallbacks = someVariable: "yay I clicked", mySuperButtonClicked: function () console.log (this.someVariable); ; var $ el = $ ("#mySuperButton"); $ el.on ("klik", someCallbacks.mySuperButtonClicked);

Er is hier een probleem. De this.someVariable binnenkant gebruikt mySuperButtonClicked () komt terug onbepaald omdat deze in de callback verwijst naar het DOM-element in plaats van naar het someCallbacks voorwerp.

Er zijn twee manieren om dit probleem te voorkomen. De eerste gebruikt een anonieme functie als de gebeurtenishandler, die op zijn beurt roept someCallbacks.mySuperButtonClicked ():

$ el.on ("klik", functie () someCallbacks.mySuperButtonClicked.apply (););

De tweede oplossing gebruikt de Function.bind () of _.binden() methoden (Function.bind () is niet beschikbaar in oudere browsers). Bijvoorbeeld:

$ el.on ("click", _.bind (someCallbacks.mySuperButtonClicked, someCallbacks));

Beide oplossingen die u kiest, bereiken hetzelfde eindresultaat: mySuperButtonClicked () wordt uitgevoerd in de context van someCallbacks.

"deze"in Bindingen en Unit Tests

In termen van Knockout, de deze probleem kan zich voordoen tijdens het werken met bindingen - met name als het gaat om $ wortel en $ ouder. Ryan Niemeyer heeft een plugin voor gedelegeerde evenementen geschreven die dit probleem meestal elimineert. Het geeft je verschillende opties voor het specificeren van functies, maar je kunt de data-click attribuut en de plug-in loopt uw ​​scopeketen op en roept de functie met de juiste aan deze.

In dit voorbeeld, $ parent.addToFavorites bindt aan het weergavemodel via een Klik verbindend. Sinds de

  • element bevindt zich in een foreach bindend, de deze binnen $ parent.addToFavorites verwijst naar een exemplaar van een bier waarop is geklikt.

    Om dit te voorkomen, de _.bindAll methode zorgt ervoor dat deze behoudt zijn waarde. Daarom is het toevoegen van het volgende aan de initialiseren () methode lost het probleem op:

    _.extend (IndexViewModel.prototype, BaseViewModel.prototype, initialize: function () this.setupSubscriptions (); this.beerListFiltered = ko.computed (this.filterBeers, this); _.bindAll (this, "addToFavorites") ;,);

    De _.bindAll () methode maakt in essentie een instantie-lid genaamd toevoegen aan favorieten() op de IndexViewModel voorwerp. Dit nieuwe lid bevat de prototype-versie van toevoegen aan favorieten() dat is gebonden aan de IndexViewModel voorwerp.

    De deze probleem is waarom sommige functies, zoals ko.computed (), accepteert een optioneel tweede argument. Zie regel vijf voor een voorbeeld. De deze geslaagd als het tweede argument dat garandeert deze correct verwijst naar de huidige IndexViewModel object binnenkant van filterBeers.

    Hoe zouden we deze code testen? Laten we eerst kijken naar de toevoegen aan favorieten() functie:

    addToFavorites: function (beer) if (! _. any (this.favorites (), function (b) return b.id () === beer.id ();)) this.favorites.push ( bier); 

    Als we het mocha-testraamwerk en expect.js gebruiken voor beweringen, zou onze unit-test er als volgt uitzien:

    it ("moet nieuwe bieren toevoegen aan favorieten", functie () verwachten (this.viewModel.favorites (). length) .to.be (0); this.viewModel.addToFavorites (new Beer (name: "abita amber ", id: 3)); // kan geen bier met een dubbele id toevoegen this.viewModel.addToFavorites (nieuw bier (name:" abita amber ", id: 3)); expect (this.viewModel. favorieten (). lengte) .to.be (1););

    Bekijk de repository om de volledige testinstelling van de unit te zien.

    Laten we het nu testen filterBeers (). Laten we eerst de code bekijken:

    filterBeers: function () var filter = this.search (). toLowerCase (); if (! filter) return this.beers ();  else return ko.utils.arrayFilter (this.beers (), function (item) return ~ item.name (). toLowerCase (). indexOf (filter);); ,

    Deze functie gebruikt de zoeken() methode, die gegevens bevat voor de waarde van een tekst element in de DOM. Dan gebruikt het de ko.utils.arrayFilter hulpprogramma om door te zoeken en overeenkomsten te zoeken in de lijst met bieren. De beerListFiltered is gebonden aan de

      element in de markup, zodat de lijst met bieren kan worden gefilterd door simpelweg in het tekstvak te typen.

      De filterBeers functie, zijnde zo'n kleine code-eenheid, kan op de juiste wijze in de eenheid getest worden:

       beforeEach (function () this.viewModel = new IndexViewModel (); this.viewModel.beers.push (nieuw bier (name: "budweiser", id: 1)); this.viewModel.beers.push (nieuw bier (name: "amberbock", id: 2));); it ("zou een lijst met bieren moeten filteren", functie () verwachten (_.isFunction (this.viewModel.beerListFiltered)) .to.be.ok (); this.viewModel.search ("bud"); expect ( this.viewModel.filterBeers (). length) .to.be (1); this.viewModel.search (""); expect (this.viewModel.filterBeers (). length) .to.be (2);) ;

      Ten eerste zorgt deze test ervoor dat de beerListFiltered is in feite een functie. Vervolgens wordt een query uitgevoerd door de waarde van "bud" door te geven aan this.viewModel.search (). Dit zou ertoe moeten leiden dat de lijst met bieren verandert en filtert elk bier dat niet overeenkomt met "knop". Dan, zoeken is ingesteld op een lege tekenreeks om dat te garanderen beerListFiltered geeft de volledige lijst terug.


      Conclusie

      Knockout.js biedt veel geweldige functies. Bij het bouwen van grote applicaties helpt het om veel van de principes die in dit artikel worden besproken, te gebruiken om ervoor te zorgen dat de code van uw app beheersbaar, te testen en te onderhouden blijft. Bekijk de volledige voorbeeldaanvraag, die een aantal extra onderwerpen bevat, zoals messaging. Het gebruikt postal.js als een berichtenbus om berichten door de hele applicatie heen te dragen. Het gebruik van berichten in een JavaScript-toepassing kan delen van de toepassing ontkoppelen door harde verwijzingen naar elkaar te verwijderen. Zorg ervoor dat je een kijkje neemt!