AngularJS で ViewModel を定義するという設計パターン


AngularJS で ViewModel オブジェクトを作り $scope へバインドする、ということをやってみました。これが嬉しいシチュエーションとしては、

- 一画面に複数のUIコンポーネントがある。
- Controller を分ける程でもない。もしくは面倒。
	- Factory や Directive 等に分割するのも面倒。

といったところでしょうか。もしくは何も意識していないと Controller が肥大化する傾向があるので、ソースの可読性があがったりとか。ちなみに副作用やパフォーマンスなどは検証していません..

> 今回の投稿の前提となる AngularJS のバージョンは `1.3.8` です。

## 実装

完成イメージはこんな感じです。

![スクリーンショット 2015-01-05 16.56.41.png](https://qiita-image-store.s3.amazonaws.com/0/35733/f5c0498b-c07b-f51d-f678-b8fa35dfec1f.png "スクリーンショット 2015-01-05 16.56.41.png")

### Controller を書く

ViewModel のメンバーは 変数 `name` `age` `message` とメソッド `hello()` とし、その ViewModel を生成する関数 `userViewModel` を定義します。
`$scope.vm = userViewModel("山田", 34);` で $scope へバインドします。

> ViewModel をインスタンス化するメリットが無かったので、コンストラクタ関数にはしていません。してもいいと思います。

```javascript:Controller
'use strict';

angular.module('angularViewmodelDemo')
  .controller('MainCtrl', function ($scope) {

    var userViewModel = function(name, age){
      return {
        name: name,
        age: age,
        message: "",
        hello : function(){
          this.message = "こんにちは！ " + this.name + " さん。";
        }
      }
    };

    $scope.vm = userViewModel("山田", 34); //ViewModelを$scopeへバインド
  });
```

### View を書く

`vm.ほげ` で変数やメソッドにアクセスします。

```html:View
<div class="container" ng-controller="MainCtrl">
  <br>
  <p>
    名前: {{vm.name}}
  </p>
  <p>
    年齢: {{vm.age}}
  </p>
  <p>
    メッセージ: {{vm.message}}
  </p>
  <p>
    <button ng-click="vm.hello()">挨拶</button>
  </p>
  <hr>
  <p>
    新しい名前: <input value="" ng-model="vm.name">
  </p>
</div>
```

冒頭にも書きましたが、一画面に複数のUIコンポーネントがある場合に、構造がわかりやすいかも？

## ViewModel をコミット/ロールバックする機能をつけてみる

ここでいう「コミット/ロールバック」は、クライアントサイドでの話です。せっかく ViewModel 形式にしたので、ViewModel ごとバックアップを保持して、ユーザ入力を無しにできる（Undoできる）機能をつけてみます。

### Controller

```diff:Controller
'use strict';

angular.module('angularViewmodelDemo')
  .controller('MainCtrl', function ($scope) {

    var userViewModel = function(name, age){

+      var _master = {}; //commitされたViewModelを保持するオブジェクト

      return {
        name: name,
        age: age,
        message: "",
        hello : function(){
          this.message = "こんにちは！ " + this.name + " さん。";
        },
+        rollback: function(){ //元に戻す
+          $scope.vm = angular.copy(_master);
+        },
+        commit: function(){ //保存する
+          _master = angular.copy(this);
+        }
      }
    };

    $scope.vm = userViewModel("山田", 34); //ViewModelを$scopeへバインド
+    $scope.vm.commit(); //まずは初期データを保存する
  });
```

### View

```diff:View
<div class="container" ng-controller="MainCtrl">
  <br>
  <p>
    名前: {{vm.name}}
  </p>
  <p>
    年齢: {{vm.age}}
  </p>
  <p>
    メッセージ: {{vm.message}}
  </p>
  <p>
    <button ng-click="vm.hello()">挨拶</button>
  </p>
  <hr>
  <p>
    新しい名前: <input value="" ng-model="vm.name">
  </p>
+  <hr>
+  <p>
+    <button ng-click="vm.rollback()">ViewModelを戻す</button>
+    <button ng-click="vm.commit()">ViewModelを保存する</button>
+  </p>
</div>
```

デモはこちら。(GitHubページです。)
⇒ http://hkusu.github.io/angularjs-viewmodel-demo/dist/

もし ViewModel を更新したらサーバサイドのデータも更新する場合は、次のように commit のタイミングでサーバへ POST すれば良いかと。

```diff:
'use strict';

angular.module('angularViewmodelDemo')
+  .controller('MainCtrl', function ($scope, $http) {

    var userViewModel = function(name, age){

      var _master = {}; //commitされたViewModelを保持するオブジェクト

      return {
        name: name,
        age: age,
        message: "",
        hello : function(){
          this.message = "こんにちは！ " + this.name + " さん。";
        },
        rollback: function(){ //元に戻す
          $scope.vm = angular.copy(_master);
        },
        commit: function(){ //保存する
          _master = angular.copy(this);
+          $http.post('api/users/hoge_id', {
+            name: this.name,
+            age: this.age
+          });
        }
      }
    };

    $scope.vm = userViewModel("山田", 34); //ViewModelを$scopeへバインド
    $scope.vm.commit(); //まずは初期データを保存する
  });
```

> 便宜上、上記のように書いてますが、API アクセスは Factory 等に逃した方がよいかと。また実際にはエラー処理も必要です。

## おわりに

今回のソースはこちらにおいて置きます。
⇒ https://github.com/hkusu/angularjs-viewmodel-demo