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

こんにちは、アプリケーションエンジニアの id:nanto_vi です。先日行われた Hatena Engineer Seminar #4 で、「TypeScript で実現する MVP アーキテクチャパターン」と題する発表を行いました。当日は皆様ご清聴いただき、また懇親会でも活発な質疑をいただきありがとうございました。

内容としては、TypeScript を用いたクライアントサイド Web プログラミングの話、及び既存の JavaScript フレームワークを採用せず、MVP (Model-View-Presenter) アーキテクチャパターンにのっとり開発を進めた事例の紹介となります。以下に発表資料を加筆修正して公開するのでご参照ください。


自己紹介

  • id:nanto_vi (外山真)
  • アプリケーションエンジニア
  • JavaScript、Perl

アジェンダ

  • 少年ジャンプルーキーでの TypeScript の利用
  • MVP アーキテクチャパターン

少年ジャンプルーキー

JavaScript の利用箇所

  • SPA (Single-Page Application) ではない
  • 投稿ツールや作品ビューワでは動的・対話的な操作

言語・ライブラリ

  • TypeScript
  • 外部ライブラリは jQuery のみ
    • DOM 操作 (HTML 要素の探索・変更)
    • HTTP 接続
    • アニメーション
    • イベントモデル

TypeScript

  • Microsoft の開発した altJS 言語
  • 既存の JavaScript コードがほぼそのまま動く
  • 追加で型に関する機能が使える
    • 静的型付け
    • 型推論
    • 構造的部分型
    • クラス
    • インターフェイス

TypeScript での型指定

  • 関数 / メソッドの引数と返り値の型を指定すれば、変数の型指定はほぼ不要
    • 例外: 空配列で初期化するときなど
function add(a: number, b: number): number {
    // 変数 sum は number 型であると推論される
    var sum = a + b;
    return sum;
}

// 型指定がないと、names は any[] 型であると推論される
var names: string[] = [];
  • HTML 要素を扱うときはダウンキャストが必要なこともしばしば
// 変数 image は Element 型であると推論される
var image = document.querySelector('.profile-image');
// Element 型には width プロパティがないので、
// width プロパティにアクセスするには
// HTMLImageElement 型へのキャストが必要
var width = (<HTMLImageElement>image).width;

ビルド

  • gulp.js を使って TypeScript から JavaScript へコンパイル
    • gulp-typescript
    • ローカルサーバー起動時に TypeScript ファイルを監視し、変更があれば都度コンパイル
  • 開発環境ではコンパイルされた JS ファイルを直接参照
  • 本番環境では縮小化・結合された JS ファイルを参照
    • 縮小化・結合は CI 環境 (Jenkins) で実行

テスト

  • 主にプレゼンテーションロジックをテスト
  • DOM を触るテストはない
  • ローカル環境ではブラウザで実行
  • CI 環境では gulp-qunit で実行

JavaScript から TypeScript への移行

  • 当初は JavaScript で記述
  • 規模が大きくなるにつれ開発がつらくなる
  • 約 4000 行の JS に対して、5 行の変更で TypeScript に移行
    • 外部変数宣言の追加: 2 行
      • declare var jQuery; など
    • any 型へのキャスト: 2 行
    • typo の修正: 1 行
  • 徐々に TypeScript のクラス、モジュールを使って書き直し

JavaScript フレームワーク

  • AngularJS
    • 学習コストが高い
    • パフォーマンスチューニングに不向き?
  • Vue.js
    • Android 2.3 で動かない
  • Flux / React.js
    • 当時はまだ文献が少なかった

やりたいこと

  • DOM に依存せずテストを書きたい
    • View とそれ以外をきっちり分離したい
  • 既存フレームワークの採用には消極的
  • データバインディング機構の独自実装は非現実的

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

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

  • Model-View-Presenter
    • Model: ビジネスロジック
    • View: 表示の更新、ユーザー入力の Presenter への伝達
    • Presenter: プレゼンテーションロジック
  • MVVM アーキテクチャパターンの前身

MVVM と MVP

  • MVVM
    • ViewModel は View の存在を知らない
    • ViewModel の変更はデータバインディングによって自動的に View に反映される
    • View への入力は (双方向バインディングなら) 自動的に ViewModel に反映される
  • MVP
    • Presenter は View のインターフェイスを知っている
    • Presenter は自身の変更を View に反映させる
    • View への入力は View 自身が Presenter に伝える

MVP それぞれの構成要素

  • Model
    • Web API (XMLHttpRequest)、タイマー (setTimeoutsetInterval)、クライアントサイドストレージ、Cookie
  • View
    • HTML 要素探索、HTML 要素作成、スタイル変更、ユーザー入力受付
  • Presenter
    • 画面構成要素の位置、サイズ、内容、アニメーション

基底クラス

interface IView {
    updateError(): void;
}

class Presenter {
    view: IView;
    lastError: Error;

    constructor(view: IView) {
        this.view      = view;
        this.lastError = null;
    }

    // 遅延初期化。View の準備が整ってから実行すべき処理。
    setup(): void {}

    // ユーザーに通知すべきエラー状態を設定する。
    setError(error: Error): void {
        this.lastError = error;
        this.view.updateError();
    }
}

class View implements IView {
    presenter: Presenter;

    createPresenter(): Presenter {
        throw new Error('You must implement createPresenter');
    }

    // ユーザーにエラー状態を通知する。
    updateError(): void {}
}

インターフェイス定義

  • DOM の何が変化するのかを宣言
  • 作品ビューワ上部のナビゲーションヘッダの場合なら:
    • マウスを近づけると上からスライドして表示される
    • マウスを離すと上へスライドして消える
    • 現在表示中のページが変わるとページ番号が更新される
interface INavigationView extends IView {
    // ナビゲーションヘッダの位置を更新する
    updatePosition(): void;
    // ナビゲーションヘッダの表示・非表示を更新する
    updateVisibility(): void;
    // ナビゲーションヘッダのページ番号表示を更新する
    updatePageNumber(): void;
}

Presenter 定義

  • DOM に依存しない形で表示ロジックを記述
  • ナビゲーションヘッダを表示するとき (show メソッド):
    • 前提として、ナビゲーションヘッダの位置 (headerTop プロパティ) はアニメーションの進行状態 (animationFraction プロパティ) から算出される
    • jQuery Animation で Presenter の状態 (animationFraction プロパティ) を変化させる
      • そのままだと View が Presenter の変更に気づけないので、アニメーションの各フレーム (step) において Presenter 自身が View に変更を知らせる (this.view.updatePosition())。
interface INavigationArgs {
    viewportWidth: number;
    viewportHeight: number;
    headerHeight: number;
    marginToShow: number;
}

class NavigationPresenter extends Presenter {
    view: INavigationView;

    // ナビゲーションヘッダの配置
    viewportWidth: number;
    viewportHeight: number;
    headerHeight: number;

    // 表示内容
    pageNumber: number;

    // 表示状態
    isShown: boolean;

    // 表示切替
    marginToShow: number;
    animationDuration: number;
    // アニメーションの割合。1.0 が完全に表示、0.0 が完全に非表示。
    animationFraction: number;
    isAnimating: boolean;

    constructor(view: INavigationView, args: INavigationArgs) {
        super(view);

        this.viewportWidth  = args.viewportWidth;
        this.viewportHeight = args.viewportHeight;
        this.headerHeight   = args.headerHeight;

        this.pageNumber = 1;

        this.isShown  = true;

        this.marginToShow      = args.marginToShow;
        this.animationDuration = 150;
        this.animationFraction = 1.0;
        this.isAnimating       = false;
    }

    show(): void {
        if (this.isShown) return;
        this.isShown = true;

        // アニメーション開始前に要素を表示させておく。
        this.view.updateVisibility();

        if (this.isAnimating) {
            $(this).stop();
        }
        this.isAnimating = true;
        $(this).animate({ animationFraction: 1.0 }, {
            duration: this.animationDuration,
            step:     () => { this.view.updatePosition() },
            complete: () => {
                this.isAnimating = false;
                this.view.updatePosition();
            },
        });
    }
    hide(): void {
        if (!this.isShown) return;
        this.isShown = false;

        if (this.isAnimating) {
            $(this).stop();
        }
        this.isAnimating = true;
        $(this).animate({ animationFraction: 0 }, {
            duration: this.animationDuration,
            step:     () => { this.view.updatePosition() },
            complete: () => {
                this.isAnimating = false;
                this.view.updatePosition();
                // アニメーション終了後に要素を非表示にする。
                this.view.updateVisibility();
            },
        });
    }

    shouldShow(pointerX: number, pointerY: number): boolean {
        return pointerY < this.headerHeight + this.marginToShow;
    }

    get headerTop(): number {
        return -this.headerHeight * (1.0 - this.animationFraction);
    }

    setViewportSize(viewportWidth: number, viewportHeight: number): void {
        this.viewportWidth  = viewportWidth;
        this.viewportHeight = viewportHeight;
    }

    setPageNumber(pageNumber: number): void {
        this.pageNumber = pageNumber;
        this.view.updatePageNumber();
    }

    handleMouseMove(pointerX: number, pointerY: number): void {
        if (this.shouldShow(pointerX, pointerY)) {
            this.show();
        } else {
            this.hide();
        }
    }
}

View 定義

  • Presenter に DOM に依存しない形でデータを渡す
    • イベントオブジェクト (イベントハンドラメソッドの引数として渡ってくる) も DOM に依存するものとみなし、Presenter に持ち込まない
  • Presenter の変更を受けて DOM を操作する
  • 以下で、$ から始まる名前の変数・プロパティは jQuery オブジェクトを指す
  • ナビゲーションヘッダの位置を更新するとき (updatePosition メソッド) は:
    • Presenter の値を、実際の HTML 要素の CSS プロパティの値に反映させる
class NavigationView extends View implements INavigationView {
    presenter: NavigationPresenter;

    $header: JQuery;
    $pageNumber: JQuery;

    constructor($header: JQuery, $footer: JQuery) {
        super();

        this.$header     = $header;
        this.$pageNumber = $header.find('.js-page-number');

        this.presenter = this.createPresenter();

        $(window).on('resize', $.proxy(this.onResize, this));
        var $doc = $(document);
        $doc.on('mousemove', $.proxy(this.onMouseMove, this));
        $doc.on('book:pagenumberchange', $.proxy(this.onPageNumberChange, this));

        this.presenter.setup();
    }

    createPresenter(): NavigationPresenter {
        var $win = $(window);

        return new NavigationPresenter(this, {
            viewportWidth:  $win.width(),
            viewportHeight: $win.height(),
            headerHeight:   this.$header.outerHeight(),
            marginToShow:   40,
        });
    }

    updatePosition(): void {
        this.$header.css('top', this.presenter.headerTop);
    }
    updateVisibility(): void {
        this.$header.toggle(this.presenter.isShown);
    }
    updatePageNumber(): void {
        this.$pageNumber.text(this.presenter.pageNumber);
    }

    onResize(event: JQueryEventObject): void {
        var $win = $(window);
        this.presenter.setViewportSize($win.width(), $win.height());
    }
    onMouseMove(event: JQueryEventObject): void {
        // 文書基準ではなく閲覧領域基準でのマウスカーソル座標を扱う
        this.presenter.handleMouseMove(
            event.pageX - window.pageXOffset,
            event.pageY - window.pageYOffset
        );
    }
    onPageNumberChange(event: JQueryEventObject, pageNumber: number): void {
        this.presenter.setPageNumber(pageNumber);
    }
}

View のインスタンス作成

  • テンプレートファイルごとに適切な View のインスタンスを生成
    • いわゆるルーティング処理
router.connect('series:episode', function () {
    var $header = $('.js-header-navigation');
    new NavigationView($header);
});

テスト

  • View のモックを作ってプレゼンテーションロジックを確認
    • Presenter が知っているのは View のインターフェイスのみなので、View の内部実装がどうあれ問題なく動作する
  • View のテストはない
function createNavigationView() {
    return {
        updatePosition:   function () {},
        updateVisibility: function () {},
        updatePageNumber: function () {},
        updateError:      function () {},
    };
}
function createNavigationArgs() {
    return {
        viewportWidth:  800,
        viewportHeight: 600,
        headerHeight:   20,
        marginToShow:   50,
    };
}

QUnit.test('NavigationPresenter#shouldShow', function (assert) {
    var presenter = new NavigationPresenter(
        createNavigationView(), createNavigationArgs()
    );

    assert.ok(presenter.shouldShow(100, 25), 'マウスカーソルがヘッダから近い');
    assert.ok(!presenter.shouldShow(100, 300), 'マウスカーソルがヘッダから遠い');
});

View 同士の連携

  • カスタムイベント
$book.trigger('book:pagenumberchange', [pageNumber]);
  • ある View がインスタンスメンバーとして別の View を持つ
class TabBoxView extends View implements ITabBoxView {
    presenter: TabBoxPresenter;
    ...
    tabListView: TabListView;
    tabBodyView: TabBodyView;

    constructor($box: JQuery) {
        ...
        this.tabListView = new TabListView($box.find('.js-tab-list'));
        this.tabBodyView = new TabBodyView($box.find('.js-tab-body'));
        ...
    }
}

テンプレート

  • HTML 中に script 要素を使って埋め込み
    • デザイナがテンプレートファイル (HTML ファイル) を編集しやすいように
<ul class="js-list">
  <script class="js-item-template" type="application/x-template">
    <li>{label}</li>
  </script>
</ul>

まとめ

  • TypeScript
    • 型の支援により安心して開発を進められる
      • 本質的でない部分に気を取られず済む
    • JavaScript からの移行が簡単
      • TypeScript をやめて素の JavaScript に戻るのも簡単
  • MVP アーキテクチャパターン
    • フレームワークに頼らなくとも、DOM 操作とそれ以外を切り離せる
    • トレンドではない
      • 「DOM に触らずにロジックをテストしたい」という (MVP 導入にあたっての) 前提が崩れている

質疑応答など

外部ライブラリの型で困ることも多いと聞くが

本プロジェクトに関しては jQuery、QUnit、Sinon.js いずれも DefinitelyTyped.d.ts ファイルが提供されていたので問題なかった。

TypeScript ではまった点などは?

以下のような JavaScript コードを書き換えようとして、

My.alert = function (message) {
    ...
    alert(message);
    ...
};

以下のようにしたら無限再帰呼び出しになった。

module My {
    export function alert(message: string): void {
        ...
        alert(message);
        ...
    }
}

期待通り動かすには window.alert(message) とする必要があった。

他の altJS 言語は使っているか?

チームによってはテストで CoffeeScript を使っているところもある。本番向けのコードでは TypeScript 以外の altJS 言語は使われていないはず。

本プロジェクトにおいては、チーム内のエンジニアのほぼ全員が TypeScript を好んでいたため TypeScript を採用した。

テストに QUnit を採用したのはなぜ?

導入が非常に簡単だったから。

React の JSX 記法について

はてなではデザイナが HTML マークアップを記述するので、Flux / React の各種サンプルのように JSX 記法をスクリプトコード中に書いてしまうと、エンジニアとデザイナが共同作業しづらくなるかもしれない。

普段 Scala を書いているのですが

Scala に比べると型システムは緩い。すべての型は nullable だし、共変性・反変性の確認もない。とはいえ、静的型のない素の JavaScript に比べれば安心感は段違い。

「『DOM に触らずにロジックをテストしたい』という前提が崩れている」とは? (2015-02-17 追記)

MVP アーキテクチャパターンを採用する動機のひとつとして、DOM に触らず (特定のデータに基づく HTML を出力することなく) プレゼンテーションロジックをテストしたいというものがあった。実際、現在は DOM に触る (View に関する) テストは行っていない。

しかしながら、近年はヘッドレスブラウザを用いたテスト実行環境も広まっており、やはりそうした環境で View も含めてテストしたほうがよいのではないかとも考える。その場合、MVP アーキテクチャパターン採用の動機づけが弱くなるのではないかということである。

jQuery でのネイティブオブジェクトのサポートについて (2015-02-17 追記)

上述の例示コードでは、ネイティブオブジェクト (Element 型ではない、JavaScript のオブジェクト) をラップした jQuery オブジェクトに対して、animate() メソッドを呼び出している。そのような操作が可能であるということは文書化されておらず、また jQuery 3.0 ではネイティブオブジェクトへの対応そのものが非推奨となるのではという指摘が社内からあった (現在は jQuery 2.1.1 を使用)。

jQuery に関しては指摘のとおりであるし、今後アニメーションを CSS TransitionsWeb Animations で実現していくことを考えると、ネイティブオブジェクト側でアニメーション途中の値まで管理するというアプローチ自体変更する必要が出てくるだろう。


結び

はてなではいくつかのチームで TypeScript の導入が進んでいますが、ビルドやモジュール管理などまだまだ手探りの部分も多いです。ユーザーが自身の目的を迷わず達成できるようにするためにも、Web フロントエンド開発を推し進めてくれるエンジニアを東京・京都双方で募集しています。