-

分页

Microscope 的功能看起来不错。我们可以想象当它 release 之后会很受欢迎。

因此我们需要考虑一下随着新帖子越来越多所带来的性能问题。

之前我们说过客户端集合会包含服务器端数据的一个子集。我们在帖子和评论集合已经实现了这些。

但是现在,如果我们还是一口气发布所有帖子给所有的连接用户。当有成千上万的新帖子时,这会带来一些问题。为了解决这些,我们需要给帖子分页。

添加更多的帖子

首先是我们的初始化数据,我们需要添加足够的帖子来使分页有意义:

// Fixture data
if (Posts.find().count() === 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
  });

  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),
      commentsCount: 0
    });
  }
}

运行完 meteor reset 重启你的 app, 你会看到如下:

无限分页

我们将实现一个"无限"的分页。意思是在第一屏显示 10 条帖子和一个在底部显示的 "load more" 链接。点击 "load more" 链接再加载另外 10 条帖子,诸如此类无限的加载。这意味着我们只用一个参数来实现分页,控制在屏幕上显示帖子的数量。

现在需要一个方法告诉服务器端返回给客户端帖子的数量。这些发生在路由订阅帖子的过程,我们会利用路由来实现分页。

最简单的限制返回帖子数量的方式是将返回数量加到 URL 中,如 http://localhost:3000/25。使用 URL 记录数量的另一个好处是,如果不小心刷新了页面,还会返回 25 条帖子。

为了恰当的实现分页,我们需要修改帖子的订阅方法。就像我们之前在评论那章做的,我们需要将订阅部分的代码从 router 级变为 route 级。

这个改变内容会比较多,通过代码可以看的比较清楚。

首先,停止 Router.configure() 代码块中的 posts 订阅。即删除 Meteor.subscribe('posts'),只留下 notifications 订阅:

Router.configure({
  layoutTemplate: 'layout',
  loadingTemplate: 'loading',
  notFoundTemplate: 'notFound',
  waitOn: function() {
    return [Meteor.subscribe('notifications')]
  }
});

我们在路由路径中加入参数 postsLimt。 参数后面的 ? 表示参数是可选的。这样路由就能同时匹配 http://localhost:3000/50http://localhost:3000

//...

Router.route('/:postsLimit?', {
  name: 'postsList',
});

//...

需要注意每个路径都会匹配路由 /:parameter?。因为每个路由都会被检查是否匹配当前路径。我们要组织好路由来减少特异性。

话句话说,更特殊的路由会优先选择,例如:路由 /posts/:_id 会在前面,而路由 postsList 会放到路由组的最后,因为它太泛泛了可以匹配所有路径。

是时候处理难题了,处理订阅和找到正确的数据。我么需要处理 postsLimit 参数不存在的情况。我们给它一个默认值 5, 这样我们能更好的演示分页。

//...

Router.route('/:postsLimit?', {
  name: 'postsList',
  waitOn: function() {
    var limit = parseInt(this.params.postsLimit) || 5;
    return Meteor.subscribe('posts', {sort: {submitted: -1}, limit: limit});
  }
});

//...

你注意到我们在订阅 posts 时传了一个 js 对象 ({sort: {submitted: -1}, limit: postsLimit}), 这个 js 对象会作为服务器端查询方法 Posts.find() 的可选参数。下面是服务器端的实现代码:

Meteor.publish('posts', function(options) {
  check(options, {
    sort: Object,
    limit: Number
  });
  return Posts.find({}, options);
});

Meteor.publish('comments', function(postId) {
  check(postId, String);
  return Comments.find({postId: postId});
});

Meteor.publish('notifications', function() {
  return Notifications.find({userId: this.userId});
});

传递参数

我们的订阅代码告诉服务器端,我们信任客户端传来的 JavaScript 对象 (在我们的例子中是 {limit: postsLimit}) 作为 find() 方法的 options 参数。这样我们能通过 browser consle 来传任何 option 对象。

在我们的例子中,这样没什么害处,因为用户可以做的无非是改变帖子顺序,或者修改 limit 值(这是我们想让用户做的)。但是对于一个 real-world app 我们必须做必要的限制!

幸好通过 check() 方法我们知道用户不能偷偷加入额外的 options (例如 fields, 在某些情况下需要对外暴露 ducoments 的私有数据)。

然而,更安全的做法是传递单个参数而不是整个对象,通过这样确保数据安全:

Meteor.publish('posts', function(sort, limit) {
  return Posts.find({}, {sort: sort, limit: limit});
});

