最初に
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" ...
適用後
$scope
をcontrollerAs
に変更しつつリファクタリングしてみました。
.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
を使うと$scope
がthis
に入れ替わってしまいます。
$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');
});
});
結果 … 動かない?
controllerAs
でも$digest
は必要
$watch
やng-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');
});
});
動いた!
まとめ
controllerAs
を導入することで$scope
とngController
が分離され、テンプレートからの参照もオブジェクト.内容
と明確になります。動作優先でコードを書いているとついつい冒頭に挙げたような問題が起こりがちなため、常に「対象が何か、何の操作をするのか」を気にしながら書けるのが一番のメリットでないかと思います。