KO + TypeScript で大規模 SPA 開発


追記: 今から SPA を始めようと思ってこの記事を見つけた方へ

この記事は2014年に書きました。

KnockoutJS はバインディングライブラリとして生まれ、Angular, Vue などの MVVM フレームワークの礎となりました。現時点で振り返ってみても、KnockoutJS はやはりフレームワークではなくバインディングライブラリです。

SPA をサクッと作りたい場合、もしくは腹を据えて開発したい場合、どちらにおいてもフレームワークを利用したほうが明らかに楽です。この記事での実装方法には、退屈なボイラープレートが数多く残っています。

薄いライブラリを使用しているがための自由度の高さがあるか、と考えてみても Angular, Vue, React を使う場合と比較した優位性はもはやありません。
KnockoutJS を dis りたいわけではありませんが、冷静に比較しても SPA 開発に利用することはよっぽどな欲求がなければあまりおすすめはできません。


こんにちは。KnockoutJS の無限の可能性を具現化していきたい私です。

今回は KnockoutJS を核に今から実務で使えるレベルのサンプルとして管理画面を作ってみました。デモはほぼ内容が空っぽですが、ページとAPIをポンポン追加していけるようになっています。

コードは こちら:kots-spa-admin-example
デモは こちら です。

この記事では、このサンプルのフレームワークとしての特徴を解説いたします。

このサンプルを作るにあたって、MKGaru さんの ko-spa-example を大いに参考にしました。いわばこのサンプルは MKGaru さんのサンプルの後輩にあたります。
MKGaru さん、本当にありがとうございます。

またこのサンプルを公開するモチベーションとなった物の一つに、KnockoutJS の作者である Steven Sanderson 氏の発表動画 Architecting large Single Page Applications with Knockout.js (KnockoutJS で大規模 SPA をアーキテクトする) があります。時間があったら是非一度ご覧いただきたいと思います。

フレームワークとしての特徴

フレームワークと書いてしまっておりますが、実際はフレームワークと呼べるほどに醸成・隠ぺいされているわけではありません。ただ個人的な理念としては、どんなにフレームワークが隠ぺいされていても、たいていの場合実務レベルで扱うにはその内部的な構造まで把握する必要があったりします。

このサンプルは幾分無骨ではありますが、その分仕組みがわかりやすく堅実に開発できると思います。

1. WebPack による資材の構造

このプロジェクトでは主なコード資材を app/ に置いておりますが、実際に public/index.html で読み込んでいるのは public/js/bundle.js というファイルです。
この bundle.js というファイルを作っているのが WebPack というソフトウェアです。

WebPack は RequireJS でよく知られる AMD モジュールライブラリ( require('hogehoge')とか書くやつ)の一種であり、かつ Grunt や Gulp などのタスクランナーのような機能を持っています。

webpack.config.js に指定したアプリケーションの基準となる JS ファイル(この場合は Application.ts をコンパイルした Application.js)を基に、require() されたファイルをすべてかき集めて一つの JS ファイルとして出力してくれます。

その出力結果が public/js/bundle.js というわけです。
なので app 配下の資材は直接読み込むのではなく、テンプレートhtml もすべて bundle.js を読み込むだけでOKとなります。

2. Components によるページ切り替え

SPA に必須となるクライアントサイドルーティングの定義は app/Shell.ts
で行っています。今回はRoutieJSを使ってルーティングを実現 しているためハッシュリンクによるページ遷移ですが、ここを page.js に変更すれば pushState によるページ遷移に変えることができます。

ページ切り替えは ルート定義のhandleによってtransitが呼ばれる ことによって発生します。
これだけでなぜ切り替わるかというと、index.html にて次のように component binding を使っているからです。

<!--ko if: page-->
    <div id="page-wrapper" class="contents { page.componentId }"
         data-bind="component:{ name:page.componentId, params:page }">
    </div>
<!--/ko-->

これは MKGaru さんのサンプルの要となっている部分で、大変勉強になりました。
component binding については3日目の記事でtango_238さんが紹介されています

template binding という選択肢もありますが、必ず <script type="text/html" id="~"></script> で囲う必要がありますし、AMD にも対応していません。
Component であればテンプレートHTMLを require で合体させることができるため非常にすっきりします。

このサンプルではページ以外にもほとんどの部品を Component で作っています。
Componentクラス を継承して register を使って登録することで、どこでも部品として使うことができるようになります。

2-1. クリアランス

これはちょっとしたワザですが、ルーティング機能に合わせて「ユーザが許可された機能のみ使用できるようにする」仕組みを持たせています。要するに権限です。

細かな権限調整はサーバ側のAPIで行うとして、このサンプルでは左のメニュー単位での権限を設定できるようになっています。なのでユーザ管理の追加や編集で「使用を許可する機能」という項目を設けております。

これを実現しているのがShell.ts#43およびShell.ts#49で、linq.js の where によって除外し許可がなければメニューにも表示されず、ページ遷移も不可能となります。

3. 独自実装の DI

私は Phalcon PHP Framework の DI 実装がとても好みで、このサンプルでも 同じように実装しました

app/di/DependencyInjector.tsが DI コンテナで、
app/di/DependencyInjectable.tsが、サービスを利用する側が継承する基底クラスです。

この Injectable のほうは DI にサービスを追加するたびに、以下のようにサービスへアクセスするための getter を追加していきます。

DependencyInjectable.ts
// application services getters

get api() : AdminApi {
    return this.di.get('api');
}

get resources() : ResourceContainer {
    return this.di.get('resources');
}

get toaster() : Toaster {
    return this.di.get('toaster');
}

get modal() : Modal {
    return this.di.get('modal');
}

こうすることで、この Injectable を継承したオブジェクトでは自分のプロパティとしてサービスにアクセスする ことができるようになります。
また HeaderNav.html のような View でも、そのコンテキストが Injectable を継承しているオブジェクトであればいつでもアクセスできるようになります。


今回の紹介はここまでといたしますが、勉強会のネタとしてどこかでまた紹介することがあるかもしれません。(あまりにも字ばっかりつらつらと書いてしまったので、あとで図を追加するかもしれません)