読者です 読者をやめる 読者になる 読者になる

TypeScript で Backbone のサンプルを書き換えてみた

はじめに

CoffeeScript や Haxe みたいな、JavaScript にコンパイルする言語は、デバッグのやりにくさを懸念して、ずっと食わず嫌いしていた。

それは TypeScript が出た当初も変わらなかったんだけど、先日 SourceMap を知って懸念は払拭された。Chrome で変換前コードを使ってデバッグできるなら、手を出さない理由はもう無いね。

解禁一発目に試すのは Microsoft の TypeScript。

選んだ理由は

  • 型システム
  • JavaScript の上位互換
  • 言語設計者が C# と同じ Anders Hejlsberg 氏

というのがツボだったから。

TypeScript の入手

プライベートで使っているマシンは MacBook Pro なので、Node のパッケージマネージャ npm でインストールする。

npm install -g typescript

ちなみに Node は nvm を使ってインストールした。

Backbone のサンプルを TypeScript で書き換えてみる

HelloWorld だと単純すぎてつまらないし、自分は TypeScript と Backbone 組み合わせることが多そうなので、Backbone 入門時に書いたサンプルを TypeScript に書き換えてみることにした。

こいつを TypeScript で書き換える。

TypeScript で jQuery と Underscore と Backbone を使えるようにする

TypeScript ではサードパーティJavaScript ライブラリが使えるんだけど、使うためにはソースコードに定義を書くか、定義ファイルを参照しないといけない。書き換え対象のサンプルは小さいので、今回は直接ソースコードに定義を書くことにした。

// jQuery, Underscore, Backbone を
// TypeScript で使えるようにする
declare var $: any;
declare var _: any;
declare module Backbone {
    export class Model {
        static extend: any;
        constructor (attr? , opts? );
        get(name: string): any;
        set(name: string, val: any): void;
        set(obj: any): void;
        save(attr? , opts? ): void;
        destroy(): void;
        bind(ev: string, f: Function, ctx?: any): void;
        toJSON(): any;
        trigger(ev: string, ctx?: any): void;
    }
    export class Collection {
        static extend: any;
        constructor (models? , opts? );
        bind(ev: string, f: Function, ctx?: any): void;
        collection: Model;
        length: number;
        create(attrs, opts? ): Collection;
        each(f: (elem: any) => void ): void;
        fetch(opts?: any): void;
        last(): any;
        last(n: number): any[];
        filter(f: (elem: any) => any): Collection;
        without(...values: any[]): Collection;
        add(elem: Model): void;
        remove(elem: Model): void;
    }
    export class View {
        constructor (options? );
        $(selector: string): any;
        el: HTMLElement;
        $el: any;
        model: Model;
        collection: Collection;
        remove(): void;
        delegateEvents: any;
        make(tagName: string, attrs? , opts? ): View;
        setElement(element: HTMLElement, delegate?: bool): void;
        tagName: string;
        events: any;

        static extend: any;
  }
}

TypeScript 公式のサンプルをベースに、いくつかメソッドの定義を追加してみた。

モデルを TypeScript 化

// タスクを表すクラス。
// タスクのデータを格納するモデルなので、Backbone.Model を継承。
class Task extends Backbone.Model {
    // モデルの初期値を返す。
    // new でオブジェクトを生成したとき、まずこの値が attributes に格納される。
    defaults() {
        return {
            name: "",
            completed: false,
            isEditing: false
        }
    }

    // destroy をオーバーライド。
    // 本来ならサーバーと通信するけど、今回のサンプルではデータを永続化しないから、
    // destroy イベントだけ発生させる。
    destroy() {
        this.trigger("destroy", this);
    }

    // set メソッドに渡されたデータを検証する。
    // 何か値を返すと検証エラー扱いになるので、
    // 不正な値だったときはエラーメッセージなんかを返すといい。
    validate(attrs: any) : string {
        // 検証には、underscore の便利メソッドを使っている。
        if (_.isString(attrs.name) && _.isEmpty(attrs.name)) {
            return "task name is empty.";
        }
    }
}

初期値の defaults はオブジェクトを返すメソッドに変更。defaults フィールドに初期値用オブジェクトをそのまま突っ込んだら、new したとき初期値が反映されなかった。

コレクションを TypeScript 化

// タスクリストを表すクラス。
// タスクのコレクションを扱うので、Backbone.Collection を継承。
class TaskList extends Backbone.Collection {
    // コレクションが扱うモデルの型。
    // 指定しなくてもいいけど、指定しておくと add に Task 以外のオブジェクトが
    // 渡されたときラップしてくれる。
    model = Task;

    // 初期化。
    constructor() {
        super();

        // タスクリストにタスクが追加されたときに発生する add イベントに
        // ハンドラを登録する。
        this.bind("add", this._onAdd, this);
    }

    private _onAdd(task: Task) {
        task.bind("destroy", this._onDestroy, this);
    }

    // タスクが destroy イベントを発生させたらタスクリストから削除する。
    private _onDestroy(task: Task) {
        this.remove(task);
    }
}

ビューを TypeScript 化

// タスクを表示するビュー。
// ビューは Backbone.View を継承。
class TaskView extends Backbone.View {
    // テンプレートを保持するメンバ変数を定義
    template: (data: any) => string;

    // 初期化
    constructor(options?) {
        // ビューをレンダリングするタグの名前を指定。
        this.tagName = "div";

        // テンプレート。
        // underscore のテンプレートを使用。
        this.template = _.template($("#task-template").html());

        // イベントハンドラのマッピング。
        // CSS セレクタにマッチした要素にイベントハンドラを自動でセットしてくれる。
        this.events = {
            "change input[type=checkbox]": "_onCheck",
            "click a.edit-link": "_onEdit",
            "click input.save-input": "_onSave",
            "click input.cancel-input": "_onCancel",
            "click input.delete-input": "_onDelete",
        };

        super(options);

        // モデルの set メソッドで値が変更されたら change イベントが発生するので、
        // 再描画する。
        this.model.bind("change", this.render, this);

        // モデルのバリデーションに失敗したらエラーメッセージを表示。
        this.model.bind("error", this._onError, this);
    }

    private _onError(model, error) {
        alert(error);
    }

    private _onCheck() {
        var completed = this.model.get("completed");
        this.model.set({ completed: !completed });
    }

    private _onEdit() {
        this.model.set("isEditing", true);
    }

    private _onSave() {
        var name = $(this.el).find("input.name-input").first().val();
        this.model.set({ name: name, isEditing: false });
    }

    private _onCancel() {
        this.model.set("isEditing", false);
    }

    private _onDelete() {
        this.model.destroy();
    }

    // ビューをレンダリング。
    render() {
        // モデルを JSON に変換してからテンプレートに渡す。
        // { "name": this.model.get("name") } みたいに、
        // オブジェクトを渡してもいい。
        var data = this.model.toJSON();
        var html = this.template(data);
        $(this.el).html(html);
    }
}

// タスクリストを表示するビュー。
class TaskListView extends Backbone.View {
    events = {
        "click #add-input": "_onAddInputClick"
    };

    constructor(options?) {
        super(options);

        // DOM 要素を紐づける
        // こうしないと、events の内容が DOM 要素に反映されない
        this.setElement($("#main"), true);
        _.bindAll(this);

        // モデル (TaskList) の要素数が変わったら再描画。
        this.collection.bind("add", this.render);
        this.collection.bind("remove", this.render);
    }

    private _onAddInputClick() {
        var name = $("#name-input").val();
        if (_.isEmpty(name)) {
            alert("task name is empty.");
            return;
        }
        var task = new Task({ "name": name });
        this.collection.add(task);
        $("#name-input").val("");
    }

    render() {
        var taskListEl = $("#task-list");
        taskListEl.empty();

        // Task と TaskView を1対1にする。
        // テンプレート内で for 文が使えるから、表示するだけなら
        // Task 1つ1つにビューを作る必要はない。
        // でも、1対1にしておいた方が、その場編集機能が実装しやすい。
        this.collection.each(task => {
            var view = new TaskView({ model: task });
            view.render();
            taskListEl.append(view.el);
        });
    }
}

イベントハンドラは外部から呼び出さないので、private メソッドにしている。

HTML ファイルを修正する

まず、メインのビューを描画しているコードを HTML から TypeScript ファイルに移動。

$(() => {
    var mainView = new TaskListView({ collection: new TaskList() });
    mainView.render();
});

HTML ファイルは TypeScript をコンパイルして生成される JavaScript ファイルを読み込むだけ。

