AngularJSで$timeoutを使わずに、ng-repeatが描画完了してから処理したい場合

AngularJSでng-repeatの描画後に処理したいとき、
多くの場合は、$timeoutを使って、描画後にevent通知をします。

この方法はすでに多くの方が、
やり方を紹介しています。

qiita.com note.onichannn.net

でも、まれではありますが
この方法を使わずに、描画後に処理を入れたい時があります。

具体的には、

  1. $timeoutを使いたくない。
  2. ng-repeatのタグに、directiveを置きたくない。

という場合です。

この記事では、
こんな場合の対処方法を紹介します。

なぜ紹介の方法以外を使いたいのか?

$timeoutを使えば描画後に通知を出す処理を簡単に作れます。
でも、この方法には以下デメリットがあります。

  1. ng-repeatの全タグに、directiveが生成され、directive生成コストがかかる。
  2. $timeoutを使うことで、描画直後ではなく、少しタイムラグが発生する。

directive生成コスト

ng-repeat記載のタグに、directiveを記載すると、 繰り返しタグの全タグに、そのタグが記載され、 インスタンス化されます。

そのため、ng-repeatの要素数が100以上の場合などには、
スペックによっては、性能劣化を引き起こします。

ng-repeatの最後の要素の描画のタイミングを知りたいのに、
繰り返しの数だけdirectiveが生成されるのは、
不要なインスタンス化にあたるので、避けたいところです。

少しタイムラグってどれくらい?

$timeoutの呼び出し時に、
他にどれだけの処理をしているか?に依存して、タイムラグが発生します。

$timeout処理には、何秒後に実行するかを引数に指定できるので、
0を指定すれば、すぐ処理してくれると思いますよね。
でも、そうなりません。。

$timeout以外の処理を一通り実施した直後に、
$timeoutの処理が実行されます。

なので、例えばng-repeatの下に他のタグがある場合は、
他のタグがある程度描画されてからでないと$timeoutの処理は実行されません。

このタイムラグが無視できれば、$timeoutを用いて大丈夫ですし、
だめなら、これから紹介する方法を用いて実現します。

では以下に具体的な実現方法を記載します。

DOMが配置され、値がbindされたタイミングを狙う。

ここで念のため、描画直後について整理します。

今回実施したい、描画直後とは、 DOMがブラウザ上に配置されて、
scopeの値がbindされたタイミングと言っていいと思います。

値がbindされないと、ng-styleやng-ifなど機能しないため、
このタイミングですね。

ではこのタイミングで通知を入れるのですが、
そのために、まず単純に通知するだけの、directiveを作ります。

具体的なコードは以下の通りです。

(function () {
  'use strict';

  angular
    .module('ngFinishDisplay')
    .directive('ngFinishDisplay', ngFinishDisplay);

  /** @ngInject */
  function ngFinishDisplay() {
    var directive = {
      restrict: 'E',
      scope: {
        notifyId: '<'
      },
      /**
       * インスタンス化されたタイミングで通知する。
       */
      link: function (scope) {
        scope.$emit(scope.notifyId);
      }
    };
    return directive;
  }
})();

ここからがポイントです。

作ったDirectiveを
検知したい描画対象のタグの閉じタグの直上に入れます。

たとえばng-repeatタグが以下のようにあります。

<div class="container">
  <div class="col-xs-2 row" ng-repeat="item in main.itemList track by $index">
    <div>
      <div class="col-xs-3">ID</div>
      <div class="col-xs-9">{{item.id}}</div>
    </div>
    <div>
      <div class="col-xs-3">Name</div>
      <div class="col-xs-9">{{item.name}}</div>
    </div>
  </div>
</div>

この場合、以下のようにng-repeatの閉じタグの直上に配置します。

<div class="container">
  <div class="col-xs-2 row" ng-repeat="item in main.itemList track by $index">
    <div>
      <div class="col-xs-3">ID</div>
      <div class="col-xs-9">{{item.id}}</div>
    </div>
    <div>
      <div class="col-xs-3">Name</div>
      <div class="col-xs-9">{{item.name}}</div>
    </div>
    <ng-finish-display notify-id="main.eventName" ng-if="$last"></ng-finish-display>
  </div>
