現(xiàn)在我們的系統(tǒng)更完善了,但是想要找到最受歡迎的帖子有點難。我們需要一個排名系統(tǒng)來給我們的帖子排個序。
我們可以建立一個基于 karma 的復雜排名系統(tǒng),權值隨著時間衰減,和許多其他因素(很多功能都在 Telescope 中實現(xiàn)了,他是 Microscope 的大哥)。但是對于我們的例子 app, 我們盡量保持簡單,我們只按照帖子收到的投票數為它們排序。
讓我們實現(xiàn)一個給用戶為帖子投票的方法。
我們將在帖子中保存投票者列表信息,這樣我們能判斷是否給用戶顯示投票按鈕,并阻止用戶給一個帖子投票兩次。
我們將向所有用戶發(fā)布投票者名單,這樣也自動使得通過瀏覽器控制臺也可以訪問這些數據。
這是一類由于集合工作方式而引發(fā)的數據隱私問題。例如,我們是否希望用戶能看到誰為他的帖子投了票。在我們的例子中,公開這些信息無關緊要,但重要的是至少知道這是個問題。
我們也要非規(guī)范化帖子的投票者數量,以便更容易取得這個數值。所以我們給帖子增加兩個屬性,upvoters
(投票者) 和 votes
(票數)。讓我們先在 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)建一個新的用戶。讓我們確認一下用戶創(chuàng)建帖子時,這兩個新的屬性也被初始化了:
//...
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
};
//...
開始時,我們在帖子部分添加一個點贊(upvote)按鈕,并在帖子的 metadata 數據中顯示被點贊次數:
<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>
接下來,當用戶點擊按鈕時調用服務器端的 upvote 方法:
//...
Template.postItem.events({
'click .upvote': function(e) {
e.preventDefault();
Meteor.call('upvote', this._id);
}
});
最后,我們回到 lib/collections/posts.js
文件,在其中加入一個服務器端方法來 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}
});
}
});
//...
這個方法很清楚。我們做了些檢查確保當前用戶已經登錄和帖子存在。然后檢查用戶并沒有給帖子投過票,檢查如果用戶沒有增加過帖子的投票分數我們將用戶添加到 upvoters 集合中。
最后一步我們使用了一些 Mongo 操作符。有很多操作符需要學習,但是這兩個尤其有用: $addToSet
將一個 item 加入集合如果它不存在的話,$inc
只是簡單的增加一個整型屬性。
如果用戶沒有登錄或者已經投過票了,他就不能再投票了。我們需要修改 UI, 我們將用一個幫助方法根據條件添加一個 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 事件處理函數。
接下來,你會發(fā)現(xiàn)被投過一票的帖子會顯示 "1 votes", 下面讓我們花點時間來處理單復數形式。處理單復數是個復雜的事,但在這里我們會用一個非常簡單的方法。我們建一個通用的 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 方法都是綁定到某個模板的。但是現(xiàn)在我們用 Template.registerHelper
創(chuàng)建一個全局的 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 方法,我們兩次調用 Mongo: 第一次找到帖子,第二次更新它。
這里有兩個問題。首先,兩次調用數據庫效率會有點低。但是更重要的是,這里引入了一個競速狀態(tài)。我們的邏輯是這樣的:
如果同一個用戶在步驟 1 和 3 之間兩次投票會如何?我們現(xiàn)在的代碼會讓用戶給同一個帖子投票兩次。幸好,Mongo 允許我們將步驟 1-3 合成一個 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
是這個并且用戶沒有投票的帖子,并更新他們?yōu)橥镀薄?。如果用戶還沒有投票,就會找到這個 id
的帖子。如果用戶已經投過票了,就不會有結果返回。
假定你想作弊通過修改帖子投票數量來讓一個帖子排到榜單的第一名:
> Posts.update(postId, {$set: {votes: 10000}});
(postId
是你某個帖子的 id)
這個無恥的企圖將會被我們系統(tǒng)的 deny()
回調函數捕獲(collections/posts.js
記得么?)并且立刻取消。
但是如果你仔細看,你可能會發(fā)現(xiàn)系統(tǒng)的延遲補償 (latency compensation)。它可能一閃而過, 會看到帖子現(xiàn)在第一位閃了一下,然后回到原來的位置。
發(fā)生了什么? 在客戶端的 Posts
集合,update
方法會被執(zhí)行。這會立刻發(fā)生,因此帖子會來到列表第一的位置。同時,在服務器端 update
方法會被拒絕。過了一會 (如果你在本地運行 Meteor 這個時間間隔會是毫秒級的), 服務器端返回一個錯誤,告訴客戶端 Posts
集合恢復到原來狀態(tài)。
最終的結果是: 在等待服務器端返回的過程中,UI 只能相信客戶端本地集合數據。當服務器端一返回拒絕了修改,UI 就會使用服務器端數據。
現(xiàn)在每個帖子都有一個基于投票數的分數,讓我們顯示一個最佳帖子的列表。這樣,我們將看到如何管理對于帖子集合的兩個不同的訂閱,并將我們的 postsList
模板變得更通用一些。
首先,我們需要兩個訂閱,分別用來排序。這里的技巧是兩個訂閱同時訂閱同一個 posts
發(fā)布,只是參數不同!
我們還需要新建兩個路由 newPosts
和 bestPosts
,分別通過 URL /new
和 /best
訪問(當然,使用 /new/5
和 /best/5
進行分頁)。
我們將繼承 PostsListController
來生成兩個獨立的 NewPostsListController
和 BestPostsListController
控制器。對于 home
和 newPosts
路由,我們可以使用完全一致的路由選項,通過繼承同一個 NewPostsListController
控制器。另外,這是一個很好的例子說明 Iron Router 的靈活性。
讓我們用 NewPostsListController
和 BestPostsListController
提供的 this.sort
替換 PostsListController
的排序屬性 {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)在我們有多個路由,我們將 nextPath
邏輯從 PostsListController
移到 NewPostsController
和 BestPostsController
, 因為兩個控制器的 path 都不相同。
另外,當我們根據投票數排序時,然后根據發(fā)布時間戳和 _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 事件處理函數:
'click .delete': function(e) {
e.preventDefault();
if (confirm("Delete this post?")) {
var currentPostId = this._id;
Posts.remove(currentPostId);
Router.go('home');
}
}
這些都做完了,現(xiàn)在我們得到了一個最佳帖子列表:
現(xiàn)在我們有兩個帖子列表頁面,你很難分清你正在看的是哪個列表。現(xiàn)在讓我們把頁面的 header 變得更明顯些。我們將創(chuàng)建一個 header.js
manager 并創(chuàng)建一個 helper 使用當前的路徑和一個或者多個命名路由來給我們的導航條加一個 active class:
支持多個命名路由的原因是 home
和 newPosts
路由 (分別對應 URL /
和 new
) 使用同一個模板。這意味著我們的 activeRouteClass
足夠聰明可以處理以上情形將 <li>
標簽標記為 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)在為止我們沒有使用特殊的設計模式,但是像其他 Spacebars 標簽一樣,模板的 helper 標簽可以帶參數。
你可以給你的函數傳遞命名的參數,你也可以傳入不指定數量的匿名參數并在函數中用 arguments
對象訪問他們。
在最后一種情況,你可能想將 arguments
對象轉換成一個一般的 JavaScript 數組,然后調用 pop()
方法移除末尾的內容。
對于每一個導航鏈接, activeRouteClass
helper 可以帶一組路由名稱,然后使用 Underscore 的 any()
helper 方法檢查哪一個通過測試 (例如: 他們的 URL 等于當前路徑)。
如果路由匹配當前路徑,any()
方法將返回 true
。最后,我們利用 JavaScript 的 boolean && string
模式,當 false && myString
返回 false
, 當 true && myString
返回 myString
。
現(xiàn)在用戶可以給帖子實時投票了,你將看到帖子隨著得票多少上下變化。如果有一些動畫效果不是更好?
更多建議: