現(xiàn)在我們的系統(tǒng)更完善了,但是想要找到最受歡迎的帖子有點(diǎn)難。我們需要一個(gè)排名系統(tǒng)來給我們的帖子排個(gè)序。
我們可以建立一個(gè)基于 karma 的復(fù)雜排名系統(tǒng),權(quán)值隨著時(shí)間衰減,和許多其他因素(很多功能都在 Telescope 中實(shí)現(xiàn)了,他是 Microscope 的大哥)。但是對于我們的例子 app, 我們盡量保持簡單,我們只按照帖子收到的投票數(shù)為它們排序。
讓我們實(shí)現(xiàn)一個(gè)給用戶為帖子投票的方法。
我們將在帖子中保存投票者列表信息,這樣我們能判斷是否給用戶顯示投票按鈕,并阻止用戶給一個(gè)帖子投票兩次。
我們將向所有用戶發(fā)布投票者名單,這樣也自動(dòng)使得通過瀏覽器控制臺也可以訪問這些數(shù)據(jù)。
這是一類由于集合工作方式而引發(fā)的數(shù)據(jù)隱私問題。例如,我們是否希望用戶能看到誰為他的帖子投了票。在我們的例子中,公開這些信息無關(guān)緊要,但重要的是至少知道這是個(gè)問題。
我們也要非規(guī)范化帖子的投票者數(shù)量,以便更容易取得這個(gè)數(shù)值。所以我們給帖子增加兩個(gè)屬性,upvoters
(投票者) 和 votes
(票數(shù))。讓我們先在 fixtures 文件中添加它們:
// Fixture data
if (Posts.find().count() === 0) {
var now = new Date().getTime();
// create two users
var tomId = Meteor.users.insert({
profile: { name: 'Tom Coleman' }
});
var tom = Meteor.users.findOne(tomId);
var sachaId = Meteor.users.insert({
profile: { name: 'Sacha Greif' }
});
var sacha = Meteor.users.findOne(sachaId);
var telescopeId = Posts.insert({
title: 'Introducing Telescope',
userId: sacha._id,
author: sacha.profile.name,
url: 'http://sachagreif.com/introducing-telescope/',
submitted: new Date(now - 7 * 3600 * 1000),
commentsCount: 2,
upvoters: [],
votes: 0
});
Comments.insert({
postId: telescopeId,
userId: tom._id,
author: tom.profile.name,
submitted: new Date(now - 5 * 3600 * 1000),
body: 'Interesting project Sacha, can I get involved?'
});
Comments.insert({
postId: telescopeId,
userId: sacha._id,
author: sacha.profile.name,
submitted: new Date(now - 3 * 3600 * 1000),
body: 'You sure can Tom!'
});
Posts.insert({
title: 'Meteor',
userId: tom._id,
author: tom.profile.name,
url: 'http://meteor.com',
submitted: new Date(now - 10 * 3600 * 1000),
commentsCount: 0,
upvoters: [],
votes: 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,
upvoters: [],
votes: 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 + 1),
commentsCount: 0,
upvoters: [],
votes: 0
});
}
}
和之前一樣,停止你的 app, 執(zhí)行 meteor reset
, 重啟 app,創(chuàng)建一個(gè)新的用戶。讓我們確認(rèn)一下用戶創(chuàng)建帖子時(shí),這兩個(gè)新的屬性也被初始化了:
//...
var postWithSameLink = Posts.findOne({url: postAttributes.url});
if (postWithSameLink) {
return {
postExists: true,
_id: postWithSameLink._id
}
}
var user = Meteor.user();
var post = _.extend(postAttributes, {
userId: user._id,
author: user.username,
submitted: new Date(),
commentsCount: 0,
upvoters: [],
votes: 0
});
var postId = Posts.insert(post);
return {
_id: postId
};
//...
開始時(shí),我們在帖子部分添加一個(gè)點(diǎn)贊(upvote)按鈕,并在帖子的 metadata 數(shù)據(jù)中顯示被點(diǎn)贊次數(shù):
<template name="postItem">
<div class="post">
<a href="#" class="upvote btn btn-default">?</a>
<div class="post-content">
<h3><a href="{{url}}">{{title}}</a><span>{{domain}}</span></h3>
<p>
{{votes}} Votes,
submitted by {{author}},
<a href="{{pathFor 'postPage'}}">{{commentsCount}} comments</a>
{{#if ownPost}}<a href="{{pathFor 'postEdit'}}">Edit</a>{{/if}}
</p>
</div>
<a href="{{pathFor 'postPage'}}" class="discuss btn btn-default">Discuss</a>
</div>
</template>
接下來,當(dāng)用戶點(diǎn)擊按鈕時(shí)調(diào)用服務(wù)器端的 upvote 方法:
//...
Template.postItem.events({
'click .upvote': function(e) {
e.preventDefault();
Meteor.call('upvote', this._id);
}
});
最后,我們回到 lib/collections/posts.js
文件,在其中加入一個(gè)服務(wù)器端方法來 upvote 帖子:
//...
Meteor.methods({
post: function(postAttributes) {
//...
},
upvote: function(postId) {
check(this.userId, String);
check(postId, String);
var post = Posts.findOne(postId);
if (!post)
throw new Meteor.Error('invalid', 'Post not found');
if (_.include(post.upvoters, this.userId))
throw new Meteor.Error('invalid', 'Already upvoted this post');
Posts.update(post._id, {
$addToSet: {upvoters: this.userId},
$inc: {votes: 1}
});
}
});
//...
這個(gè)方法很清楚。我們做了些檢查確保當(dāng)前用戶已經(jīng)登錄和帖子存在。然后檢查用戶并沒有給帖子投過票,檢查如果用戶沒有增加過帖子的投票分?jǐn)?shù)我們將用戶添加到 upvoters 集合中。
最后一步我們使用了一些 Mongo 操作符。有很多操作符需要學(xué)習(xí),但是這兩個(gè)尤其有用: $addToSet
將一個(gè) item 加入集合如果它不存在的話,$inc
只是簡單的增加一個(gè)整型屬性。
如果用戶沒有登錄或者已經(jīng)投過票了,他就不能再投票了。我們需要修改 UI, 我們將用一個(gè)幫助方法根據(jù)條件添加一個(gè) disabled
CSS class 到 upvote 按鈕。
<template name="postItem">
<div class="post">
<a href="#" class="upvote btn btn-default {{upvotedClass}}">?</a>
<div class="post-content">
//...
</div>
</template>
Template.postItem.helpers({
ownPost: function() {
//...
},
domain: function() {
//...
},
upvotedClass: function() {
var userId = Meteor.userId();
if (userId && !_.include(this.upvoters, userId)) {
return 'btn-primary upvotable';
} else {
return 'disabled';
}
}
});
Template.postItem.events({
'click .upvotable': function(e) {
e.preventDefault();
Meteor.call('upvote', this._id);
}
});
我們將 css class 從 .upvote
變成 .upvotable
,別忘了修改 click 事件處理函數(shù)。
接下來,你會發(fā)現(xiàn)被投過一票的帖子會顯示 "1 votes", 下面讓我們花點(diǎn)時(shí)間來處理單復(fù)數(shù)形式。處理單復(fù)數(shù)是個(gè)復(fù)雜的事,但在這里我們會用一個(gè)非常簡單的方法。我們建一個(gè)通用的 Spacebars helper 方法來處理他們:
UI.registerHelper('pluralize', function(n, thing) {
// fairly stupid pluralizer
if (n === 1) {
return '1 ' + thing;
} else {
return n + ' ' + thing + 's';
}
});
之前我們創(chuàng)建的 helper 方法都是綁定到某個(gè)模板的。但是現(xiàn)在我們用 Template.registerHelper
創(chuàng)建一個(gè)全局的 helper 方法,我們可以在任何模板中使用它:
<template name="postItem">
//...
<p>
{{pluralize votes "Vote"}},
submitted by {{author}},
<a href="{{pathFor 'postPage'}}">{{pluralize commentsCount "comment"}}</a>
{{#if ownPost}}<a href="{{pathFor 'postEdit'}}">Edit</a>{{/if}}
</p>
//...
</template>
現(xiàn)在我們看到的是 "1 vote"。
我們的投票代碼看起來還行,但是我們能做的更好。在 upvote 方法,我們兩次調(diào)用 Mongo: 第一次找到帖子,第二次更新它。
這里有兩個(gè)問題。首先,兩次調(diào)用數(shù)據(jù)庫效率會有點(diǎn)低。但是更重要的是,這里引入了一個(gè)競速狀態(tài)。我們的邏輯是這樣的:
如果同一個(gè)用戶在步驟 1 和 3 之間兩次投票會如何?我們現(xiàn)在的代碼會讓用戶給同一個(gè)帖子投票兩次。幸好,Mongo 允許我們將步驟 1-3 合成一個(gè) Mongo 命令:
//...
Meteor.methods({
post: function(postAttributes) {
//...
},
upvote: function(postId) {
check(this.userId, String);
check(postId, String);
var affected = Posts.update({
_id: postId,
upvoters: {$ne: this.userId}
}, {
$addToSet: {upvoters: this.userId},
$inc: {votes: 1}
});
if (! affected)
throw new Meteor.Error('invalid', "You weren't able to upvote that post");
}
});
//...
我們的代碼是說“找到 id
是這個(gè)并且用戶沒有投票的帖子,并更新他們?yōu)橥镀薄薄H绻脩暨€沒有投票,就會找到這個(gè) id
的帖子。如果用戶已經(jīng)投過票了,就不會有結(jié)果返回。
假定你想作弊通過修改帖子投票數(shù)量來讓一個(gè)帖子排到榜單的第一名:
> Posts.update(postId, {$set: {votes: 10000}});
(postId
是你某個(gè)帖子的 id)
這個(gè)無恥的企圖將會被我們系統(tǒng)的 deny()
回調(diào)函數(shù)捕獲(collections/posts.js
記得么?)并且立刻取消。
但是如果你仔細(xì)看,你可能會發(fā)現(xiàn)系統(tǒng)的延遲補(bǔ)償 (latency compensation)。它可能一閃而過, 會看到帖子現(xiàn)在第一位閃了一下,然后回到原來的位置。
發(fā)生了什么? 在客戶端的 Posts
集合,update
方法會被執(zhí)行。這會立刻發(fā)生,因此帖子會來到列表第一的位置。同時(shí),在服務(wù)器端 update
方法會被拒絕。過了一會 (如果你在本地運(yùn)行 Meteor 這個(gè)時(shí)間間隔會是毫秒級的), 服務(wù)器端返回一個(gè)錯(cuò)誤,告訴客戶端 Posts
集合恢復(fù)到原來狀態(tài)。
最終的結(jié)果是: 在等待服務(wù)器端返回的過程中,UI 只能相信客戶端本地集合數(shù)據(jù)。當(dāng)服務(wù)器端一返回拒絕了修改,UI 就會使用服務(wù)器端數(shù)據(jù)。
現(xiàn)在每個(gè)帖子都有一個(gè)基于投票數(shù)的分?jǐn)?shù),讓我們顯示一個(gè)最佳帖子的列表。這樣,我們將看到如何管理對于帖子集合的兩個(gè)不同的訂閱,并將我們的 postsList
模板變得更通用一些。
首先,我們需要兩個(gè)訂閱,分別用來排序。這里的技巧是兩個(gè)訂閱同時(shí)訂閱同一個(gè) posts
發(fā)布,只是參數(shù)不同!
我們還需要新建兩個(gè)路由 newPosts
和 bestPosts
,分別通過 URL /new
和 /best
訪問(當(dāng)然,使用 /new/5
和 /best/5
進(jìn)行分頁)。
我們將繼承 PostsListController
來生成兩個(gè)獨(dú)立的 NewPostsListController
和 BestPostsListController
控制器。對于 home
和 newPosts
路由,我們可以使用完全一致的路由選項(xiàng),通過繼承同一個(gè) NewPostsListController
控制器。另外,這是一個(gè)很好的例子說明 Iron Router 的靈活性。
讓我們用 NewPostsListController
和 BestPostsListController
提供的 this.sort
替換 PostsListController
的排序?qū)傩?{submitted: -1}
:
//...
PostsListController = RouteController.extend({
template: 'postsList',
increment: 5,
postsLimit: function() {
return parseInt(this.params.postsLimit) || this.increment;
},
findOptions: function() {
return {sort: this.sort, 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();
return {
posts: this.posts(),
ready: this.postsSub.ready,
nextPath: hasMore ? this.nextPath() : null
};
}
});
NewPostsController = PostsListController.extend({
sort: {submitted: -1, _id: -1},
nextPath: function() {
return Router.routes.newPosts.path({postsLimit: this.postsLimit() + this.increment})
}
});
BestPostsController = PostsListController.extend({
sort: {votes: -1, submitted: -1, _id: -1},
nextPath: function() {
return Router.routes.bestPosts.path({postsLimit: this.postsLimit() + this.increment})
}
});
Router.route('/', {
name: 'home',
controller: NewPostsController
});
Router.route('/new/:postsLimit?', {name: 'newPosts'});
Router.route('/best/:postsLimit?', {name: 'bestPosts'});
注意現(xiàn)在我們有多個(gè)路由,我們將 nextPath
邏輯從 PostsListController
移到 NewPostsController
和 BestPostsController
, 因?yàn)閮蓚€(gè)控制器的 path 都不相同。
另外,當(dāng)我們根據(jù)投票數(shù)排序時(shí),然后根據(jù)發(fā)布時(shí)間戳和 _id
確保順序。
有了新的控制器,我們可以安全的刪除之前的 postList
路由。刪除下面的代碼:
Router.route('/:postsLimit?', {
name: 'postsList'
})
在 header 中加入鏈接:
<template name="header">
<nav class="navbar navbar-default" role="navigation">
<div class="container-fluid">
<div class="navbar-header">
<button type="button" class="navbar-toggle collapsed" data-toggle="collapse" data-target="#navigation">
<span class="sr-only">Toggle navigation</span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
</button>
<a class="navbar-brand" href="{{pathFor 'home'}}">Microscope</a>
</div>
<div class="collapse navbar-collapse" id="navigation">
<ul class="nav navbar-nav">
<li>
<a href="{{pathFor 'newPosts'}}">New</a>
</li>
<li>
<a href="{{pathFor 'bestPosts'}}">Best</a>
</li>
{{#if currentUser}}
<li>
<a href="{{pathFor 'postSubmit'}}">Submit Post</a>
</li>
<li class="dropdown">
{{> notifications}}
</li>
{{/if}}
</ul>
<ul class="nav navbar-nav navbar-right">
{{> loginButtons}}
</ul>
</div>
</div>
</nav>
</template>
最后,我們還需要更新帖子的刪除 deleting 事件處理函數(shù):
'click .delete': function(e) {
e.preventDefault();
if (confirm("Delete this post?")) {
var currentPostId = this._id;
Posts.remove(currentPostId);
Router.go('home');
}
}
這些都做完了,現(xiàn)在我們得到了一個(gè)最佳帖子列表:
現(xiàn)在我們有兩個(gè)帖子列表頁面,你很難分清你正在看的是哪個(gè)列表?,F(xiàn)在讓我們把頁面的 header 變得更明顯些。我們將創(chuàng)建一個(gè) header.js
manager 并創(chuàng)建一個(gè) helper 使用當(dāng)前的路徑和一個(gè)或者多個(gè)命名路由來給我們的導(dǎo)航條加一個(gè) active class:
支持多個(gè)命名路由的原因是 home
和 newPosts
路由 (分別對應(yīng) URL /
和 new
) 使用同一個(gè)模板。這意味著我們的 activeRouteClass
足夠聰明可以處理以上情形將 <li>
標(biāo)簽標(biāo)記為 active。
<template name="header">
<nav class="navbar navbar-default" role="navigation">
<div class="container-fluid">
<div class="navbar-header">
<button type="button" class="navbar-toggle collapsed" data-toggle="collapse" data-target="#navigation">
<span class="sr-only">Toggle navigation</span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
</button>
<a class="navbar-brand" href="{{pathFor 'home'}}">Microscope</a>
</div>
<div class="collapse navbar-collapse" id="navigation">
<ul class="nav navbar-nav">
<li class="{{activeRouteClass 'home' 'newPosts'}}">
<a href="{{pathFor 'newPosts'}}">New</a>
</li>
<li class="{{activeRouteClass 'bestPosts'}}">
<a href="{{pathFor 'bestPosts'}}">Best</a>
</li>
{{#if currentUser}}
<li class="{{activeRouteClass 'postSubmit'}}">
<a href="{{pathFor 'postSubmit'}}">Submit Post</a>
</li>
<li class="dropdown">
{{> notifications}}
</li>
{{/if}}
</ul>
<ul class="nav navbar-nav navbar-right">
{{> loginButtons}}
</ul>
</div>
</div>
</nav>
</template>
Template.header.helpers({
activeRouteClass: function(/* route names */) {
var args = Array.prototype.slice.call(arguments, 0);
args.pop();
var active = _.any(args, function(name) {
return Router.current() && Router.current().route.getName() === name
});
return active && 'active';
}
});
到現(xiàn)在為止我們沒有使用特殊的設(shè)計(jì)模式,但是像其他 Spacebars 標(biāo)簽一樣,模板的 helper 標(biāo)簽可以帶參數(shù)。
你可以給你的函數(shù)傳遞命名的參數(shù),你也可以傳入不指定數(shù)量的匿名參數(shù)并在函數(shù)中用 arguments
對象訪問他們。
在最后一種情況,你可能想將 arguments
對象轉(zhuǎn)換成一個(gè)一般的 JavaScript 數(shù)組,然后調(diào)用 pop()
方法移除末尾的內(nèi)容。
對于每一個(gè)導(dǎo)航鏈接, activeRouteClass
helper 可以帶一組路由名稱,然后使用 Underscore 的 any()
helper 方法檢查哪一個(gè)通過測試 (例如: 他們的 URL 等于當(dāng)前路徑)。
如果路由匹配當(dāng)前路徑,any()
方法將返回 true
。最后,我們利用 JavaScript 的 boolean && string
模式,當(dāng) false && myString
返回 false
, 當(dāng) true && myString
返回 myString
。
現(xiàn)在用戶可以給帖子實(shí)時(shí)投票了,你將看到帖子隨著得票多少上下變化。如果有一些動(dòng)畫效果不是更好?
更多建議: