TypeScriptで書くAngularJSのMVC

8 min read読了の目安(約5300字

2014-02-21に Qiita に投稿した記事のアーカイブです。本文中のリンクは動作しないことがあります。

(150522 追記)最新のチュートリアルをまとめたAngularJS モダンプラクティスを掲載しました。この記事は 2014 年 2 月に掲載したとてもふるい記事です。最新記事をどうぞご覧ください。

この記事は記録のため残します。


AngularJS はチュートリアルに沿ってただ書くだけにするとすぐ Fat Controller になる、とは他でも指摘されていますが、複数のコントローラを実装し始めた辺りから問題となってくるのが処理の重複です。すぐにでも一つのファイルにまとめて参照したいところです。

この記事(お前の Angular.js はもう MVC ではない。と言われないための Tutorial)やこの記事(AngularJS を TypeScript で書くときのあれこれ)にはずいぶんと助けられましたが、ここで自分なりの TypeScript での書き方についてまとめておきます。

Factory の例

クラスやメソッドの命名はすべて例示のためのものです。

/// <reference path="../vendor/angular.d.ts" />
/// <reference path="../app.ts" />

angular.module("myTable", []).factory("Table", ($rootScope) => {
  return new Table($rootScope);
});

interface MainScope extends ng.IScope {}

class Table {
  private rootScope: MainScope;
  private numberOfSelected: number;
  private checkboxes: {
    items: any;
  };

  constructor($rootScope) {
    this.rootScope = $rootScope;
    this.initCheckboxes();
  }

  public initCheckboxes(): void {
    this.checkboxes = {
      items: {},
    };
    this.broadcastCheckboxes();
  }

  public toggleRecord(index, event): void {
    // クリックで単一選択したり、
    // キーコンビネーションで複数選択したりの処理色々…

    this.setNumberOfSelected(n);
    this.broadcastCheckboxes();
    return;
  }

  public broadcastCheckboxes(): void {
    this.rootScope.$broadcast("Table.checkboxes", this.checkboxes);
  }

  public setNumberOfSelected(value: number): void {
    this.numberOfSelected = value;
    this.rootScope.$broadcast("Table.numberOfSelected", this.numberOfSelected);
  }
}

app.ts の例

定義したmyTableモジュールを読み込みます。

interface MyApp extends ng.IModule {}
var app: MyApp = angular.module("myApplication", ["ngResource", "myTable"]);

Controller の例

行ごとにチェックボックスの並んだ表をイメージしてください。選択処理を行うごとに選択済みの件数を格納します。($resource$qは今回の例では使用しませんが ajax 処理をしている雰囲気として…。急いでいたので any が多いのですが、TypeScript としてはきちんと書いたほうがいいです)

/// <reference path="../vendor/angular.d.ts" />
/// <reference path="../app.ts" />

app.controller("IndexCtrl", ($scope, $resource, $q, Table) => {
  return new IndexCtrl($scope, $resource, $q, Table);
});

interface MainScope extends ng.IScope {
  entities: string[];
  toggleRecord: Function;
}

class IndexCtrl {
  private scope: MainScope;
  private resource: any;
  private q: any;
  private Table: any;

  constructor($scope: MainScope, $resource, $q, Table) {
    this.scope = $scope;
    this.resource = $resource;
    this.q = $q;
    this.Table = Table;

    $scope.toggleRecord = angular.bind(this, this.toggleRecord);

    this.initEntities();
    this.on();
  }

  public on(): void {
    // Table
    this.scope.$on("Table.checkboxes", (event, value) => {
      this.scope.checkboxes = value;
    });
    this.scope.$on("Table.numberOfSelected", (event, value) => {
      this.scope.numberOfSelected = value;
    });
  }

  /**
   * Tableに委譲
   **/
  public toggleRecord(index, event): void {
    this.Table.toggleRecord(index, event);
  }

  public initEntities(): void {
    // 以下略
  }
}

View の例

実際、私は CakePHP で ctp ファイルから HTML を出力させています。

<!--略-->
<div ng-controller="IndexCtrl">
  <table class="records">
    <thead>
      <tr class="table-header">
        <th class="head-kind">KIND</th>
        <th class="head-name">NAME</th>
      </tr>
    </thead>
    <tbody>
      <tr
        ng-repeat="e in entities"
        ng-class="{selected: checkboxes.items[$index]}"
        ng-click="toggleRecord([$index], $event)"
      >
        <td class="cell-kind">{{e.kind}}</td>
        <td class="cell-name">{{e.name}}</td>
      </tr>
    </tbody>
  </table>
</div>
<!--略-->

View でのイベントを Factory で処理

<tr>に設定したng-clickをトリガーにしてtoggleRecord(index, event)が処理されますが、ただ単に Factory 内に実装しただけではトリガーが反応しないので、IndexCrtlController からTableFactory に委譲するためだけのインタフェースとしてIndexCtrlにもtoggleRecord()を実装します。event引数はキーコンビネーション選択(shift 押しながらなど)のために用意しました。

Factory のインスタンス変数を Controller が共有

どの行がチェックされているかの情報はTableFactory の変数が保有します。このままでは View 側でng-classの判定が行えないので、この変数をIndexCtrlも共有する必要があります。

このためTable側にbroadcastCheckboxes()メソッドを実装します。$rootScope.$broadcast()の引数にはチャンネル名(と便宜上呼びます)と値を設定し、これはindexCtrl側の$scope.$onで受け取ります。$onでもチャンネル名を合わせて、中の関数で受け取った値を処理します。こうすることでチェック済み行や行数の情報をTableIndexCtrlとで共有でき、値をng-classが判定し選択された行の色が CSS によって変わる仕組みです。

140224 Module を使用するよう改訂

色々考えた末に、汎用的な動作をまとめたものを特定のアプリケーションで縛るのは良くないと判断しangular.module('myTable', []).factory(...と変更しました。var appの宣言時にモジュールはまとめて読み込みますが、モジュール内との依存関係(今回の場合TableFactory)はコントローラーにて注入するため混沌とはしないはずです。Module 定義と Factory 定義の扱いについては、AngularUI の手法を参考にしました。

もうひとつ、$broadcastの名称に名前空間を与えました。AngularJS style guideによると名前リストを作れとありますが、これは煩雑になってしまうので、ドット表記による命名規則で管理するようにしています。

まとめ

長くなりましたが、Factory と Controller 間の値渡しをマスターするとこういった簡単な Web アプリケーションが自分のイメージ通りに組めていくので気分も楽です。ぜひ$broadcastを使ってみてください。

拙著の関連記事