キノコの自省録

日々適当クリエイト

AngularJS1.xで$scopeを使わず親子間通信

今ホットなライブラリのAngularJSですが、あまりにも更新が速いためか、ちょっと前の情報でもすぐ陳腐化してしまっています。そのせいか、なかなか欲しい情報に辿り着けないという困った状況になっている気がします。

そんなAngularJSですが、1.5以上ではDirectiveの代わりにComponentが導入され、さらに$scopeを使わない書き方が推奨されています。Directiveの代わりにComponentを使用する点はあまり問題がないのですが、$scopeの便利さにべったり依存していると、いざ$scopeが禁止されてしまった場合、どう書いていいかわからない事態に陥ります。特に親子間通信($emit, $broadcast)で困ります。実際困りました。ということで、$scopeの甘い誘惑に耐えて親子通信をするお話がメイン。

解決課題

$scopeを使わず、

  • 子から親のオブジェクトを更新
  • 親から子へのオブジェクト更新通知
  • 子から親へ引数付きメソッド呼び出し

を行います。

なお、対象はタイトルの通り、AngularJS(1.x系)です。Angular2系ではありません。このエントリは2017年9月27日に書かれています。

例題

例として、こんなイメージのページを作るとします。左に毒キノコの一覧が出ていて、キノコを選択すると、右ペインに説明や写真が表示されるといった具合です。

f:id:kinokorori:20170904015407p:plain

Componentおさらい

DirectiveはComponentに置き換えましょう。もうng-controllerとかも使っていませんよね?ng-controllerは早めに消しましょう。オブジェクトの意識が低くなり、再利用が非常にしづらくなります。

下記は、キノコの一覧をサーバから取得して表示するみたいなイメージのコードです。

<!-- kinoko.html -->
<div>
    <!-- キノコ一覧(左ペイン)-->
    <kinoko-list></kinoko-list>
</div>
<div>
    <!-- キノコの説明文(右ペイン) -->
    <kinoko-explain></kinoko-explain>
</div>

//kinokolist.js

class KinokoListController {
    //インジェクションはコンストラクタで指定可能
    constructor($http) {
        this.$http = $http;
    }

    $onInit() {
        this.kinokolist = [];
        //$httpでリストを取得するなど
        var self = this;
        this.$http({
            method: 'get',
            url:'/kinokolist',
        })
        .then(function(res) {
            //メンバ変数kinokolistにデータを入れる
            self.kinokolist = [...];
        }, function(err) {
            //エラー処理
        });
    }

    onclick(kinoko) {
        console.log(kinoko);
        // 親へ通知したい!
    }
}

angular.module()
.Component("kinokoList", {
    template: ['<div id="kinokolist" ng-repeat="kinoko in KinokoCtrl.kinokolist">',
                  '<p ng-click="KinokoCtrl.onclick(kinoko)">{{kinoko}}</p>',
               '</div>'].join(""),
    controller: KinokoListController,
    controllerAs: 'KinokoCtrl'
});

※ $httpですが、Angular1.6ではsuccess()が使えなくなっています。上記例のようにthen()に置き換えましょう。

親への通知

左ペインのキノコ一覧のキノコが選択されたとき、選択されたキノコを親に通知したいという場合を考えます。

敗北例

$emitを使用する敗北例です。constructor($http, $scope)にして$scopeをインジェクション、onclickで$emitを使用するとこんな感じになります。

//kinokolist.js

