Construire de grandes applications Knockout.js maintenables et testables

Knockout.js est un framework JavaScript MVVM Open Source (MIT) populaire, créé par Steve Sandersen. Son site Web fournit d'excellentes informations et des démonstrations sur la manière de créer des applications simples, mais malheureusement, il ne le fait pas pour les applications plus volumineuses. Comblons certaines de ces lacunes!


AMD et Require.js

AMD est un format de module JavaScript et l'un des frameworks les plus populaires (sinon le plus) est http://requirejs.org by https://twitter.com/jrburke. Il se compose de deux fonctions globales appelées exiger() et définir(), Bien que require.js intègre également un fichier JavaScript de démarrage, tel que main.js.

Il existe principalement deux versions de require.js: une vanille require.js fichier et celui qui inclut jQuery (require-jquery). Naturellement, ce dernier est principalement utilisé dans les sites Web activés pour jQuery. Après avoir ajouté l’un de ces fichiers à votre page, vous pouvez ensuite ajouter le code suivant à votre main.js fichier:

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

le exiger() la fonction est généralement utilisée dans le main.js fichier, mais vous pouvez l’utiliser pour inclure directement un module n’importe où. Il accepte deux arguments: une liste de dépendances et une fonction de rappel.

La fonction de rappel s'exécute lorsque le chargement de toutes les dépendances est terminé et les arguments transmis à la fonction de rappel sont les objets. Champs obligatoires dans le tableau susmentionné.

Il est important de noter que les dépendances se chargent de manière asynchrone. Toutes les bibliothèques ne sont pas compatibles AMD, mais require.js fournit un mécanisme permettant de cerner ces types de bibliothèques afin qu'elles puissent être chargées..

Ce code nécessite un module appelé app, qui pourrait ressembler à ceci:

define (["jquery", "ko"], fonction ($, ko) var App = function () ; App.prototype.init = function () // INIT TOUT CE QUI SUIT; retourne une nouvelle App ( ););

le définir() Le but de la fonction est de définir un module. Il accepte trois arguments: le nom du module (qui est typiquement non inclus), une liste de dépendances et une fonction de rappel. le définir() fonction vous permet de séparer une application en plusieurs modules, chacun ayant une fonction spécifique. Cela favorise le découplage et la séparation des préoccupations, car chaque module a son propre ensemble de responsabilités spécifiques..

Utiliser Knockout.js et Require.js ensemble

Knockout est prêt pour AMD et se définit comme un module anonyme. Vous n'avez pas besoin de le crier; il suffit de l'inclure dans vos chemins. La plupart des plugins Knockout prêts pour AMD le nomment "knockout" plutôt que "ko", mais vous pouvez utiliser l'une des valeurs suivantes:

require.config (chemins: ko: "vendeur / knockout-min", postal: "vendeur / postal", trait de soulignement: "vendeur / underscore-min", amplifie: "vendeur / amplifie", shim: soulignement: exports: "_", amplify: exports: "amplify", baseUrl: "/ js");

Ce code va au sommet de main.js. le les chemins L'option définit un mappage de modules communs chargés avec un nom de clé plutôt que le nom de fichier complet..

le cale option utilise une clé définie dans les chemins et peut avoir deux touches spéciales appelées exportations et deps. le exportations key définit ce que le module shimmed renvoie, et deps définit d'autres modules dont dépend le module shimmed. Par exemple, la cale de jQuery Validate pourrait ressembler à ceci:

shim: //… "jquery-validate": deps: ["jquery"]

Applications simples ou multi-pages

Il est courant d'inclure tout le code JavaScript nécessaire dans une application à une seule page. Ainsi, vous pouvez définir la configuration et le besoin initial d’une application à page unique dans main.js ainsi:

require.config (chemins: ko: "vendeur / knockout-min", postal: "vendeur / postal", trait de soulignement: "vendeur / underscore-min", amplifier: "vendeur / amplifier", shim: ko: exports: "ko", trait de soulignement: exports: "_", amplifier: exports: "amplifier", baseUrl: "/ js"); require (["https://twitter.com/jrburkeapp"], fonction (App) App.init ();)

Vous aurez peut-être également besoin de pages distinctes contenant non seulement des modules spécifiques à une page, mais également un ensemble commun de modules. James Burke a deux référentiels qui implémentent ce type de comportement.

Le reste de cet article suppose que vous construisez une application de plusieurs pages. Je vais renommer main.js à common.js et inclure le nécessaire require.config dans l'exemple ci-dessus dans le fichier. C'est purement pour la sémantique.

Maintenant, je vais avoir besoin common.js dans mes fichiers, comme ceci:

   

le require.config La fonction sera exécutée, nécessitant le fichier principal pour la page spécifique. le pages / index Le fichier principal peut ressembler à ceci:

require (["app", "postal", "ko", "viewModels / indexViewModel"], fonction (app, postal, ko, IndexViewModel) window.app = app; window.postal = postal; ko.applyBindings (nouveau IndexViewModel ()););

Ce page / index Le module est maintenant responsable du chargement de tout le code nécessaire à la index.html page. Vous pouvez ajouter d'autres fichiers principaux au répertoire pages qui sont également responsables du chargement de leurs modules dépendants. Cela vous permet de diviser les applications multi-pages en éléments plus petits, tout en évitant les inclusions de script inutiles (par exemple, y compris JavaScript pour index.html dans le about.html page).


Exemple d'application

Écrivons un exemple d'application utilisant cette approche. Il affichera une liste interrogeable des marques de bière et nous permettra de choisir vos favoris en cliquant sur leur nom. Voici la structure de dossiers de l'application:

Regardons d'abord index.htmlLe balisage HTML:

Des pages

La structure de notre application utilise plusieurs "pages" ou "principaux" dans un des pages annuaire. Ces pages distinctes sont responsables de l’initialisation de chaque page de l’application.

le ViewModels sont responsables de la configuration des liaisons Knockout.

ViewModels

le ViewModels Le dossier est l'endroit où réside la logique d'application Knockout.js principale. Par exemple, le IndexViewModel ressemble à ce qui suit:

// https://github.com/jcreamer898/NetTutsKnockout/blob/master/lib/js/viewModels/indexViewModel.js define (["ko", "underscore", "postal", "modèles / bière", "modèles / baseViewModel "," shared / bus "], fonction (ko, _, postal, bière, BaseViewModel, bus) var IndexViewModel = function () this.beers = []; this.search =" "; BaseViewModel.apply (this, arguments);; _.extend (IndexViewModel.prototype, BaseViewModel.prototype, initialize: function () //…, filterBeers: function () / *… * /, analyse: fonction (bières ) / *… * /, SetupSubscriptions: function () / *… * /, addToFavorites: function () / *… * /, removeFromFavorites: function () / *… * /); retourne IndexViewModel;);

le IndexViewModel définit quelques dépendances de base en haut du fichier, et il hérite BaseViewModel d'initialiser ses membres en tant qu'objets observables knockout.js (nous en reparlerons bientôt).

Ensuite, plutôt que de définir toutes les fonctions ViewModel en tant que membres d’instance, le underscore.js étendre() la fonction étend la prototype du IndexViewModel Type de données.

Héritage et un modèle de base

L'héritage est une forme de réutilisation de code, vous permettant de réutiliser des fonctionnalités entre des types d'objets similaires au lieu de réécrire ces fonctionnalités. Il est donc utile de définir un modèle de base dont d’autres modèles peuvent hériter. Dans notre cas, notre modèle de base est BaseViewModel:

var BaseViewModel = fonction (options) this._setup (options); this.initialize.call (this, options); ; _.extend (BaseViewModel.prototype, initialize: function () , _setup: fonction (options) var prop; options = options || ; pour (prop dans ceci) 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]);); renvoyer BaseViewModel;