现在我们在 route 级订阅数据,同样的我们可以在这里设置数据的 context。我们要偏离一下之前的模式,我们让 data 函数返回一个 js 对象而不是一个 cursor。 这样我们可以创建一个命名的数据 context。我们称之为 posts

这意味着我们的数据 context 将存在于 posts 中,而不是简单的在模板中隐式的存在于 this 中。除去这一点,代码看起来很相似:

//...

Router.route('/:postsLimit?', {
  name: 'postsList',
  waitOn: function() {
    var limit = parseInt(this.params.postsLimit) || 5;
    return Meteor.subscribe('posts', {sort: {submitted: -1}, limit: limit});
  },
  data: function() {
    var limit = parseInt(this.params.postsLimit) || 5;
    return {
      posts: Posts.find({}, {sort: {submitted: -1}, limit: limit})
    };
  }
});

//...

因为我们在 route 级设置数据 context, 现在我们可以去掉在 posts_list.js 文件中 posts 模板的帮助方法。

我们的数据 context 叫做 posts (和 helper 同名),所以我们甚至不需要修改 postsList 模板!

下面是我们修改过的 router.js 代码:

Router.configure({
  layoutTemplate: 'layout',
  loadingTemplate: 'loading',
  notFoundTemplate: 'notFound',
  waitOn: function() {
    return [Meteor.subscribe('notifications')]
  }
});

Router.route('/posts/:_id', {
  name: 'postPage',
  waitOn: function() {
    return Meteor.subscribe('comments', this.params._id);
  },
  data: function() { return Posts.findOne(this.params._id); }
});

Router.route('/posts/:_id/edit', {
  name: 'postEdit',
  data: function() { return Posts.findOne(this.params._id); }
});

Router.route('/submit', {name: 'postSubmit'});

Router.route('/:postsLimit?', {
  name: 'postsList',
  waitOn: function() {
    var limit = parseInt(this.params.postsLimit) || 5;
    return Meteor.subscribe('posts', {sort: {submitted: -1}, limit: limit});
  },
  data: function() {
    var limit = parseInt(this.params.postsLimit) || 5;
    return {
      posts: Posts.find({}, {sort: {submitted: -1}, limit: limit})
    };
  }
});

var requireLogin = function() {
  if (! Meteor.user()) {
    if (Meteor.loggingIn()) {
      this.render(this.loadingTemplate);
    } else {
      this.render('accessDenied');
    }
  } else {
    this.next();
  }
}

Router.onBeforeAction('dataNotFound', {only: 'postPage'});
Router.onBeforeAction(requireLogin, {only: 'postSubmit'});

试一下我们的分页。现在我们可以通过 URL 参数来控制页面显示帖子的数量,试一下 http://localhost:3000/3。你可以看到如下:

为什么不用传统的分页?

为什么我们使用“无限分页”而不用每页显示 10 条帖子的连续分页,就像 Google 的搜索结果分页一样?这是由于 Meteor 的实时性决定的。

让我们想象一下使用类似 Google 搜索结果的连续分页模式,我们在第2页,显示的是 10 到 20 条帖子。这是碰巧有另外一个用户删除了前面 10 条帖子中的帖子。

因为 app 是实时的,我们的数据集会马上变化,这样第 10 条帖子变成了第 9 条,从当前页面消失了,第 21 条帖子会出现在页面中。这样用户会觉得没什么原由的结果集变了!

即使我们可以容忍这种怪异的 UX, 由于技术的原因传统的分页还是很难实现。

让我们回到前一个例子。我们从 Posts 集合中发布第 10 到 20 条帖子,但是在客户端我们如何找到这些帖子?我们不能在客户端选择第 10 到 20 条帖子,因为客户端集合只有 10 个帖子。

一个简单的方案是服务器端发布 10 条帖子,在客户端执行一下 Posts.find() 找到这 10 条发布的帖子。

这个方案在只有一个用户订阅的情况下有效,但是如果有多个用户订阅呢,下面我们会看到。

我们假设一个用户需要第 10 到 20 条帖子,而另一个需要第 30 到 40。这样在客户端我们有两个 20 条帖子,我们不能区分他们属于哪个订阅。

基于这些原因,我们在 Meteor 中不能使用传统的分页。

创建路由控制器

