---
title: 投票
slug: voting
date: 0013/01/01
number: 13
points: 10
photoUrl: http://www.flickr.com/photos/ikewinski/8561920811/
photoAuthor: Mike Lewinski
contents: 完善系統讓用戶可以為帖子投票。|在“最佳”帖子排名頁面將帖子按投票排序。|學習開發一個通用的 Spacebars helper。|學習一些關于 Meteor 數據安全的知識。|了解一些關于提高 MongoDB 性能的內容。
paragraphs: 49
---
現在我們的系統更完善了,但是想要找到最受歡迎的帖子有點難。我們需要一個排名系統來給我們的帖子排個序。
我們可以建立一個基于 karma 的復雜排名系統,權值隨著時間衰減,和許多其他因素(很多功能都在 [Telescope](http://telesc.pe) 中實現了,他是 Microscope 的大哥)。但是對于我們的例子 app, 我們盡量保持簡單,我們只按照帖子收到的投票數為它們排序。
讓我們實現一個給用戶為帖子投票的方法。
### 數據模型
我們將在帖子中保存投票者列表信息,這樣我們能判斷是否給用戶顯示投票按鈕,并阻止用戶給一個帖子投票兩次。
<% note do %>
### 數據隱私與發布
我們將向所有用戶發布投票者名單,這樣也自動使得通過瀏覽器控制臺也可以訪問這些數據。
這是一類由于集合工作方式而引發的數據隱私問題。例如,我們是否希望用戶能看到誰為他的帖子投了票。在我們的例子中,公開這些信息無關緊要,但重要的是至少知道這是個問題。
<% end %>
我們也要非規范化帖子的投票者數量,以便更容易取得這個數值。所以我們給帖子增加兩個屬性,`upvoters`(投票者) 和 `votes`(票數)。讓我們先在 fixtures 文件中添加它們:
~~~js
// 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
});
}
}
~~~
<%= caption "server/fixtures.js" %>
<%= highlight "22,23,49,50,60,61,72,73" %>
和之前一樣,停止你的 app, 執行 `meteor reset`, 重啟 app,創建一個新的用戶。讓我們確認一下用戶創建帖子時,這兩個新的屬性也被初始化了:
~~~js
//...
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
};
//...
~~~
<%= caption "collections/posts.js" %>
<%= highlight "17~18" %>
### 投票模板
開始時,我們在帖子部分添加一個點贊(upvote)按鈕,并在帖子的 metadata 數據中顯示被點贊次數:
~~~html
<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>
~~~
<%= caption "client/templates/posts/post_item.html" %>
<%= highlight "3,7" %>
<%= screenshot "13-1", "Upvote 按鈕" %>
接下來,當用戶點擊按鈕時調用服務器端的 upvote 方法:
~~~js
//...
Template.postItem.events({
'click .upvote': function(e) {
e.preventDefault();
Meteor.call('upvote', this._id);
}
});
~~~
<%= caption "client/templates/posts/post_item.js" %>
<%= highlight "3~8" %>
最后,我們回到 `lib/collections/posts.js` 文件,在其中加入一個服務器端方法來 upvote 帖子:
~~~js
//...
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}
});
}
});
//...
~~~
<%= caption "lib/collections/posts.js" %>
<%= highlight "8~25" %>
<%= commit "13-1", "添加基本的投票機制." %>
這個方法很清楚。我們做了些檢查確保當前用戶已經登錄和帖子存在。然后檢查用戶并沒有給帖子投過票,檢查如果用戶沒有增加過帖子的投票分數我們將用戶添加到 upvoters 集合中。
最后一步我們使用了一些 Mongo 操作符。有很多操作符需要學習,但是這兩個尤其有用: `$addToSet` 將一個 item 加入集合如果它不存在的話,`$inc` 只是簡單的增加一個整型屬性。
### 用戶界面微調
如果用戶沒有登錄或者已經投過票了,他就不能再投票了。我們需要修改 UI, 我們將用一個幫助方法根據條件添加一個 `disabled` CSS class 到 upvote 按鈕。
~~~html
<template name="postItem">
<div class="post">
<a href="#" class="upvote btn btn-default {{upvotedClass}}">?</a>
<div class="post-content">
//...
</div>
</template>
~~~
<%= caption "client/templates/posts/post_item.html" %>
<%= highlight "3" %>
~~~js
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);
}
});
~~~
<%= caption "client/templates/posts/post_item.js" %>
<%= highlight "8~15, 19" %>
我們將 css class 從 `.upvote` 變成 `.upvotable`,別忘了修改 click 事件處理函數。
<%= screenshot "13-2", "變灰 upvote 按鈕." %>
<%= commit "13-2", "變灰 upvote 鏈接,當未登錄或已經投票。" %>
接下來,你會發現被投過一票的帖子會顯示 "1 vote**s**", 下面讓我們花點時間來處理單復數形式。處理單復數是個復雜的事,但在這里我們會用一個非常簡單的方法。我們建一個通用的 Spacebars helper 方法來處理他們:
~~~js
UI.registerHelper('pluralize', function(n, thing) {
// fairly stupid pluralizer
if (n === 1) {
return '1 ' + thing;
} else {
return n + ' ' + thing + 's';
}
});
~~~
<%= caption "client/helpers/spacebars.js" %>
之前我們創建的 helper 方法都是綁定到某個模板的。但是現在我們用 `Template.registerHelper` 創建一個*全局*的 helper 方法,我們可以在任何模板中使用它:
~~~html
<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>
~~~
<%= caption "client/templates/posts/post_item.html" %>
<%= highlight "6, 8" %>
<%= screenshot "13-3", "完美復數處理(現在說 10 遍)" %>
<%= commit "13-3", "添加復數 helper 去更好地格式化文字" %>
現在我們看到的是 "1 vote"。
### 更智能的投票機制
我們的投票代碼看起來還行,但是我們能做的更好。在 upvote 方法,我們兩次調用 Mongo: 第一次找到帖子,第二次更新它。
這里有兩個問題。首先,兩次調用數據庫效率會有點低。但是更重要的是,這里引入了一個競速狀態。我們的邏輯是這樣的:
1. 從數據庫中找到帖子。
2. 檢查用戶是否已經投票。
3. 如果沒有,用戶可以投一票。
如果同一個用戶在步驟 1 和 3 之間兩次投票會如何?我們現在的代碼會讓用戶給同一個帖子投票兩次。幸好,Mongo 允許我們將步驟 1-3 合成一個 Mongo 命令:
~~~js
//...
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");
}
});
//...
~~~
<%= caption "collections/posts.js" %>
<%= highlight "12~21" %>
<%= commit "13-4", "更好的投票機制。" %>
我們的代碼是說“找到 `id` 是這個并且用戶沒有投票的帖子,并更新他們為投票”。如果用戶還*沒有*投票,就會找到這個 `id` 的帖子。如果用戶*已經*投過票了,就不會有結果返回。
<% note do %>
### Latency Compensation
假定你想作弊通過修改帖子投票數量來讓一個帖子排到榜單的第一名:
~~~js
> Posts.update(postId, {$set: {votes: 10000}});
~~~
<%= caption "瀏覽器控制臺" %>
(`postId` 是你某個帖子的 id)
這個無恥的企圖將會被我們系統的 `deny()` 回調函數捕獲(`collections/posts.js` 記得么?)并且立刻取消。
但是如果你仔細看,你可能會發現系統的延遲補償 (latency compensation)。它可能一閃而過, 會看到帖子現在第一位閃了一下,然后回到原來的位置。
發生了什么? 在客戶端的 `Posts` 集合,`update` 方法會被執行。這會立刻發生,因此帖子會來到列表第一的位置。同時,在服務器端 `update` 方法會被拒絕。過了一會 (如果你在本地運行 Meteor 這個時間間隔會是毫秒級的), 服務器端返回一個錯誤,告訴客戶端 `Posts` 集合恢復到原來狀態。
最終的結果是: 在等待服務器端返回的過程中,UI 只能相信客戶端本地集合數據。當服務器端一返回拒絕了修改,UI 就會使用服務器端數據。
<% end %>
### 排列首頁的帖子
現在每個帖子都有一個基于投票數的分數,讓我們顯示一個最佳帖子的列表。這樣,我們將看到如何管理對于帖子集合的兩個不同的訂閱,并將我們的 `postsList` 模板變得更通用一些。
首先,我們需要*兩個*訂閱,分別用來排序。這里的技巧是兩個訂閱同時訂閱*同一個* `posts` 發布,只是參數不同!
我們還需要新建兩個路由 `newPosts` 和 `bestPosts`,分別通過 URL `/new` 和 `/best` 訪問(當然,使用 `/new/5` 和 `/best/5` 進行分頁)。
我們將繼承 `PostsListController` 來生成兩個獨立的 `NewPostsListController` 和 `BestPostsListController` 控制器。對于 `home` 和 `newPosts` 路由,我們可以使用完全一致的路由選項,通過繼承同一個 `NewPostsListController` 控制器。另外,這是一個很好的例子說明 Iron Router 的靈活性。
讓我們用 `NewPostsListController` 和 `BestPostsListController` 提供的 `this.sort` 替換 `PostsListController` 的排序屬性 `{submitted: -1}`:
~~~js
//...
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'});
~~~
<%= caption "lib/router.js" %>
<%= highlight "10,23,27~55" %>
注意現在我們有多個路由,我們將 `nextPath` 邏輯從 `PostsListController` 移到 `NewPostsController` 和 `BestPostsController`, 因為兩個控制器的 path 都不相同。
另外,當我們根據投票數排序時,然后根據發布時間戳和 `_id` 確保順序。
有了新的控制器,我們可以安全的刪除之前的 `postList` 路由。刪除下面的代碼:
```
Router.route('/:postsLimit?', {
name: 'postsList'
})
```
<%= caption "lib/router.js" %>
在 header 中加入鏈接:
~~~html
<template name="header">
<nav class="navbar navbar-default" role="navigation">
<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>
</nav>
</template>
~~~
<%= caption "client/templates/includes/header.html" %>
<%= highlight "11, 15~20" %>
最后,我們還需要更新帖子的刪除 deleting 事件處理函數:
~~~html
'click .delete': function(e) {
e.preventDefault();
if (confirm("Delete this post?")) {
var currentPostId = this._id;
Posts.remove(currentPostId);
Router.go('home');
}
}
~~~
<%= caption "client/templates/posts/posts_edit.js" %>
<%= highlight "7" %>
這些都做完了,現在我們得到了一個最佳帖子列表:
<%= screenshot "13-4", "通過票數排列" %>
<%= commit "13-5", "添加帖子列表路由和顯示頁面。" %>
### 更好的 Header
現在我們有兩個帖子列表頁面,你很難分清你正在看的是哪個列表。現在讓我們把頁面的 header 變得更明顯些。我們將創建一個 `header.js` manager 并創建一個 helper 使用當前的路徑和一個或者多個命名路由來給我們的導航條加一個 active class:
支持多個命名路由的原因是 `home` 和 `newPosts` 路由 (分別對應 URL `/` 和 `new`) 使用同一個模板。這意味著我們的 `activeRouteClass` 足夠聰明可以處理以上情形將 `<li>` 標簽標記為 active。
~~~html
<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>
~~~
<%= caption "client/templates/includes/header.html" %>
<%= highlight "15,18,22" %>
~~~js
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';
}
});
~~~
<%= caption "client/templates/includes/header.js" %>
<%= screenshot "13-5", "顯示當前頁面" %>
<% note do %>
### Helper 參數
到現在為止我們沒有使用特殊的設計模式,但是像其他 Spacebars 標簽一樣,模板的 helper 標簽可以帶參數。
你可以給你的函數傳遞命名的參數,你也可以傳入不指定數量的匿名參數并在函數中用 `arguments` 對象訪問他們。
在最后一種情況,你可能想將 `arguments` 對象轉換成一個一般的 JavaScript 數組,然后調用 `pop()` 方法移除末尾的內容。
<% end %>
對于每一個導航鏈接, `activeRouteClass` helper 可以帶一組路由名稱,然后使用 Underscore 的 `any()` helper 方法檢查哪一個通過測試 (例如: 他們的 URL 等于當前路徑)。
如果路由匹配當前路徑,`any()` 方法將返回 `true`。最后,我們利用 JavaScript 的 `boolean && string` 模式,當 `false && myString` 返回 `false`, 當 `true && myString` 返回 `myString`。
<%= commit "13-6", "在 header 中添加當前樣式。" %>
現在用戶可以給帖子實時投票了,你將看到帖子隨著得票多少上下變化。如果有一些動畫效果不是更好?