AngularJS使ってます。

よくあるフォームの処理で、半日ほどハマッたのでメモ。

やりたいことは、

  • サーバからデータを取得
  • フォームに読み込み
  • ユーザーがなんか入力
  • 送信ボタンをアクティブにする

って、よくあるパターンのやつ。

HTML

サンプルの構成はindex.htmlとapp.jsのみ。

angularjs,bootstrapは面倒なんでCDNを使わせていただきました。

HTMLはこんな感じ。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <title>watch test</title>

    <!-- Bootstrap -->
    <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.5/css/bootstrap.min.css">
    <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.5/css/bootstrap-theme.min.css">

    <!-- AngularJS -->
    <script src="https://ajax.googleapis.com/ajax/libs/angularjs/1.4.3/angular.min.js"></script>
    <script src="./app.js"></script>
  </head>
  <body>
    <h3>Test</h3>
    <div ng-app="app">
        <div ng-controller="FormTestController">
            <form>
                <input type="text" ng-model="Model.Name" ng-change="changed($event)" />
                <input type="text" ng-model="Model.Age" />

                <button ng-click="reload($event);">Reload!</button>
                <br />
                <a class="btn btn-primary" href="" role="button" ng-class="{disabled : !Modified}">変更したらアクティブになるボタン</a>
            </form>
            変更済み?{{Modified}}<br />
            氏名:{{Model.Name}}<br />
            年齢:{{Model.Age}}<br />
        </div>
    </div>

    <!-- jQuery (necessary for Bootstrap's JavaScript plugins) -->
    <script src="https://ajax.googleapis.com/ajax/libs/jquery/1.11.3/jquery.min.js"></script>
    <script src="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.5/js/bootstrap.min.js"></script>
  </body>
</html>

ブラウザで見るとこんな感じ。(app.jsでModelをセットしてます)

Modifiedフラグでボタンのactive/inactiveを制御します。

面白くもなんとも無いですが、ま、サンプルですし。

うまくいかなかったapp.js

最初app.jsはこんな感じで書いたんですが、期待した動作にはなりませんでした。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
//このスクリプトはちゃんと動きません。
(function () {
    var app = angular.module("app",[]);

    angular.module("app").controller("FormTestController", ['$scope', function ($scope) {
        //メンバー
        var loadTimes = 0;          //ダミー生成用

        $scope.Model = null;        //編集対象
        $scope.Modified = false;    //編集されたか?フラグ

        // 初期化
        loadModel();            //初期値ロード

        function loadModel() {
            //web apiとかでモデルをゲット(今回はダミー)
            $scope.Model = { Name: "氏名", Age: 18 + (loadTimes++)};
            //クライアント上では更新されてないのでModifiedをfalseにセット
            $scope.Modified = false;
        }

        //監視対象が更新されたらModifiedをtrueにする
        $scope.$watchCollection("Model", function (newVal, oldVal) {
            if (newVal != oldVal) {
                $scope.Modified = true;
            }
        });

        //ダミー更新用イベントハンドラ
        $scope.reload = function () {
            loadModel();    //データがリフレッシュされて、変更済みフラグがリセットされるはず!
        };

    }]);
})();

やってることは単純で、$scope.$watchCollectionでModelの変更を監視して、変更があったらModified をTrueにするだけ。

これ、最初はうまくいくんですが、reloadしてもModified はtrueのままになってしまいます。

loadModelでModel変更にModifiedをリセットしてるんで、falseのままなはずと思ってたんですが、デバッグしてみるとreloadイベント終了後に$watchCollectionが発火しているご様子。しかもnewVal != oldValで。

なぜ?

理由はangularの仕様で、watch関係はイベントハンドラ処理が終わった後に実行されるから。

$applyとそれに続くLife cycleの説明をよーーーーく読むと解るような気がします。

angularでのイベント処理は

  • Formイベントとかタイマーイベントとかのときはangularが$apply経由でハンドラ呼ぶよ。
  • $applyの中ではハンドラ実行後に$digest呼ぶよ。
  • $digestでwatch処理するよ。

ということなので、ハンドラ内で処理順を気にしても無駄なのでした。

結局どうしたか

ロード直後のModelと変更後のModelを比較するという、とても効率の悪そうな方法しか思い浮かびませんでした。

ただwatch内で比較するので、変更されたっぽいタイミングのみでの比較になるので、そんなに負荷はかからないはずと信じます。

んで、改良版がこちら

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
(function () {
    var app = angular.module("app",[]);

    angular.module("app").controller("FormTestController", ['$scope', function ($scope) {
        //メンバー
        var loadTimes = 0;          //ダミー生成用
        var originModel=null;       //編集前Model(シリアライズ済み)

        $scope.Model = null;        //編集対象
        $scope.Modified = false;    //編集されたか?フラグ

        // 初期化
        loadModel();            //初期値ロード

        function loadModel() {
            //web apiとかでモデルをゲット(今回はダミー)
            $scope.Model = { Name: "氏名", Age: 18 + (loadTimes++)};
            //編集前モデルを保存
            originModel=JSON.stringify($scope.Model );
            //クライアント上では更新されてないのでModifiedをfalseにセット
            $scope.Modified = false;
        }

        //監視対象が更新されたらModifiedをtrueにする
        $scope.$watchCollection("Model", function (newVal, oldVal) {
            if (newVal != oldVal) {
                var curModel=JSON.stringify(newVal);    //現在のModelをシリアライズ
                if(originModel!=curModel){              //シリアライズした値を比較(Deep Compare)
                    $scope.Modified = true;             //変更あり!
                } else{
                    $scope.Modified = false;            //読み込んだ当時の姿です。
                }
            }
        });

        //ダミー更新用イベントハンドラ
        $scope.reload = function () {
            loadModel();    //データがリフレッシュされて、変更済みフラグがリセットされるはず!
        };

    }]);
})();

読み込み直後にJSON.stringifyでオリジナルを文字列(originModel)として保存して、watchで比較してます。

この方法だと、フォーム上で変更しても元の値を入力しなおすとModifiedをfalseになるような処理もかけます。

angularの理解が進めば、もっと良い方法があるような気もしますが、現時点ではこれで。

だれか良い方法おしえてー。