Meteor 分頁(yè)

2022-06-30 13:59 更新

分頁(yè)

Microscope 的功能看起來(lái)不錯(cuò)。我們可以想象當(dāng)它 release 之后會(huì)很受歡迎。

因此我們需要考慮一下隨著新帖子越來(lái)越多所帶來(lái)的性能問(wèn)題。

之前我們說(shuō)過(guò)客戶(hù)端集合會(huì)包含服務(wù)器端數(shù)據(jù)的一個(gè)子集。我們?cè)谔雍驮u(píng)論集合已經(jīng)實(shí)現(xiàn)了這些。

但是現(xiàn)在,如果我們還是一口氣發(fā)布所有帖子給所有的連接用戶(hù)。當(dāng)有成千上萬(wàn)的新帖子時(shí),這會(huì)帶來(lái)一些問(wèn)題。為了解決這些,我們需要給帖子分頁(yè)。

添加更多的帖子

首先是我們的初始化數(shù)據(jù),我們需要添加足夠的帖子來(lái)使分頁(yè)有意義:

// 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
    });
  }
}

運(yùn)行完 meteor reset 重啟你的 app, 你會(huì)看到如下:

無(wú)限分頁(yè)

我們將實(shí)現(xiàn)一個(gè)"無(wú)限"的分頁(yè)。意思是在第一屏顯示 10 條帖子和一個(gè)在底部顯示的 "load more" 鏈接。點(diǎn)擊 "load more" 鏈接再加載另外 10 條帖子,諸如此類(lèi)無(wú)限的加載。這意味著我們只用一個(gè)參數(shù)來(lái)實(shí)現(xiàn)分頁(yè),控制在屏幕上顯示帖子的數(shù)量。

現(xiàn)在需要一個(gè)方法告訴服務(wù)器端返回給客戶(hù)端帖子的數(shù)量。這些發(fā)生在路由訂閱帖子的過(guò)程,我們會(huì)利用路由來(lái)實(shí)現(xiàn)分頁(yè)。

最簡(jiǎn)單的限制返回帖子數(shù)量的方式是將返回?cái)?shù)量加到 URL 中,如 http://localhost:3000/25。使用 URL 記錄數(shù)量的另一個(gè)好處是,如果不小心刷新了頁(yè)面,還會(huì)返回 25 條帖子。

為了恰當(dāng)?shù)膶?shí)現(xiàn)分頁(yè),我們需要修改帖子的訂閱方法。就像我們之前在評(píng)論那章做的,我們需要將訂閱部分的代碼從 router 級(jí)變?yōu)?route 級(jí)。

這個(gè)改變內(nèi)容會(huì)比較多,通過(guò)代碼可以看的比較清楚。

首先,停止 Router.configure() 代碼塊中的 posts 訂閱。即刪除 Meteor.subscribe('posts'),只留下 notifications 訂閱:

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

我們?cè)诼酚陕窂街屑尤雲(yún)?shù) postsLimt。 參數(shù)后面的 ? 表示參數(shù)是可選的。這樣路由就能同時(shí)匹配 http://localhost:3000/50http://localhost:3000。

//...

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

//...

需要注意每個(gè)路徑都會(huì)匹配路由 /:parameter?。因?yàn)槊總€(gè)路由都會(huì)被檢查是否匹配當(dāng)前路徑。我們要組織好路由來(lái)減少特異性。

話(huà)句話(huà)說(shuō),更特殊的路由會(huì)優(yōu)先選擇,例如:路由 /posts/:_id 會(huì)在前面,而路由 postsList 會(huì)放到路由組的最后,因?yàn)樗悍毫丝梢云ヅ渌新窂健?/p>

是時(shí)候處理難題了,處理訂閱和找到正確的數(shù)據(jù)。我么需要處理 postsLimit 參數(shù)不存在的情況。我們給它一個(gè)默認(rèn)值 5, 這樣我們能更好的演示分頁(yè)。

//...

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

//...

你注意到我們?cè)谟嗛?posts 時(shí)傳了一個(gè) js 對(duì)象 ({sort: {submitted: -1}, limit: postsLimit}), 這個(gè) js 對(duì)象會(huì)作為服務(wù)器端查詢(xún)方法 Posts.find() 的可選參數(shù)。下面是服務(wù)器端的實(shí)現(xiàn)代碼:

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});
});

傳遞參數(shù)

我們的訂閱代碼告訴服務(wù)器端,我們信任客戶(hù)端傳來(lái)的 JavaScript 對(duì)象 (在我們的例子中是 {limit: postsLimit}) 作為 find() 方法的 options 參數(shù)。這樣我們能通過(guò) browser consle 來(lái)傳任何 option 對(duì)象。

