Microscope 的功能看起來(lái)不錯(cuò)。我們可以想象當(dāng)它 release 之后會(huì)很受歡迎。
因此我們需要考慮一下隨著新帖子越來(lái)越多所帶來(lái)的性能問(wèn)題。
之前我們說(shuō)過(guò)客戶(hù)端集合會(huì)包含服務(wù)器端數(shù)據(jù)的一個(gè)子集。我們?cè)谔雍驮u(píng)論集合已經(jīng)實(shí)現(xiàn)了這些。
但是現(xiàn)在,如果我們還是一口氣發(fā)布所有帖子給所有的連接用戶(hù)。當(dāng)有成千上萬(wàn)的新帖子時(shí),這會(huì)帶來(lái)一些問(wèn)題。為了解決這些,我們需要給帖子分頁(yè)。
首先是我們的初始化數(shù)據(jù),我們需要添加足夠的帖子來(lái)使分頁(yè)有意義:
// 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
});
}
}
運(yùn)行完 meteor reset
重啟你的 app, 你會(huì)看到如下:
我們將實(shí)現(xiàn)一個(gè)"無(wú)限"的分頁(yè)。意思是在第一屏顯示 10 條帖子和一個(gè)在底部顯示的 "load more" 鏈接。點(diǎn)擊 "load more" 鏈接再加載另外 10 條帖子,諸如此類(lèi)無(wú)限的加載。這意味著我們只用一個(gè)參數(shù)來(lái)實(shí)現(xiàn)分頁(yè),控制在屏幕上顯示帖子的數(shù)量。
現(xiàn)在需要一個(gè)方法告訴服務(wù)器端返回給客戶(hù)端帖子的數(shù)量。這些發(fā)生在路由訂閱帖子
的過(guò)程,我們會(huì)利用路由來(lái)實(shí)現(xiàn)分頁(yè)。
最簡(jiǎn)單的限制返回帖子數(shù)量的方式是將返回?cái)?shù)量加到 URL 中,如 http://localhost:3000/25
。使用 URL 記錄數(shù)量的另一個(gè)好處是,如果不小心刷新了頁(yè)面,還會(huì)返回 25 條帖子。
為了恰當(dāng)?shù)膶?shí)現(xiàn)分頁(yè),我們需要修改帖子的訂閱方法。就像我們之前在評(píng)論那章做的,我們需要將訂閱部分的代碼從 router 級(jí)變?yōu)?route 級(jí)。
這個(gè)改變內(nèi)容會(huì)比較多,通過(guò)代碼可以看的比較清楚。
首先,停止 Router.configure()
代碼塊中的 posts
訂閱。即刪除 Meteor.subscribe('posts')
,只留下 notifications
訂閱:
Router.configure({
layoutTemplate: 'layout',
loadingTemplate: 'loading',
notFoundTemplate: 'notFound',
waitOn: function() {
return [Meteor.subscribe('notifications')]
}
});
我們?cè)诼酚陕窂街屑尤雲(yún)?shù) postsLimt
。 參數(shù)后面的 ?
表示參數(shù)是可選的。這樣路由就能同時(shí)匹配 http://localhost:3000/50
和 http://localhost:3000
。
//...
Router.route('/:postsLimit?', {
name: 'postsList',
});
//...
需要注意每個(gè)路徑都會(huì)匹配路由 /:parameter?
。因?yàn)槊總€(gè)路由都會(huì)被檢查是否匹配當(dāng)前路徑。我們要組織好路由來(lái)減少特異性。
話(huà)句話(huà)說(shuō),更特殊的路由會(huì)優(yōu)先選擇,例如:路由 /posts/:_id
會(huì)在前面,而路由 postsList
會(huì)放到路由組的最后,因?yàn)樗悍毫丝梢云ヅ渌新窂健?/p>
是時(shí)候處理難題了,處理訂閱和找到正確的數(shù)據(jù)。我么需要處理 postsLimit
參數(shù)不存在的情況。我們給它一個(gè)默認(rèn)值 5, 這樣我們能更好的演示分頁(yè)。
//...
Router.route('/:postsLimit?', {
name: 'postsList',
waitOn: function() {
var limit = parseInt(this.params.postsLimit) || 5;
return Meteor.subscribe('posts', {sort: {submitted: -1}, limit: limit});
}
});
//...
你注意到我們?cè)谟嗛?posts
時(shí)傳了一個(gè) js 對(duì)象 ({sort: {submitted: -1}, limit: postsLimit}), 這個(gè) js 對(duì)象會(huì)作為服務(wù)器端查詢(xún)方法 Posts.find()
的可選參數(shù)。下面是服務(wù)器端的實(shí)現(xiàn)代碼:
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});
});
我們的訂閱代碼告訴服務(wù)器端,我們信任客戶(hù)端傳來(lái)的 JavaScript 對(duì)象 (在我們的例子中是 {limit: postsLimit}
) 作為 find()
方法的 options
參數(shù)。這樣我們能通過(guò) browser consle 來(lái)傳任何 option 對(duì)象。
在我們的例子中,這樣沒(méi)什么害處,因?yàn)橛脩?hù)可以做的無(wú)非是改變帖子順序,或者修改 limit 值(這是我們想讓用戶(hù)做的)。但是對(duì)于一個(gè) real-world app 我們必須做必要的限制!
幸好通過(guò) check()
方法我們知道用戶(hù)不能偷偷加入額外的 options (例如 fields
, 在某些情況下需要對(duì)外暴露 ducoments 的私有數(shù)據(jù))。
然而,更安全的做法是傳遞單個(gè)參數(shù)而不是整個(gè)對(duì)象,通過(guò)這樣確保數(shù)據(jù)安全:
Meteor.publish('posts', function(sort, limit) {
return Posts.find({}, {sort: sort, limit: limit});
});
現(xiàn)在我們?cè)?route 級(jí)訂閱數(shù)據(jù),同樣的我們可以在這里設(shè)置數(shù)據(jù)的 context。我們要偏離一下之前的模式,我們讓 data
函數(shù)返回一個(gè) js 對(duì)象而不是一個(gè) cursor。 這樣我們可以創(chuàng)建一個(gè)命名的數(shù)據(jù) context。我們稱(chēng)之為 posts
。
這意味著我們的數(shù)據(jù) context 將存在于 posts
中,而不是簡(jiǎn)單的在模板中隱式的存在于 this
中。除去這一點(diǎn),代碼看起來(lái)很相似:
//...
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})
};
}
});
//...
因?yàn)槲覀冊(cè)?route 級(jí)設(shè)置數(shù)據(jù) context, 現(xiàn)在我們可以去掉在 posts_list.js
文件中 posts
模板的幫助方法。
我們的數(shù)據(jù) context 叫做 posts
(和 helper 同名),所以我們甚至不需要修改 postsList
模板!
下面是我們修改過(guò)的 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'});
試一下我們的分頁(yè)?,F(xiàn)在我們可以通過(guò) URL 參數(shù)來(lái)控制頁(yè)面顯示帖子的數(shù)量,試一下 http://localhost:3000/3
。你可以看到如下:
為什么我們使用“無(wú)限分頁(yè)”而不用每頁(yè)顯示 10 條帖子的連續(xù)分頁(yè),就像 Google 的搜索結(jié)果分頁(yè)一樣?這是由于 Meteor 的實(shí)時(shí)性決定的。
讓我們想象一下使用類(lèi)似 Google 搜索結(jié)果的連續(xù)分頁(yè)模式,我們?cè)诘?頁(yè),顯示的是 10 到 20 條帖子。這是碰巧有另外一個(gè)用戶(hù)刪除了前面 10 條帖子中的帖子。
因?yàn)?app 是實(shí)時(shí)的,我們的數(shù)據(jù)集會(huì)馬上變化,這樣第 10 條帖子變成了第 9 條,從當(dāng)前頁(yè)面消失了,第 21 條帖子會(huì)出現(xiàn)在頁(yè)面中。這樣用戶(hù)會(huì)覺(jué)得沒(méi)什么原由的結(jié)果集變了!
即使我們可以容忍這種怪異的 UX, 由于技術(shù)的原因傳統(tǒng)的分頁(yè)還是很難實(shí)現(xiàn)。
讓我們回到前一個(gè)例子。我們從 Posts
集合中發(fā)布第 10 到 20 條帖子,但是在客戶(hù)端我們?nèi)绾握业竭@些帖子?我們不能在客戶(hù)端選擇第 10 到 20 條帖子,因?yàn)榭蛻?hù)端集合只有 10 個(gè)帖子。
一個(gè)簡(jiǎn)單的方案是服務(wù)器端發(fā)布 10 條帖子,在客戶(hù)端執(zhí)行一下 Posts.find()
找到這 10 條發(fā)布的帖子。
這個(gè)方案在只有一個(gè)用戶(hù)訂閱的情況下有效,但是如果有多個(gè)用戶(hù)訂閱呢,下面我們會(huì)看到。
我們假設(shè)一個(gè)用戶(hù)需要第 10 到 20 條帖子,而另一個(gè)需要第 30 到 40。這樣在客戶(hù)端我們有兩個(gè) 20 條帖子,我們不能區(qū)分他們屬于哪個(gè)訂閱。
基于這些原因,我們?cè)?Meteor 中不能使用傳統(tǒng)的分頁(yè)。
你可能已經(jīng)注意到了我們代碼中重復(fù)了 var limit = parseInt(this.params.postsLimit) || 5;
兩次。而且硬編碼數(shù)字 5,這不是個(gè)理想的做法。雖然這不會(huì)導(dǎo)致世界末日,但是我們最好還是遵循 DRY 原則 (Don't Repeat Yourself), 讓我們看看如何能把代碼重構(gòu)的更好些。
我們將介紹 Iron Router 的一個(gè)新功能, Route Controllers。Route controller 是通過(guò)簡(jiǎn)單的方式將一組路由特性打包,其他的 route 可以繼承他們?,F(xiàn)在我們只在一個(gè)路由中使用它,在下一章我們會(huì)看到它如何派上用場(chǎng)。
//...
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'
});
//...
讓我們一步接一步的往下看。首先,我們的創(chuàng)建一個(gè)繼承 RouteController
的控制器。然后像之前一樣設(shè)置 template
屬性,然后添加一個(gè)新的 increment
屬性。
然后我們定義一個(gè) postsLimit
函數(shù)用來(lái)返回當(dāng)前限制的數(shù)量,然后定義一個(gè) findOptions
函數(shù)用來(lái)返回 options 對(duì)象。這看起來(lái)像是個(gè)對(duì)于的步驟,但是我們后面會(huì)用到它。
接下來(lái)我們定義 waitOn
和 data
函數(shù),除了他們現(xiàn)在會(huì)用到新的 findOptions
函數(shù)外其余和之前相同。
因?yàn)槲覀兊目刂破鹘凶?PostsListController
路由叫做 postsList
, Iron Router 會(huì)自動(dòng)使用他們。因此我們只需要從路由定義中移除 waitOn
和 data
(因?yàn)槁酚梢呀?jīng)會(huì)處理他們了)。如果我們需要給路由起別的名字,我們可以使用 controller
選項(xiàng)(我們將在下一章看到一個(gè)例子)。
我們現(xiàn)在實(shí)現(xiàn)了分頁(yè),代碼看起來(lái)還不錯(cuò)。只有一個(gè)問(wèn)題:我們的分頁(yè)需要手工修改 URL。這顯然不是一個(gè)好的用戶(hù)體驗(yàn),現(xiàn)在讓我們來(lái)修改它。
我們要做的很簡(jiǎn)單。我們將在帖子列表的下面加一個(gè) "load more" 按鈕,點(diǎn)擊按鈕將增加 5 條帖子。如果當(dāng)前的 URL 是 http://localhost:3000/5
, 點(diǎn)擊 "load more" 按鈕 URL 將變成 http://localhost:3000/10
。如果你之前已經(jīng)實(shí)現(xiàn)過(guò)這種功能,我們相信你很強(qiáng)!
因?yàn)樵谇懊妫覀兊姆猪?yè)邏輯是在 route 中。記得我們是什么時(shí)候顯式命名數(shù)據(jù)上下文,而非使用匿名 cursor 的么? 沒(méi)有規(guī)則說(shuō)我們的 data
函數(shù)只能使用 cursors, 因此,我們將用同樣的技巧來(lái)生成 "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
};
}
});
//...
讓我們來(lái)深入的看一下 router 帶來(lái)的魔術(shù)。記住 postsList
route (它將繼承 PostsListController
控制器) 使用一個(gè) postsLimit
參數(shù)。
因此當(dāng)我們給 this.route.path()
傳遞參數(shù) {postsLimit: this.postsLimit() + this.increment}
時(shí),我們告訴 postsList
route 使用這個(gè) js 對(duì)象做數(shù)據(jù)上線(xiàn)文建立自己的 path。
換句話(huà)說(shuō),這和使用 {{pathFor 'postsList'}}
Spacebars 幫助方法一樣, 除了我們用自己的數(shù)據(jù)上下文替換了隱式的 this
。
我們使用這個(gè)路徑并將它添加到我們模板的數(shù)據(jù)上下文中,但是只有多條帖子時(shí)會(huì)顯示。我們的實(shí)現(xiàn)方法有一點(diǎn)小花招。
我們知道 this.limit()
方法會(huì)返回當(dāng)前我們想要顯示帖子的數(shù)量,它可能是當(dāng)前 URL 中的值,如果 URL 中沒(méi)有參數(shù)它會(huì)是默認(rèn)值 (5)。
另一方面, this.posts
引用當(dāng)前的 cursor, 因此 this.posts.count()
的值是在 cursor 中帖子的數(shù)量。
因此我們說(shuō)當(dāng)我們要求發(fā)揮 n
條帖子,實(shí)際返回了 n
條帖子,我們將繼續(xù)顯示 "load more" 按鈕。但是如果我們要求返回 n
條帖子,而實(shí)際返回的數(shù)量比 n
少,這樣我們就知道記錄已經(jīng)到頭了,我們就不再顯示加載按鈕。
這就是說(shuō),我們的系統(tǒng)在一種情況下會(huì)有點(diǎn)問(wèn)題:當(dāng)我們的數(shù)據(jù)庫(kù)恰好有 n
條記錄時(shí)。如果是這樣,當(dāng)客戶(hù)端要求返回 n
條帖子,我們得到了 n
條,然后繼續(xù)顯示 "load more" 按鈕,這是我們不知道其實(shí)已經(jīng)沒(méi)有記錄可以繼續(xù)返回了。
不幸的是,我們沒(méi)有好的方法去解決這個(gè)問(wèn)題,因此我們不得不接受這個(gè)不算完美的實(shí)現(xiàn)方式。
下面剩下的就是在帖子列表下面加上 "load more" 鏈接,并且保證在還有帖子時(shí)才顯示它:
<template name="postsList">
<div class="posts">
{{#each posts}}
{{> postItem}}
{{/each}}
{{#if nextPath}}
<a class="load-more" href="{{nextPath}}">Load more</a>
{{/if}}
</div>
</template>
下面是你帖子列表現(xiàn)在看上去的樣子:
現(xiàn)在我們的分頁(yè)可以工作了,但是有個(gè)煩人小問(wèn)題: 每次我們點(diǎn)擊 "load more" 按鈕向 router 加載更多的帖子時(shí),Iron Router 的 waitOn
特性會(huì)在我們等待時(shí)顯示 loading
模板。當(dāng)結(jié)果到來(lái)時(shí)我們又會(huì)回到頁(yè)面的頂端,我們每次都要滾動(dòng)頁(yè)面回到之前看的位置。
因此,首先我們要告訴 Iron Router 不要 waintOn
訂閱,我們將定義自己的訂閱在一個(gè) subscriptions
hook 中。
注意我們我們不是在 hook 中返回這個(gè)訂閱。返回它(這是一般 訂閱
hook 常做的工作)將觸發(fā)一個(gè)全局的 loading hook, 這正是我們想要避免的。我們只是想在 subscriptions
hook 中定義我們的訂閱,就像使用一個(gè) onBeforeAction
hook。
我們還要在我們的數(shù)據(jù)上下文中傳入一個(gè) ready
變量,它指向 this.postsSub.ready
。它會(huì)告訴我們帖子訂閱何時(shí)加載完畢。
//...
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
變量的狀態(tài),并在加載帖子時(shí)在帖子列表的下面顯示一個(gè)加載圖標(biāo)(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>
現(xiàn)在我們默認(rèn)每次加載 5 條新帖子,但是當(dāng)用戶(hù)訪(fǎng)問(wèn)某個(gè)帖子的單獨(dú)頁(yè)面時(shí)會(huì)發(fā)生什么?
試一下,我們會(huì)得到一個(gè) "not found" 錯(cuò)誤。這是有原因的: 我們告訴 router 當(dāng)我們加載 postList
route 時(shí)訂閱 帖子
發(fā)布。但是我們沒(méi)有說(shuō)訪(fǎng)問(wèn) postPage
route 時(shí)該做什么。
但是到目前,我們知道如何訂閱一個(gè) n
個(gè)最新帖子的列表。我們?nèi)绾蜗蚍?wù)器端要求單個(gè)具體帖子的內(nèi)容? 我們將告訴你一個(gè)小秘密: 對(duì)于一個(gè) collection 你可以有多個(gè) publication!
讓我們找回丟失的帖子,我們定義一個(gè)新的 publication singlePost
,它只發(fā)布一個(gè)帖子,用 _id
鑒別。
Meteor.publish('posts', function(options) {
return Posts.find({}, options);
});
Meteor.publish('singlePost', function(id) {
check(id, String)
return Posts.find(id);
});
//...
現(xiàn)在,讓我們?cè)诳蛻?hù)端訂閱正確的帖子。我們已經(jīng)在 postPage
route 的 wainOn
函數(shù)中訂閱了 comments
發(fā)布,因此我們可以也在這里加入 singlePost
訂閱。讓后別忘了在 postEdit
route 中加入我們的訂閱, 因?yàn)槟抢镆残枰嗤臄?shù)據(jù):
//...
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); }
});
//...
有了分頁(yè),我們的程序?qū)⒉辉偈芤?guī)模問(wèn)題的困擾了,用戶(hù)可以加入更多的帖子。如果有某種方法可以給帖子鏈接加上等級(jí) (rank) 不是更好么?我們將在下一章去實(shí)現(xiàn)它!
更多建議: