ページネーション

12

翻訳の進捗

本章で学ぶこと:

  • Meteorのサブスクリプションを使ったデータの操作方法を学びます。
  • 無限に続くスタイルのページネーションを実装します
  • `iron-router-progress`パッケージでiOSスタイルのプログレスバーを作ります。
  • 投稿ページへの直接リンクを扱う特別なサブスクリプションを作成します。
  • 見た目がすごいMicroscopeであれば、それが世界にリリースされたとき、大ヒットを期待することができます。

    ですので、実際にリリースする前に、 新しい投稿の数によるパフォーマンスの意義について少し考える必要があります!

    これまでに、どのようにクライアントサイドのコレクションが サーバーのデータの一部を含めるのか説明しました。 また、notificationとcommentsコレクションでも同じことを行いました。

    しかし今のところ、まだ私たちは接続しているユーザーに対して、1回のアクセスでパブリッシュしています。 リンクが数千掲載されてた場合、最終的には、これは問題になります。 これを解決するために、我々は我々の記事をページ分割する必要があります。

    もっとたくさんの投稿を追加します

    最初に、ページネーションが実際に意味をなすように、fixture.jsにデータに十分な投稿を詰め込みましょう。:

    // 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
        });
      }
    }
    
    server/fixtures.js

    meteor resetを実行した後で、アプリを再起動すると、こんな感じになります:

    Displaying dummy data.
    Displaying dummy data.

    コミット 12-1

    Added enough posts that pagination is necessary.

    無限ページネーション

    私たちは"無限"スタイルのページネーションを実装しています。 この意味というのは、最初にスクリーンに10個の投稿を表示し、 下部に“load more”リンクを配置するということです。 このリンクをクリックすると、さらに10個の投稿をリストに追加し、際限なく続きます。 つまり、画面上に表示する投稿の数を表す単一のパラメータを使用して、 全体のページネーションシステムを制御できることを意味します。

    この単一パラメータがサーバーにどれだけの数の投稿をクライアントに送信するか識別できるように指示する方法が必要となります。 それは、すでにルータのpostsパブリケーションにサブスクライブしているので、 これを利用して、ルータに同様にページネーションを処理してもらいます。

    これを設定する最も簡単な方法は、フォームに単純にパスのポストリミットパラメータ部分を設定したhttp://localhost:3000/25のようなURLを与えることです。 他の方法に比べて、URLを使用することの追加ボーナスは、 現在25の記事を表示し、誤ってブラウザウィンドウをリロードしてしまっている場合、あなたはまだ、 再びページが読み込また時に、同じ25の投稿を見ることができる点です。

    これを正しく行うために、私たちは投稿にサブスクリプションする方法を変える必要があります。 以前にコメントの作成の章で行ったように、 私たちはサブスクリプションのコードをルーターレベルからルートレベルに移行する必要があります。

    これは、すべてを一度に取り込むことがたくさんかもしれないが、コードは綺麗になるはずです。

    最初に、私たちはRouter.configure()ブロック内でpostsパブリケーションへのサブスクリプションを停止します。 といっても、Meteor.subscribe('posts')を削除するだけで、notificationsサブスクリプションだけを残します。

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

    それからpostsLimit引数をルートのパスに追加します。引数の名前の後に?を追加することは、オプショナルであることを意味しています。そのため、ルートはhttp://localhost:3000/50にマッチするだけでなく、元の古いhttp://localhost:3000にもマッチします。

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

    /: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});
      }
    });
    
    //...
    
    lib/router.js

    postsパブリケーションの名前と一緒に、私たちが JavaScript オブジェクト({limit: postsLimit})を引き渡していることにお気づきでしょう。 このオブジェクトは サーバーサイドの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});
    });
    
    server/publications.js

    パラメータを渡す

    パブリケーションコードはfind()文のオプションとして渡す、 サーバーにクライアントから送られたどのようなJavaScriptオブジェクト (この場合は{limit: postsLimit})でも信頼するように指示を出しています。 これでブラウザーコンソールでユーザーが好きなそんなオプションも送信することができるようになります。  

    この場合、これは比較的無害です。 というのは、一人のユーザーがするすべてのことは違う投稿に再指示することだったり、 (最初に許可したいなど)したい制限を変えることだからです。 実際のアプリはおそらく限界を制限する必要があるでしょう!

    ありがたいことに、check()を使用することにより、我々は、ユーザーが (いくつかのケースではfieldsのような書類上のプライベートデータを公開したいというような) こっそり追加のオプションを設定することはできないことを知っています。

    より安全なパターンは、あなたのデータの制御にとどまることを確認するために、 個々のパラメータ自身の代わりに、オブジェクト全体を渡すことかもしれないです。:

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

    今、私たちは、ルートレベルでサブスクライブしており、それはまた、同じ場所でのデータコンテキストを設定するために理にかなっています。 既存パターンから少し外れますが data関数は、単にカーソルを返す代わりにJavaScriptオブジェクトを返すようにします。 これは、私たちがpostsと呼ぶことにした名付けられたのデータコンテキストを、作成しています。

    Now that we’re subscribing at the route level, it would also make sense to set the data context in the same place. We’ll deviate a bit from our previous pattern and make the data function return a JavaScript object instead of simply returning a cursor. This lets us create a named data context, which we’ll call posts.

    これが意味することは、その代わり、暗黙的にテンプレート内部のthisは、 データコンテキストとしてpostsで利用できるようになるということです。 これとは別に小さな要素から、コードが身近に感じなければならない:

    What this means is simply that instead of being implicitly available as this inside the template, our data context will be available at posts. Apart from this small element, the code should feel familiar:

    //...
    
    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})
        };
      }
    });
    
    //...
    
    lib/router.js

    ルートレベルでデータコンテキストをセットしたので、 posts_list.jsファイル内のpostsテンプレートヘルパーを安全に取り除くことができます。 私たちはデータコンテキストをヘルパーと同じ名前のpostsと名づけたので、 私たちは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'});
    
    lib/router.js

    コミット 12-2

    Augmented the postsList route to take a limit.

    私たちのブランドの新しいページネーションシステムを試してみましょう。 今、単純にURLパラメータを変更することで、 ホームページ上の記事を任意の数を表示する機能を持っています。 たとえば、http://localhost:3000/3にアクセスしてみてください。 あなたは今、このようなものが表示されるはずです。:

    Controlling the number of posts on the homepage.
    Controlling the number of posts on the homepage.

    なんでページじゃない?

    なぜ私たちはGoogleの検索結果ページのように 10個の投稿ごとに 次のページを表示するのではなく、 “無限ページネーション”アプローチを使っているのでしょうか?。 それは、Meteorによるリアルタイムパラダイムだからです。

    Googleの検索結果ページネーションパターンを使って、Postsコレクションをページングすることを想像しましょう。 そして、10番目から20番目の投稿を表示する2ページ目にいるとします。 もし他のユーザーが1ページ目の10個の投稿のどれかを削除したとしたら、どうなるのでしょうか?   このアプリはリアルタイムなので、データセットが変化します。 10番目の投稿は今、9番目になり、画面から消え去ります。 11番目の投稿が表示対象となります。 最終的に、ユーザーは目に見えない理由で急に投稿の変化を見ることになります!

    たとえ私たちが このUXの特異な行動を我慢したとしても、 従来のページネーションは技術的な理由で実行することが難しいです。

    先ほどの例に戻りましょう。 私たちは10から20の投稿をPostsコレクションからパブリッシュしています。 しかし、クライアントでのこうした投稿を、あなたはどのようにして見つけるのでしょうか? クライアントサイドのデータセットの中に全体で10個の投稿しかないので、 あなたは10から20の投稿を取得することはできません。

    1つの解決策はサーバーで10個の投稿をパブリッシュすることで、 そのときにパブリッシュされたすべての投稿を取り出すためにクライアントサイドでPosts.find()を行います。

    もしあなたが1つだけのサブスクリプションをもつとしたら、 これはうまくいきます。  すぐにできます。しかし、もし1つ以上の投稿サブスクリプションを持つするとしたら、どうなるのでしょうか?

    それではひとつ目のサブスクリプションが10〜20番目のポストを要求し、 別の一つが30〜40番目の投稿を要求したとしましょう。 合計でクライアント側のロードされた20個の記事を持っていますが、どのサブスクリプションから得たものか 知る方法はありません。

    以上のような理由から、Meteorと連携している時に従来のページネーションはあまり意味をなさないのです。

    ルートコントローラーの作成

    var limit = parseInt(this.params.postsLimit) || 5;の行を2度繰り返していることにお気づきかもしれません。 さらに、数字の “5” を ハードコーディングすることは 理想的ではありません。 これはそれほど深刻なことではありませんが、 できればDRY (Don’t Repeat Yourself) 原則に従うのがよいので、 どのようにリファクタリングするのか少し見ていきましょう。

    ここで Iron Router の新しい機能、ルートコントローラを紹介します。 ルートコントローラはルーティング機能をどんなルートでも引き継いで、 再利用可能で素晴らしいパッケージにまとめるためのシンプルな方法です。 ここでは1つのルートにルートコントローラを使いますが、 次の章ではこの機能がいかに役立つのか見ていきます。

    //...
    
    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'
    });
    
    //...
    
    lib/router.js

    一つ一つ見ていきましょう。最初に、RouteControllerを拡張してコントローラを作ります。 それから以前行ったように、templateプロパティをセットして、 その次に新たにincrementプロパティをセットします。

    新たに現在の上限を返すlimit関数と オプションオブジェクトを返すfindOptions関数を定義します。 これは余分なステップのように見えるかもしれませんが、後々これを利用します。

    次に以前と同じようにwaitOn関数とdata関数を定義します。 ただし、新しく利用しているfindOptions関数は除きます。

    コントローラはPostsListControllerと呼ばれ、ルートがpostsListと命名されているので、 Iron Routerは自動的にコントローラを使用します。 ですので(コントローラは、ここでそれらを処理しているため) ルート定義からwaitOndataを削除する必要があります。 私たちは別の名前でコントローラを使用するために必要な場合、 controllerオプションはを使用できます。(次の章でその一例が表示されます)

    コミット 12-3

    Refactored postsLists route into a RouteController.

    “More Link"のロードを追加する

    ページネーションは動作しているので、コードは良さそうに見えます。 1つだけ問題があります: ページネーションを実際に使うには、URL を手動で変えるしかありません。 これではユーザーエクスペリエンスに全く役立ちません。 そのため、この修正に取り掛かりましょう。

    私たちがやりたいことはシンプルです。 投稿リストの下に “load more” ボタンを追加して  クリックされるごとに、 現在表示されている投稿の数値を5増加させます。 つまり、私が現在http://localhost:3000/5にいるとしたら、 “load more”ボタンを押すと、私はhttp://localhost:3000/10に移動します。 ここまで読み進めた方だったら、このちょっとした算数ができるはずです!

    すでに述べたように、ルートにページネーションロジックを追加します。 私たちは、明示的に匿名カーソルを使用するのではなく、 命名したデータコンテキストを使ったことを覚えていますか? まあ、そこにdata関数はカーソルのみを渡すことができないと言うルールはありませんので、 “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
        };
      }
    });
    
    //...
    
    lib/router.js

    このルーターマジックについて、さらに深く見ていきましょう。 私たちが現在取り組んでいるPostsListControllerコントローラーから引き継がれた postsListルートがpostsLimit引数をとることを思い出しましょう。

    {postsLimit: this.limit() + this.increment}this.route.path()に入力した時、 私たちはデータコンテキストとしてJavaScript オブジェクトを使って、 postsListルートに自身のパスを作るように命令しています。   言い換えると、自身のカスタムメイドのデータコンテキストによって、 暗黙的なthisを取り替えることを除いて、 これはまさに{{pathFor 'postsList'}}Spacebarsヘルパーを使うことと同じことです。   

    表示するより多くの記事がある場合にのみ、 そのパスを取って、私たちのテンプレートのデータコンテキストにそれを追加している。 それを行う方法は少しトリッキーです。

    this.limit()は、私たちが表示したい現在の投稿数を返します。 または、現在のURLを基にした値か、URLにパラメータが含まれていなければ、デフォルト値(5)を返します。 

    他方で、this.postsは現在のカーソルを参照するので、 this.posts.count()は実際にカーソルの中の投稿数を参照します。

    ここで私たちが言っていることは、もし私たちがnposts を要求してnを戻すと、 “load more”ボタンを表示し続けるということです。 リミットに達すると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>
    
    client/templates/posts/posts_list.html

    これで投稿リストは現在このようになっているはずです:

    The “load more” button.
    The “load more” button.

    コミット 12-4

    Added nextPath() to the controller and use it to step thr…

    より良いユーザー体験

    ページネーションは現在正常に動作していますが、変な癖があります: 毎回私たちは“load more”をクリックして、ルータは、新しいデータが入ってくるようにするために、 我々は待っている間Iron RouterのwaitOn機能はloadingテンプレートに私たちを送信し、 より多くのポストを要求します。 その結果、私たちは、送信さるたびにページの上部に戻され、 私たちのブラウジングを再開する度に下にスクロールする必要があるということです。

    だから最初、私たちはすべての後にサブスクリプションをwaitOnしないように Iron Router に指示する必要があります。 その代わりに、我々は、subscriptionsフックで私たちのサブスクリプションを定義します。

    また、データコンテキストの一部としてthis.postsSub.readyを参照できる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
        };
      }
    });
    
    //...
    
    lib/router.js

    ポストの新しいセットをロードしている間、ポストリストの下部にあるスピナーを表示するために、 テンプレートにこのready変数をチェックします:

    <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>
    
    client/templates/posts/posts_list.html

    コミット 12-5

    Add a spinner to make pagination nicer.

    Accessing Any Post

    私たちは現在、デフォルトで最新の5つの投稿をロードしていますが、 投稿の個別ページを見たい人がいたらどうなるでしょうか?

    An empty template.
    An empty template.

    これを試すと、“not found”エラーに出くわします。 これはこういうことです: postsListルートをロードする時に、 私たちはルーターがpostsパブリケーションにサブスクライブするように指示を出しています。 しかし、私たちはpostPageルートに何をするのか指示を出しませんでした。

    しかし、今までで私たちが 行う方法のすべてはnの最新の投稿リストにサブスクライブすることです。 では、サーバーに1つの特定の投稿を要求するにはどうしたら良いのでしょうか? ここでちょっとした秘密を教えましょう: あなたは 各コレクションに対して1つ以上のパブリケーションを持つことができます! 

    行方不明の投稿を取り戻すために、新たに独立したsinglePostパブリケーションを作ります。 これは_idで識別して1つの投稿だけをパブリッシュします。

    Meteor.publish('posts', function(options) {
      return Posts.find({}, options);
    });
    
    Meteor.publish('singlePost', function(id) {
      check(id, String)
      return Posts.find(id);
    });
    
    //...
    
    server/publications.js

    さあ、クライアントサイドに正しい投稿をサブスクライブしましょう。 私たちはすでにpostPageルートのwaitOn関数にcommentsパブリケーションをサブスクライブしています。 そのため、ここででsinglePostにサブスクリプションを簡単に追加できます。 postEditルートへのサブスクリプション追加することを忘れずに。 というのは、postEditルートも同じデータが必要だからです。

    //...
    
    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); }
    });
    
    //...
    
    lib/router.js

    コミット 12-6

    Use a single post subscription to ensure that we can alwa…

    ページネーションが完了すると、このアプリはスケーリングの問題に苦しむことはありません。 そのため、ユーザーは以前よりも多くのリンクを投稿することでしょう。 では、こうしたリンクにどうにかしてランク付けする良い方法はないものでしょうか? これこそが、次の章の話題です!