最初に

AngularJSを使い始めて1年半、ここ最近ではまったのがスコープ継承による値の問題でした。

【AngularJS】スコープの継承で地味にハマりがちなこと で丁寧に解説されています)

そんな時にcontrollerAsを使うと値の問題から解放されるよ、と教えてもらいました。 controllerAsの使い方と既存コードに導入する方法を調べているうちに、自分が書いていたコードでスコープの使い方が間違っていたことも浮き彫りになりました。 この記事ではそんな問題点を解消しつつcontrollerAsを使う方法について紹介したいと思います。

スコープの使い方の何が間違っていたか

問題のあるコード

// controller
.controller('MainCtrl', ['$scope', function ($scope) {
  $scope.list = [{id:1}, {id:2}];
  $scope.flags = [true, false];
  $scope.value = 0;
  $scope.process = funtion (index) { if ($scope.list[index].id == 1) ... };
}]);
// template
<div ng-controller="MainCtrl">
  <div ng-repeat="item in list">
    <div ng-hide="flags[$index]">
      <button ng-click="process($index)" ...
      <input type="text" ng-model="$parent.value" ...

自分で気づいた、または指摘された問題箇所は以下の通りです。

1. コントローラに何でも突っ込むくせがついている

  • 子コントローラで持つべきプロパティを親コントローラが配列で持っている

2. $indexで配列の位置を特定

  • フィルタ機能を使用すると$indexはフィルタ後のインデックスになりバグを産みやすい

3. $parentを多用

  • ng-repeatの階層が変わると位置が崩れる
  • $parent.$parent.$parent...と書くと対象のオブジェクトが分かりづらい

4. 何も考えていなかったためスコープ継承の値の問題にはまった

  • $watchが実行されない
  • AngularJSでは子スコープ内で親スコープの値を変更すると別の変数になるため
// template
<div ng-controller="MainCtrl">
  <div my-directive ng-model="value" ...
// directive
.directive('myDirective', function () {
  return {
    scope: true, // <-- 新たなスコープを作成
    link: function (scope) {
        scope.value = xxx; // 子スコープ内で値を変更
    }
  }
});
// controller
$scope.$watch('value', function (newVal, oldVal) { 
  console.log(newVal); // <-- 実行されない
  // $scope.value と <ng-model="value" は別の変数になっているため
});

plunkerにサンプルコードを上げてみたので、動かしてみてください。

controllerAsを導入しよう

controllerAsはangular 1.2で登場した機能です。

メリット

  • コントローラにas 名前を付けることでオブジェクトとして扱う
  • テンプレートからは[オブジェクト名].[プロパティ名]と明示的に指定するため、分かりやすい
  • スコープ間で別の変数が生成される問題から解放される

AngularJS Documentを読んでみる

AngularJS / API Reference / ngController

Exampleの部分に従来の$scopeを使ったものとcontrollerAsを使ったものの2種類のサンプルがあります。 また「一般的に使われているのは$scopeのほうだけどcontrollerAsを使うとこんなメリットがあるよ」と、以下のような点が挙げられています。

  • 複数のコントローラがある場合にどれを使うのか明示的に書ける
  • コントローラをクラスとして書いている場合、コントローラ内からスコープのプロパティ・メソッドへ簡単にアクセスできるようになる
  • バインディングの中に常に.が入るので、プロトタイプ継承を気にしなくて良くなる

書き方

1. コントローラに名前を付ける

テンプレートから参照する時に名前を付ける

<div ng-controller = "MainCtrl as main" 

またはルーティングの時に名前を付けることもできる

$routeProvider.when('/page/', {controller: 'MainCtrl', controllerAs: 'main'})

2. コントローラの処理とテンプレートの参照を変更する

従来通りの$scopeを使用する書き方

.controller('MainCtrl', ['$scope', function ($scope) {
    $scope.value = 'test';
    $scope.func = function () { ... };
}]);
// template
<div ng-controller="MainCtrl">
    <p>{{ value }}</p>
    <button ng-click="func()">ボタン</button>
</div>

controllerAsを使うとこう変わる