<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="utf-8">
    <title>Backbone Todo</title>
  </head>
  <body>
    <h1>Backbone Todo</h1>
    <div id="main">
      <div id="task-list">
        <!-- ここにタスクリストを表示する -->
      </div>
      <input id="name-input" type="text"/>
      <input id="add-input" type="button" value="add"/>
    </div>

    <!--TaskView の描画に使うテンプレート-->
    <script id="task-template" type="text/html">
      <% if (isEditing) { %>
        <input class="name-input" type="text" value="<%= name %>"/>
        <input class="save-input" type="button" value="save"/>
        <input class="cancel-input" type="button" value="cancel"/>
        <input class="delete-input" type="button" value="delete"/>
        <% } else { %>
        <% if (completed) { %>
          <input type="checkbox" checked>
          <del><%= name %></del>
          <% } else { %>
          <input type="checkbox">
          <%= name %>
          <% } %>
        </input>
        <a class="edit-link" href="javascript:void(0);">[edit]</a>
        <% } %>
    </script>

    <script type="text/javascript" src="http://ajax.googleapis.com/ajax/libs/jquery/1.9.0/jquery.min.js"></script>
    <script type="text/javascript" src="http://underscorejs.org/underscore-min.js"></script>
    <script type="text/javascript" src="http://backbonejs.org/backbone-min.js"></script>
    <script type="text/javascript" src="app.js"></script>
  </body>
</html>

Underscore や Backbone の URL は新しいものに変更。

TypeScript で書き直した Backbone サンプル全体

// jQuery, Underscore, Backbone を
// TypeScript で使えるようにする
declare var $: any;
declare var _: any;
declare module Backbone {
    export class Model {
        static extend: any;
        constructor (attr? , opts? );
        get(name: string): any;
        set(name: string, val: any): void;
        set(obj: any): void;
        save(attr? , opts? ): void;
        destroy(): void;
        bind(ev: string, f: Function, ctx?: any): void;
        toJSON(): any;
        trigger(ev: string, ctx?: any): void;
    }
    export class Collection {
        static extend: any;
        constructor (models? , opts? );
        bind(ev: string, f: Function, ctx?: any): void;
        collection: Model;
        length: number;
        create(attrs, opts? ): Collection;
        each(f: (elem: any) => void ): void;
        fetch(opts?: any): void;
        last(): any;
        last(n: number): any[];
        filter(f: (elem: any) => any): Collection;
        without(...values: any[]): Collection;
        add(elem: Model): void;
        remove(elem: Model): void;
    }
    export class View {
        constructor (options? );
        $(selector: string): any;
        el: HTMLElement;
        $el: any;
        model: Model;
        collection: Collection;
        remove(): void;
        delegateEvents: any;
        make(tagName: string, attrs? , opts? ): View;
        setElement(element: HTMLElement, delegate?: bool): void;
        tagName: string;
        events: any;

        static extend: any;
  }
}

// タスクを表すクラス。
// タスクのデータを格納するモデルなので、Backbone.Model を継承。
class Task extends Backbone.Model {
    // モデルの初期値を返す。
    // new でオブジェクトを生成したとき、まずこの値が attributes に格納される。
    defaults() {
        return {
            name: "",
            completed: false,
            isEditing: false
        }
    }

    // destroy をオーバーライド。
    // 本来ならサーバーと通信するけど、今回のサンプルではデータを永続化しないから、
    // destroy イベントだけ発生させる。
    destroy() {
        this.trigger("destroy", this);
    }

    // set メソッドに渡されたデータを検証する。
    // 何か値を返すと検証エラー扱いになるので、
    // 不正な値だったときはエラーメッセージなんかを返すといい。
    validate(attrs: any) : string {
        // 検証には、underscore の便利メソッドを使っている。
        if (_.isString(attrs.name) && _.isEmpty(attrs.name)) {
            return "task name is empty.";
        }
    }
}

// タスクリストを表すクラス。
// タスクのコレクションを扱うので、Backbone.Collection を継承。
class TaskList extends Backbone.Collection {
    // コレクションが扱うモデルの型。
    // 指定しなくてもいいけど、指定しておくと add に Task 以外のオブジェクトが
    // 渡されたときラップしてくれる。
    model = Task;

    // 初期化。
    constructor() {
        super();

        // タスクリストにタスクが追加されたときに発生する add イベントに
        // ハンドラを登録する。
        this.bind("add", this._onAdd, this);
    }

    private _onAdd(task: Task) {
        task.bind("destroy", this._onDestroy, this);
    }

    // タスクが destroy イベントを発生させたらタスクリストから削除する。
    private _onDestroy(task: Task) {
        this.remove(task);
    }
}

// タスクを表示するビュー。
// ビューは Backbone.View を継承。
class TaskView extends Backbone.View {
    // テンプレートを保持するメンバ変数を定義
    template: (data: any) => string;

    // 初期化
    constructor(options?) {
        // ビューをレンダリングするタグの名前を指定。
        this.tagName = "div";

        // テンプレート。
        // underscore のテンプレートを使用。
        this.template = _.template($("#task-template").html());

        // イベントハンドラのマッピング。
        // CSS セレクタにマッチした要素にイベントハンドラを自動でセットしてくれる。
        this.events = {
            "change input[type=checkbox]": "_onCheck",
            "click a.edit-link": "_onEdit",
            "click input.save-input": "_onSave",
            "click input.cancel-input": "_onCancel",
            "click input.delete-input": "_onDelete",
        };

        super(options);

        // モデルの set メソッドで値が変更されたら change イベントが発生するので、
        // 再描画する。
        this.model.bind("change", this.render, this);

        // モデルのバリデーションに失敗したらエラーメッセージを表示。
        this.model.bind("error", this._onError, this);
    }

    private _onError(model, error) {
        alert(error);
    }

    private _onCheck() {
        var completed = this.model.get("completed");
        this.model.set({ completed: !completed });
    }

    private _onEdit() {
        this.model.set("isEditing", true);
    }

    private _onSave() {
        var name = $(this.el).find("input.name-input").first().val();
        this.model.set({ name: name, isEditing: false });
    }

    private _onCancel() {
        this.model.set("isEditing", false);
    }

    private _onDelete() {
        this.model.destroy();
    }

    // ビューをレンダリング。
    render() {
        // モデルを JSON に変換してからテンプレートに渡す。
        // { "name": this.model.get("name") } みたいに、
        // オブジェクトを渡してもいい。
        var data = this.model.toJSON();
        var html = this.template(data);
        $(this.el).html(html);
    }
}

// タスクリストを表示するビュー。
class TaskListView extends Backbone.View {
    events = {
        "click #add-input": "_onAddInputClick"
    };

    constructor(options?) {
        super(options);

        // DOM 要素を紐づける
        // こうしないと、events の内容が DOM 要素に反映されない
        this.setElement($("#main"), true);
        _.bindAll(this);

        // モデル (TaskList) の要素数が変わったら再描画。
        this.collection.bind("add", this.render);
        this.collection.bind("remove", this.render);
    }

    private _onAddInputClick() {
        var name = $("#name-input").val();
        if (_.isEmpty(name)) {
            alert("task name is empty.");
            return;
        }
        var task = new Task({ "name": name });
        this.collection.add(task);
        $("#name-input").val("");
    }

    render() {
        var taskListEl = $("#task-list");
        taskListEl.empty();

        // Task と TaskView を1対1にする。
        // テンプレート内で for 文が使えるから、表示するだけなら
        // Task 1つ1つにビューを作る必要はない。
        // でも、1対1にしておいた方が、その場編集機能が実装しやすい。
        this.collection.each(task => {
            var view = new TaskView({ model: task });
            view.render();
            taskListEl.append(view.el);
        });
    }
}

$(() => {
    var mainView = new TaskListView({ collection: new TaskList() });
    mainView.render();
});

こいつを

tsc app.ts

でコンパイルすれば、JavaScript コードが生成される。あ、app.ts は今回のサンプルのファイル名ね。コンパイル後のコードは紙面の都合で省略したけど、読みやすい JavaScript コードが生成されていた。

おわりに

書き味は JavaScript よりもだいぶ C# 寄り。型を指定した変数の定義の書き方は、最初違和感あったけどすぐ慣れた。型のおかげで書いてて安心感を感じる不思議。

サードパーティJavaScript ライブラリが使えるのも長所だけど、使うための準備が面倒なのが玉に瑕。TypeScript ユーザーが増えれば定義ファイルも増えるだろうから、今後に期待だな。なんという他力本願。

単純なサンプルならともかく、Web アプリを作るなら UI は JavaScript じゃなく TypeScript で実装したい。サーバーサイドも、 Node なら TypeScript で書けるな。TypeScript 気に入った。