La Pagination

12

Traduction complétée à

Au programme de ce chapitre :

  • En apprendre plus sur les abonnements de Meteor, et comment nous pouvons les utiliser pour contrôler les données.
  • Implémenter la pagination style infini.
  • Utiliser le paquet `iron-router-progress` pour implémenter une barre de changement type IOS.
  • Créer un abonnement spécial pour traiter des liens directs vers les pages d'articles.
  • Les choses semblent bien avec Microscope, et nous pouvons nous attendre à un succès quand il va sortir pour tout le monde.

    Donc nous devrions probablement réfléchir un peu à propos de la conséquence sur les performances du nombre de nouveaux articles qui vont être entrés dans le site au moment de son envol !

    Nous avons précédemment parlé de comment une collection côté client peut contenir un sous-ensemble des données du serveur, et nous avons même appris à réaliser cela pour nos collections de notifications et de commentaires.

    À présent, nous publions encore tous nos articles d'un seul coup, à tous les utilisateurs connectés. Éventuellement, si des milliers de liens sont postés, ceci deviendra problématique. Pour résoudre cela, nous avons besoin de paginer nos articles.

    Ajouter plus d'articles

    Premièrement, dans nos données de pré-installation, chargeons assez d'articles pour que cette pagination ait un sens :

    // Fixture data
    if (Posts.find().count() === 0) {
    
      //...
    
      Posts.insert({
        title: 'The Meteor Book',
        userId: tom._id,
        author: tom.profile.name,
        url: 'http://themeteorbook.com',
        submitted: new Date(now - 12 * 3600 * 1000),
        commentsCount: 0
      });
    
      for (var i = 0; i < 10; i++) {
        Posts.insert({
          title: 'Test post #' + i,
          author: sacha.profile.name,
          userId: sacha._id,
          url: 'http://google.com/?q=test-' + i,
          submitted: new Date(now - i * 3600 * 1000),
          commentsCount: 0
        });
      }
    }
    
    server/fixtures.js

    Après avoir exécuté meteor reset et relancé votre appli, vous devriez obtenir quelque chose comme ça :

    Afficher des données factices.
    Afficher des données factices.

    Commit 12-1

    Ajout d'assez d'articles pour que la pagination soit néce…

    Pagination infinie

    Nous allons implémenter une pagination de style “infini”. Ce que nous voulons dire par là c'est que nous voulons premièrement afficher, disons, 10 articles à l'écran, avec un lien “charger plus d'articles” inscrit en bas. Cliquer sur le lien affichera 10 articles supplémentaires dans la liste, et ainsi de suite ad infinitum. Cela signifie que nous pouvons contrôler notre système de pagination entier avec un simple paramètre représentant le nombre d'articles à afficher à l'écran.

    Maintenant nous allons avoir besoin d'un moyen d'indiquer au serveur ce paramètre afin qu'il sache combien d'articles envoyer au client. Il se trouve que nous nous abonnons déjà à la publication posts dans le routeur, donc nous allons profiter de ça et laisser le routeur gérer notre pagination.

    La façon la plus facile de configurer cela est simplement de faire du paramètre de limitation d'articles partie intégrante du chemin, nous donnant des URLs de la forme http://localhost:3000/25. Un autre bon côté d'utiliser l'URL plutôt que d'autres méthodes est que si vous affichez 25 articles et que vous recharger la page dans le navigateur par erreur, vous verrez toujours 25 articles une fois la page chargée.

    Afin de faire ça proprement, nous allons avoir besoin de changer la façon dont nous nous abonnons aux articles. Juste comme nous l'avons fait dans le chapitre Commentaires, nous allons avoir besoin de déplacer notre code de l'abonnement du niveau routeur au niveau route.

    Tout ça est peut-être un peu beaucoup à comprendre en une seule fois, mais cela deviendra plus clair avec le code.

    Premièrement, nous arrêterons l'abonnement à la publication posts dans le bloc Routeur.configure(). Supprimez juste Meteor.suscribe('posts'), en laissant seulement l'abonnement aux notifications :

    Router.configure({
      layoutTemplate: 'layout',
      loadingTemplate: 'loading',
      notFoundTemplate: 'notFound',
      waitOn: function() {
        return [Meteor.subscribe('notifications')]
      }
    });
    
    lib/router.js

    Nous ajouterons un paramètre postsLimit au chemin de la route. Ajouter un ? après le nom du paramètre signifie que c'est optionnel. Donc cette route ne correspondra pas seulement à http://localhost:3000/50, mais également au simple et ancien http://localhost:3000.

    //...
    
    Router.route('/:postsLimit?', {
      name: 'postsList',
    });
    
    //...
    
    lib/router.js

    C'est important de noter qu'un chemin de la forme /:parameter? correspondra à tous les chemins possibles. Chaque route sera analysée successivement pour voir si elle correspond avec le chemin courant, nous avons besoin de nous assurer que nous organisons nos routes dans un ordre de spécificité décroissante.

    En d'autres mots, les routes qui ciblent des routes plus spécifiques comme /posts/:_id viendraient en première, et notre route postsList serait déplacée en bas du groupe de routes vu qu'elle correspond pratiquement avec tout.

    Il est maintenant temps d'aborder le dur problème de s'abonner et trouver les bonnes données. Nous avons besoin de gérer le cas où le paramètre postsLimit n'est pas présent, donc nous l'assignerons à une valeur par défaut. Nous utiliserons “5” pour nous donner vraiment assez de place pour jouer avec les paginations.

    //...
    
    Router.route('/:postsLimit?', {
      name: 'postsList',
      waitOn: function() {
        var limit = parseInt(this.params.postsLimit) || 5;
        return Meteor.subscribe('posts', {sort: {submitted: -1}, limit: limit});
      }
    });
    
    //...
    
    lib/router.js

    Vous noterez que nous passons maintenant un objet JavaScript ({sort: {submitted: -1}, limit: postsLimit}) avec le nom de notre publication posts. Cet objet servira au même titre que le paramètre options pour l'expression de Posts.find() côté serveur. Faisons quelques modifications dans notre code côté serveur pour implémenter ça :

    Meteor.publish('posts', function(options) {
      check(options, {
        sort: Object,
        limit: Number
      });
      return Posts.find({}, options);
    });
    
    Meteor.publish('comments', function(postId) {
      check(postId, String);
      return Comments.find({postId: postId});
    });
    
    Meteor.publish('notifications', function() {
      return Notifications.find({userId: this.userId});
    });
    
    server/publications.js

    Passer des paramètres

    Notre code de publications est effectivement en train de dire au serveur qu'il peut faire confiance à tout objet JavaScript envoyé par le client (dans notre cas, {limit: postsLimit}) pour servir comme les options d'expression de find(). Ceci rend possible pour les utilisateurs de soumettre des options qu'ils aiment via la console du navigateur.

    Dans notre cas, c'est relativement anodin, vu que tout ce qu'un utilisateur peut faire est réordonner les articles différemment, ou changer la limite (ce que nous voulons mettre en place en premier lieu). En revanche une appli du monde réel devrait probablement avoir besoin de limiter les limites !

    Heureusement, en utilisant check() nous savons que les utilisateurs ne peuvent pas passer en douce des options supplémentaires (comme fields, qui pourrait dans certains cas exposer des données privées).

    Malgré tout, un pattern plus sécurisé pourrait être de passer les paramètres individuels eux-mêmes au lieu de l'objet entier, pour nous assurer que nous gardons le contrôle de nos données :

    Meteor.publish('posts', function(sort, limit) {
      return Posts.find({}, {sort: sort, limit: limit});
    });
    

    Maintenant que nous nous sommes abonnés au niveau de la route, cela aurait du sens de mettre le contexte de données au même endroit. Nous dévierons un peu de notre précédent pattern et feront en sorte que la fonction data retourne un objet JavaScript au lieu de simplement retourner un curseur. Ceci nous laisse créer un contexte de données nommé, que nous appellerons posts.

    Ce que cela signifie est simplement qu'au lieu d'être implicitement disponible comme this à l'intérieur du template, notre contexte de données sera disponible à posts. A part ce petit élément, le code devrait être familier :

    //...
    
    Router.route('/:postsLimit?', {
      name: 'postsList',
      waitOn: function() {
        var limit = parseInt(this.params.postsLimit) || 5;
        return Meteor.subscribe('posts', {sort: {submitted: -1}, limit: limit});
      },
      data: function() {
        var limit = parseInt(this.params.postsLimit) || 5;
        return {
          posts: Posts.find({}, {sort: {submitted: -1}, limit: limit})
        };
      }
    });
    
    //...
    
    lib/router.js

    Et puisque que nous avons mis le contexte de données au niveau des routes, nous pouvons maintenant nous débarrasser sans problème du template helper posts dans le fichier posts_list.js et supprimer le contenu de ce fichier.

    Nous avons nommé notre contexte de données posts (le même nom que le helper), donc nous n'avons même pas besoin de toucher au template postsList !

    Récapitulons, Voici à quoi notre nouveau code amélioré de router.js ressemble :

    Router.configure({
      layoutTemplate: 'layout',
      loadingTemplate: 'loading',
      notFoundTemplate: 'notFound',
      waitOn: function() {
        return [Meteor.subscribe('notifications')]
      }
    });
    
    Router.route('/posts/:_id', {
      name: 'postPage',
      waitOn: function() {
        return Meteor.subscribe('comments', this.params._id);
      },
      data: function() { return Posts.findOne(this.params._id); }
    });
    
    Router.route('/posts/:_id/edit', {
      name: 'postEdit',
      data: function() { return Posts.findOne(this.params._id); }
    });
    
    Router.route('/submit', {name: 'postSubmit'});
    
    Router.route('/:postsLimit?', {
      name: 'postsList',
      waitOn: function() {
        var limit = parseInt(this.params.postsLimit) || 5;
        return Meteor.subscribe('posts', {sort: {submitted: -1}, limit: limit});
      },
      data: function() {
        var limit = parseInt(this.params.postsLimit) || 5;
        return {
          posts: Posts.find({}, {sort: {submitted: -1}, limit: limit})
        };
      }
    });
    
    var requireLogin = function() {
      if (! Meteor.user()) {
        if (Meteor.loggingIn()) {
          this.render(this.loadingTemplate);
        } else {
          this.render('accessDenied');
        }
      } else {
        this.next();
      }
    }
    
    Router.onBeforeAction('dataNotFound', {only: 'postPage'});
    Router.onBeforeAction(requireLogin, {only: 'postSubmit'});
    
    lib/router.js

    Commit 12-2

    Améliorer la route postsList pour avoir une limite.

    Essayons notre tout nouveau système de pagination. Nous avons maintenant la possibilité d'afficher un nombre arbitraire d'articles sur la page d'accueil simplement en changeant le paramètre dans l'URL. Par exemple, essayez d'accéder à http://localhost:3000/3. Vous verrez maintenant quelque chose comme ça :

    Contrôler le nombre d'articles sur la page d'accueil.
    Contrôler le nombre d'articles sur la page d'accueil.

    Pourquoi pas des pages ?

    Pourquoi utilisons-nous une approche de “pagination infinie” au lieu de montrer des pages successives avec 10 articles sur chaque, comme ce que Google fait pour ses résultats de recherche ? C'est actuellement dû au paradigme temps-réel embrassé par Meteor.

    Imaginons que nous paginons notre collection Posts en utilisant le pattern de pagination de Google, et que nous sommes à la page 2, qui affiche les articles 10 à 20. Que se passe-t-il si un utilisateur supprime un des 10 précédents articles ?

    Étant donné que notre application est temps-réel, notre ensemble de données pourrait changer. L'article 10 deviendrait l'article 9, et disparaîtrait de notre vue, pendant que l'article 11 serait maintenant dans le créneau. Le résultat final serait que l'utilisateur verrait soudainement ses articles changer sans raisons apparentes.

    Même si nous tolérions cette bizarrerie dans l'expérience utilisateur, la pagination traditionnelle est également difficile à implémenter pour des raisons techniques.

    Revenons à notre exemple précédent. Nous publions les articles 10 à 20 de la collection Posts, mais comment trouver ces articles sur le client ? Vous ne pouvez pas prendre les articles de 10 à 20, comme il y a seulement dix articles en tout dans l'ensemble de données côté client.

    Une solution serait simplement de publier ces 10 articles sur le serveur, et ensuite faire un Posts.find() côté client pour récupérer tous les articles publiés.

    Ceci fonctionne si vous avez seulement un abonnement. Mais que va-t-il se passer si vous avez plus d'un abonnement aux articles, comme nous le ferons bientôt ?

    Disons qu'un abonnement demande les articles 10 à 20, et un autre les articles 30 à 40. Vous avez maintenant 20 articles chargés côté client au total, avec aucun moyen de savoir lesquels appartiennent à quel abonnement.

    Pour toutes ces raisons, la pagination traditionnelle n'a pas beaucoup de sens lorsqu'on travaille avec Meteor.

    Créer un contrôleur de route

    Vous avez noté que nous répétons la ligne var limit = parseInt(this.params.postsLimit) || 5; deux fois. De plus, coder en dur le nombre “5” n'est pas vraiment idéal. Ce n'est pas la fin du monde, mais comme c'est toujours mieux de suivre le principe DRY (Don’t Repeat Yourself : Ne vous répétez pas) si vous le pouvez, voyons comment nous pouvons remanier un petit peu les choses.

    Nous allons présenter un nouvel aspect de Iron Router, les Contrôleurs de Route. Un contrôleur de route est simplement un moyen de grouper les fonctionnalités de routage ensemble dans un paquet réutilisable dont toutes les routes peuvent hériter. Maintenant nous allons l'utiliser uniquement pour une seule route, mais vous verrez dans le prochain chapitre comment cette fonctionnalité deviendra pratique.

    //...
    
    PostsListController = RouteController.extend({
      template: 'postsList',
      increment: 5,
      postsLimit: function() {
        return parseInt(this.params.postsLimit) || this.increment;
      },
      findOptions: function() {
        return {sort: {submitted: -1}, limit: this.postsLimit()};
      },
      waitOn: function() {
        return Meteor.subscribe('posts', this.findOptions());
      },
      data: function() {
        return {posts: Posts.find({}, this.findOptions())};
      }
    });
    
    //...
    
    Router.route('/:postsLimit?', {
      name: 'postsList'
    });
    
    //...
    
    lib/router.js

    Allons y pas à pas. Premièrement, nous créons notre contrôleur en étendant RouteController. Puis Nous mettons la propriété template juste comme nous l'avons fait avant, et ensuite une nouvelle propriété increment.

    Puis nous définissions une nouvelle fonction postslimit qui retournera la limite courante, et une fonction findOptions qui retournera un objet options. Cela pourrait ressembler à une étape supplémentaire, mais nous en ferons usage plus tard.

    Après, nous définissions des fonctions waitOn et data juste comme avant, excepté que maintenant elles vont utiliser notre nouvelle fonction findOptions.

    Puisque notre contrôleur est appelé PostsListController et notre route postsList, Iron Router utilisera automatiquement le contrôleur. Nous avons donc seulement besoin de supprimer waitOn et data de la définition de notre route (puisque dorénavant le contrôleur s'en occupe). Si nous avions besoin d'utiliser un contrôleur avec un autre nom, nous aurions pu utiliser l'option controller (nous verrons un exemple de ça dans le prochain chapitre).

    Commit 12-3

    Route postsList remaniées dans un contrôleur de route.

    Ajouter un lien « Charger plus »

    Nous avons une pagination qui fonctionne, et notre code est bien fait. Il y a juste un problème : il n'y a aucun moyen d'utiliser actuellement cette pagination excepté en changeant l'URL manuellement. Ceci ne fait définitivement pas une bonne expérience utilisateur, donc retournons au travail pour corriger ça.

    Ce que nous voulons faire est assez simple. Nous allons ajouter un bouton “Charger plus” en bas de notre liste d'articles, qui incrémentera le nombre d'articles affichés par 5 chaque fois qu'on clique dessus. Donc si je suis actuellement sur l'URL http://localhost:3000/5, cliquer sur “Charger plus” nous amène à http://localhost:3000/10. Si vous êtes arrivé aussi loin dans le livre, nous pensons que vous pouvez supporter un peu d'arithmétique.

    Comme précédemment, nous ajouterons notre logique de pagination dans notre route. Vous vous souvenez quand nous avons explicitement nommé notre contexte de données plutôt que juste utiliser un curseur anonyme ? Bien, il n'y a pas de règle qui dit que la fonction data peut seulement passer des curseurs, donc nous utiliserons la même technique pour générer l'URL de notre bouton “charger plus”.

    //...
    
    PostsListController = RouteController.extend({
      template: 'postsList',
      increment: 5,
      postsLimit: function() {
        return parseInt(this.params.postsLimit) || this.increment;
      },
      findOptions: function() {
        return {sort: {submitted: -1}, limit: this.postsLimit()};
      },
      waitOn: function() {
        return Meteor.subscribe('posts', this.findOptions());
      },
      posts: function() {
        return Posts.find({}, this.findOptions());
      },
      data: function() {
        var hasMore = this.posts().count() === this.postsLimit();
        var nextPath = this.route.path({postsLimit: this.postsLimit() + this.increment});
        return {
          posts: this.posts(),
          nextPath: hasMore ? nextPath : null
        };
      }
    });
    
    //...
    
    lib/router.js

    Regardons plus en détail la magie du routeur. Vous vous souvenez que la route postsList (qui hérite du contrôleur PostsListController sur lequel nous travaillons) prend un paramètre postsLimit.

    Donc quand nous alimentons this.route.path() avec {postsLimit: this.postslimit() + this.increment}, nous disons à la route postsList de construire son propre chemin en utilisant cet objet JavaScript comme contexte de données.

    En d'autres mots, c'est exactement la même chose qu'utiliser le helper Spacebars {{pathFor 'postsList'}}, excepté que nous remplaçons le this implicite par notre contexte de données personnalisé.

    Nous prenons ce chemin et l'ajoutons au contexte de données pour notre template, mais seulement s'il y a plus d'articles à afficher. La manière dont on fait ça est un peu rusée.

    Nous savons que this.limit() retourne le nombre courant d'articles que nous aimerions montrer, qui peut être la valeur dans l'URL courante, ou notre valeur par défaut (5) si l'URL ne contient pas de paramètre.

    D'un autre côté, this.posts réfère au curseur courant, donc this.posts.count() réfère au nombre d'articles qui sont actuellement dans le curseur.

    Donc ce que nous disons ici est que si nous demandons n articles et nous récupérons n, nous continuerons d'afficher le bouton “charger plus”. Mais si nous demandons n et que nous récupérons moins de n, ça voudra dire que nous avons atteint la limite et que nous voulons arrêter d'afficher ce bouton.

    Ceci étant dit, notre système échoue dans un cas ; quand le nombre d'items dans notre base de données est exactement n. Si cela arrive, le client demandera n articles et récupérera n articles et continuera à afficher le bouton “charger plus”, inconscient qu'il n'y a plus d'items restants.

    C'est triste, il n'y a pas de contournements simples à ce problème, donc pour l'instant nous devrons nous contenter de cette implémentation moins-que-parfaite.

    Tout ce qu'il reste à faire est d'ajouter le lien “charger plus” en bas de notre liste d'articles, en nous assurant de l'afficher seulement si nous avons encore des articles à charger :

    <template name="postsList">
      <div class="posts">
        {{#each posts}}
          {{> postItem}}
        {{/each}}
    
        {{#if nextPath}}
          <a class="load-more" href="{{nextPath}}">Load more</a>
        {{/if}}
      </div>
    </template>
    
    client/templates/posts/posts_list.html

    Voici à quoi votre liste d'articles devrait ressembler :

    Le bouton “Charger plus“.
    Le bouton “Charger plus“.

    Commit 12-4

    nextPath() ajouté au contrôleur et utilisé pour franchir …

    Une meilleure expérience utilisateur

    Notre pagination fonctionne maintenant correctement, mais elle souffre d'une ennuyeuse bizarrerie : à chaque fois que nous cliquons sur « charger plus » et que le routeur demande plus d'articles, nous sommes envoyés vers le template loading pendant que nous attendons l'arrivée des nouvelles données. Le résultat est que nous sommes envoyés en haut de la page à chaque fois et nous avons besoin de faire défiler la page vers le bas pour continuer notre navigation.

    Donc premièrement, nous devrons dire à Iron Router de ne pas waitOn l'abonnement après tout. Au lieu de ça, nous définirons nos abonnements dans un hook subscriptions.

    Notez que nous ne renvoyons pas cet abonnement dans le hook. Le renvoyer (ce qui est fait normalement avec le hook subscription) déclencherais le hook de chargement global, et c'est exactement ce que nous voulons éviter. À la place, nous utilisons simplement le hook subscriptions comme un endroit pratique pour définir notre abonnement, de la même façon que pour le hook onBeforeAction.

    Nous passons aussi une variable ready qui se réfère à this.postsSub.ready comme un élément de notre contexte de données. Cela nous permettra de dire au template quand l'abonnement au post a terminé de charger.

    //...
    
    PostsListController = RouteController.extend({
      template: 'postsList',
      increment: 5,
      postsLimit: function() {
        return parseInt(this.params.postsLimit) || this.increment;
      },
      findOptions: function() {
        return {sort: {submitted: -1}, limit: this.postsLimit()};
      },
      subscriptions: function() {
        this.postsSub = Meteor.subscribe('posts', this.findOptions());
      },
      posts: function() {
        return Posts.find({}, this.findOptions());
      },
      data: function() {
        var hasMore = this.posts().count() === this.postsLimit();
        var nextPath = this.route.path({postsLimit: this.postsLimit() + this.increment});
        return {
          posts: this.posts(),
          ready: this.postsSub.ready,
          nextPath: hasMore ? nextPath : null
        };
      }
    });
    
    //...
    
    lib/router.js

    Nous allons ensuite vérifier dans le template que cette variable ready affiche un spinner en bas de la liste de posts pendant que nous chargeons un nouveau lot de posts :

    <template name="postsList">
      <div class="posts">
        {{#each posts}}
          {{> postItem}}
        {{/each}}
    
        {{#if nextPath}}
          <a class="load-more" href="{{nextPath}}">Load more</a>
        {{else}}
          {{#unless ready}}
            {{> spinner}}
          {{/unless}}
        {{/if}}
      </div>
    </template>
    
    client/templates/posts/posts_list.html

    Commit 12-5

    Avec un spinner pour rendre la pagination plus agréable.

    Accéder à des articles

    Nous sommes en train de charger les cinq articles les plus récents par défaut, mais que se passe-t-il quand quelqu'un explore une page d'article individuelle ?

    Un template vide.
    Un template vide.

    Si vous essayez, vous allez faire face à une erreur « non trouvé ». Cela semble normal : nous avons dit au routeur de s'abonner à la publication posts quand il charge la route postsList, mais nous ne lui avons pas dit quoi faire au sujet de la route postPage.

    Mais, tout ce que nous savons faire c'est nous abonner à une liste des n derniers articles. Comment demande-t-on au serveur pour un seul article spécifique ? Nous allons vous donner un petit secret ici : vous pouvez avoir plus d'une publication pour chaque collection !

    Donc pour retrouver nos articles manquants, nous allons simplement créer une nouvelle publication singlePost séparée qui publie seulement un article, identifié par son _id.

    Meteor.publish('posts', function(options) {
      return Posts.find({}, options);
    });
    
    Meteor.publish('singlePost', function(id) {
      check(id, String)
      return Posts.find(id);
    });
    
    //...
    
    server/publications.js

    Maintenant, abonnons-nous aux bons articles côté client. Nous nous sommes déjà abonnés à la publication comments sur la fonction waitOn de la route postPage, donc nous pouvons simplement ajouter l'abonnement à singlePost ici. Et n'oublions pas d'ajouter également notre abonnement à la route postEdit, vu qu'elle nécessite aussi les mêmes données :

    //...
    
    Router.route('/posts/:_id', {
      name: 'postPage',
      waitOn: function() {
        return [
          Meteor.subscribe('singlePost', this.params._id),
          Meteor.subscribe('comments', this.params._id)
        ];
      },
      data: function() { return Posts.findOne(this.params._id); }
    });
    
    Router.route('/posts/:_id/edit', {
      name: 'postEdit',
      waitOn: function() {
        return Meteor.subscribe('singlePost', this.params._id);
      },
      data: function() { return Posts.findOne(this.params._id); }
    });
    
    //...
    
    lib/router.js

    Commit 12-6

    Utiliser un abonnement à un unique article pour nous assu…

    Avec la pagination activée, notre application ne souffre plus de problèmes de montée en charge, et les utilisateurs sont sûrs de contribuer avec même plus de liens qu'avant. Donc ne serait-il pas super de pouvoir d'une manière ou d'une autre classer ces liens ? Si vous ne le saviez pas, c'est précisément l'objet du chapitre suivant.