薄いフレームワーク指向の Web クライアントサイドプログラミング

こんにちは、Web アプリケーションエンジニアの id:nanto_vi です。先日開催された Kyoto.js #12 において、「薄いフレームワーク指向の Web クライアントサイドプログラミング」と題した発表を行いました。とある Web アプリケーションの開発にあたって、JavaScript による GUI プログラミングにどう取り組んだかという話になります。当日のスライドの内容に口頭で伝えた内容を加え、以下にまとめます。


前提


Web アプリケーションを新規開発するにあたり、クライアントサイドをどう実現するか。ここでは開発期間が決まっているというのが大きな要因となり、チームメンバーの経験がある MVP アーキテクチャパターンを採用することにしました。

また、JavaScript を直接記述するのではなく、TypeScript を使います。

MVP アーキテクチャパターン

TypeScript で実現する MVP アーキテクチャパターン - Hatena Developer Blog

View は Presenter にユーザー入力を伝える、Presenter は View に表示更新させる、Presenter は Model を操作する、Presenter は Model の更新を通知される

  • MVVM (Model-View-ViewModel)
    • View と ViewModel が双方向データバインディング機構で暗黙的につながる
  • MVP (Model-View-Presenter)
    • View は Presenter へ明示的にユーザー入力を伝える
    • Presenter は View の表示を明示的に更新する

MVP アーキテクチャパターンは MVVM アーキテクチャパターンと似ています。しかしながら、MVP アーキテクチャパターンには (MVVM アーキテクチャパターンにおける双方向データバインディング機構のような) ブラックボックスとなる部分がないので、既存のフレームワークを用いなくても簡単に実装できます。

ライブラリの使用

  • View
    • jQuery (DOM 操作、イベントハンドリング)
  • Presenter / Model
    • jQuery (HTTP 接続、Promise)

既存のフレームワークを用いないといっても、既存のライブラリも使わないということではありません。ともすれば冗長になりがちな処理を簡潔化するため、jQuery を使っています。とはいっても、jQuery の機能を野放図に使うのではなく、レイヤーごとに特定の機能だけ使うようにしています。

コンポーネントの例

interface IMyView {
    update(): void;
}
interface IMyArgs {
    foo: string;
}

class MyPresenter {
    view: IMyView;
    foo: string;

    constructor(view: IMyView, args: IMyArgs) {
        this.view = view;
        this.foo = args.foo;
    }

    changeFoo(foo: string): void {
        this.foo = foo;
        // (4) Presenter が View に状態変更を知らせる
        this.view.update();
    }
}

class MyView implements IMyView {
    presenter: MyPresenter;
    $element: JQuery;

    // (1) ルーターから View のインスタンスを生成する
    constructor($element: JQuery) {
        this.$element = $element;
        // (2) View から Presenter のインスタンスを生成する
        this.presenter = new MyPresenter(this, {
            foo: this.$element.attr('data-foo'),
        });
        this.$element.on('click', this.onClick.bind(this));
    }

    onClick(event: JQueryEventObject): void {
        // (3) View が Presenter の状態を変更する
        this.presenter.changeFoo(Date());
    }

    update(): void {
        // (5) View が Presenter の状態を表示に反映させる
        this.$element.text(this.presenter.foo);
    }
}

View のインスタンスと Presenter のインスタンスは一対一対応しています。コンポーネントの表示に必要な「状態」は Presenter が持っていますが、それを実際の表示 (HTML 要素) に反映させるのは View の役目です。

View は Presenter の実装を知っていますが、Presenter は View のインターフェイスしか知りません。こうすることで、Presenter をテストする際に View のモックを使うことができ、DOM に触らずプレゼンテーションロジックをテストできます。

コンポーネント間の連携

  • 当初は、HTML 要素のカスタムイベントを通じて View 同士が連携
    • 親子関係にないコンポーネントの連携が大変
      • 共通の親に仲介コンポーネントを設けるなど

当初はコンポーネント間の連携を実現するのに、jQuery のイベント機能を使って View 同士をつなげていました。しかし、それだと親子関係にない HTML 要素それぞれに対するコンポーネント同士を連携させるため、共通の祖先要素に対して仲介役のコンポーネントを設置するといった必要が出てきます。そのままだと連携するコンポーネントの数が増えたときに複雑さが増加しそうなので、後述する Model レイヤーでの連携を試みました。

Model を通じたコンポーネント間の連携

  • 複数の Presenter が Model の同一インスタンスを参照する
    • Multiton パターン (Singleton パターンの拡張)
class MyModel {
    private static instances: { [myId: string]: MyModel };
    static getInstance(myId: string): MyModel {
        let instance = MyModel.instances[myId];
        if (!instance) {
            instance = MyModel.instances[myId] = new MyModel(myId);
        }
        return instance;
    }

    constructor(myId: string) {
        ...
    }
}
  • Model の状態変化を複数の Presenter に通知する
class MyPresenter {
    constructor(...) {
        ...
        this.myModel = MyModel.getInstance(myId);
        this.myModel.on('foo', () => { this.view.update(); });
    }
}
class MyModel {
    on = Emittable.on;
    off = Emittable.off;
    emit = Emittable.emit;

    doSomething(): void {
        ...
        this.emit('foo');
    }
}

Presenter 同士が Model を介して 連携するためには、複数の Presenter のインスタンスが同一の Model のインスタンスを参照する必要があります。きちんとやるならレジストリだ依存性注入だといった話になるのでしょうが、簡単化のため Multiton パターンを使って Model のインスタンスを生成することにしました。

Presenter 同士が Model を介して連携するためには、ある Model のインスタンスの変化を複数の Presenter のインスタンスへ伝える必要があります。ここでは、Observer パターンを使って Model の変化を Presenter へ伝えることにしました。

例: 「いいね!」できるユーザー

class LikePerformer {
    static getInstance(...) { ... }

    userId: string;
    targetId: string;
    isLiking: boolean;

    constructor(userId: string, targetId: string, isLiking: boolean) {
        this.userId = userId;
        this.targetId = targetId;
        this.isLiking = isLiking;
    }

    toggleLike(): JQueryPromise<void> {
        let isLiking = this.isLiking;
        let apiURL = `/users/{ this.userId }/targets/{ this.targetId }/{ isLiking ? 'unlike' : 'like' }`;
        return $.post(apiURL).then(() => {
            this.isLiking = !isLiking
            this.emit('status-change');
        });
    }
}

例として、Facebook の「いいね!」のような機能を実現するための Model を考えてみます。「『いいね!』できるユーザー」を表すクラス LikePerformer は、対象を今現在「いいね!」しているかどうかの状態 isLiking を持っています。isLiking の値が変わるたび status-change イベントを発行し、Presenter に向けて状態の変化を伝えます。

toggleLike メソッドが Promise を返しているのは、アプリケーションを利用するユーザーへのエラー通知のためです。エラーもイベントとして複数の Presenter に通知してしまうと、各コンポーネントがそれぞれエラー通知を出し、その内容が重複してしまうかもしれません。そこで、ユーザーへのエラー通知は toggleLike メソッドを呼び出した Presenter のインスタンスが責任を持つこととします。

例: コメントを促すメッセージ

「いいね!」したがコメントしていないユーザーに対して、「『いいね!』の次はコメントしましょう」というメッセージを表示したい。

class CommentSupportMessagePresenter {
    constructor(...) {
        this.likePerformer = LikePerformer.getInstance(...);
        this.commentAuthor = CommentAuthor.getInstance(...);

        this.likePerformer.on('status-change', () => { this.view.update(); });
        this.commentAuthor.on('status-change', () => { this.view.update(); });
    }

    get isShown(): boolean {
        return this.likePerformer.isLiking && !this.commentAuthor.hasComment;
    }
}

class CommentSupportMessageView {
    update(): void {
        this.$element.toggle(this.presenter.isShown);
    }
}

前述の Model を利用する Presenter の例です。ユーザーが「いいね!」しているかどうかを知りたい Presenter は、LikePerformer のインスタンスを参照し、その状態が変化したときに自らの状態変化も View に伝えます。結果として、別のコンポーネントが引き起こした「いいね!」状態の変更により、このコンポーネントの表示が変化します。

テスト

  • テスト時にできるだけ DOM に触りたくない
    • DOM は状態の塊であり、テストのために用意するのも大変
  • Presenter のテスト時には View のモックを使う
    • DOM を用意せずにプレゼンテーションロジックをテストできる
  • 一から始めるJavaScriptユニットテスト - Hatena Developer Blog
    • Presenter / Model のテストはメソッドごとに
    • View のテストは JSDOM を使い、「ボタンがクリックされたら要素が表示される」といったシナリオに沿って

上で述べたように、Presenter をテストする際には View のモックを用意します。また、Sinon.JS を使って setTimeoutXMLHttpRequest といったブラウザの機能をモックしています。

View と Presenter の分担

  • View に条件分岐が出てきたら黄信号
// ○ OK
class MyView {
    updateVisibility(): void {
        if (this.presenter.isShown) {
            this.element.classList.remove('isHidden');
        } else {
            this.element.classList.add('isHidden');
        }
    }
}
// × NG
class MyView {
    updateVisibility(): void {
        if (this.presenter.currentScore < this.presenter.maxScore) {
            this.element.classList.remove('isHidden');
        } else {
            this.element.classList.add('isHidden');
        }
    }
}
  • イベントのデフォルトアクションをキャンセルする
class MyFormPresenter {
    handleSubmit(): boolean {
        if (!this.shouldHandle) return false;

        this.doSomething();
        return true;
    }
}

class MyFormView {
    onSubmit(event: JQueryEventObject): void {
        let isHandled = this.presenter.handleSubmit();
        if (isHandled) {
            event.preventDefault();
        }
    }
}
  • Presenter → View → Presenter と呼び出してはいけない
// × NG
class MyPresenter {
    doSomething(): void {
        this.updateVisibility();
    }

    hide(): void {
        ...
    }
}

class MyView {
    updateVisibility(): void {
        ...
        // View をモックに置換すると hide メソッドが呼び出されなくなる
        this.presenter.hide();
    }
}
  • 「状態」と「状態遷移のトリガー」は Presenter が持つ
    • View は「状態遷移のトリガー」を呼び出し、「状態」を DOM に写すだけ

MVP アーキテクチャパターンの悩みどころのひとつが、View と Presenter をどう分割するかです。「DOM に触らずプレゼンテーションロジックをテストする」という大前提があるため、個別のコンポーネントに関することは基本的に Presenter に記述し、どうしても DOM (HTML 要素) を扱わざるを得ない部分だけ View に切り出すようにします。

DOM イベントを扱うときも、イベントオブジェクトを直接 View から Presenter に渡すのではなく、DOM に依存しない数値・文字列を取り出してそれを Presenter に渡すようにします。イベントのデフォルトアクションのキャンセルも、するかどうかの判断は Presenter で、実際にキャンセルする (イベントオブジェクトの preventDefault メソッドを呼び出す) のは View でとなります。

複雑になる処理

  • Presenter が View に対して Data Transfer Object を公開する
    • ある View に特化した DTO を用意することで、View を薄くできる
  • リストの項目の追加・削除・並べ替え
    • VirtualDOM が本領を発揮する分野
  • 手続き的か宣言的か
    • UI は宣言的に書けたほうが楽
// 手続き的 (素の DOM API)
let link = document.createElement('a');
link.href = url;
link.textContent = label;
element.appendChild(link);
// 手続き的 (jQuery)
let $link = $('<a>').attr('href', url).text(label);
$element.append($link);
// 宣言的 (JSX)
<a href={url}>{label}</a>
[%- # 宣言的 (Perl の Text::Xslate::Syntax::TTerse) %]
<a href="[% url %]">[% label %]</a>

View はできるだけ薄く保つべきですが、複雑な処理が入ってしまうこともあります。代表的な例がリストの項目の追加・削除・並べ替えでしょう。この部分をうまいことを隠蔽してくれる仮想 DOM ライブラリ (React など) がうらやましくなります。

View での DOM 操作はどうしても手続き的になりがちです。コンポーネントによってはサーバー側で生成した HTML 片を挿入することで宣言的な性質を取り入れているところもありますが、仮想 DOM ライブラリを使って最初から宣言的に記述するほうがわかりやすいことも多いでしょう。

質疑応答

  • 結局開発期間に間に合ったのか?
    • 間に合ったが、今後も同様の方針を継続するには文書整備の必要があり、保守性に不安が残る
  • 別のアーキテクチャパターンを混ぜられるか?
    • 基本的にコンポーネント単位で実装しているので、あるコンポーネントだけ Flux アーキテクチャパターンで実装するといったことも可能
  • MVP パターンで検索すると Web の事例が出てこない。どう教育していくのか?
    • この資料を含め、自分たちで文書を整備するしかない

今後もこの方針を続けていけるかどうかというのは難しい点です。慣れていればまずコンポーネントの状態と状態遷移を洗い出して Presenter から書き始められるのですが、Web クライアントサイドプログラミングに慣れていないと View から書き出し、都度 Web ブラウザで確認することになります。そうなると View にプレゼンテーションロジックが詰め込まれ、View が厚くなってしまいがちです。

Flux / Redux アーキテクチャパターンのほうが文書も豊富で学習しやすく、状態と状態遷移を意識づけることもしやすいかもしれません。


まとめ

以上で見てきたように、クライアントサイドプログラミングが小・中規模に収まるなら、大掛かりなフレームワークを使わずとも開発は可能です。しかし、それにはアプリケーションの状態の把握や DOM 操作の知識が必要であり、一概に学習コストが低いとは言い切れません。

はてなでは AngularJS や React + Redux を導入しているチームもあり、それぞれのメリット・デメリットを共有して今後に役立てていければと思っています。あなたもはてなで一緒に JavaScript (TypeScript) を書いてみませんか?

hatenacorp.jp