Paginación

12

Porcentaje completado

En este capítulo:

  • Aprenderemos más sobre las suscripciones y cómo usarlas para controlar los datos.
  • Implementaremos la paginación de estilo infinito
  • Usaremos el paquete iron-router-progress para implementar una bonita barra de progreso al estilo iOS.
  • Crearemos una suscripción especial para tratar con los enlaces a las páginas de posts.
  • Nuestra aplicación va tomando forma y podemos esperar un gran éxito cuando todo el mundo la conozca.

    Así que quizás debamos pensar un poco sobre cómo afectará al rendimiento el gran número de nuevos posts que vamos a recibir.

    Hemos visto antes cómo una colección en el cliente pude contener un subconjunto de los datos en el servidor y lo hemos usado para nuestras notificaciones y comentarios.

    Ahora pensemos en que todavía estamos publicando todos nuestros posts de una sola vez a todos los usuarios conectados. Si se publicaran miles de enlaces, esto sería un problema. Para solucionarlo tenemos que paginar nuestros posts.

    Añadiendo unos cuantos posts

    En primer lugar, vamos a cargar los suficientes posts para que la paginación tenga sentido:

    // 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

    Después de ejecutar meteor reset e iniciar la aplicación de nuevo, deberíamos ver algo como esto:

    Mostrando un montón de datos.
    Mostrando un montón de datos.

    Commit 12-1

    Añadidos suficientes posts para hacer necesaria la pagina…

    Paginación infinita

    Vamos a implementar una paginación de estilo “infinito”. Lo que queremos decir con esto es que primero mostramos, por ejemplo, 10 posts, con un enlace de “cargar más” en la parte inferior. Al hacer clic en este enlace se cargarán 10 más, y así hasta el infinito y más allá. Esto significa que podemos controlar todo nuestro sistema de paginación con un solo parámetro que representa el número de posts que mostraremos en pantalla.

    Vamos a necesitar entonces una forma de pasar este parámetro al servidor para que sepa la cantidad de mensajes que debe enviar al cliente. Se da la circunstancia de que ya estamos suscritos a la publicación posts en el router, así que vamos a aprovecharlo y a dejar que el router maneje también la paginación.

    La forma más fácil de configurar esto es hacer que el parámetro límite forme parte de la ruta, quedando las URL de la forma http://localhost:3000/25. Una ventaja añadida de utilizar la URL en vez de otros métodos es que si estamos viendo 25 posts y resulta que se recarga la página por error, todavía seguiremos viendo 25 posts.

    Para hacer esto correctamente, tenemos que cambiar la forma en que nos suscribimos a los posts. Al igual que hicimos en el capítulo Comentarios, tendremos que mover nuestro código de suscripción desde el nivel de router al nivel de ruta.

    Parece demasiado para hacerlo todo de una sola vez, pero se verá más claro escribiendo el código.

    En primer lugar, vamos a dejar de suscribirnos a la publicación posts en el bloque Router.configure(). Simplemente elimina Meteor.subscribe('posts'), dejando solo la suscripción notifications:

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

    A continuación, añadiremos el parámetro postsLimit al path de la ruta. Si añadimos un ? después del parámetro, lo hacemos opcional. De forma que nuestra ruta no solo coincidirá con http://localhost:3000/50, sino también con http://localhost:3000.

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

    Es importante señalar que un path de la forma /:parameter? coincide con todos los path posibles. Dado que cada ruta se analiza en orden secuencial para comprobar si coincide con la ruta actual, tenemos que asegurarnos que organizamos bien nuestras rutas con el fin de disminuir la especificidad.

    En otras palabras, las rutas más específicas como /posts/:_id deben ir primero, y nuestra ruta postsList debería ir en la parte inferior del grupo de rutas para que todo coincida correctamente.

    Es el momento de abordar el difícil problema de suscribirse y encontrar los datos correctos. Tenemos que lidiar con el caso en el que el parámetro postsLimit no está presente, por lo que vamos a asignarle un valor predeterminado. Usaremos “5”, que nos dará suficiente espacio para jugar con la paginación.

    //...
    
    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

    Ya te habrás dado cuenta de que estamos pasando un objeto JavaScript ({sort: {submitted: -1}, limit: postsLimit}) junto con el nombre de nuestra publicación posts. Este objeto servirá como parámetro options en la llamada a Posts.find() en el lado del servidor. Vamos a cambiar nuestro código en el servidor para implementarlo:

    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

    Paso de parámetros

    Nuestro código de publicaciones está diciendo al servidor que puede confiar en cualquier objeto JavaScript enviado por el cliente (en nuestro caso, {limit: postsLimit}) para servir como opciones para find(). Esto hace posible que los usuarios envíen cualquier opción a través de la consola del navegador.

    En nuestro caso, esto es relativamente inofensivo, ya que todo lo que un usuario podría hacer es reordenar los mensajes de manera diferente, o cambiar el límite (que es lo que queremos hacer). ¡De todas formas una aplicación del mundo real debería probablemente limitar el límite!

    Afortunadamente, usando check() sabemos que los usuarios no podrán inyectar opciones adicionales (como la opción fields, que en algunos casos podría exponer datos privados en los documentos).

    De todas formas, un patrón más seguro para asegurarnos el control de nuestros datos podría ser pasar los parámetros de forma individual en lugar de todo el objeto:

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

    Ahora que nos suscribimos a nivel de ruta, tiene sentido establecer el contexto de datos en ese mismo lugar. Vamos a desviarnos un poco de nuestro patrón anterior y hacer que la función data devuelva un objeto JavaScript en lugar de simplemente devolver un cursor. Esto nos permite crear un contexto de datos con nombre que llamaremos posts.

    Lo que significa es que en lugar de disponer implícitamente de los datos en this dentro de la plantilla, estará disponible también como posts. Aparte de este pequeño elemento, el código debe sernos familiar:

    //...
    
    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

    Ahora que hemos establecido el contexto de datos a nivel de router podemos deshacernos del ayudante de plantilla posts del archivo posts_list.js borrando el contenido de este archivo.

    Y como hemos llamado posts al contexto de datos (igual que en el ayudante), ¡ni siquiera necesitamos tocar la plantilla postsList!

    Recapitulemos. Así es como ha quedado nuestro router.js:

    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

    Aumentada la ruta `postsList` para que tenga un límite.

    Vamos a probar nuestro nuevo sistema de paginación. Ahora podemos mostrar un número arbitrario de posts en la página principal simplemente cambiando el parámetro en la URL. Por ejemplo, intenta acceder a http://localhost:3000/3. Deberías ver algo como esto:

    Controlando el número de posts en la página principal.
    Controlando el número de posts en la página principal.

    ¿Y porqué no usamos páginas?

    ¿Por qué usamos el enfoque “paginación infinita” en lugar de mostrar páginas sucesivas con 10 posts cada uno, como hace Google para en sus resultados de búsqueda? En realidad se debe al paradigma de tiempo real que utiliza Meteor.

    Imaginemos que paginamos nuestra colección Posts utilizando el patrón de resultados Google, y que estamos en la página 2, que muestra los mensajes de 10 a 20. ¿Qué pasa si otro usuario elimina una de las 10 entradas anteriores?

    Como nuestra aplicación es en tiempo real, nuestra base de datos cambiaría. El post 10 se convertiría en el 9, y desaparecería de nuestra vista y ahora veríamos el 11. El resultado sería que el usuario vería aparecer y desaparecer posts sin razón aparente.

    Incluso si toleramos esta peculiaridad, la paginación tradicional también es difícil de implementar por razones técnicas.

    Volvamos a nuestro ejemplo anterior. Estamos publicando los posts 10-20 de la colección Posts, pero ¿cómo los encontramos en el cliente? No se pueden seleccionar los mensajes 10 a 20 porque solo hay diez posts en total en el conjunto de datos del lado del cliente.

    Una solución sería publicar esos 10 mensajes en el servidor y, a continuación, hacer un Posts.find() en el lado del cliente para recoger todos los posts publicados.

    Esto funciona si solo tienes una suscripción. Pero, ¿y si tenemos más de una?

    Digamos que una suscripción pide los posts 10 a 20, y otra 30 a 40. Ahora tenemos 20 mensajes cargados del lado del cliente en total, y no hay forma de saber cuáles pertenecen a cada suscripción.

    Por todas estas razones, la paginación tradicional simplemente no tiene mucho sentido cuando se trabaja con Meteor.

    Creando un controlador de rutas

    Te habrás dado cuenta de que repetimos dos veces la línea var limit = parseInt(this.params.postsLimit) || 5;. Además, codificar el número “5” no es lo ideal. No te preocupes, no es el fin del mundo, pero como siempre es mejor seguir el principio DRY (Don’t Repeat Yourself), vamos a ver cómo podemos refactorizar un poco las cosas.

    Introduciremos un nuevo aspecto de Iron Router, los controladores de ruta. Un controlador de ruta es simplemente una forma de agrupar en un paquete reutilizable, características de enrutamiento que puede heredar cualquier ruta. Ahora solo lo utilizaremos para una sola ruta, pero en el próximo capítulo veremos que esta característica es muy útil.

    //...
    
    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

    Vamos a verlo paso a paso. En primer lugar, creamos nuestro controlador extendiendo RouteController. A continuación, establecemos la propiedad template tal y como hicimos antes, y luego una nueva propiedad increment.

    A continuación, definimos una nueva función postsLimit que devolverá el límite actual, y una función findOptions que devolverá un objeto con las opciones. Esto puede parecer un paso extra, pero vamos a hacer uso de él en el futuro.

    A continuación, definimos nuestras funciones waitOn y de data igual que antes, excepto que ahora usan la nueva función findOptions.

    Como nuestro controlador se llama PostsListController y nuestra ruta se llama postsList, Iron Router usará el controlador automáticamente. Así que sólo necesitamos eliminar waitOn y data de la definicíon de la ruta (ya que es el controlador quien los gestiona ahora). Si necesitáramos utilizar un controlador con un nombre diferente, lo podemos hacer usando la opción controller (veremos un ejemplo de esto en el siguiente capítulo).

    Commit 12-3

    postLists refactorizado en un controlador de rutas

    Añadiendo un botón “Load more”

    Ya tenemos funcionando la paginación. Solo hay un problema: no hay manera de utilizarla realmente si no escribimos en la URL manualmente. Desde luego, no parece una gran experiencia de usuario, así que vamos a arreglarlo.

    Lo que queremos hacer es bastante simple. Vamos a añadir un botón “Load more” en la parte inferior de nuestra lista de posts, que incrementará en 5 el número de posts que se muestran. Así que si estamos en la URL http://localhost:3000/5, haciendo clic en “Load more” debería llevarnos a http://localhost:3000/10. Si has llegado hasta aquí, ¡confiamos en que podrás manejar un poco de aritmética!

    Al igual que antes, vamos a añadir nuestra lógica de paginación en la ruta. ¿Recuerdas que nombramos explícitamente el contexto de datos en lugar de usar un cursor anónimo? Bueno, pues no hay ninguna regla que diga que la función data solo puede pasar cursores, de modo que usaremos la misma técnica para generar la URL del botón “Load more”.

    //...
    
    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

    Echemos un vistazo en profundidad a este pequeño truco de magia que hemos puesto en el router. Recuerda que la ruta postsList (que se hereda del controlador PostsListController con el que estamos trabajando) toma un parámetro postsLimit.

    Cuando alimentamos {postsLimit: this.postsLimit() + this.increment} a this.route.path(), le estamos diciendo a la ruta postsList que construya su propio path utilizando ese objeto JavaScript como contexto de datos.

    En otras palabras, es exactamente lo mismo que usar el ayudante Spacebars {{pathFor 'postsList'}}, salvo que reemplazamos el this implícito por nuestro propio contexto de datos a medida.

    Estamos cogiendo ese path y añadiéndolo al contexto de datos de nuestra plantilla, pero sólo si hay más posts que mostrar. La forma de hacerlo es un poco complicada.

    Sabemos que this.limit() devuelve el número actual de posts que nos gustaría mostrar, que puede ser el valor de la URL actual, o el valor por defecto (5) si la URL no contiene ningún parámetro.

    Por otro lado, this.posts se refiere al cursor actual, de modo que this.posts.count() es el número de mensajes que hay en el cursor.

    Así que lo que estamos diciendo es que si pedimos n posts y obtenemos n, seguiremos mostrando el botón “Load more”. Pero si pedimos n y tenemos menos de n, significa que hemos llegado al límite y deberíamos dejar de mostrarlo.

    Con todo esto, nuestro sistema falla en un caso: cuando el número de posts en nuestra base de datos es exactamente n. Si eso ocurre, el cliente pedirá n posts y obtendrá n por lo que seguirá mostrando el botón “Load more”, sin darse cuenta de que ya no quedan más elementos.

    Lamentablemente, no hay soluciones sencillas para este problema, así que por ahora vamos a tener que conformarnos con esto.

    Todo lo que queda por hacer es añadir el botón “Load more” en la parte inferior de nuestra lista de posts, asegurándonos de mostrarlo solo si tenemos más posts que cargar:

    <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

    Así es como se debería ver la lista ahora:

    El botón “Load more”.
    El botón “Load more”.

    Commit 12-4

    Añadido nextPath() al controlador para desplazarnos por l…

    Mejorando la experiencia de usuario

    La paginación funciona correctamente, pero tiene una peculiaridad algo molesta: cada vez que se hace clic en “Load more” y el router pide más posts, nos envía a la plantilla de la carga mientras esperamos los nuevos datos. El resultado es que cada vez, nos envía a la parte superior de la página y tenemos que desplazarnos hasta el final para reanudar la navegación.

    Así que primero, tenemos que decirle a Iron Router que no espere (waitOn) la suscripción. En su lugar, definiremos nuestras suscripciones en el hook subscriptions.

    También estamos pasando una variable ready que hace referencia a this.postsSub.ready como parte de nuestro contexto de datos, que informará a la plantilla cuando se haya terminado de cargar la suscripción a los posts.

    //...
    
    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

    Comprobaremos esta variable ready en la plantilla para mostrar un spinner al final de la lista de posts mientras estemos cargando el nuevo conjunto 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

    Añadir un spinner para hacer la pagición mas atractiva.

    Accediendo a cualquier post

    Por defecto, cargamos los cinco últimos posts, pero ¿qué pasa si vamos a la página de un post individual?

    Una plantilla vacía.
    Una plantilla vacía.

    Si lo pruebas, se mostrará un error “no encontrado”. En realidad, tiene sentido: le hemos dicho al router que se suscriba a la publicación posts cuando carga la ruta postsList, pero no le hemos dicho qué debe hacer con la ruta postpage.

    Pero hasta el momento, lo único que sabemos es suscribirnos a una lista de los n últimos posts. ¿Cómo pedimos al servidor un solo post? Te contaré un pequeño secreto: ¡podemos usar más de una publicación para cada colección!

    Así que para volver a ver los posts perdidos, crearemos una nueva y separada publicación singlePost que solo publica un post, identificado por _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

    Ahora, vamos a suscribirnos a los posts correctos en el lado del cliente. Ya estamos suscritos a la publicación comments en la función waitOn de la ruta postPage, por lo que simplemente podemos añadir ahí la suscripción a singlePost. Sin olvidarnos de añadir la suscripción a la ruta postEdit, que también necesita los mismos datos:

    //...
    
    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

    Usando una sola suscripción a los posts para asegurarnos…

    Terminada la paginación, nuestra aplicación ya no sufre de problemas de escalado, y los usuarios pueden contribuir con muchos más enlaces que antes. ¿No estaría bien tener una forma de clasificarlos? Si no lo sabías, ¡este es precisamente el tema del siguiente capítulo!