-
Notifications
You must be signed in to change notification settings - Fork 12
/
Copy path12-pagination.md.erb
464 lines (340 loc) · 22 KB
/
12-pagination.md.erb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
---
title: Paginazione
slug: pagination
date: 0012/01/01
number: 12
contents: 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.
paragraphs: 67
---
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:
~~~js
// 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
});
}
}
~~~
<%= caption "server/fixtures.js" %>
<%= highlight "15~24" %>
Dopo aver eseguito `meteor reset`, dovreste vedere un risultato simile a questo:
<%= screenshot "12-1", "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`:
~~~js
Router.configure({
layoutTemplate: 'layout',
loadingTemplate: 'loading',
waitOn: function() {
return [Meteor.subscribe('notifications')]
}
});
~~~
<%= caption "lib/router.js" %>
<%= highlight "5" %>
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`.
~~~js
Router.map(function() {
//...
this.route('postsList', {
path: '/:postsLimit?'
});
});
~~~
<%= caption "lib/router.js" %>
<%= highlight "5" %>
È 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.
~~~js
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});
}
});
});
~~~
<%= caption "lib/router.js" %>
<%= highlight "6~9" %>
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:
~~~js
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});
});
~~~
<%= caption "server/publications.js" %>
<%= highlight "1~3" %>
<% note do %>
### 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:
~~~js
Meteor.publish('posts', function(sort, limit) {
return Posts.find({}, {sort: sort, limit: limit});
});
~~~
<% end %>
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:
~~~js
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})
};
}
});
});
~~~
<%= caption "lib/router.js" %>
<%= highlight "10~15" %>
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ì:
~~~js
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})
};
}
});
});
~~~
<%= caption "lib/router.js" %>
<%= highlight "5, 11~21" %>
<%= 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:
<%= screenshot "12-2", "Controlling the number of posts on the homepage. " %>
<% note do %>
### 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.
<% end %>
### 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.
~~~js
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
});
});
~~~
<%= caption "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".
~~~js
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
};
}
});
~~~
<%= caption "lib/router.js" %>
<%= highlight "13~15,17~22" %>
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:
~~~html
<template name="postsList">
<div class="posts">
{{#each posts}}
{{> postItem}}
{{/each}}
{{#if nextPath}}
<a class="load-more" href="{{nextPath}}">Load more</a>
{{/if}}
</div>
</template>
~~~
<%= caption "client/views/posts/posts_list.html" %>
<%= highlight "7~10" %>
Questo è quello che dovremmo vedere ora:
<%= screenshot "12-3", "The “load more” button. " %>
<%= commit "12-4", "Added nextPath() to the controller and use it to step through posts." %>
### 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:
~~~bash
mrt add iron-router-progress
~~~
<%= caption "bash console" %>
Attraverso la magia dei pacchetti, la nostra nuova barra di progresso funziona perfettamente appena installata! La barra di progresso 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):
~~~js
Router.map(function() {
//...
this.route('postSubmit', {
path: '/submit',
disableProgress: true
});
});
~~~
<%= caption "lib/router.js" %>
<%= highlight "7" %>
<%= commit "12-5", "Use the iron-router-progress package to make pagination nicer." %>
### 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?
<%= screenshot "12-4", "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`.
~~~js
Meteor.publish('posts', function(options) {
return Posts.find({}, options);
});
Meteor.publish('singlePost', function(id) {
return id && Posts.find(id);
});
~~~
<%= caption "server/publications.js" %>
<%= highlight "5~7" %>
Ora, facciamo la sottoscrizione lato client ai post corretti. Stiamo già sottoscrivendo alla pubblicazione `comments` nella funzion `waitOn` della route `postPage`, così possiamo semplicemente aggiungere qui la sottoscrizione. Non dimentichiamo di aggiungere la sottoscrizione anche alla route `postEdit` dato che necessita degli stessi dati:
~~~js
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); }
});
/...
});
~~~
<%= caption "lib/router.js" %>
<%= highlight "7~12,18~20" %>
<%= commit "12-6","Use a single post subscription to ensure that we can always see the right post." %>
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*.