-
Notifications
You must be signed in to change notification settings - Fork 79
/
13-voting.md.erb
615 lines (486 loc) · 19.7 KB
/
13-voting.md.erb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
---
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 中添加当前样式。" %>
现在用户可以给帖子实时投票了,你将看到帖子随着得票多少上下变化。如果有一些动画效果不是更好?