你可能已经注意到了我们代码中重复了 var limit = parseInt(this.params.postsLimit) || 5; 两次。而且硬编码数字 5,这不是个理想的做法。虽然这不会导致世界末日,但是我们最好还是遵循 DRY 原则 (Don't Repeat Yourself), 让我们看看如何能把代码重构的更好些。

我们将介绍 Iron Router 的一个新功能, Route Controllers。Route controller 是通过简单的方式将一组路由特性打包,其他的 route 可以继承他们。现在我们只在一个路由中使用它,在下一章我们会看到它如何派上用场。

//...

PostsListController = RouteController.extend({
  template: 'postsList',
  increment: 5,
  postsLimit: function() {
    return parseInt(this.params.postsLimit) || this.increment;
  },
  findOptions: function() {
    return {sort: {submitted: -1}, limit: this.postsLimit()};
  },
  waitOn: function() {
    return Meteor.subscribe('posts', this.findOptions());
  },
  data: function() {
    return {posts: Posts.find({}, this.findOptions())};
  }
});

//...

Router.route('/:postsLimit?', {
  name: 'postsList'
});

//...

让我们一步接一步的往下看。首先,我们的创建一个继承 RouteController 的控制器。然后像之前一样设置 template 属性,然后添加一个新的 increment 属性。

然后我们定义一个 postsLimit 函数用来返回当前限制的数量,然后定义一个 findOptions 函数用来返回 options 对象。这看起来像是个对于的步骤,但是我们后面会用到它。

接下来我们定义 waitOndata 函数,除了他们现在会用到新的 findOptions 函数外其余和之前相同。

因为我们的控制器叫做 PostsListController 路由叫做 postsList, Iron Router 会自动使用他们。因此我们只需要从路由定义中移除 waitOndata (因为路由已经会处理他们了)。如果我们需要给路由起别的名字,我们可以使用 controller 选项(我们将在下一章看到一个例子)。

添加加载更多链接

我们现在实现了分页,代码看起来还不错。只有一个问题:我们的分页需要手工修改 URL。这显然不是一个好的用户体验,现在让我们来修改它。

我们要做的很简单。我们将在帖子列表的下面加一个 "load more" 按钮,点击按钮将增加 5 条帖子。如果当前的 URL 是 http://localhost:3000/5, 点击 "load more" 按钮 URL 将变成 http://localhost:3000/10。如果你之前已经实现过这种功能,我们相信你很强!

因为在前面,我们的分页逻辑是在 route 中。记得我们是什么时候显式命名数据上下文,而非使用匿名 cursor 的么? 没有规则说我们的 data 函数只能使用 cursors, 因此,我们将用同样的技巧来生成 "load more" 按钮的 URL。

//...

PostsListController = RouteController.extend({
  template: 'postsList',
  increment: 5,
  postsLimit: function() {
    return parseInt(this.params.postsLimit) || this.increment;
  },
  findOptions: function() {
    return {sort: {submitted: -1}, limit: this.postsLimit()};
  },
  waitOn: function() {
    return Meteor.subscribe('posts', this.findOptions());
  },
  posts: function() {
    return Posts.find({}, this.findOptions());
  },
  data: function() {
    var hasMore = this.posts().count() === this.postsLimit();
    var nextPath = this.route.path({postsLimit: this.postsLimit() + this.increment});
    return {
      posts: this.posts(),
      nextPath: hasMore ? nextPath : null
    };
  }
});

//...

让我们来深入的看一下 router 带来的魔术。记住 postsList route (它将继承 PostsListController 控制器) 使用一个 postsLimit 参数。

因此当我们给 this.route.path() 传递参数 {postsLimit: this.postsLimit() + this.increment} 时,我们告诉 postsList route 使用这个 js 对象做数据上线文建立自己的 path。

换句话说,这和使用 {{pathFor 'postsList'}} Spacebars 帮助方法一样, 除了我们用自己的数据上下文替换了隐式的 this

我们使用这个路径并将它添加到我们模板的数据上下文中,但是只有多条帖子时会显示。我们的实现方法有一点小花招。

我们知道 this.limit() 方法会返回当前我们想要显示帖子的数量,它可能是当前 URL 中的值,如果 URL 中没有参数它会是默认值 (5)。

另一方面, this.posts 引用当前的 cursor, 因此 this.posts.count() 的值是在 cursor 中帖子的数量。

因此我们说当我们要求发挥 n 条帖子,实际返回了 n 条帖子,我们将继续显示 "load more" 按钮。但是如果我们要求返回 n 条帖子,而实际返回的数量比 n 少,这样我们就知道记录已经到头了,我们就不再显示加载按钮。

这就是说,我们的系统在一种情况下会有点问题:当我们的数据库恰好n 条记录时。如果是这样,当客户端要求返回 n 条帖子,我们得到了 n 条,然后继续显示 "load more" 按钮,这是我们不知道其实已经没有记录可以继续返回了。

不幸的是,我们没有好的方法去解决这个问题,因此我们不得不接受这个不算完美的实现方式。

下面剩下的就是在帖子列表下面加上 "load more" 链接,并且保证在还有帖子时才显示它:

<template name="postsList">
  <div class="posts">
    {{#each posts}}
      {{> postItem}}
    {{/each}}

    {{#if nextPath}}
      <a class="load-more" href="{{nextPath}}">Load more</a>
    {{/if}}
  </div>
</template>

下面是你帖子列表现在看上去的样子:

更好的用户体验

现在我们的分页可以工作了,但是有个烦人小问题: 每次我们点击 "load more" 按钮向 router 加载更多的帖子时,Iron Router 的 waitOn 特性会在我们等待时显示 loading 模板。当结果到来时我们又会回到页面的顶端,我们每次都要滚动页面回到之前看的位置。

因此,首先我们要告诉 Iron Router 不要 waintOn 订阅,我们将定义自己的订阅在一个 subscriptions hook 中。

注意我们我们不是在 hook 中返回这个订阅。返回它(这是一般 订阅 hook 常做的工作)将触发一个全局的 loading hook, 这正是我们想要避免的。我们只是想在 subscriptions hook 中定义我们的订阅,就像使用一个 onBeforeAction hook。

我们还要在我们的数据上下文中传入一个 ready 变量,它指向 this.postsSub.ready。它会告诉我们帖子订阅何时加载完毕。

//...

PostsListController = RouteController.extend({
  template: 'postsList',
  increment: 5,
  postsLimit: function() {
    return parseInt(this.params.postsLimit) || this.increment;
  },
  findOptions: function() {
    return {sort: {submitted: -1}, 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();
    var nextPath = this.route.path({postsLimit: this.postsLimit() + this.increment});
    return {
      posts: this.posts(),
      ready: this.postsSub.ready,
      nextPath: hasMore ? nextPath : null
    };
  }
});

//...

我们将在模板中检查 ready 变量的状态,并在加载帖子时在帖子列表的下面显示一个加载图标(spinner):

<template name="postsList">
  <div class="posts">
    {{#each posts}}
      {{> postItem}}
    {{/each}}

    {{#if nextPath}}
      <a class="load-more" href="{{nextPath}}">Load more</a>
    {{else}}
      {{#unless ready}}
        {{> spinner}}
      {{/unless}}
    {{/if}}
  </div>
</template>

访问任何帖子

现在我们默认每次加载 5 条新帖子,但是当用户访问某个帖子的单独页面时会发生什么?

试一下,我们会得到一个 "not found" 错误。这是有原因的: 我们告诉 router 当我们加载 postList route 时订阅 帖子 发布。但是我们没有说访问 postPage route 时该做什么。

但是到目前,我们知道如何订阅一个 n 个最新帖子的列表。我们如何向服务器端要求单个具体帖子的内容? 我们将告诉你一个小秘密: 对于一个 collection 你可以有多个 publication!

让我们找回丢失的帖子,我们定义一个新的 publication singlePost,它只发布一个帖子,用 _id 鉴别。

Meteor.publish('posts', function(options) {
  return Posts.find({}, options);
});

Meteor.publish('singlePost', function(id) {
  check(id, String)
  return Posts.find(id);
});

//...

现在,让我们在客户端订阅正确的帖子。我们已经在 postPage route 的 wainOn 函数中订阅了 comments 发布,因此我们可以也在这里加入 singlePost 订阅。让后别忘了在 postEdit route 中加入我们的订阅, 因为那里也需要相同的数据:

//...

Router.route('/posts/:_id', {
  name: 'postPage',
  waitOn: function() {
    return [
      Meteor.subscribe('singlePost', this.params._id),
      Meteor.subscribe('comments', this.params._id)
    ];
  },
  data: function() { return Posts.findOne(this.params._id); }
});

Router.route('/posts/:_id/edit', {
  name: 'postEdit',
  waitOn: function() {
    return Meteor.subscribe('singlePost', this.params._id);
  },
  data: function() { return Posts.findOne(this.params._id); }
});

//...

有了分页,我们的程序将不再受规模问题的困扰了,用户可以加入更多的帖子。如果有某种方法可以给帖子链接加上等级 (rank) 不是更好么?我们将在下一章去实现它!