Paginação

12

Porcentagem Traduzida

Neste capítulo, você irá:

  • Aprenda mais sobre subscrições em Meteor, e com o podemos usá-la para controlar dados.
  • Implemente o estilo infinito de paginação.
  • Use o pacote `iron-router-progress` para implementar uma estilosa barra de progressão no estilo iOS
  • Crie uma subscrição especial para lidar com links diretos para a página de artigos.
  • O Microscope está com muito bom aspeto, e podemos esperar uma recepção calorosa quando este for lançado para o mundo.

    Assim provavelmente é boa ideia pensar um pouco sobre as implicações de performance do número de artigos novos que vão ser criados no site quando este for lançado!

    Anteriormente falámos de como uma coleção no lado do cliente deve conter um sub conjunto dos dados no servidor, e até conseguimos atingir isto para as nossas coleções de notificações e comentários.

    No entanto, atualmente ainda estamos a publicar todos os artigos de uma vez, para todos os utilizadores ligados. Eventualmente, se milhares de links forem submetidos, isto será problemático. Para resolver isto, precisamos de paginar os artigos.

    Adicionando Mais Artigos

    Primeiro, nos nossos dados de teste, vamos carregar artigos suficientes para que a paginação faça realmente 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: 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

    Depois de executar meteor reset, você deve obter algo como:

    Mostrando dados de teste.
    Mostrando dados de teste.

    Commit 12-1

    Foram adicionados artigos suficientes para que a paginaçã…

    Paginação Infinita

    Vamos implementar uma paginação estilo “infinito”. O que queremos dizer por este termo é que vamos primeiro mostrar, por exemplo, 10 artigos no ecrã, com um link “carregar mais” no fundo. Clicar neste link vai adicionar mais 10 artigos à lista, e por ai adiante ad infinitum. Isto significa que podemos controlar todo o sistema de paginação com um único parâmetro que representa o número de artigos a mostrar no ecrã.

    Agora vamos precisar de uma forma de comunicar este parâmetro ao servidor para que ele fique a saber quantos artigos enviar para o cliente. Acontece que já estamos a subscrever à publicação posts no roteador, portanto vamos tirar vantagem disto e deixar ser também o roteador lidar com a paginação.

    A forma mais fácil de configurar isto é simplesmente fazendo o parâmetro limite de artigos parte do caminho, dando-nos URLs na forma http://localhost:3000/25. Um bonús adicional de usar o URL comparativamente a outros métodos é que se estamos atualmente a mostrar 25 artigos e por acaso fazemos refresh ao navegador por engano, vamos continuar a ver 25 artigos quando a página voltar a carregar.

    Para fazer isto corretamente, vamos precisar de alterar a forma como subscrevemos aos artigos. Tal como previamente fizemos no capitulo Comentários, vamos precisar de mover o nosso código da subscrição do nível do roteador para o nível da rota.

    Isto tudo pode ser muito para interiorizar de uma vez, mas vai ficar mais claro vendo o código.

    Primeiro, vamos parar de subscrever à publicação posts no bloco Router.configure(). Basta remover Meteor.subscribe('posts'),, deixando apenas a subscrição às notifications:

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

    Depois vamos adicionar um parâmetro postsLimit ao caminho da rota. Adicionar um ? depois do nome do parâmetro significa que é opcional. Ou seja, a nossa rota não só vai combinar com http://localhost:3000/50, mas também com o antigo http://localhost:3000.

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

    É importante notar que um caminho na forma /:parameter? vai combinar com todos os caminhos possíveis. Dado que cada rota vai ser analisada sucessivamente para ver se combina com o caminho atual, temos de garantir que organizamos as nossas rotas por ordem de especificidade decrescente.

    Noutras palavras, rotas que apontem para rotas mais específicas como /posts/:_id devem vir primeiro, e a nossa rota postsList deve ser movida para o fundo do ficheiro dado que basicamente combina com tudo.

    Está agora na altura de lidar com o problema difícil de subscrever e encontrar os dados corretos. Precisamos de lidar com o caso em que o parâmetro postsLimit não está presente, caso em que lhe damos um valor por defeito. Vamos usar “5” para podermos ter espaço para brincar com a paginação.

    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

    Vai notar que estamos agora a passar um objecto JavaScript ({limit: postsLimit}) em conjunto com o nome da nossa publicação posts (artigos). Este objecto vai servir como o parâmetro options para o código de servidor Posts.find(). Vamos passar para o nosso código de servidor para implementar isto:

    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

    Passando Parâmetros

    O nosso código das publicações está com efeito a dizer ao servidor que este pode confiar em qualquer objecto JavaScript enviado pelo cliente (no nosso caso, {limit: postsLimit}) para servir como as options da operação de find(). Isto torna possível que utilizadores possam submeter qualquer opção que queiram através da consola do navegador.

    No nosso caso, isto é relativamente segura, dado que tudo o que o utilizador pode fazer é re-ordenar artigos de forma diferente, ou mudar o limite (que é o que queríamos fazer em primeiro lugar).

    No entanto esta abordagem não deve ser usada quando se estão a guardar dados privados em campos não publicadas, dado que o utilizador poderia manipular a opção fields para lhes aceder, e também se deve evitar usar esta abordagem no argumento do selector do find() pelas mesmas razões de segurança.

    Uma abordagem mais segura poderia ser passar os parâmetros individuais em vez do objecto todo, para garantir que se tem controlo sobre os seus dados:

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

    Agora que estamos a subscrever ao nível da rota, também faz sentido definir o contexto de dados no mesmo lugar. Vamos afastar-nos um pouco da nossa abordagem anterior e fazer a função data devolver um objecto JavaScript em vez de simplesmente devolver um cursor. Isto permite-nos criar um contexto de dados com nome, a que vamos chamar posts.

    O que isto significa é simplesmente que em vez estar implicitamente disponível com o this dentro do template, o nosso contexto de dados vai estar disponível em posts. Para além deste pequeno elemento, o código deverá ser familiar:

    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

    Agora que estamos a definir o contexto de dados ao nivel do roteador podemos com segurança vermo-nos livres do ajudante de template posts dentro do ficheiro posts_list.js. E dado que chamámos ao nosso contexto de dados posts (o mesmo nome do ajudante), nem precisamos de tocar no template postsList!

    Vamos recapitular. Aqui está como o nosso código do router.js novo e melhorado deve parecer:

    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

    Rota postsList melhorada para receber um limite.

    Vamos experimentar o nosso novo sistema de paginação. Temos agora a habilidade de mostrar um número arbitrário de artigos na página inicial simplesmente mudando o parâmetro de URL. Por exemplo, experimente aceder a http://localhost:3000/3. Deve ver algo como isto:

    Controlando o número de artigos na página inicial.
    Controlando o número de artigos na página inicial.

    Porque não páginas?

    Porque é que estamos a usar uma abordagem de “paginação infinita” em vez de mostrar páginas sucessivas com 10 artigos cada, como o Google faz com os resultados de pesquisas? Isto é na realidade devido ao paradigma de tempo real usado por Meteor.

    Vamos imaginar que estamos a paginar a nossa coleção de Posts usando o formato de resultados paginados do Google, e que estamos atualmente na página 2, que mostra os artigos 10 a 20. O que acontece se outro utilizador apaga qualquer um dos 10 artigos anteriores?

    Dados que a nossa aplicação é de tempo real, o nosso conjunto de dados iria mudar. O artigo 10 seria agora o artigo 9, e desapareceria de vista, enquanto que o artigo 11 estaria agora dentro do intervalo. O resultado final seria que o utilizador de repente veria os artigos mudar sem razão aparente!

    Mesmo que tolerássemos este problema de usabilidade, paginação tradicional é complicada de implementar por motivos técnicos.

    Vamos voltar ao nosso exemplo anterior. Nós publicamos os artigos 10 até 20 da coleção Posts, mas como iriamos encontrar esses artigos no cliente? Não se pode selecionar os artigos 10 até 20, dados que existem apenas 10 artigos no total no conjunto de dados do cliente.

    Uma solução simples seria simplesmente publicar esses 10 artigos no servidor, e depois fazer um Posts.find() no cliente para apanhar todos os artigos publicados.

    Isto funciona se tivermos apenas uma subscrição. Mas e se começarmos a ter mais que uma subscrição de artigos, como vai acontecer em breve?

    Vamos supor que uma subscrição pede os artigos 10 até 20, e a outra pelos artigos 30 até 40. Temos agora 20 artigos carregados no cliente no total, sem forma nenhuma de saber quais pertencem a qual subscrição.

    Por todas estas razões, paginação tradicional simplesmente não faz muito sentido ao trabalhar com Meteor.

    Criando um Controlador de Rota

    Pode ter reparado que estamos a repetir a linha var limit = parseInt(this.params.postsLimit) || 5; duas vezes. Além disso, ter o número “5” hard-coded não é exatamente ideal. Isto não é o fim do munda, mas dado que é sempre melhor seguir o principio DRY (Don’t Repeat Yourself, Não se repita a si próprio) se tal for possível, vamos refatorizar as coisas.

    Vamos introduzir um novo aspeto do Iron Router, Controladores de Rota. Um controlador de rota é simplesmente uma forma de agrupar características de roteamento juntas num pacote reutilizável do qual qualquer rota pode herdar. Neste momento vamos apenas utilizá-lo numa única rota, mas vai ver no próximo capítulo como esta característica vai ser útil.

    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

    Vamos passar por cada passo. Primeiro, estamos a criar o nosso controlador estendendo de RouteController. Depois definimos a propriedade template tal como fizemos antes, e depois uma nova propriedade increment.

    Depois definimos uma nova função limit que vai devolver o limite atual, e uma função findOptions que vai devolver um objecto de opções. Isto pode parecer como um passo extra, mas vamos fazer uso dele mais tarde.

    A seguir, definimos as nossas funções de waitOn de data tal como antes, excepto que estas vão agora usar as nossa nossa função findOptions.

    Uma última coisa a fazer é dizer à rota postsList para rotear o nosso controlador novo, com a propriedade controller.

    Commit 12-3

    Rota postsList refatorizada num RouteController.

    Adicionando um Link de Carregar Mais

    Temos a paginação a funcionar, e o nosso código tem bom aspeto. Existe só um problema: não existe nenhuma forma de de facto usar essa paginação excepto alterando o URL manualmente. Isto definitivamente não é uma boa experiência de utilização, portanto vamos ao trabalho de corrigir isto.

    O que queremos fazer é bastante simples. Vamos adicionar um botão de “carregar mais” no fundo da nossa lista de artigos, que vai aumentar o número de artigos atualmente mostrados por 5 cada vez que é clicado. Ou seja, se atualmente estou no URL http://localhost:3000/5, clicar “carregar mais” deve trazer-me para http://localhost:3000/10. Se chegou a este ponto no livro, confiamos que pode lidar com alguma aritmética!

    Como dantes, vamos adicionar a nossa lógica de paginação no roteador. Lembra-se quando demos um nome explicito ao nosso contexto de dados em vez de simplesmente usar um cursor anonimo? Bem, não existe nenhuma regra que diga que a função data pode apenas passar cursores, por isso vamos usar a mesma técnica para gerar o URL do nosso botão “carregar mais”.

    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

    Vamos olhar mais a fundo para este pedaço de magia de roteador. Lembre-se que a rota postsList (que vai herdar do controlador PostsListController no qual estamos atualmente a trabalhar) recebe um parâmetro postsLimit.

    Assim quando nós passamos {postsLimit: this.limit() + this.increment} ao this.route.path(), estamos a dizer à rota postsList para construir o seu próprio caminho usando esse objeto JavaScript como contexto de dados.

    Noutras palavras, isto é exatamente o mesmo que usar o ajudante {{pathFor 'postsList'}} do Handlebars, excepto que estamos a substituir o this implícito pelo nosso contexto de dados feito à medida.

    Nós estamos a usar esse caminho e a adicioná-lo ao contexto de dados para o nosso template, mas apenas se existirem mais artigos para mostrar. A forma como fazemos isto é algo complicada.

    Nós sabemos que this.limit() devolve o número atual de artigos que gostaríamos de mostrar, que pode ou ser o valor no URL atual, ou o nosso valor por omissão (5) se o URL não contém nenhum parâmetro.

    Por outro lado, this.posts refere-se ao cursor atual, por isso this.posts.count() refere-se ao número de artigos que estão atualmente no cursor.

    O que estamos a dizer aqui é que se pedimos por n artigos e recebemos n de volta, vamos continuar a mostrar o botão de “carregar mais”. Mas se pedimos por n e recebemos menos que n, então isto significa que atingimos o limite e que temos de parar de mostrar esse botão.

    Tendo dito isto, o nosso sistema falha num caso: quando o número de itens na nossa base de dados é exatamente n. Se isso acontecer, o cliente vai pedir n artigos e receber n artigos de volta e continuar a mostrar o botão “carregar mais”, não sabendo que não existem mais itens.

    Infelizmente, não existem formas simples de dar a volta a este problema, e por agora vamos ter de nos contentar com esta implementação menos-que-perfeita.

    Tudo o que resta fazer é adicionar o link de “carregar mais” no funda da nossa lista de artigos, garantindo que só o mostramos se de facto existirem mais artigos para carregar:

    <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

    Isto é como a sua lista de artigos deve parecer agora:

    O botão de “carregar mais”.
    O botão de “carregar mais”.

    Commit 12-4

    nextPath() foi adicionado ao controlador e é agora usado …

    Uma Melhor Barra de Progresso

    A nossa paginação está agora a funcionar perfeitamente, mas sofre de um problema irritante: cada vez que carregamos “carregar mais” e o roteador pede mais artigos, voltamos ao template de loading enquando esperamos que os novos dados cheguem. O resultado é que de cada vez somos enviados de novo para o topo da página e precisamos de fazer scroll até ao fundo para poder continuar a nossa navegação.

    Seria muito, muito melhor se pudéssemos ficar na mesma página durante toda a operação, e ao mesmo tempo providenciar algum tipo de indicação de que novos dados estão a ser carregados. Felizmente, isto é precisamente o que o pacote iron-router-progress faz.

    De forma semelhante ao Safari de iOS ou a sites como Medium e YouTube, iron-router-progress adiciona uma fina barra de carregamento ao topo do ecrã. Implementar isto é tão simples como adicionar o pacote à sua aplicação:

    mrt add iron-router-progress
    
    consola bash

    Através da magina dos pacotes inteligentes, o nosso novo indicador de progresso funciona perfeitamente após instalar! A barra de progresso vai ser ativada para cada rota, e automaticamente completar assim que os dados de cada rota terminem de carregar.

    Vamos fazer apenas um pequeno ajuste. Vamos desligar iron-router-progress para a rota postSubmit dado que esta não precisa de esperar por nenhuns dados de subscrição (no final de contas, é apenas um formulário vazio):

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

    Commit 12-5

    Usar o pacote iron-router-progress para fazer a paginação…

    Acedendo Qualquer Artigo

    Estamos atualmente a carregar os 5 mais recentes por omissão, mas o que acontece quando alguém navega para a página individual de um artigo?

    Um template vazio
    Um template vazio

    Se tentar, vai ver um template de artigo vazio. Isto faz sentido: dissemos ao roteador para subscrever à publicação posts ao carregar a rota postsList, mas não lhe dissemos o que fazer com a rota postPage.

    Mas até agora, tudo o que sabemos fazer é subscrever a uma lista do n últimos artigos. Como pedimos ao servidor por um único artigo específico? Vamos partilhar um pequeno segredo consigo: é possível ter mais que uma publicação para cada coleção!

    Então para obter os nossos artigos em falta de volta, vamos simplesmente fazer uma publicação nova, separada, chamada singlePost que apenas publica um artigo, identificado pelo _id.

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

    Agora, vamos subscrever aos artigos corretos no lado do cliente. Já estamos a subscrever à publicação comments na função waitOn da rota postPage, por isso podemos simplesmente adicionar a subscrição a singlePost ai. E não nos vamos esquecer de também adicionar a nossa subscrição à rota postEdit, uma vez que esta precisa dos mesmo dados:

    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

    Usar uma subscrição a um único artigo para garantir que p…

    Com a paginação implementada, a nossa aplicação já não sofre de problemas de escalabilidade, e os utilizadores de certeza vão contribuir com ainda mais links que antes. Não seria então bom ter uma forma qualquer de classificar esses links? Isto é precisamente o tópico do próximo capítulo, Votação.