.controller('MainCtrl', function () {
    // $scopeはthisに変わる
    this.value = 'test';
    this.func = function () { ... };
});
// template
<div ng-controller="MainCtrl as main">
    // [オブジェクト].[プロパティ]の形式で書く
    <p>{{ main.value }}</p>
    <button ng-click="main.func()">ボタン</button>
</div>

一番最初の問題のあるコードに適用してみる

元のコード

// controller
.controller('MainCtrl', ['$scope', function ($scope) {
  $scope.list = [{id:1}, {id:2}];
  $scope.flags = [true, false];
  $scope.value = 0;
  $scope.process = funtion (index) { if ($scope.list[index].id == 1) ... };
}]);
// template
<div ng-controller="MainCtrl">
  <div ng-repeat="item in list">
    <div ng-hide="flags[$index]">
      <button ng-click="process($index)" ...
      <input type="text" ng-model="$parent.value" ...

適用後

$scopecontrollerAsに変更しつつリファクタリングしてみました。

.controller('MainCtrl', [function () {
    this.list = [{id:1}, {id:2}];
    this.value = 0;
}])
.controller('ItemCtrl', [function () {
    // thisに対しての操作をまとめることで、処理を書く場所が明確になる
    this.getFlag = function () { ... };
    this.process = function () { if (this.id == 1) ... };
}]);
// template
<div ng-controller="MainCtrl as main">
  <div ng-repeat="item in list" ng-controller="ItemCtrl as item">
    <div ng-hide="item.getFlag()">
      <button ng-click="item.process()" ... // 何に対する操作か分かりやすくなる
      <input type="text" ng-model="main.value" ... // スコープ間の値問題は発生しない

乱用していた$index $parentがなくなり、またコントローラ名を明記するため、処理の内容が分かりやすくなりました。

$scopeからthisになると何が変わるのか

controllerAsを導入するにあたり一番気になったのがここです。 ようやくスコープの継承や使い方について慣れてきた頃だと思ったのにcontrollerAsを使うと$scopethisに入れ替わってしまいます。

$scopeはもう使えない?便利だった$watchはなくなるの??と疑問が沸く中、まずは$scope thisとは一体何なのかを調べてみました。

$scope

  • $scopeはAngularJSが提供する$scopeオブジェクトのインスタンスである
  • $scopeをコントローラにインジェクトして使う
  • 親スコープ、子スコープへの参照を持っている
.controller('MainCtrl', ['$scope', function ($scope) {
    $scope.value = 1;
    $scope.func = function () {};

    console.log($scope); 
    // --> Object { $id, $ChildScope: function, $$watchers: array, $parent... }
}]);

this

  • JavaScriptのオブジェクトである
  • コントローラ内で宣言されたプロパティとメソッドを持っている
.controller('MainCtrl', [function () {
    this.value = 1;
    this.func = function () {};

    console.log(this); 
    // --> Object { value: 1, func: function }
}]);

controllerAsでは$scopeは使えないのか?

インジェクトすれば普通に使えました。

.controller('MainCtrl', ['$scope', function ($scope) {
    // 但し$scopeとthisはオブジェクトが違うので注意
    this.value = 1;
    $scope.value = 2;
}]);

注意

上記の例ではサンプルとしてthis$scopeの両方に値を保存していますが、本来はthisを使うのが正しい方法です。

$scope.$watchはどこへ行った?

$scope

$scope.$watch('value', function (value) { 
    console.log('new value is ' + value); 
});

controllerAs

$watch$scopeのメソッドなのでインジェクトして利用します。 ただし$scopeのプロパティでないものを監視するため、ひと工夫が必要です。(詳細は後述)

.controller('MainCtrl', ['$scope', function ($scope) {
    this.value = 1;
    $scope.$watch(
        angular.bind(this, function () { return this.value; }),
        function (value) { console.log('new value is ' + value); }
    );
}]);

ユニットテストはどう変わる?

$scope

describe('scope value', function() {
    var $scope = null;

    beforeEach(inject(function($rootScope, $controller) {
        $scope = $rootScope.$new();
    }));

    it('test', function() {
        expect($scope.value).toEqual('expected value');
    });
});

controllerAs