</div>

また、ng-repeatの描画完了後に通知がほしいので、
ng-if=“$last”
を使ってng-repeatの最後の要素の描画時に通知するようにしました。

動作確認

動作確認のために、
以下のControllerでitemListに24個の要素を入れた場合の
通知時の描画を見てみます。

(function() {
  'use strict';

  angular
    .module('ngFinishDisplay')
    .controller('MainController', MainController);

  /** @ngInject */
  function MainController($scope, $log) {
    var vm = this;
    var itemList = [];
    for(var index = 1; index <= 24; index++) {
      var object = {
        id: 'book-' + index,
        name: 'book name ' + index
      };
      itemList.push(object);
    }
    vm.itemList = itemList;
    vm.eventName = 'finish-draw-event';

    $scope.$on(vm.eventName, function(){
      // ここをDevToolで止めて、描画状況を確認
      $log.info('通知');
    });
  }
})();

以下はデバッガで通知直後に止め、
ブラウザでの表示内容をキャプチャしたものです。
全ての値が画面に表示されていますので、
描画直後に通知が来ています。
f:id:s-nakagawa:20170708170630p:plain

これだけだと、本当に直後なのかがわからないので、
以下のように、場所に変えて配置して実験しました。

<div class="container">
  <div class="col-xs-2 row" ng-repeat="item in main.itemList track by $index">
    <div>
      <div class="col-xs-3">ID</div>
      <div class="col-xs-9">{{item.id}}</div>
    </div>
    <ng-finish-display notify-id="main.eventName" ng-if="$last"></ng-finish-display>
    <div>
      <div class="col-xs-3">Name</div>
      <div class="col-xs-9">{{item.name}}</div>
    </div>
  </div>
</div>

すると以下のように、
最後の本の名前が{{item.name}}のままであることが分かります。
f:id:s-nakagawa:20170708170712p:plain

このように、$timeoutを使わずに、
ng-repeatの描画直後を検知できていることが分かります。

ちなみに、$lastを$firstにしてみましょう。

<div class="container">
  <div class="col-xs-2 row" ng-repeat="item in main.itemList track by $index">
    <ng-finish-display notify-id="main.eventName" ng-if="$first"></ng-finish-display>
    <div>
      <div class="col-xs-3">ID</div>
      <div class="col-xs-9">{{item.id}}</div>
    </div>
    <div>
      <div class="col-xs-3">Name</div>
      <div class="col-xs-9">{{item.name}}</div>
    </div>
  </div>
</div>

以下のように描画されます。
想像通りでしたでしょうか?

f:id:s-nakagawa:20170708171131p:plain

bind式が一つも評価されていないタイミングで通知を受け取れていますね。

このように、directiveを利用することで、
$timeoutを使わずに描画直後に通知を飛ばすことができます。

最適化

$timeoutを使わないという目的を達することはできましたが、

処理コストがかかるという点に着目すると、
もう一点最適化が必要です。

現状のコードでは、ng-ifにおいて双方向バインディングを使っています。
すると、何か他の値の変更をするごとに、
ng-ifのチェック処理が実行されます。

しかもこのチェックは、ng-repeatの繰り返し数分処理するため、
素数によっては性能への影響が生じます。

ですので、以下のようにOne-time Bindingを使って最適化します。

<div class="container">
  <div class="col-xs-2 row" ng-repeat="item in main.itemList track by $index">
    <div>
      <div class="col-xs-3">ID</div>
      <div class="col-xs-9">{{item.id}}</div>
    </div>
    <div>
      <div class="col-xs-3">Name</div>
      <div class="col-xs-9">{{item.name}}</div>
    </div>
    <ng-finish-display notify-id="::main.eventName" ng-if="::$last"></ng-finish-display>
  </div>
</div>

こうすることで、
ng-repeatの要素数に比例した不要なインスタンスや、Watch式を生成せずに、
描画後のイベントを検知することができました。

※ソース一式は、以下にありますので、
 ご参考まで github.com