在我們的例子中,這樣沒(méi)什么害處,因?yàn)橛脩?hù)可以做的無(wú)非是改變帖子順序,或者修改 limit 值(這是我們想讓用戶(hù)做的)。但是對(duì)于一個(gè) real-world app 我們必須做必要的限制!

幸好通過(guò) check() 方法我們知道用戶(hù)不能偷偷加入額外的 options (例如 fields, 在某些情況下需要對(duì)外暴露 ducoments 的私有數(shù)據(jù))。

然而,更安全的做法是傳遞單個(gè)參數(shù)而不是整個(gè)對(duì)象,通過(guò)這樣確保數(shù)據(jù)安全:

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

現(xiàn)在我們?cè)?route 級(jí)訂閱數(shù)據(jù),同樣的我們可以在這里設(shè)置數(shù)據(jù)的 context。我們要偏離一下之前的模式,我們讓 data 函數(shù)返回一個(gè) js 對(duì)象而不是一個(gè) cursor。 這樣我們可以創(chuàng)建一個(gè)命名的數(shù)據(jù) context。我們稱(chēng)之為 posts。

這意味著我們的數(shù)據(jù) context 將存在于 posts 中,而不是簡(jiǎn)單的在模板中隱式的存在于 this 中。除去這一點(diǎn),代碼看起來(lái)很相似:

//...

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})
    };
  }
});

//...

因?yàn)槲覀冊(cè)?route 級(jí)設(shè)置數(shù)據(jù) context, 現(xiàn)在我們可以去掉在 posts_list.js 文件中 posts 模板的幫助方法。

我們的數(shù)據(jù) context 叫做 posts (和 helper 同名),所以我們甚至不需要修改 postsList 模板!

下面是我們修改過(guò)的 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'});

試一下我們的分頁(yè)?,F(xiàn)在我們可以通過(guò) URL 參數(shù)來(lái)控制頁(yè)面顯示帖子的數(shù)量,試一下 http://localhost:3000/3。你可以看到如下:

為什么不用傳統(tǒng)的分頁(yè)?

為什么我們使用“無(wú)限分頁(yè)”而不用每頁(yè)顯示 10 條帖子的連續(xù)分頁(yè),就像 Google 的搜索結(jié)果分頁(yè)一樣?這是由于 Meteor 的實(shí)時(shí)性決定的。

讓我們想象一下使用類(lèi)似 Google 搜索結(jié)果的連續(xù)分頁(yè)模式,我們?cè)诘?頁(yè),顯示的是 10 到 20 條帖子。這是碰巧有另外一個(gè)用戶(hù)刪除了前面 10 條帖子中的帖子。

因?yàn)?app 是實(shí)時(shí)的,我們的數(shù)據(jù)集會(huì)馬上變化,這樣第 10 條帖子變成了第 9 條,從當(dāng)前頁(yè)面消失了,第 21 條帖子會(huì)出現(xiàn)在頁(yè)面中。這樣用戶(hù)會(huì)覺(jué)得沒(méi)什么原由的結(jié)果集變了!

即使我們可以容忍這種怪異的 UX, 由于技術(shù)的原因傳統(tǒng)的分頁(yè)還是很難實(shí)現(xiàn)。

讓我們回到前一個(gè)例子。我們從 Posts 集合中發(fā)布第 10 到 20 條帖子,但是在客戶(hù)端我們?nèi)绾握业竭@些帖子?我們不能在客戶(hù)端選擇第 10 到 20 條帖子,因?yàn)榭蛻?hù)端集合只有 10 個(gè)帖子。

一個(gè)簡(jiǎn)單的方案是服務(wù)器端發(fā)布 10 條帖子,在客戶(hù)端執(zhí)行一下 Posts.find() 找到這 10 條發(fā)布的帖子。

這個(gè)方案在只有一個(gè)用戶(hù)訂閱的情況下有效,但是如果有多個(gè)用戶(hù)訂閱呢,下面我們會(huì)看到。

我們假設(shè)一個(gè)用戶(hù)需要第 10 到 20 條帖子,而另一個(gè)需要第 30 到 40。這樣在客戶(hù)端我們有兩個(gè) 20 條帖子,我們不能區(qū)分他們屬于哪個(gè)訂閱。

基于這些原因,我們?cè)?Meteor 中不能使用傳統(tǒng)的分頁(yè)。

創(chuàng)建路由控制器

