Knockoutでページャを設置するスニペットとデザインパターン

Knockout でページャを設置する際私がよく使う手法と、再利用可能な部品をご紹介します！
あくまでもコードスニペットですので、使い回しがききますし、変更もききます。

# サンプル

まずはこちらで動作を見ていただければおおよそ察して頂けるかと思います。
[サンプル:jsFiddle](http://jsfiddle.net/sukobuto/LWXYc/) 

# Pager の機能

この Pager には次の機能があります。

* 最初のページへの移動
* 前ページへの移動
* 次ページへの移動
* 最後のページヘの移動
* 任意のページ番号を入力しての移動 (ページ番号を入力して Enter)
* 表示中のアイテムが何件目であるか等の表示
* ローディングインジケータの表示

Knockout なので、機能の追加や不必要な機能の除外は簡単です♪
それぞれのデザインや配置も自由に決められます。

# 使う部品

こちらのコードはコピペで使えますので、まずは読み飛ばして頂いても大丈夫です。

* PagerViewModel クラス
* Pager View (HTML)

## PagerViewModel クラス

```js:PagerViewModel
/**
 * ページャ ViewModel
 * 再利用可能モジュール
 *
 * @param {ko.observableArray} observableItems アイテムを格納するリスト
 * @param {number} [itemsLimit] ページ毎の最大アイテム数
 * @constructor
 */
function PagerViewModel(observableItems, itemsLimit) {
    var self = this;

    self.isLoading = ko.observable(false);        // 読み込み中か否か
    self.count = ko.observable(0);                // アイテム総数
    self.current = ko.observable(1);              // 現在のページ番号
    self.current_input = ko.observable(1);        // 現在のページ番号(入力値)
    self.limit = ko.observable( itemsLimit || 20 ); // 1ページ中の最大アイテム数
    self.pages = ko.computed(function() {         // 総ページ数
        return Math.ceil(self.count() / self.limit())
    });
    self.offset = ko.computed(function() {        // 表示中のアイテムオフセット
        return (self.current() - 1) * self.limit()
    });
    self.items = observableItems;                 // アイテムリスト

    // ページ移動アクション
    self.goTo = function(page) {
        if (page < 1) page = 1;
        if (page > self.pages()) page = self.pages();
        self.current(page);
        self.current_input(page);
    };
    self.goToFirst = function() { self.goTo(1) };
    self.goToPrev = function() { self.goTo(self.current() - 1) };
    self.goToNext = function() { self.goTo(self.current() + 1) };
    self.goToLast = function() { self.goTo(self.pages()) };
    self.goToInputted = function() { self.goTo(self.current_input()) };
}
```

## Pager View (HTML)

```html:PagerView
<!-- ページャ View 再利用可能なので WAF での部品化オススメ -->
<ul class="ko-pager" data-bind="with: pager">
    <li class="btn btn-small" data-bind="click: goToFirst"> 最初 </li>
    <li class="btn btn-small" data-bind="click: goToPrev"> 前ページ </li>
    <li class="pager-input" onclick="$(this).find('input').focus();">
        <form data-bind="submit: goToInputted">
            <input type="text" onfocus="this.select();"
                   data-bind="value: current_input,
                          valueUpdate: 'afterkeydown'" />
            /<span data-bind="text: pages"> </span>
        </form>
    </li>
    <li class="btn btn-small" data-bind="click: goToNext"> 次ページ </li>
    <li class="btn btn-small" data-bind="click: goToLast"> 最後 </li>
    <li class="pager-summary" data-bind="visible: !isLoading()">
        <!--ko if: count() > 0 -->
        <span data-bind="text: count"> </span> 件中
        <span data-bind="text: offset() + 1"> </span> ～
        <span data-bind="text: offset() + items().length"> </span> 件目を表示中
        <!--/ko-->
        <!--ko if: count() == 0 -->
        アイテムが登録されていません
        <!--/ko-->
    </li>
    <li class="indicator" data-bind="visible: isLoading">
        <!-- 実際はローディング画像を入れる -->
        now loading ...
    </li>
</ul>
```

# 使い方

## トップの ViewModel に組み込む

```js:AppViewModel
/**
 * アプリケーション ViewModel
 * @constructor
 */
function AppViewModel() {
    var self = this;
    
    // fields
    self.api = new DummyApi();
    self.entries = ko.observableArray([]);
    self.pager = new PagerViewModel(self.entries, 15); // ここで生成！
    
    // methods
    self.reload = function() {
        self.pager.isLoading(true);
        self.api
            .getEntries( self.pager.offset(), self.pager.limit() )
            .then(function(response) {
                if (response.result != 'success') {
                    alert(response.error);
                    return;
                }
                // Pager にページ情報を反映
                self.pager.count(response.entries_count);
                self.entries([]);
                for (var i in response.entries) {
                    self.entries.push(new EntryViewModel(response.entries[i]));
                }
            }, function() {
                alert('通信エラー');
            }).always(function() {
                self.pager.isLoading(false);
            });
    }
    
    /* アイテムを追加・加工・削除するメソッド等はここに配置 */
    
    // ページ変更通知を受けて自動的に再読み込みさせる
    ko.computed(self.reload);
}
```

トップでなくても、たとえばリストやテーブル専用の ViewModel がある場合はそちらに組み込みます。
ただしその場合は、Pager View をリスト ViewModel のコンテキスト内に配置する必要があります。
このように階層化していけば大規模な UI になってもスパゲティ化せずに済みますね。

### 部品化

上記コード例のように、基本的にはトップの ViewModel (AppViewModel) のプロパティとして PagerViewModel を生成します。
引数に observableArray である entries を渡しているのは、View を部品化させるためです。
部品化した View をいろんなところで使えるようにするためには、その部品に機能固有の情報を残していけません。
そこで、PagerViewModel の引数としてアイテムリストを渡すようにしました。
AppViewModel でのアイテムリストの名前が `entries` だろうと `articles` だろうと、Pager View では `items` として参照できるのです。これも Dependency Injection の一種ですね。

### ページ変更を反映する仕組み

ページの変更は「アイテムリストの再読み込み」で実現します。
アイテムリストの再読み込みはいろんなところで必要になるので、まず AppViewModel の reload メソッドとして公開します。
さらにユーザによりページ変更を要求された時、自動的に reload メソッドが呼び出されるようにします。

これには Knockout の十八番である `ko.computed` を使えば一発です。
`ko.computed` は、内部で参照した observable プロパティに依存する仕組みを持っています。
つまり `pager.offset` が変更されると、自動的に reload メソッドが呼ばれるということです。

reload の処理では、この例では jQuery Deferred を使っています。
API のメソッドは必ず Deferred オブジェクトを返却するように作っています。
$.ajax の返却値でももちろん構いません。

再読み込み成功時に、アイテムの全体数を反映させています。
アイテムの全体数はサーバが持つ情報ですので、リストアイテムを取得する API はついでにアイテム全体数も返却するようにします。

### メリット

Knockout でページャを部品化するメリットは、

* 何度も同じ View を書かなくて良くなる
* デザイン変更も View を書き換えるだけ

というところです。
リストなんてだいたい同じような機能ですから、毎回ページャ機構を実装して View も書いてなんてやってたらかったるくてしょうがないですよね。

さらに View は増やすこともできます。
例えば「テーブルの上と下 両方にページャを付けたい」なんて事になったら、jQuery だけで実装してたら大変なことになるわけですが、このパターンを使うと **「View をコピーするだけ」** で済みます。

たとえば Symfony の Twig や Phalcon の Volt だと、次のようになります。

```html:partials/ko-pager.volt
<!-- Pager View -->
<ul class="ko-pager" data-bind="with: pager">
    <!-- 説明のために省略 -->
</ul>
```

```html:entries.volt
{% include 'partials/ko-pager.volt' %}
<!-- ここにテーブルを表示 -->
{% include 'partials/ko-pager.volt' %}
```

これだけです。 ViewModel 側はなにも変えなくてOKです。
データバインドBANZAI！

***

以上です。
あまり説明できていない気がしますので、あとあと解説をブラッシュアップするかもしれません。
ご質問ご意見ありましたらお手柔らかにどうぞ。。。