$controllerでコントローラを作成できるため、それほど難しくなさそうです。

describe('controllerAs value', function() {
    var ctrl = null;

    beforeEach(inject(function($controller) {
        // テスト対象はスコープからコントローラに変わる
        ctrl = $controller('MainCtrl', {});
    }));

    it('test', function() {
        // コントローラを通して値を検査する
        expect(ctrl.value).toEqual('expected value');
    });
});

thisの落とし穴

これはAngularJSというよりJavaScriptの仕様の問題ですが、$watchの動作を調べていた時につまづいたので紹介します。

ネストされた関数内でthisはグローバルオブジェクトを参照する

.controller('MainCtrl', ['$scope', function ($scope) {
    this.value = 1;
    this.value2 = 'initial value';

    // value1に連動してvalue2を変更
    $scope.$watch(
        angular.bind(this, function () { return this.value; }),
        function (value) {
             // グローバルオブジェクト(window.value2)に値を設定しているため期待通りの動作にならない
             this.value2 = 'changed value';
        }
    );
}]);

関数の引数にクロージャを渡した場合、関数がネストするためthisはグローバルオブジェクトを参照します。 (ECMAScriptの仕様、将来的に変わる可能性もあります)

thisがコントローラを参照するにはどうしたらいいのか?

angular.bind()することでthisを指定することができます。

.controller('MainCtrl', ['$scope', function ($scope) {
    this.value = 1;
    this.value2 = 'initial value';

    $scope.$watch(
        angular.bind(this, function () { return this.value; }),
        angular.bind(this, function (value) {
          this.value2 = 'changed value';
        })
    );
}]);

補足

  • JavaScriptではthisを指定するためにcall() apply() bind()が用意されている
  • これらのメソッドを使用するとクロージャのthisを指定する事ができる
  • AngularJSにはFunction.apply()をラップしたangular.bind()があるためこれを使用する

apply()についてはapplyとcallの使い方を丁寧に説明してみるがとても分かりやすかったです。

または$watchを定義している関数内でthisへの参照を保存する方法も有効です。

.controller('MainCtrl', ['$scope', function ($scope) {
    this.value = 1;
    this.value2 = 'initial value';
    
    var ctrl = this;
    
    $scope.$watch(
        function () {
          // 関数内にctrlが存在しないためスコープチェーンをたどって上の階層のctrlが参照される
          return ctrl.value;
        },
        function (value) {
          ctrl.value2 = 'changed value';
        }
    );
}]);

どちらを使うかは個人的な好みですねが、angularJSのようなフレームワークを使う以上は提供されている機能を利用したほうが得策だと思うので、個人的には前者のangular.bind()が好みです。

テストしてみよう

describe('test', function() {
    var ctrl = null;

    beforeEach(inject(function($rootScope, $controller) {
        $scope = $rootScope.$new();
        ctrl = $controller('MainCtrl', { $scope: $scope });
    }));

    it('value2 is changed', function() {
        ctrl.value = 100;
        expect(ctrl.value2).toEqual('changed value');
    });
});

結果 … 動かない?

test-fail

controllerAsでも$digestは必要

  • $watchng-xxxはAngularJSのダイジェストループで評価・処理される
  • ダイジェストループはDOMイベントやHTTPのレスポンス受信によって起こる
  • テスト環境ではイベントが発生しないためダイジェストループを発生させる必要がある
describe('test', function() {
    var $scope = null;
    var ctrl = null;

    beforeEach(inject(function($rootScope, $controller) {
        $scope = $rootScope.$new();
        ctrl = $controller('MainCtrl', { $scope: $scope });
    }));

    it('value2 is changed', function() {
        ctrl.value = 100;
        $scope.$digest(); // <-- ダイジェストループを発生させる
        expect(ctrl.value2).toEqual('changed value'); 
    });
});

動いた!

test-success

まとめ

controllerAsを導入することで$scopengControllerが分離され、テンプレートからの参照もオブジェクト.内容と明確になります。動作優先でコードを書いているとついつい冒頭に挙げたような問題が起こりがちなため、常に「対象が何か、何の操作をするのか」を気にしながら書けるのが一番のメリットでないかと思います。