実践!AngularJS 前編 Controller間で値を共有 1

AngularJSを多少使い始めた人がぶつかりがちな問題を、さまざまな方法で解決していきます。今回は$scopeオブジェクトを使い、コントローラー間で値を共有する様子を見てみましょう。

発行

著者 宇野 陽太 フロントエンド・エンジニア
実践!AngularJS シリーズの記事一覧

はじめに

このシリーズは、AngularJSを使うときにぶつかりやすい問題について、さまざまな解決方法を紹介するものです。ある程度AngularJSを理解している人に向けた記事ですので、AngularJSについて基本から知りたいという人は、CodeGridの過去のシリーズ「攻略!AngularJS」を参照してください。

AngularJSを用いて、ある程度の規模の開発をした場合、あるコントローラーの中に別のコントローラーを作る機会がよくあると思います。そのときに、親のコントローラーと子のコントローラーの間で値を共有したいと思うことがあるでしょう。本記事では、コントローラー間で値を共有する方法について、いくつかのパターンを紹介していきます。

なお、このシリーズで紹介するサンプルは次のリポジトリにまとめてあります。併せて参照してください。

実践!AngularJSサンプルリポジトリ

$scopeオブジェクトを使う

コントローラー間で値を共有する方法でまず始めに思い浮かぶのは、$scopeオブジェクトを使って値を共有することだと思います。AngularJSでは、親のコントローラーの$scopeに定義されたプロパティは、子のコントローラーへと継承されます。

その仕組みを使えば簡単に値の共有を行えるように思えるかもしれませんが、実はここには落とし穴があります。次のような条件で簡単なフォームを作ってみましょう。

  • 親のコントローラーのinput要素に入力した値が、子のコントローラーのinput要素へ反映される
  • 子のコントローラーのinput要素へ値を入力すると、親のコントローラーのinput要素に反映される

$scopeを使って作ったものが、以下のデモです。

しかし、実際にこのデモを動かしてみると、意図した通りに動いてくれません。親のinput要素から値を入力すると、子のinput要素へと変更が反映されます。ところが、子のinputに値を入力しても、親のinputへと変更が反映されません。さらに、一度子のinput要素に値を入力してしまうと、その後親のinput要素で値を変更しても、子のinput要素へと変更が反映されなくなってしまいます。

なぜこのようなことが起こってしまうのでしょうか。

$scopeの継承の仕組みを知る

この現象の原因を探るためには、$scopeの継承の仕組みを知る必要があります。まずはこのデモのソースコードを見てみましょう。

var app = angular.module('app', []);

app.controller('ParentCtrl', function($scope) {
  $scope.message = 'Hello World!!'
});

app.controller('ChildCtrl', function($scope) { });
<div ng-controller="ParentCtrl" class="parent">
  <h2>ParentCtrl</h2>
  Message here
  <input type="text" ng-model="message">
  <div ng-controller="ChildCtrl" class="child">
    <h3>ChildCtrl</h3>
    Change parent message<br>
    <input type="text" ng-model="message">
  </div>
</div>

コントローラーParentCtrl$scopemessageという名前でプロパティが定義されています。テンプレートでは、ParentCtrlで定義したmessageプロパティを、ParentCtrlChildCtrlが持つそれぞれのinput要素のng-model属性値にセットしています。一見すると意図したとおりに動きそうですが、そうはなりません。

コントローラーが$scopeを継承するとき、親の$scopeに定義されたプロパティは、子の$scopeのprototypeへと格納されます。このとき、子の$scopeに存在しないプロパティを参照すると、自分自身のprototypeからそのプロパティを探そうとします。

つまり、ChildCtrlのinput要素がmessageプロパティを取得する際、自分自身にはmessageプロパティが存在していないため、prototypeに格納されたParentCtrl$scopeを参照します。そのため、「親のinput要素を変更すると子のinput要素へと変更が反映される」ということが可能になるのです。

一方、ChildCtrlのinput要素から変更した場合はどうでしょう。この場合、入力が行われた子のinput要素のng-modelにより、ChildCtrl$scopeに新しく同名のプロパティ(この場合はmessage)が作られます。この時点で、ChildCtrl$scopeから見ると、messageプロパティは自分自身が持っていることになるので、ParentCtrl$scopeが格納されているprototypeへの参照を切ってしまいます。

そうなると、ParentCtrlChildCtrl$scopeは、お互いに値を受け取ることも値を取り出すこともできなくなってしまい、「子のinput要素で変更しても親のinput要素に反映されない」「一度でも子のinput要素で変更すると、親のinput要素で変更しても子に反映されない」という現象が起きてしまうのです。

この現象は、コントローラー間に限った話ではなく、ディレクティブを作る際にscopeプロパティにtrueを指定した場合でも起こり得るので、注意が必要です。

