Meteor 分页
分页
Microscope 的功能看起来不错。我们可以想象当它 release 之后会很受欢迎。
因此我们需要考虑一下随着新帖子越来越多所带来的性能问题。
之前我们说过客户端集合会包含服务器端数据的一个子集。我们在帖子和评论集合已经实现了这些。
但是现在,如果我们还是一口气发布所有帖子给所有的连接用户。当有成千上万的新帖子时,这会带来一些问题。为了解决这些,我们需要给帖子分页。
添加更多的帖子
首先是我们的初始化数据,我们需要添加足够的帖子来使分页有意义:
// 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 }); } }
运行完meteor reset
重启你的 app, 你会看到如下:
无限分页
我们将实现一个"无限"的分页。意思是在第一屏显示 10 条帖子和一个在底部显示的 "load more" 链接。点击 "load more" 链接再加载另外 10 条帖子,诸如此类无限的加载。这意味着我们只用一个参数来实现分页,控制在屏幕上显示帖子的数量。
现在需要一个方法告诉服务器端返回给客户端帖子的数量。这些发生在路由订阅帖子
的过程,我们会利用路由来实现分页。
最简单的限制返回帖子数量的方式是将返回数量加到 URL 中,如 http://localhost:3000/25
。使用 URL 记录数量的另一个好处是,如果不小心刷新了页面,还会返回 25 条帖子。
为了恰当的实现分页,我们需要修改帖子的订阅方法。就像我们之前在评论那章做的,我们需要将订阅部分的代码从router级变为route级。
这个改变内容会比较多,通过代码可以看的比较清楚。
首先,停止Router.configure()
代码块中的posts
订阅。即删除Meteor.subscribe('posts')
,只留下notifications
订阅:
Router.configure({ layoutTemplate: 'layout', loadingTemplate: 'loading', notFoundTemplate: 'notFound', waitOn: function() { return [Meteor.subscribe('notifications')] } });
我们在路由路径中加入参数postsLimt
。 参数后面的 ?
表示参数是可选的。这样路由就能同时匹配http://localhost:3000/50
和http://localhost:3000
。
//... Router.route('/:postsLimit?', { name: 'postsList', }); //...
需要注意每个路径都会匹配路由/:parameter?
。因为每个路由都会被检查是否匹配当前路径。我们要组织好路由来减少特异性。
话句话说,更特殊的路由会优先选择,例如:路由/posts/:_id
会在前面,而路由postsList
会放到路由组的最后,因为它太泛泛了可以匹配所有路径。
是时候处理难题了,处理订阅和找到正确的数据。我么需要处理postsLimit
参数不存在的情况。我们给它一个默认值 5, 这样我们能更好的演示分页。
//... Router.route('/:postsLimit?', { name: 'postsList', waitOn: function() { var limit = parseInt(this.params.postsLimit) || 5; return Meteor.subscribe('posts', {sort: {submitted: -1}, limit: limit}); } }); //...
你注意到我们在订阅posts
时传了一个 js 对象 ({sort: {submitted: -1}, limit: postsLimit}), 这个 js 对象会作为服务器端查询方法 Posts.find()
的可选参数。下面是服务器端的实现代码:
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}); });
传递参数
我们的订阅代码告诉服务器端,我们信任客户端传来的 JavaScript 对象 (在我们的例子中是 {limit: postsLimit}
) 作为 find()
方法的options
参数。这样我们能通过 browser consle 来传任何 option 对象。
在我们的例子中,这样没什么害处,因为用户可以做的无非是改变帖子顺序,或者修改 limit 值(这是我们想让用户做的)。但是对于一个 real-world app 我们必须做必要的限制!
幸好通过check()
方法我们知道用户不能偷偷加入额外的 options (例如 fields
, 在某些情况下需要对外暴露 ducoments 的私有数据)。
然而,更安全的做法是传递单个参数而不是整个对象,通过这样确保数据安全:
Meteor.publish('posts', function(sort, limit) { return Posts.find({}, {sort: sort, limit: limit}); });
现在我们在 route 级订阅数据,同样的我们可以在这里设置数据的 context。我们要偏离一下之前的模式,我们让 data
函数返回一个 js 对象而不是一个 cursor。 这样我们可以创建一个命名的数据 context。我们称之为 posts
。
这意味着我们的数据 context 将存在于 posts
中,而不是简单的在模板中隐式的存在于this
中。除去这一点,代码看起来很相似:
//... 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}) }; } }); //...
因为我们在 route 级设置数据 context, 现在我们可以去掉在 posts_list.js
文件中posts
模板的帮助方法。
我们的数据 context 叫做 posts
(和 helper 同名),所以我们甚至不需要修改 postsList
模板!
下面是我们修改过的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'});
试一下我们的分页。现在我们可以通过 URL 参数来控制页面显示帖子的数量,试一下 http://localhost:3000/3
。你可以看到如下:
为什么不用传统的分页?
为什么我们使用“无限分页”而不用每页显示 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 中不能使用传统的分页。
创建路由控制器
你可能已经注意到了我们代码中重复了var limit = parseInt(this.params.postsLimit) || 5;
两次。而且硬编码数字 5,这不是个理想的做法。虽然这不会导致世界末日,但是我们最好还是遵循 DRY 原则 (Don't Repeat Yourself), 让我们看看如何能把代码重构的更好些。
我们将介绍 Iron Router 的一个新功能, Route Controllers。Route controller 是通过简单的方式将一组路由特性打包,其他的 route 可以继承他们。现在我们只在一个路由中使用它,在下一章我们会看到它如何派上用场。
//... 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' }); //...
让我们一步接一步的往下看。首先,我们的创建一个继承RouteController
的控制器。然后像之前一样设置template
属性,然后添加一个新的increment
属性。
然后我们定义一个postsLimit
函数用来返回当前限制的数量,然后定义一个findOptions
函数用来返回 options 对象。这看起来像是个对于的步骤,但是我们后面会用到它。
接下来我们定义waitOn
和data
函数,除了他们现在会用到新的findOptions
函数外其余和之前相同。
因为我们的控制器叫做PostsListController
路由叫做postsList
, Iron Router 会自动使用他们。因此我们只需要从路由定义中移除 waitOn
和data
(因为路由已经会处理他们了)。如果我们需要给路由起别的名字,我们可以使用controller
选项(我们将在下一章看到一个例子)。
添加加载更多链接
我们现在实现了分页,代码看起来还不错。只有一个问题:我们的分页需要手工修改 URL。这显然不是一个好的用户体验,现在让我们来修改它。
我们要做的很简单。我们将在帖子列表的下面加一个 "load more" 按钮,点击按钮将增加 5 条帖子。如果当前的 URL 是 http://localhost:3000/5
, 点击 "load more" 按钮 URL 将变成 http://localhost:3000/10
。如果你之前已经实现过这种功能,我们相信你很强!
因为在前面,我们的分页逻辑是在 route 中。记得我们是什么时候显式命名数据上下文,而非使用匿名 cursor 的么? 没有规则说我们的 data
函数只能使用 cursors, 因此,我们将用同样的技巧来生成 "load more" 按钮的 URL。
//... 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 }; } }); //...
让我们来深入的看一下 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" 链接,并且保证在还有帖子时才显示它:
<template name="postsList"> <div class="posts"> {{#each posts}} {{> postItem}} {{/each}} {{#if nextPath}} <a class="load-more" href="{{nextPath}}">Load more</a> {{/if}} </div> </template>
下面是你帖子列表现在看上去的样子:
更好的用户体验
现在我们的分页可以工作了,但是有个烦人小问题: 每次我们点击 "load more" 按钮向 router 加载更多的帖子时,Iron Router 的 waitOn
特性会在我们等待时显示loading
模板。当结果到来时我们又会回到页面的顶端,我们每次都要滚动页面回到之前看的位置。
因此,首先我们要告诉 Iron Router 不要 waintOn
订阅,我们将定义自己的订阅在一个subscriptions
hook 中。
注意我们我们不是在 hook 中返回这个订阅。返回它(这是一般订阅
hook 常做的工作)将触发一个全局的 loading hook, 这正是我们想要避免的。我们只是想在 subscriptions
hook 中定义我们的订阅,就像使用一个 onBeforeAction
hook。
我们还要在我们的数据上下文中传入一个ready
变量,它指向this.postsSub.ready
。它会告诉我们帖子订阅何时加载完毕。
//... 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 }; } }); //...
我们将在模板中检查ready
变量的状态,并在加载帖子时在帖子列表的下面显示一个加载图标(spinner):
<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>
访问任何帖子
现在我们默认每次加载 5 条新帖子,但是当用户访问某个帖子的单独页面时会发生什么?
试一下,我们会得到一个 "not found" 错误。这是有原因的: 我们告诉 router 当我们加载 postList
route 时订阅 帖子
发布。但是我们没有说访问postPage
route 时该做什么。
但是到目前,我们知道如何订阅一个n
个最新帖子的列表。我们如何向服务器端要求单个具体帖子的内容? 我们将告诉你一个小秘密: 对于一个 collection 你可以有多个 publication!
让我们找回丢失的帖子,我们定义一个新的 publication singlePost
,它只发布一个帖子,用_id
鉴别。
Meteor.publish('posts', function(options) { return Posts.find({}, options); }); Meteor.publish('singlePost', function(id) { check(id, String) return Posts.find(id); }); //...
现在,让我们在客户端订阅正确的帖子。我们已经在postPage
route 的 wainOn
函数中订阅了comments
发布,因此我们可以也在这里加入singlePost
订阅。让后别忘了在postEdit
route 中加入我们的订阅, 因为那里也需要相同的数据:
//... 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); } }); //...
有了分页,我们的程序将不再受规模问题的困扰了,用户可以加入更多的帖子。如果有某种方法可以给帖子链接加上等级 (rank) 不是更好么?我们将在下一章去实现它!