Meteor 投票

2022-06-30 13:59 更新

投票

現(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è)給用戶為帖子投票的方法。

數(shù)據(jù)模型

我們將在帖子中保存投票者列表信息,這樣我們能判斷是否給用戶顯示投票按鈕,并阻止用戶給一個(gè)帖子投票兩次。

數(shù)據(jù)隱私與發(fā)布

我們將向所有用戶發(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è)整型屬性。

用戶界面微調(diào)

如果用戶沒有登錄或者已經(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"。

更智能的投票機(jī)制

我們的投票代碼看起來還行,但是我們能做的更好。在 upvote 方法,我們兩次調(diào)用 Mongo: 第一次找到帖子,第二次更新它。

這里有兩個(gè)問題。首先,兩次調(diào)用數(shù)據(jù)庫效率會有點(diǎn)低。但是更重要的是,這里引入了一個(gè)競速狀態(tài)。我們的邏輯是這樣的:

  1. 從數(shù)據(jù)庫中找到帖子。
  2. 檢查用戶是否已經(jīng)投票。
  3. 如果沒有,用戶可以投一票。

如果同一個(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é)果返回。

Latency Compensation

假定你想作弊通過修改帖子投票數(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è)路由 newPostsbestPosts,分別通過 URL /new/best 訪問(當(dāng)然,使用 /new/5/best/5 進(jìn)行分頁)。

我們將繼承 PostsListController 來生成兩個(gè)獨(dú)立的 NewPostsListControllerBestPostsListController 控制器。對于 homenewPosts 路由,我們可以使用完全一致的路由選項(xiàng),通過繼承同一個(gè) NewPostsListController 控制器。另外,這是一個(gè)很好的例子說明 Iron Router 的靈活性。

讓我們用 NewPostsListControllerBestPostsListController 提供的 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 移到 NewPostsControllerBestPostsController, 因?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è)最佳帖子列表:

更好的 Header

現(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è)命名路由的原因是 homenewPosts 路由 (分別對應(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';
  }
});

Helper 參數(shù)

到現(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)畫效果不是更好?

以上內(nèi)容是否對您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號
微信公眾號

編程獅公眾號