JavaScriptのprototype継承

JavaScriptにおいて、あるオブジェクトが特定のプロパティを探すとき、まず自分自身からそのプロパティを探し、見つからなかった場合は自分自身のprototypeに同名のプロパティがあるかを探します。それでも見つからない場合はprototypeのprototype……prototypeのprototypeのprototype……と探していき、最終的に見つからない場合はundefinedとなります。

AngularJSの$scopeでも、これと同じことが行われています。

中間オブジェクトを使って継承させる

これらのことを踏まえ、次のデモではどちらのinputを編集してもお互いに変更が反映されるようにしました。ParentCtrlの一部を修正しています。

ソースコードを見てみましょう。どこが変わったのでしょうか。

app.controller('ParentCtrl', function($scope) {
  // 中間オブジェクトを作ってプロパティを紐付ける
  $scope.vm = {};
  $scope.vm.message = 'Hello World!!'
});
<div ng-controller="ParentCtrl" class="parent">
  <h2>ParentCtrl</h2>
  Message here
  <input type="text" ng-model="vm.message">
  <div ng-controller="ChildCtrl" class="child">
    <h3>ChildCtrl</h3>
    Change parent message<br>
    <input type="text" ng-model="vm.message">
  </div>
</div>

最初の動かなかった例と比べて変更しているのは、一箇所だけです。動かなかった例では、$scopeに直接messageプロパティを定義していましたが、今度はvmというオブジェクトを間に挟んでいます。こうすると、input要素によって変更されるのは、vmオブジェクトの中のmessageプロパティとなり、vmオブジェクトそのものは書き換わらなくなります。

これにより、ChildCtrl$scopeの中に新しくvmというオブジェクトが作られることもなくなり、prototypeへの参照が残り続け、意図した通りに親子のコントローラー間で値の共有を行えるようになりました。

Controller As記法を使う

AngularJS 1.2からController As記法が使えるようになっています。これは、$scopeを用いる場合に$scopeオブジェクトにプロパティを定義していた代わりに、コントローラーのthisに各プロパティを定義していき、ng-controllerで任意の変数にコントローラーのインスタンスを格納してテンプレートで使えるようにする、というものです。

このController As記法を使って、コントローラー間の値を共有してみましょう。まずはデモを見てみます。

前のデモと同様に、親と子のinput要素のどちらで入力しても、同じ内容がそれぞれのinput要素に表示されます。コードを見てみましょう。

app.controller('ParentCtrl', function($scope) {
  this.message = 'Hello World!!'
});

app.controller('ChildCtrl', function($scope) { });
<div ng-controller="ParentCtrl as parent" class="parent">
  <h2>ParentCtrl</h2>
  Message here
  <input type="text" ng-model="parent.message">
  <div ng-controller="ChildCtrl as child" class="child">
    <h3>ChildCtrl</h3>
    Change parent message<br>
    <input type="text" ng-model="parent.message">
  </div>
</div>

JavaScriptのコードでは、ParentCtrlthismessageというプロパティを作り、文字列を代入しています。ここはあまり大きく変わっていません。

一方、HTMLでは、前掲のデモのコードでng-controller="[コントローラー名]"となっていたところが、ng-controller="[コントローラー名] as [変数名]"となっています。このように書くことで、ParentCtrlのインスタンスが変数parentへ、ChildCtrlのインスタンスが変数childへ格納され、それぞれの変数からそれぞれのコントローラーのthisに定義したプロパティ/メソッドを参照することができるようになります。

input要素のng-model属性値も、親子ともにparent.messageとなっています。$scopeを用いた場合は、コントローラーの中では$scopeに定義(もしくは継承)されたプロパティを使わざるを得ませんでしたが、Controller As記法を用いた場合では、任意のコントローラーが持っているプロパティそのものを参照することができます。

$scopeを用いた場合では、継承により意図しないプロパティの参照や上書きなどが発生する可能性があります。一方、Controller As記法を用いた場合では、それぞれのコントローラーで独立したプロパティを定義できるため、そういった懸念が必要なく、メンテナンス性を向上できるなどのメリットがあります。

ただ、今回のデモは値を受け取って表示するだけでしたが、parent.messageの値をChildCtrlで受け取って加工したいなどといった場合は、値を受け取るメソッドを用意したり、次回で解説するサービスを利用するなど工夫が必要になります。

まとめ

今回はAngularJSのコントローラー間で値を共有するという観点から、$scopeの仕組みと気を付けたい落とし穴について解説しました。また、各コントローラーで独立したプロパティを定義できる、Controller As記法を使った値の共有も解説しました。

次回は、イベントやサービスを使った値の共有を解説します。