-
Notifications
You must be signed in to change notification settings - Fork 79
/
12-pagination.md.erb
542 lines (406 loc) · 20.7 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
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
---
title: 分页
slug: pagination
date: 0012/01/01
number: 12
points: 10
photoUrl: http://www.flickr.com/photos/ikewinski/8625379401/
photoAuthor: Mike Lewinski
contents: 进一步学习 Meteor 的订阅, 学习如何控制数据订阅。|实现无限风格分页|使用包 `iron-router-progress` 实现一个 iOS 风格的进度条。|为直接指向帖子的链接创建特殊的订阅。
paragraphs: 67
---
Microscope 的功能看起来不错。我们可以想象当它 release 之后会很受欢迎。
因此我们需要考虑一下随着新帖子越来越多所带来的性能问题。
之前我们说过客户端集合会包含服务器端数据的一个子集。我们在帖子和评论集合已经实现了这些。
但是现在,如果我们还是一口气发布所有帖子给所有的连接用户。当有成千上万的新帖子时,这会带来一些问题。为了解决这些,我们需要给帖子分页。
### 添加更多的帖子
首先是我们的初始化数据,我们需要添加足够的帖子来使分页有意义:
~~~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: 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
});
}
}
~~~
<%= caption "server/fixtures.js" %>
<%= highlight "15~24" %>
运行完 `meteor reset` 重启你的 app, 你会看到如下:
<%= screenshot "12-1", "显示初始化数据。" %>
<%= commit "12-1", "添加足够的帖子使分页有必要。" %>
### 无限分页
我们将实现一个"无限"的分页。意思是在第一屏显示 10 条帖子和一个在底部显示的 "load more" 链接。点击 "load more" 链接再加载另外 10 条帖子,诸如此类*无限的加载*。这意味着我们只用一个参数来实现分页,控制在屏幕上显示帖子的数量。
现在需要一个方法告诉服务器端返回给客户端帖子的数量。这些发生在路由订阅`帖子`的过程,我们会利用路由来实现分页。
最简单的限制返回帖子数量的方式是将返回数量加到 URL 中,如 `http://localhost:3000/25`。使用 URL 记录数量的另一个好处是,如果不小心刷新了页面,还会返回 25 条帖子。
为了恰当的实现分页,我们需要修改帖子的订阅方法。就像我们之前在*评论*那章做的,我们需要将订阅部分的代码从 *router* 级变为 *route* 级。
这个改变内容会比较多,通过代码可以看的比较清楚。
首先,停止 `Router.configure()` 代码块中的 `posts` 订阅。即删除 `Meteor.subscribe('posts')`,只留下 `notifications` 订阅:
~~~js
Router.configure({
layoutTemplate: 'layout',
loadingTemplate: 'loading',
notFoundTemplate: 'notFound',
waitOn: function() {
return [Meteor.subscribe('notifications')]
}
});
~~~
<%= caption "lib/router.js" %>
<%= highlight "6" %>
我们在路由路径中加入参数 `postsLimt`。 参数后面的 `?` 表示参数是可选的。这样路由就能同时匹配 `http://localhost:3000/50` 和 `http://localhost:3000`。
~~~js
//...
Router.route('/:postsLimit?', {
name: 'postsList',
});
//...
~~~
<%= caption "lib/router.js" %>
<%= highlight "3" %>
需要注意每个路径都会匹配路由 `/:parameter?`。因为每个路由都会被检查是否匹配当前路径。我们要组织好路由来减少特异性。
话句话说,更特殊的路由会优先选择,例如:路由 `/posts/:_id` 会在前面,而路由 `postsList` 会放到路由组的最后,因为它太泛泛了可以匹配所有路径。
是时候处理难题了,处理订阅和找到正确的数据。我么需要处理 `postsLimit` 参数不存在的情况。我们给它一个默认值 5, 这样我们能更好的演示分页。
~~~js
//...
Router.route('/:postsLimit?', {
name: 'postsList',
waitOn: function() {
var limit = parseInt(this.params.postsLimit) || 5;
return Meteor.subscribe('posts', {sort: {submitted: -1}, limit: limit});
}
});
//...
~~~
<%= caption "lib/router.js" %>
<%= highlight "5~8" %>
你注意到我们在订阅 `posts` 时传了一个 js 对象 ({sort: {submitted: -1}, limit: postsLimit}), 这个 js 对象会作为服务器端查询方法 `Posts.find()` 的可选参数。下面是服务器端的实现代码:
~~~js
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});
});
~~~
<%= caption "server/publications.js" %>
<%= highlight "1~7" %>
<% note do %>
### 传递参数
我们的订阅代码告诉服务器端,我们信任客户端传来的 JavaScript 对象 (在我们的例子中是 `{limit: postsLimit}`) 作为 `find()` 方法的 `options` 参数。这样我们能通过 browser consle 来传任何 option 对象。
在我们的例子中,这样没什么害处,因为用户可以做的无非是改变帖子顺序,或者修改 limit 值(这是我们想让用户做的)。但是对于一个 real-world app 我们必须做必要的限制!
幸好通过 `check()` 方法我们知道用户不能偷偷加入额外的 options (例如 `fields`, 在某些情况下需要对外暴露 ducoments 的私有数据)。
然而,更安全的做法是传递单个参数而不是整个对象,通过这样确保数据安全:
~~~js
Meteor.publish('posts', function(sort, limit) {
return Posts.find({}, {sort: sort, limit: limit});
});
~~~
<% end %>
现在我们在 route 级订阅数据,同样的我们可以在这里设置数据的 context。我们要偏离一下之前的模式,我们让 `data` 函数返回一个 js 对象而不是一个 cursor。 这样我们可以创建一个*命名*的数据 context。我们称之为 `posts`。
这意味着我们的数据 context 将存在于 `posts` 中,而不是简单的在模板中隐式的存在于 `this` 中。除去这一点,代码看起来很相似:
~~~js
//...
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})
};
}
});
//...
~~~
<%= caption "lib/router.js" %>
<%= highlight "9~14" %>
因为我们在 route 级设置数据 context, 现在我们可以去掉在 `posts_list.js` 文件中 `posts` 模板的帮助方法。
我们的数据 context 叫做 `posts` (和 helper 同名),所以我们甚至不需要修改 `postsList` 模板!
下面是我们修改过的 `router.js` 代码:
~~~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'});
~~~
<%= caption "lib/router.js" %>
<%= highlight "6,25~37" %>
<%= commit "12-2", "为 postsList 路径添加限制。" %>
试一下我们的分页。现在我们可以通过 URL 参数来控制页面显示帖子的数量,试一下 `http://localhost:3000/3`。你可以看到如下:
<%= screenshot "12-2", "控制首页显示帖子的数量。" %>
<% note do %>
### 为什么不用传统的分页?
为什么我们使用“无限分页”而不用每页显示 10 条帖子的连续分页,就像 Google 的搜索结果分页一样?这是由于 Meteor 的实时性决定的。
让我们想象一下使用类似 Google 搜索结果的连续分页模式,我们在第2页,显示的是 10 到 20 条帖子。这是碰巧有另外一个用户删除了前面 10 条帖子中的帖子。
因为 app 是实时的,我们的数据集会马上变化,这样第 10 条帖子变成了第 9 条,从当前页面消失了,第 21 条帖子会出现在页面中。这样用户会觉得没什么原由的结果集变了!
即使我们可以容忍这种怪异的 UX, 由于技术的原因传统的分页还是很难实现。
让我们回到前一个例子。我们从 `Posts` 集合中发布第 10 到 20 条帖子,但是在客户端我们如何找到这些帖子?我们不能在客户端选择第 10 到 20 条帖子,因为客户端集合只有 10 个帖子。
一个简单的方案是服务器端发布 10 条帖子,在客户端执行一下 `Posts.find()` 找到这 10 条发布的帖子。
这个方案在只有一个用户订阅的情况下有效,但是如果有多个用户订阅呢,下面我们会看到。
我们假设一个用户需要第 10 到 20 条帖子,而另一个需要第 30 到 40。这样在客户端我们有两个 20 条帖子,我们不能区分他们属于哪个订阅。
基于这些原因,我们在 Meteor 中不能使用传统的分页。
<% end %>
### 创建路由控制器(Route controller)
你可能已经注意到了我们代码中重复了 `var limit = parseInt(this.params.postsLimit) || 5;` 两次。而且硬编码数字 5,这不是个理想的做法。虽然这不会导致世界末日,但是我们最好还是遵循 DRY 原则 (Don't Repeat Yourself), 让我们看看如何能把代码重构的更好些。
我们将介绍 Iron Router 的一个新功能, *Route Controllers*。Route controller 是通过简单的方式将一组路由特性打包,其他的 route 可以继承他们。现在我们只在一个路由中使用它,在下一章我们会看到它如何派上用场。
~~~js
//...
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'
});
//...
~~~
<%= caption "lib/router.js" %>
<%= highlight "3~18, 25" %>
让我们一步接一步的往下看。首先,我们的创建一个继承 `RouteController` 的控制器。然后像之前一样设置 `template` 属性,然后添加一个新的 `increment` 属性。
然后我们定义一个 `postsLimit` 函数用来返回当前限制的数量,然后定义一个 `findOptions` 函数用来返回 options 对象。这看起来像是个对于的步骤,但是我们后面会用到它。
接下来我们定义 `waitOn` 和 `data` 函数,除了他们现在会用到新的 `findOptions` 函数外其余和之前相同。
因为我们的控制器叫做 `PostsListController` 路由叫做 `postsList`, Iron Router 会自动使用他们。因此我们只需要从路由定义中移除 `waitOn` 和 `data` (因为路由已经会处理他们了)。如果我们需要给路由起别的名字,我们可以使用 `controller` 选项(我们将在下一章看到一个例子)。
<%= commit "12-3", "重构 postsLists 路由为 RouteController." %>
### 添加加载更多链接
我们现在实现了分页,代码看起来还不错。只有一个问题:我们的分页需要手工修改 URL。这显然不是一个好的用户体验,现在让我们来修改它。
我们要做的很简单。我们将在帖子列表的下面加一个 "load more" 按钮,点击按钮将增加 5 条帖子。如果当前的 URL 是 `http://localhost:3000/5`, 点击 "load more" 按钮 URL 将变成 `http://localhost:3000/10`。如果你之前已经实现过这种功能,我们相信你很强!
因为在前面,我们的分页逻辑是在 route 中。记得我们是什么时候显式命名数据上下文,而非使用匿名 cursor 的么? 没有规则说我们的 `data` 函数只能使用 cursors, 因此,我们将用同样的技巧来生成 "load more" 按钮的 URL。
~~~js
//...
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
};
}
});
//...
~~~
<%= caption "lib/router.js" %>
<%= highlight "15~25" %>
让我们来深入的看一下 router 带来的魔术。记住 `postsList` route (它将继承 `PostsListController` 控制器) 使用一个 `postsLimit` 参数。
因此当我们给 `this.route.path()` 传递参数 `{postsLimit: this.postsLimit() + this.increment}` 时,我们告诉 `postsList` route 使用这个 js 对象做数据上线文建立自己的 path。
换句话说,这和使用 `{{pathFor 'postsList'}}` Spacebars 帮助方法一样, 除了我们用自己的数据上下文替换了隐式的 `this`。
我们使用这个路径并将它添加到我们模板的数据上下文中,但是*只有*多条帖子时会显示。我们的实现方法有一点小花招。
我们知道 `this.limit()` 方法会返回当前我们想要显示帖子的数量,它可能是当前 URL 中的值,如果 URL 中没有参数它会是默认值 (5)。
另一方面, `this.posts` 引用当前的 cursor, 因此 `this.posts.count()` 的值是在 cursor 中帖子的数量。
因此我们说当我们要求发挥 `n` 条帖子,实际返回了 `n` 条帖子,我们将继续显示 "load more" 按钮。但是如果我们要求返回 `n` 条帖子,而实际返回的数量比 `n` 少,这样我们就知道记录已经到头了,我们就不再显示加载按钮。
这就是说,我们的系统在一种情况下会有点问题:当我们的数据库*恰好*有 `n` 条记录时。如果是这样,当客户端要求返回 `n` 条帖子,我们得到了 `n` 条,然后继续显示 "load more" 按钮,这是我们不知道其实已经没有记录可以继续返回了。
不幸的是,我们没有好的方法去解决这个问题,因此我们不得不接受这个不算完美的实现方式。
下面剩下的就是在帖子列表下面加上 "load more" 链接,并且保证在还有帖子时才显示它:
~~~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/templates/posts/posts_list.html" %>
<%= highlight "7~10" %>
下面是你帖子列表现在看上去的样子:
<%= screenshot "12-3", "“load more” 按钮。 " %>
<%= commit "12-4", "添加 nextPath() 到控制器中并使用它来取得更多帖子。" %>
### 更好的用户体验
现在我们的分页可以工作了,但是有个烦人小问题: 每次我们点击 "load more" 按钮向 router 加载更多的帖子时,Iron Router 的 `waitOn` 特性会在我们等待时显示 `loading` 模板。当结果到来时我们又会回到页面的顶端,我们每次都要滚动页面回到之前看的位置。
因此,首先我们要告诉 Iron Router 不要 `waintOn` 订阅,我们将定义自己的订阅在一个 `subscriptions` hook 中。
注意我们我们不是在 hook 中*返回*这个订阅。返回它(这是一般 `订阅` hook 常做的工作)将触发一个全局的 loading hook, 这正是我们想要避免的。我们只是想在 `subscriptions` hook 中定义我们的订阅,就像使用一个 `onBeforeAction` hook。
我们还要在我们的数据上下文中传入一个 `ready` 变量,它指向 `this.postsSub.ready`。它会告诉我们帖子订阅何时加载完毕。
~~~js
//...
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
};
}
});
//...
~~~
<%= caption "lib/router.js" %>
<%= highlight "12~14, 23" %>
我们将在模板中检查 `ready` 变量的状态,并在加载帖子时在帖子列表的下面显示一个加载图标(spinner):
~~~html
<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>
~~~
<%= caption "client/templates/posts/posts_list.html" %>
<%= highlight "10~12" %>
<%= commit "12-5", "添加加载图标使分页更好。" %>
### 访问任何帖子
现在我们默认每次加载 5 条新帖子,但是当用户访问某个帖子的单独页面时会发生什么?
<%= screenshot "12-4", "一个空模板。" %>
试一下,我们会得到一个 "not found" 错误。这是有原因的: 我们告诉 router 当我们加载 `postList` route 时订阅 `帖子` 发布。但是我们没有说访问 `postPage` route 时该做什么。
但是到目前,我们知道如何订阅一个 `n` 个最新帖子的列表。我们如何向服务器端要求单个具体帖子的内容? 我们将告诉你一个小秘密: 对于一个 collection 你可以有多个 publication!
让我们找回丢失的帖子,我们定义一个新的 publication `singlePost`,它只发布一个帖子,用 `_id` 鉴别。
~~~js
Meteor.publish('posts', function(options) {
return Posts.find({}, options);
});
Meteor.publish('singlePost', function(id) {
check(id, String)
return Posts.find(id);
});
//...
~~~
<%= caption "server/publications.js" %>
<%= highlight "5~7" %>
现在,让我们在客户端订阅正确的帖子。我们已经在 `postPage` route 的 `wainOn` 函数中订阅了 `comments` 发布,因此我们可以也在这里加入 `singlePost` 订阅。让后别忘了在 `postEdit` route 中加入我们的订阅, 因为那里也需要相同的数据:
~~~js
//...
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); }
});
//...
~~~
<%= caption "lib/router.js" %>
<%= highlight "6~9,16~18" %>
<%= commit "12-6","使用单一帖子订阅来确保我们总会看到正确的帖子。" %>
有了分页,我们的程序将不再受规模问题的困扰了,用户可以加入更多的帖子。如果有某种方法可以给帖子链接加上等级 (rank) 不是更好么?我们将在下一章去实现它!