constructor($http, $scope) {
    this.$scope = $scope;
..(略)..

onclick(kinoko) {
    this.$scope.$emit('selectedkinoko', kinoko);
}

これで親に通知されます。しかし$scopeを使用しています。敗北です。

勝利例

bindingsを使用して、親のオブジェクトを透過的に参照する方法を使います。イメージとしてはこんな感じです。

f:id:kinokorori:20170903212614p:plain

コード的には、以下のスキームに従います。

  1. 親のコントローラにオブジェクト実体を定義
  2. コンポーネントのhtmlタグに属性を追加し、valueとして親コントローラのオブジェクトを参照するよう定義
  3. 子のコンポーネント(KinokoList)のbindingsに、オブジェクト参照(つまり'=')で属性を追加
  4. onclickでその属性を書き換え

① 親のコントローラにオブジェクト実体を定義

コンポーネントを司るControllerをKinokoParentControllerとして、次のような感じでオブジェクトを保持しておきます。

//kinokoparent.js
class KinokoParentController {
    constructor() {
    }

    $onInit() {
        this.selected_kinoko = ""
    }
}

app.Component("kinokoParent", {
    templateUrl: "kinoko.html",
    controller: KinokoParentController,
    controllerAs: 'ParentCtrl'
}

コンポーネントのhtmlタグに属性を追加し、valueとして親コントローラのオブジェクトを参照するよう定義

kinoko.htmlのkinoko-listタグに属性を追加します。

<!-- kinoko.html -->
<div>
    <!-- キノコ一覧(左ペイン)-->
    <kinoko-list selectkinoko="ParentCtrl.selected_kinoko"></kinoko-list>
</div>
<div>
    <!-- キノコの説明文(右ペイン) -->
    <kinoko-explain></kinoko-explain>
</div>

③ 子のコンポーネント(KinokoList)のbindingsに、オブジェクト参照(つまり'=')で属性を追加

要するに、②で追加したselectkinokoのbindingsを定義するということです。

//kinokolist.js

app.Component("kinokoList", {
    template: ['<div id="kinokolist" ng-repeat="kinoko in KinokoCtrl.kinokolist">,
                  '<p ng-click="KinokoCtrl.onclick(kinoko)">{{kinoko}}</p>',
               '</div>].join(""),
    controller: KinokoListController,
    controllerAs: 'KinokoCtrl'
    bindings: {
        selectkinoko: '='
    }
});

④ onclickでその属性を書き換え

//kinokolist.js

onclick(kinoko) {
    this.selectkinoko = kinoko;
}

これで、親(KinokoParentController)のselected_kinokoが書き換わります。

子への通知

親のオブジェクトが変わったことを子が知らないと、記事の更新ができません。今回の例ですと、選択されたキノコに応じて、説明文や写真を変更する必要がありますので、なんとかして変更を知る必要があります。

敗北例

$broadcastを使用します。略。

勝利例

bindingsとLifeCycle Hookの$onChangesを使用します。

まず、親コンポーネントのhtmlであるkinoko.htmlに定義したkinokoExplainコンポーネントに対し、次のような形でselectkinoko属性を追加します。

<!-- kinoko.html -->
<div>
    <!-- キノコ一覧(左ペイン)-->
    <kinoko-list selectkinoko="ParentCtrl.selected_kinoko"></kinoko-list>
</div>
<div>
    <!-- キノコの説明文(右ペイン) -->
    <!-- ここを変更 -->
    <kinoko-explain selectkinoko="{{ParentCtrl.selected_kinoko}}"></kinoko-explain>
</div>

これは、kinokoExplainコンポーネントの属性selectkinokoに対して、文字列としてParentCtrl.selected_kinokoの値を設定しています。次に、kinokoExplainコンポーネントのbindingsを設定します。

//kinokoexplain.js

app.Component('kinokoExplain', {
    templateUrl: kinokoexplain.html,
    controller: KinokoExplainController,
    controllerAs: 'KinokoExplainCtrl'
    bindings: {
        selectkinoko: '@'
    }
});

最後に、コントローラクラスにLifeCycle Hookの$onChangesを定義します。

//kinokoexplain.js

class KinokoExplainController {
    constructor($http) {
        this.$http = $http;
    }

    $onChanges(changedobj) {
        if (changedobj.selectkinoko) {
            var obj = changedobj.selectkinoko;
            if (!obj.isFirstChange()) {
                //selectkinokoが更新されたので、記事を取りに行くなど
            }
        }
    }
}

これで、キノコリストコンポーネント→親コンポーネント→キノコ説明文コンポーネントという更新通知の系列が出来上がりました。

親がselected_kinokoの書き換わりを知るには

実は、selected_kinokoが書き換わった時、親コンポーネント自身は、値が書き換わったことがわかりません。親コンポーネント自身がキノコリストの変更を受けて何か処理をしたい場合、これでは困ってしまいます。解決策として、親のメソッドを呼び出してみます。

<!-- kinoko.hml -->
<div>
    <!-- キノコ一覧(左ペイン)-->
    <kinoko-list selectkinoko="ParentCtrl.selected_kinoko" change="ParentCtrl.change();"></kinoko-list>
</div>
<div>
    <!-- キノコの説明文(右ペイン) -->
    <kinoko-explain selectkinoko="{{ParentCtrl.selected_kinoko}}"></kinoko-explain>
</div>
//kinokolist.js

onclick(kinoko) {
    this.selectkinoko = kinoko;
    this.change();
}
//kinokolist.js

app.Component("kinokoList", {
      :
    controllerAs: 'KinokoCtrl'
    bindings: {
        selectkinoko: '=',
        change: '&'       
    }
});

change属性をメソッドでバインディングして、onclick()発生時にParentCtrlのchange()メソッドを呼び出しています。あとは、ParentControllerにchangeメソッドを定義してやるだけなんですが、実はこれ、問題があります。

//kinokoparent.js

class KinokoParentController {
       :

    change() {
        console.log(this.selected_kinoko);
    }
}

console.logで出力すると、書き換わる前のselected_kinokoが出力されます。これでは困ります。なので、引数を付けて呼び出しましょう。その引数ですが、JSONで指定する必要があります。

//kinokolist.js

//kinokolist.js

onclick(kinoko) {
    this.selectkinoko = kinoko;
    //this.change(kinoko)はダメ
    this.change({selectkinoko: kinoko});
}

呼び出されるchangeメソッドは、次のようになります。

//kinokoparent.js

class KinokoParentController {
       :

    change(selectkinoko) {
        console.log(selectkinoko);
    }
}

終わりに

Angular2系の親和性のために、$scopeを投げ捨てようという風潮に従ってみました。

AngularJSは一つの課題に対し、現状、複数のやり方が存在するので、これ以外にも方法はあると思います。どれがObsoleteで、どれがup-to-dateなのかわかりにくいため、参照する記事はくれぐれも慎重に選びましょう。この記事も、実際のところ正しい保証はありません。