-

投票

现在我们的系统更完善了,但是想要找到最受欢迎的帖子有点难。我们需要一个排名系统来给我们的帖子排个序。

我们可以建立一个基于 karma 的复杂排名系统,权值随着时间衰减,和许多其他因素(很多功能都在 Telescope 中实现了,他是 Microscope 的大哥)。但是对于我们的例子 app, 我们尽量保持简单,我们只按照帖子收到的投票数为它们排序。

让我们实现一个给用户为帖子投票的方法。

数据模型

我们将在帖子中保存投票者列表信息,这样我们能判断是否给用户显示投票按钮,并阻止用户给一个帖子投票两次。

数据隐私与发布

我们将向所有用户发布投票者名单,这样也自动使得通过浏览器控制台也可以访问这些数据。

这是一类由于集合工作方式而引发的数据隐私问题。例如,我们是否希望用户能看到谁为他的帖子投了票。在我们的例子中,公开这些信息无关紧要,但重要的是至少知道这是个问题。

我们也要非规范化帖子的投票者数量,以便更容易取得这个数值。所以我们给帖子增加两个属性,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, 执行 meteor reset, 重启 app,创建一个新的用户。让我们确认一下用户创建帖子时,这两个新的属性也被初始化了:

//...

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 事件处理函数。

接下来,你会发现被投过一票的帖子会显示 "1 votes", 下面让我们花点时间来处理单复数形式。处理单复数是个复杂的事,但在这里我们会用一个非常简单的方法。我们建一个通用的 Spacebars helper 方法来处理他们:

UI.registerHelper('pluralize', function(n, thing) {
  // fairly stupid pluralizer
  if (n === 1) {
    return '1 ' + thing;
  } else {
    return n + ' ' + thing + 's';
  }
});

之前我们创建的 helper 方法都是绑定到某个模板的。但是现在我们用 Template.registerHelper 创建一个全局的 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>

现在我们看到的是 "1 vote"。

更智能的投票机制

我们的投票代码看起来还行,但是我们能做的更好。在 upvote 方法,我们两次调用 Mongo: 第一次找到帖子,第二次更新它。

这里有两个问题。首先,两次调用数据库效率会有点低。但是更重要的是,这里引入了一个竞速状态。我们的逻辑是这样的:

  1. 从数据库中找到帖子。
  2. 检查用户是否已经投票。
  3. 如果没有,用户可以投一票。

如果同一个用户在步骤 1 和 3 之间两次投票会如何?我们现在的代码会让用户给同一个帖子投票两次。幸好,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 是这个并且用户没有投票的帖子,并更新他们为投票”。如果用户还没有投票,就会找到这个 id 的帖子。如果用户已经投过票了,就不会有结果返回。

Latency Compensation

假定你想作弊通过修改帖子投票数量来让一个帖子排到榜单的第一名:

> Posts.update(postId, {$set: {votes: 10000}});

(postId 是你某个帖子的 id)

这个无耻的企图将会被我们系统的 deny() 回调函数捕获(collections/posts.js 记得么?)并且立刻取消。

但是如果你仔细看,你可能会发现系统的延迟补偿 (latency compensation)。它可能一闪而过, 会看到帖子现在第一位闪了一下,然后回到原来的位置。

发生了什么? 在客户端的 Posts 集合,update 方法会被执行。这会立刻发生,因此帖子会来到列表第一的位置。同时,在服务器端 update 方法会被拒绝。过了一会 (如果你在本地运行 Meteor 这个时间间隔会是毫秒级的), 服务器端返回一个错误,告诉客户端 Posts 集合恢复到原来状态。

最终的结果是: 在等待服务器端返回的过程中,UI 只能相信客户端本地集合数据。当服务器端一返回拒绝了修改,UI 就会使用服务器端数据。

排列首页的帖子

现在每个帖子都有一个基于投票数的分数,让我们显示一个最佳帖子的列表。这样,我们将看到如何管理对于帖子集合的两个不同的订阅,并将我们的 postsList 模板变得更通用一些。

首先,我们需要两个订阅,分别用来排序。这里的技巧是两个订阅同时订阅同一个 posts 发布,只是参数不同!

我们还需要新建两个路由 newPostsbestPosts,分别通过 URL /new/best 访问(当然,使用 /new/5/best/5 进行分页)。

我们将继承 PostsListController 来生成两个独立的 NewPostsListControllerBestPostsListController 控制器。对于 homenewPosts 路由,我们可以使用完全一致的路由选项,通过继承同一个 NewPostsListController 控制器。另外,这是一个很好的例子说明 Iron Router 的灵活性。

让我们用 NewPostsListControllerBestPostsListController 提供的 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'});

注意现在我们有多个路由,我们将 nextPath 逻辑从 PostsListController 移到 NewPostsControllerBestPostsController, 因为两个控制器的 path 都不相同。

另外,当我们根据投票数排序时,然后根据发布时间戳和 _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');
    }
  }

这些都做完了,现在我们得到了一个最佳帖子列表:

更好的 Header

现在我们有两个帖子列表页面,你很难分清你正在看的是哪个列表。现在让我们把页面的 header 变得更明显些。我们将创建一个 header.js manager 并创建一个 helper 使用当前的路径和一个或者多个命名路由来给我们的导航条加一个 active class:

支持多个命名路由的原因是 homenewPosts 路由 (分别对应 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';
  }
});

Helper 参数

到现在为止我们没有使用特殊的设计模式,但是像其他 Spacebars 标签一样,模板的 helper 标签可以带参数。

你可以给你的函数传递命名的参数,你也可以传入不指定数量的匿名参数并在函数中用 arguments 对象访问他们。

在最后一种情况,你可能想将 arguments 对象转换成一个一般的 JavaScript 数组,然后调用 pop() 方法移除末尾的内容。

对于每一个导航链接, activeRouteClass helper 可以带一组路由名称,然后使用 Underscore 的 any() helper 方法检查哪一个通过测试 (例如: 他们的 URL 等于当前路径)。

如果路由匹配当前路径,any() 方法将返回 true。最后,我们利用 JavaScript 的 boolean && string 模式,当 false && myString 返回 false, 当 true && myString 返回 myString

现在用户可以给帖子实时投票了,你将看到帖子随着得票多少上下变化。如果有一些动画效果不是更好?