你可能已經(jīng)注意到了我們代碼中重復(fù)了 var limit = parseInt(this.params.postsLimit) || 5; 兩次。而且硬編碼數(shù)字 5,這不是個(gè)理想的做法。雖然這不會(huì)導(dǎo)致世界末日,但是我們最好還是遵循 DRY 原則 (Don't Repeat Yourself), 讓我們看看如何能把代碼重構(gòu)的更好些。

我們將介紹 Iron Router 的一個(gè)新功能, Route Controllers。Route controller 是通過(guò)簡(jiǎn)單的方式將一組路由特性打包,其他的 route 可以繼承他們?,F(xiàn)在我們只在一個(gè)路由中使用它,在下一章我們會(huì)看到它如何派上用場(chǎng)。

//...

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'
});

//...

讓我們一步接一步的往下看。首先,我們的創(chuàng)建一個(gè)繼承 RouteController 的控制器。然后像之前一樣設(shè)置 template 屬性,然后添加一個(gè)新的 increment 屬性。

然后我們定義一個(gè) postsLimit 函數(shù)用來(lái)返回當(dāng)前限制的數(shù)量,然后定義一個(gè) findOptions 函數(shù)用來(lái)返回 options 對(duì)象。這看起來(lái)像是個(gè)對(duì)于的步驟,但是我們后面會(huì)用到它。

接下來(lái)我們定義 waitOndata 函數(shù),除了他們現(xiàn)在會(huì)用到新的 findOptions 函數(shù)外其余和之前相同。

因?yàn)槲覀兊目刂破鹘凶?PostsListController 路由叫做 postsList, Iron Router 會(huì)自動(dòng)使用他們。因此我們只需要從路由定義中移除 waitOndata (因?yàn)槁酚梢呀?jīng)會(huì)處理他們了)。如果我們需要給路由起別的名字,我們可以使用 controller 選項(xiàng)(我們將在下一章看到一個(gè)例子)。

添加加載更多鏈接

我們現(xiàn)在實(shí)現(xiàn)了分頁(yè),代碼看起來(lái)還不錯(cuò)。只有一個(gè)問(wèn)題:我們的分頁(yè)需要手工修改 URL。這顯然不是一個(gè)好的用戶(hù)體驗(yàn),現(xiàn)在讓我們來(lái)修改它。

我們要做的很簡(jiǎn)單。我們將在帖子列表的下面加一個(gè) "load more" 按鈕,點(diǎn)擊按鈕將增加 5 條帖子。如果當(dāng)前的 URL 是 http://localhost:3000/5, 點(diǎn)擊 "load more" 按鈕 URL 將變成 http://localhost:3000/10。如果你之前已經(jīng)實(shí)現(xiàn)過(guò)這種功能,我們相信你很強(qiáng)!

因?yàn)樵谇懊妫覀兊姆猪?yè)邏輯是在 route 中。記得我們是什么時(shí)候顯式命名數(shù)據(jù)上下文,而非使用匿名 cursor 的么? 沒(méi)有規(guī)則說(shuō)我們的 data 函數(shù)只能使用 cursors, 因此,我們將用同樣的技巧來(lái)生成 "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
    };
  }
});

//...

讓我們來(lái)深入的看一下 router 帶來(lái)的魔術(shù)。記住 postsList route (它將繼承 PostsListController 控制器) 使用一個(gè) postsLimit 參數(shù)。

因此當(dāng)我們給 this.route.path() 傳遞參數(shù) {postsLimit: this.postsLimit() + this.increment} 時(shí),我們告訴 postsList route 使用這個(gè) js 對(duì)象做數(shù)據(jù)上線(xiàn)文建立自己的 path。

換句話(huà)說(shuō),這和使用 {{pathFor 'postsList'}} Spacebars 幫助方法一樣, 除了我們用自己的數(shù)據(jù)上下文替換了隱式的 this。

我們使用這個(gè)路徑并將它添加到我們模板的數(shù)據(jù)上下文中,但是只有多條帖子時(shí)會(huì)顯示。我們的實(shí)現(xiàn)方法有一點(diǎn)小花招。

我們知道 this.limit() 方法會(huì)返回當(dāng)前我們想要顯示帖子的數(shù)量,它可能是當(dāng)前 URL 中的值,如果 URL 中沒(méi)有參數(shù)它會(huì)是默認(rèn)值 (5)。

另一方面, this.posts 引用當(dāng)前的 cursor, 因此 this.posts.count() 的值是在 cursor 中帖子的數(shù)量。

