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