こんにちは、JavaScripterの三宅です。
最近AngularJSを用いたアプリケーションを開発する機会が多いのですが、その際の考え方や実装の方針について書いていきます。
説明に使うソースコードはここで確認することができます。
Angular1.xとAngular2.0
現在、angular.ioでAngular2.0の開発が進められています。
正式なリリース日は発表されていないのですが、2015年末から2016年初めと言われています。
現時点ではまだα版でありまだ仕様が決定していないのですが、Angular1.xと比較し別のフレームワークと言えるほどの大きな変更が加えられます。
特に以下の変更はアプリケーションのアーキテクチャによっては、移行に際して大幅な改修が必要になることが予想されます。
- TypeScript/ECMAScript6の採用
- Controllerや$scopeの廃止によるコンポーネントベースへの変化
- 双方向データバインディングの廃止
現時点でα版の2.0を採用することは現実的でなはいのでAngular1.xを用いることになるのですが、新しくアプリケーションを開発する際は上記を意識したアーキテクチャとすると、将来2.0がリリースされた際にスムーズに移行できるのではないかと考えています。
今回は、上記の3点を踏まえてどのような考え方で実装しているかを簡単に紹介していきます。
TypeScript/ECMAScript6の採用
AngularJSに限らず、新しいプロジェクトではほぼECMAScript6(ES6)を用いて開発しています。
ES6で追加された機能が魅力的であることに加え、Babelなどのトランスパイラの充実、Node.jsのES6のサポートなど、ブラウザ、サーバサイド共にES6を利用できる環境がほぼ整ったと考えているためです。
AngularJSは独自のモジュール機構を有しているため、JavaScriptファイルを全て結合してからトランスパイルする手法を取ることが多いのですが、Angular1.3.14からCommonJSが採用されたため、Browserifyなどのモジュール管理の仕組みを用いることも可能だと思います。
class MyAppComponent {
constructor(PersonDataStore) {
this.PersonDataStore = PersonDataStore;
this.person = PersonDataStore.person;
}
}
上記はControllerをES6のClass構文を用いて定義したものです。
angular.module('app').controller('MyAppComponent', MyAppComponent);
controller()メソッドの引数として定義したクラスを渡すと、Controllerとして登録することができます。
クラスのconstructor()の引数であるPersonDataStoreはServiceで、DIを行っています。現在のプロジェクトでは、いわゆるminify対策としてng-annotateを使っていますが、使わないのであれば、constructor()内に以下を追記することで対応可能です。
MyAppComponent.$inject = ['PersonDataStore'];
コントローラを利用する際は、ng-controller="MyAppComponent as app"のようにController As構文を用います。
これにより、Viewから$scopeを介さずに直接Controllerを参照でき、クラスで定義したプロパティやメソッドを利用することができます。
コンポーネント志向
Angular2.0はReact.jsの同様のコンポーネント志向のフレームワークとなります。
データとロジックをカプセル化したコンポーネントを定義し、それを組み合わせていくことでアプリケーションを構築するイメージです。
アプリケーション全体の状態やデータを保持するルートコンポーネントを起点としたツリー構造となります。
React.jsではコンポーネントのデータは親コンポーネントから与えられ、通常不変となります。コンポーネントの状態が変更された際はその配下のコンポーネントは全て再描画されます。
React.jsはVirtual DOMを用いて差分だけを実際に再描画することでパフォーマンスを担保しています。Angular1.xはその仕組みを持たないので変更が起こった際に再描画を行うのは現実的ではありませんが、ツリー構造であること、ルートコンポーネントが状態を持つことを意識することで、データの流れが把握しやすくなります。
class GreetingComponent {
constructor($scope) {
this.name = null;
$scope.$watch('name', (newValue) => {
this.name = newValue;
});
}
}
function createGreetingComponent() {
return {
controller: GreetingComponent,
controllerAs: 'greeting',
template: '<h1>Hello {{greeting.name}}</h1>',
scope: {
name: '=appName'
}
};
}
angular.module('app')
.directive('appGreeting', createGreetingComponent);
Angular1.xのDirectiveを用いてコンポーネントを作成したコードです。
<app-greeting app-name="{{parent.value}}"></app-greeting>
Directiveを利用する際は、上記のように記述します。
app-nameはコンポーネントが親コンポーネントから与えられるデータで、Directiveの$scopeのプロパティとなります。
GreetingComponentのconstructor()でクラスのプロパティにその値を代入し、コンポーネントの内部ではその値を利用します。
$scope.$watch('name', (newValue) => {
this.name = newValue;
});
constructor()内で、$scopeのnameプロパティを監視する記述です。再描画の代替として親コンポーネントから与えられた値が変更された際に、そのイベントをトリガとして明示的にクラスのプロパティをアップデートします。
ブラウザにレンダリングされる値は、Angularの双方向データバインディングによってクラスのプロパティがアップデートによって更新されます。
双方向データバインディングの廃止
Angular1.xの特徴とも言える双方向データバインディングはAngular2.0では廃止されます。
双方向データバインディングは非常に便利ではあるのですが、個人的には以下の理由から限定的な範囲で利用するようにしています。
- 複数のController間やネストしたスコープ間でモデルを共有した際にいつ、どこで値が変更されたのか把握できず、バグの温床になる
- モデルの更新方法によっては(setTimeout()など)画面の更新が行われず、コンポーネントを定義する際に外部の状態を意識する必要がある
class Person {
constructor(name) {
this.name = name;
}
}
class PersonDataStore {
constructor() {
this.person = new Person('Alice');
}
}
angular.module('app')
.service('PersonDataStore', PersonDataStore);
サンプルでは、データを保持するServiceを定義しています。ルートコンポーネントがデータの参照を保持し、それを配下のコンポーネントに伝達するような構成としています。
class TextFieldComponent {
constructor($scope, PersonDataStore) {
this.PersonDataStore = PersonDataStore;
this.text = $scope.text;
}
didChange() {
this.PersonDataStore.person.name = this.text;
}
}
function createTextFieldComponent() {
return {
controller: TextFieldComponent,
controllerAs: 'textField',
template: '<input type="text" ng-model="textField.text" ng-change="textField.didChange()"></input>',
scope: {
text: '=appText'
}
};
}
angular.module('app')
.directive('appTextField', createTextFieldComponent);
ng-modelの双方向バインディングによってTextFieldComponentのtextプロパティは入力によって更新されますが、そのスコープはコンポーネントの内部に閉じています。
ng-changeによって呼び出される、didChange()メソッドによって変更された値でDataStoreが保持するオブジェクトのプロパティを明示的に更新しています。
各コンポーネントも親から渡されたDataStoreの参照を$scopeに保持していますが、その変更をViewに反映するのは各コンポーネントの責務となります。
GreetingComponentは$scope.nameが変更された際に自身のnameプロパティにその変更を反映しViewを更新していますが、TextFieldComponentは特にアクションを行いません。親コンンポーネントから渡されたデータは、初期値の設定にしか用いないようになっています。
このように、DataStoreからルートコンポーネントを経由し、その配下のコンポーネントというような一方向のデータフローのアーキテクチャとすることで、モデルの値の変更を制御しやすくなると考えています。
今回は簡単なサンプルで、コンポーネントベース、一方向のデータフローのアーキテクチャに基づくAngularJSアプリケーションの考え方を説明しました。
実際の開発ではui-routerの利用やサードパーティのライブラリを用いる場合など、そのまま適用できないケースもあるとは思います。
また、CSSも合わせてコンポーネント志向の設計とする必要が出てきます。
ただコンポーネントベースの考え方は、
Angular2.0だけでなくReact.jsでも取り入れられていること、HTML/JavaScript/CSSを一つのコンポーネントとして定義するWeb Componentsの策定が進んでいることなどから、今後のフロントエンドアプリケーションのスタンダートになってくると予想されます。
AngularJSを用いない場合でも、今回説明した考え方は有効だと思います。