因此我們說(shuō)當(dāng)我們要求發(fā)揮 n 條帖子,實(shí)際返回了 n 條帖子,我們將繼續(xù)顯示 "load more" 按鈕。但是如果我們要求返回 n 條帖子,而實(shí)際返回的數(shù)量比 n 少,這樣我們就知道記錄已經(jīng)到頭了,我們就不再顯示加載按鈕。

這就是說(shuō),我們的系統(tǒng)在一種情況下會(huì)有點(diǎn)問(wèn)題:當(dāng)我們的數(shù)據(jù)庫(kù)恰好n 條記錄時(shí)。如果是這樣,當(dāng)客戶(hù)端要求返回 n 條帖子,我們得到了 n 條,然后繼續(xù)顯示 "load more" 按鈕,這是我們不知道其實(shí)已經(jīng)沒(méi)有記錄可以繼續(xù)返回了。

不幸的是,我們沒(méi)有好的方法去解決這個(gè)問(wèn)題,因此我們不得不接受這個(gè)不算完美的實(shí)現(xiàn)方式。

下面剩下的就是在帖子列表下面加上 "load more" 鏈接,并且保證在還有帖子時(shí)才顯示它:

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

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

下面是你帖子列表現(xiàn)在看上去的樣子:

更好的用戶(hù)體驗(yàn)

現(xiàn)在我們的分頁(yè)可以工作了,但是有個(gè)煩人小問(wèn)題: 每次我們點(diǎn)擊 "load more" 按鈕向 router 加載更多的帖子時(shí),Iron Router 的 waitOn 特性會(huì)在我們等待時(shí)顯示 loading 模板。當(dāng)結(jié)果到來(lái)時(shí)我們又會(huì)回到頁(yè)面的頂端,我們每次都要滾動(dòng)頁(yè)面回到之前看的位置。

因此,首先我們要告訴 Iron Router 不要 waintOn 訂閱,我們將定義自己的訂閱在一個(gè) subscriptions hook 中。

注意我們我們不是在 hook 中返回這個(gè)訂閱。返回它(這是一般 訂閱 hook 常做的工作)將觸發(fā)一個(gè)全局的 loading hook, 這正是我們想要避免的。我們只是想在 subscriptions hook 中定義我們的訂閱,就像使用一個(gè) onBeforeAction hook。

我們還要在我們的數(shù)據(jù)上下文中傳入一個(gè) ready 變量,它指向 this.postsSub.ready。它會(huì)告訴我們帖子訂閱何時(shí)加載完畢。

//...

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 變量的狀態(tài),并在加載帖子時(shí)在帖子列表的下面顯示一個(gè)加載圖標(biāo)(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>

訪(fǎng)問(wèn)任何帖子

現(xiàn)在我們默認(rèn)每次加載 5 條新帖子,但是當(dāng)用戶(hù)訪(fǎng)問(wèn)某個(gè)帖子的單獨(dú)頁(yè)面時(shí)會(huì)發(fā)生什么?

試一下,我們會(huì)得到一個(gè) "not found" 錯(cuò)誤。這是有原因的: 我們告訴 router 當(dāng)我們加載 postList route 時(shí)訂閱 帖子 發(fā)布。但是我們沒(méi)有說(shuō)訪(fǎng)問(wèn) postPage route 時(shí)該做什么。

但是到目前,我們知道如何訂閱一個(gè) n 個(gè)最新帖子的列表。我們?nèi)绾蜗蚍?wù)器端要求單個(gè)具體帖子的內(nèi)容? 我們將告訴你一個(gè)小秘密: 對(duì)于一個(gè) collection 你可以有多個(gè) publication!

讓我們找回丟失的帖子,我們定義一個(gè)新的 publication singlePost,它只發(fā)布一個(gè)帖子,用 _id 鑒別。

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

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

//...

現(xiàn)在,讓我們?cè)诳蛻?hù)端訂閱正確的帖子。我們已經(jīng)在 postPage route 的 wainOn 函數(shù)中訂閱了 comments 發(fā)布,因此我們可以也在這里加入 singlePost 訂閱。讓后別忘了在 postEdit route 中加入我們的訂閱, 因?yàn)槟抢镆残枰嗤臄?shù)據(jù):

//...

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); }
});

//...

有了分頁(yè),我們的程序?qū)⒉辉偈芤?guī)模問(wèn)題的困擾了,用戶(hù)可以加入更多的帖子。如果有某種方法可以給帖子鏈接加上等級(jí) (rank) 不是更好么?我們將在下一章去實(shí)現(xiàn)它!

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

掃描二維碼

下載編程獅App

公眾號(hào)
微信公眾號(hào)

編程獅公眾號(hào)