Paginazione

12

Percentuale Tradotta

In questo capitolo imparerai:

  • Maggiori informazioni sulle sottoscrizioni in Meteor e su come utilizzarle per controllare i dati.
  • Implementare una paginazione infinita.
  • Usare il pacchetto `iron-router-progress` per implementare una barra di stato simile a quella di iOS.
  • Creare una particolare sottoscrizione per gestire i link diretti alle pagine dei post.
  • Le cose stanno andando alla grande con Microscope e dovremmo aspettarci un ottimo riscontro quando verrà finalmente rilasciato.

    Dovremmo quindi pensare a quali implicazioni ci saranno sulla performance dato il numero di nuovi post che verranno inseriti appena il sito prenderà il volo!

    Abbiamo parlato di come una collezione lato client può contenere solo una parte dei dati presenti sul server, e abbiamo già utilizzato questa particolarità con le collezioni di notifiche e commenti.

    Al momento stiamo ancora pubblicando tutti i post insieme, a tutti gli utenti connessi e se ci saranno migliaia di post pubblicati, questo potrebbe diventare un problema. Per risolverlo dobbiamo paginare i nostri post.

    Aggiungere Post

    Prima di tutto, nei nostri dati di esempio, carichiamo abbastanza post perché la paginazione abbia senso:

    // 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: 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: now - i * 3600 * 1000,
          commentsCount: 0
        });
      }
    }
    
    server/fixtures.js

    Dopo aver eseguito meteor reset, dovreste vedere un risultato simile a questo:

    Displaying dummy data.
    Displaying dummy data.

    Commit 12-1

    Added enough posts that pagination is necessary.

    Paginazione infinita

    Implementeremo un paginazione “infinita”, il che significa che caricheremo inizialmente 10 post, con un bottone “carica più post” in fondo alla pagina. Cliccando sul bottone verranno aggiunti altri 10 post e così via, ad infinitum. In questo modo possiamo controllare tutto il sistema di paginazione con un solo parametro, che rappresenta il numero di post da visualizzare sullo schermo.

    Ora dobbiamo trovare un modo per passare al server questo parametro così che sappia quanti post deve inviare al client. Dato che abbiamo già fatto una sottoscrizione alla pubblicazione posts nel router, ne trarremo vantaggio lasciando che sia il router a gestire la nostra paginazione.

    Il metodo più semplice è quello di aggiungere il parametro del limite di post direttamente nel path, con URL di questo tipo http://localhost:3000/25. Un vantaggio di usare questa tecnica è che se già si stanno visualizzando 25 post e capita di ricaricare la finestra del browser per errore, verranno visualizzati 25 post anche quando la pagina si ricarica.

    Per farlo in maniera appropriata, dobbiamo cambiare il modo in cui eseguiamo la sottoscrizione ai post. Come abbiamo fatto nel capitolo Commenti, dobbiamo spostare il codice della nostra sottoscrizione dal livello del router al livello della route.

    Potrebbe sembrare un gran lavoro da fare tutto in una volta, ma diventerà tutto chiaro procedendo col codice.

    Come prima cosa, fermiamo la sottoscrizione alla pubblicazione posts nel blocco Router.configure(). Eliminiamo Meteor.subscribe('posts'), lasciando solo la sottoscrizione notifications:

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

    Aggiugiamo un parametro postsLimit al percorso della route. Aggiungendo un ? dopo il nome del parametro stiamo indicando che è opzionale. In questo modo la route non solo riconoscerà un url come http://localhost:3000/50, ma anche http://localhost:3000.

    Router.map(function() {
      //...
    
      this.route('postsList', {
        path: '/:postsLimit?'
      });
    });
    
    lib/router.js

    È importante contare che un percorso come /:parameter? combacerà con ogni possibile percorso. Siccome ogni route viene analizzata in maniera successiva per vedere se corrisponde al percorso attuale, dobbiamo organizzare le nostre route in ordine di specificità decrescente.

    In altre parole, le route più specifiche come /posts/:_id devono essere messe per prime, e la route postsList dev'essere posizionata alla fine del file dato che si combina praticamente con tutti i percorsi.

    È ora di gestire il problema più complesso di sottoscrivere e trovare i dati corretti. Dobbiamo gestire il caso in cui il parametro postsLimit non sia presente, assegnando un valore di default. Useremo come limite “5”, che ci da la possibilità di giocare abbastanza con la paginazione.

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

    Notate che stiamo passando un oggetto JavaScript ({limit: postsLimit}) insieme al nome della nostra pubblicazione posts. Questo oggetto ci servirà come parametro options per l'asserzione lato sevrer Posts.find(). Passiamo al codice lato server per implementarlo:

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

    Passare Parametri

    Il codice delle nostre pubblicazioni sta dicendo al server che può fidarsi di ogni oggetto JavaScript che gli venga inviato dal client (nel nostro caso, {limit: postsLimit}) per passarlo all'asserzione find() come parametor options. Questo rende possibile agli utenti di inviare qualsiasi opzione tramite la console del browser.

    Nel nostro caso si tratta di una cosa relativamente senza pericoli dato che tutto quello che può fare un utente è di riordinare i post in maniera diversa o cambiare il limite (che è quello che volevamo abilitare inizialmente).

    Non dovete usare questo schema quando archiviate dati privati in campi non pubblicati, dato che l'utente può modificare l'opzione fields per accedervi, e dovete probabilmente evitare di usarla come argomento di selezione dell'asserzione find() per le stesse ragioni di sicurezza.

    Uno schema più sicuro è quello di passare i parametri individualmente al posto dell'intero oggetto, per essere sicuri di avere completo controllo sui dati:

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

    Ora che stiamo eseguendo la sottoscrizione al livello della route, dobbiamo spostare il contesto dei dati nello stesso posto. Facciamo una deviazione dal nostro precedente schema e facciamo in modo che la funzione data ritorni un oggetto JavaScript invece di un cursore. Questo ci permette di creare un contesto dati nominato, che chiameremo posts.

    Questo significa che invece di essere implicitamente disponibile come this all'interno del template, il nostro contesto dati sarà disponibile come posts. Oltre a questa piccola modifica, il codice dovrebbe risultare familiare:

    Router.map(function() {
      //..
    
      this.route('postsList', {
        path: '/:postsLimit?',
        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

    Ora che stiamo spostando il contesto dati al livello del router, possiamo con sicurezza eliminare l'helper del template posts all'interno del file posts_list.js e siccome abbiamo chiamato il contesto dati posts (come l'helper), non dobbiamo nemmeno toccare il template postsList!

    Facciamo il punto della situazione. Il codice modificato di router.js dovrebbe essere così:

    Router.configure({
      layoutTemplate: 'layout',
      loadingTemplate: 'loading',
      waitOn: function() {
        return [Meteor.subscribe('notifications')]
      }
    });
    
    Router.map(function() {
      //...
    
      this.route('postsList', {
        path: '/:postsLimit?',
        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

    Commit 12-2

    Augmented the postsList route to take a limit.

    Proviamo il nostro nuovo sistema di paginazione. Abbiamo ora la possibilità di visualizzare un numero arbitrario di post semplicemente cambiando un parametro nell'URL. Ad esempio, proviamo ad accedere a http://localhost:3000/3, dovremmo vedere qualcosa di simile:

    Controlling the number of posts on the homepage.
    Controlling the number of posts on the homepage.

    Perché non usare delle pagine?

    Perché stiamo usando un approccio a “paginazione infinita” invece di mostare pagine di 10 post ciascuna, come fa Google con i risultati di ricerca? Questo è dovuto al paradigma di un'applicazione in tempo reale utilizzato da Meteor.

    Immaginiamo di star paginando la nostra collezione Posts utilizzando lo schema di paginazione dei risultati di Google e che siamo al momento in pagina 2, che mostra i post da 10 a 20. Cosa succede se uno degli utenti cancella un post dei precedenti 10?

    Dato che la nostra applicazione è in tempo reale, la nostra base dati cambierebbe. Il post numero 10 diventerebbe ora il post numero 9 e sarebbe eliminato dalla nostra vista, mentre il post numero 11 resterebbe nell'intervallo. Il risultato finale sarebbe che l'utente vedrebbe i post cambiare all'improvviso senza motivo!

    Anche se tollerassimo questo malfunzionamento nell'esperienza utente, la paginazione tradizionale è anche difficile da implementare per ragioni tecniche.

    Torniamo indietro al nostro esempio precedente. Stiamo pubblicando i post da 10 a 20 della collezione Posts, ma come troviamo questi post sul client. Non si possono prendere i post da 10 a 20 dato che ci sono solo 10 post al momento nei dati lato client.

    Una soluzione sarebbe di pubblicare quei 10 post sul server, e poi fare un Posts.find() lato client per prendere tutti i post pubblicati.

    Questo funziona se avete una sola sottoscrizione. Ma cosa accede se iniziamo ad avere più di una sottoscrizione ai post come capiterà a breve?

    Diciamo che una sottoscrizione chiede i post da 10 a 20 e un'altra quelli da 30 a 40. Abbiamo ora 20 post pubblicati lato client in totale, senza modo di sapere quali appartengono a quale sottoscrizione.

    Per tutte queste ragioni, la paginazione tradizionale non ha molto senso quando lavoriamo con Meteor.

    Creare un Controller per una route

    Potete notare che abbiamo ripetuto due volte la riga var limit = parseInt(this.params.postsLimit) || 5;. Inoltre, non è una buona scelta inserire il valore “5” direttamente nel codice. Non è certo la fine del mondo, ma dato che è sempre meglio seguire il principio DRY (Don’t Repeat Yourself - Non ripeterti) quando possibile, vediamo come possiamo riscrivere il nostro codice.

    Introduciamo ora un nuovo aspetto dell'Iron Router, i Controller delle Route. Un controller di route è semplicemente un modo per raggruppare alcune funzionalità di routing in un pacchetto riutilizzabile, dal quale ogni route può ereditare queste funzionalità. Per ora lo useremo per una sola route, ma vedrete nel prossimo capitolo come questo aspetto diventerà utile.

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

    Seguiamo i passaggi: come prima cosa, abbiamo creato il controller estendendo RouteController. Poi dichiariamo la proprietà template come abbiamo fatto prima e poi la nuova proprietà ìncrement`.

    Definiamo poi un nuova funzione limit che resituisce il limite corrente, e una funzione findOptions che restituisce un oggetto di opzioni. Questo può sembrare un passaggio in più, ma ci verrà utile più avanti.

    Definiamo ora le funzioni waitOn e data come abbiamo fatto prima, eccetto per il fatto che ora utilizziamo in esse la nuova funzione findOptions.

    L'ultima cosa da fare è di dire alla route postsList di puntare al nuovo controller, tramite la proprietà controller.

    Commit 12-3

    Refactored postsLists route into a RouteController.

    Aggiungere un link ‘Carica più post’

    Abbiamo ora una paginazione funzionante e il nostro codice ha una buona struttura. C'è solo un problema: non c'è ancora un modo per usare la paginazione se non cambiando manualmente l'URL. Questo di certo non rende pratica l'esperienza dell'utente, vediamo come poterla sistemare.

    Ciò che vogliamo fare è abbastanza semplice. Aggiungiamo un bottone “Carica più post” alla fine della nostra lista di post, che incrementerà di 5 il numero di post visualizzati ogni volta che viene cliccato. Se sto visitando l'URL http://localhost:3000/5, cliccando su “Carica più post” dovrebbe portarmi a http://localhost:3000/10. Se siete riusciti ad arrivare fino a questo punto del libro, crediamo che possiate cavarvela con un po’ di aritmetica!

    Come prima, aggiungiamo la logica della paginazione nella nostra route. Ricordate quando abbiamo esplicitamente dato un nome al contesto dati piuttosto che utilizzare un cursore anonimo? Non c'è nessuna regola che dice che la funzione data può solo ritornare cursori, così useremo la stessa tecnica per generare l'URL del bottone “Carica più post”.

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

    Diamo uno sguardo più approfondito a questa piccola magia del router. Ricordate che la route postsList (che eredita dal controller PostsListController su cui stiamo attualmente lavorando) accetta un parametro postsLimit.

    In questo modo quando passiamo {postsLimit: this.limit() + this.increment} alla funzione this.route.path(), stiamo dicendo alla route postsList di costruire il proprio percorso usando questo oggetto JavaScript come contesto di dati.

    In altre parole, questa è identico ad utilizzare l'helper di Spacebars {{pathFor 'postsList'}}, escluso che stiamo rimpiazzando il this implicito con il nostro contesto di dati personalizzato.

    Stiamo prendendo quel percorso e lo stiamo aggiungendo al contesto di dati per il nostro template, ma solo se ci sono più post da mostrare. Il modo in cui lo facciamo è un po’ complesso.

    Sappiamo che this.limit() ritorna il numero corrente di post che vogliamo mostrare, che può essere sia il valore dell'URL corrente, o del nostro valore di default (5) se l'URL non contiene nessun parametro.

    D'altra parte, this.posts si riferisce al cursore corrente, così this.posts.count() si riferisce al numero di post che sono attualmente nel cursore.

    Quello che stiamo dicendo qui è che se chiediamo n post e ne otteniamo n, continuiamo a mostrare il bottone “Carica di più”. Ma se chiediamo n post e otteniamo meno di n, significa che abbiamo raggiunto il limite e dobbiamo smettere di mostrare quel bottone.

    Detto questo, il nostro sistema ha un problema quando il numero di elemente nel database è esattamente n. Quando accade il client richiede n post, ottiene n post e continua a mostrare il bottone “Carica di più”, non sapendo che non sono rimasti elementi.

    Sfortunatamente non ci sono sistemazioni semplici per questo problema, perciò al momento ci accontentiamo di questa implementazione non proprio perfetta.

    Quel che rimane da fare è aggiungere il link “Carica di più” in fondo alla lista di post, facendo in modo che si visualizzi solo se abbiamo più post da caricare:

    <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/views/posts/posts_list.html

    Questo è quello che dovremmo vedere ora:

    The “load more” button.
    The “load more” button.

    Commit 12-4

    Added nextPath() to the controller and use it to step thr…

    Una miglior barra di progresso

    La nostra paginazione sta ora funzionando bene, ma soffre di un problema noioso: ogni volta che clicchiamo su “Carica di più” e il router richiede più post, veniamo indirizzati al template loading mentre aspettiamo che i nuovi dati arrivino. Il risultato è che veniamo rimandati all'inizio della pagina ogni volta e bisogna scrollare fino in fondo per riprendere la nostra navigazione.

    Sarebbe molto meglio se potessimo stare sulla stessa pagina durante l'intera operazione, indicando comunque che i dati si stanno caricando. Fortunatamente è quello che fa il pacchetto iron-router-progress.

    Come Safari per iOS o siti come Medium e YouTube, iron-router-progress aggiunge una sottile barra di caricamento nella parte alta dello schermo. Implementarlo è semplice come aggiungere il pacchetto alla nostra applicazione:

    mrt add iron-router-progress
    
    bash console

    Attraverso la magia dei pacchetti, la nostra nuova barra di progresso funziona perfettamente appena installata! La barra di progresoo si attiverà per ogni route e si completerà automaticamente appena la route avrà caricato i dati richiesti.

    Facciamo solo una modifica. Disabilitiamo iron-router-progress per la route postSubmit dato che non deve aspettare per i dati da nessuna sottoscrizione (dopo tutto è solo un form vuoto):

    Router.map(function() {
    
      //...
    
      this.route('postSubmit', {
        path: '/submit',
        disableProgress: true
      });
    });
    
    lib/router.js

    Commit 12-5

    Use the iron-router-progress package to make pagination n…

    Accedere ad ogni post

    Stiamo attualmente caricando i cinque post più recenti come impostazione predefinita, ma cosa accade quando si naviga alla pagina di un post?

    An empty template.
    An empty template.

    Se ci provate, troverete il template di un post vuoto. Questo è corretto: abbiamo detto al router di sottoscrivere la pubblicazione posts quando carica la route postsList, ma non abbiamo detto cosa vogliamo fare con la route postPage.

    Finora tutto quello che sappiamo è come sottoscrivere a una lista dei n post più recenti. Come facciamo a chiedere al server un post specifico? Vi sveliamo un piccolo segreto: potete avere più di una pubblicazione per ogni collezione!

    Per riavere indietro i post mancanti, creiamo semplicemente una nuova pubblicazione singlePost che pubblica solo un post, identificato tramite _id.

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

    Ora, facciamo la sottoscrizione lato client ai post corretti. Stiamo già sottoscrivendo alla ppubblicazione comments nella funzion waitOn della route postPage, così possiamo semplicemente aaggiungere qui la sottoscrizione. Non dimentichiamo di aggiungere la sottoscrizione anche alla route postEdit dato che necessita degli stessi dati:

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

    Commit 12-6

    Use a single post subscription to ensure that we can alwa…

    Terminata la paginazione, la nostra applicazione non soffre più di problemi di scalabilità, e gli utenti sono sicuri di poter contribuire con molti più link di prima. Non sarebbe carino avere un sistema per poter dare un voto a questi link? È esattamente l'argomento del prossimo capitolo, Votare.