le BaseViewModel type définit deux méthodes sur son prototype. Le premier est initialiser(), qui devrait être remplacé dans les sous-types. La seconde est _installer(), qui configure l'objet pour la liaison de données.

le _installer méthode boucle sur les propriétés de l'objet. Si la propriété est un tableau, il définit la propriété comme observableArray. Tout ce qui est autre qu'un tableau est fait observable. Il vérifie également les valeurs initiales des propriétés, en les utilisant par défaut si nécessaire. C’est une petite abstraction qui élimine le besoin de répéter constamment observable et observableArray les fonctions.

Le "ce"Problème

Les personnes qui utilisent Knockout ont tendance à préférer les membres d'instance aux membres de prototype en raison des problèmes liés au maintien de la valeur appropriée de ce. le ce mot-clé est une fonctionnalité compliquée de JavaScript, mais ce n'est pas si mal une fois complètement absorbé.

Du MDN:

"En général, l'objet lié à ce dans l'étendue actuelle, elle est déterminée par la façon dont la fonction actuelle a été appelée, elle ne peut pas être définie par une affectation pendant l'exécution et peut être différente à chaque appel de la fonction. "

Ainsi, la portée change en fonction de COMMENT une fonction est appelée. Ceci est clairement démontré dans jQuery:

var $ el = $ ("#mySuperButton"); $ el.on ("click", function () // ici, cela fait référence au bouton);

Ce code met en place un simple Cliquez sur gestionnaire d'événement sur un élément. Le rappel est une fonction anonyme, et il ne fait rien jusqu'à ce que quelqu'un clique sur l'élément. Lorsque cela se produit, la portée de ce l'intérieur de la fonction fait référence à l'élément DOM actuel. Gardant cela à l'esprit, considérons l'exemple suivant:

var someCallbacks = someVariable: "yay on m'a cliqué", mySuperButtonClicked: function () console.log (this.someVariable); ; var $ el = $ ("#mySuperButton"); $ el.on ("click", someCallbacks.mySuperButtonClicked);

Il y a un problème ici. le this.someVariable utilisé à l'intérieur mySuperButtonClicked () résultats indéfini parce que ce dans le rappel renvoie à l'élément DOM plutôt que le des rappels objet.

Il y a deux façons d'éviter ce problème. Le premier utilise une fonction anonyme en tant que gestionnaire d’événements, qui appelle à son tour someCallbacks.mySuperButtonClicked ():

$ el.on ("click", function () someCallbacks.mySuperButtonClicked.apply (););

La deuxième solution utilise soit le Function.bind () ou _.lier() méthodes (Function.bind () n'est pas disponible dans les anciens navigateurs). Par exemple:

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

Quelle que soit la solution que vous choisissez, vous obtiendrez le même résultat final: mySuperButtonClicked () s'exécute dans le cadre de des rappels.

"ce"dans les liaisons et les tests unitaires

En termes de KO, le ce problème peut se manifester lorsqu’on travaille avec des liaisons - en particulier lorsqu’il s’agit de $ root et $ parent. Ryan Niemeyer a écrit un plug-in d'événements délégués qui élimine généralement ce problème. Il vous donne plusieurs options pour spécifier des fonctions, mais vous pouvez utiliser le clic de données attribut, et le plug-in parcourt votre chaîne de portée et appelle la fonction avec le bon ce.

Dans cet exemple, $ parent.addToFavorites se lie au modèle de vue via un Cliquez sur contraignant. Depuis le

  • l'élément réside dans un pour chaque contraignant, le ce à l'intérieur $ parent.addToFavorites fait référence à une instance de la bière sur laquelle l'utilisateur a cliqué.

    Pour contourner cela, le _.bindAll méthode assure que ce conserve sa valeur. Par conséquent, en ajoutant ce qui suit au initialiser() méthode corrige le problème:

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

    le _.bindAll () méthode crée essentiellement un membre d'instance appelé ajouter aux Favoris() sur le IndexViewModel objet. Ce nouveau membre contient la version prototype de ajouter aux Favoris() qui est lié à la IndexViewModel objet.

    le ce Le problème est pourquoi certaines fonctions, telles que ko.computed (), accepte un deuxième argument optionnel. Voir la ligne cinq pour un exemple. le ce passé comme deuxième argument assure que ce se réfère correctement au courant IndexViewModel objet à l'intérieur de filtreBières.

    Comment pourrions-nous tester ce code? Regardons d'abord le ajouter aux Favoris() une fonction:

    addToFavorites: fonction (bière) if (! _. any (this.favorites (), fonction (b) return b.id () === beer.id ();)) this.favorites.push ( Bière ); 

    Si nous utilisons le framework de test mocha et expect.js pour les assertions, notre test unitaire ressemblerait à ceci:

    it ("devrait ajouter de nouvelles bières aux favoris", function () expect (this.viewModel.favorites (). length) .to.be (0); this.viewModel.addToFavorites (nouvelle bière (name: "abita amber ", id: 3)); // ne peut pas ajouter de bière avec un identifiant en double this.viewModel.addToFavorites (new Beer (name:" abita amber ", id: 3)); expect (this.viewModel. favoris () .longueur) .à.be (1););

    Pour voir la configuration complète des tests unitaires, consultez le référentiel..

    Testons maintenant filtreBières (). Tout d'abord, regardons son code:

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

    Cette fonction utilise le chercher() méthode, qui est liée à la valeur d'un texte élément dans le DOM. Ensuite, il utilise le ko.utils.arrayFilter utilitaire pour rechercher et trouver des correspondances dans la liste des bières. le bièreListeFiltrée est lié à la

      élément dans le balisage, afin que la liste des bières puisse être filtrée en tapant simplement dans la zone de texte.

      le filtreBières fonction, étant une petite unité de code, peut être correctement testée:

       beforeEach (function () this.viewModel = new IndexViewModel (); this.viewModel.beers.push (nouvelle bière (name: "budweiser", id: 1)); this.viewModel.beers.push (nouvelle bière (name: "amberbock", id: 2));); it ("devrait filtrer une liste de bières", function () expect (_.isFunction (this.viewModel.beerListFiltered)) .to.be.ok (); this.viewModel.search ("bour"); expect ( this.viewModel.filterBeers (). length) .to.be (1); this.viewModel.search (""); expect (this.viewModel.filterBeers (). length) .to.be (2);) ;

      Tout d’abord, ce test permet de s’assurer que le bièreListeFiltrée est en fait une fonction. Ensuite, une requête est faite en passant la valeur de "bourgeon" à this.viewModel.search (). Cela devrait entraîner une modification de la liste des bières, en éliminant par filtrage toutes les bières ne correspondant pas à "bourgeon". ensuite, chercher est défini sur une chaîne vide pour garantir que bièreListeFiltrée renvoie la liste complète.


      Conclusion

      Knockout.js offre de nombreuses fonctionnalités intéressantes. Lors de la création d'applications volumineuses, il est utile d'adopter de nombreux principes décrits dans cet article pour que le code de votre application reste gérable, testable et maintenable. Découvrez l'exemple complet d'application, qui inclut quelques sujets supplémentaires tels que Messagerie. Il utilise postal.js comme bus de messagerie pour acheminer des messages dans l’application. L'utilisation de la messagerie dans une application JavaScript peut aider à découpler des parties de l'application en supprimant les références directes les unes aux autres. Assurez-vous et jetez un oeil!