🧊

Angular UI-Routerで、ui-viewな要素が増えていく

という奇怪な出来事に遭遇したのでその原因と対策をメモ。

`angular ui-view dupulicate`とか調べてる人には一見の価値ありかもしれません。

きっかけ

  • Angularのui-routerを使ってるページで
  • 特定のstateに遷移すると
  • ui-view="foo"を指定してる要素が増える

DOMのイメージとしては、

<div ui-view="foo"></div>
<div ui-view="foo"></div>
<div ui-view="foo"></div>
<div ui-view="foo"></div>
<div ui-view="foo"></div>

みたいになる。
本来の正常な動作であれば、要素は1つだけ残ってその中身だけが置き換わるはず。

原因

というか、調べてみてわかった仕組みからの推察。
DOMの置換まわりがおかしいのは明確なので、どういうロジックでDOMを触ってるかを調べる。

ui-viewディレクティブ

https://github.com/angular-ui/ui-router/blob/legacy/src/viewDirective.js

ディレクティブを処理してるコードがここ。

L:203あたりで、stateが変わるタイミングになんやかんやしてるのがわかる。
肝心のDOMにさわってるのはL:243あたり。

説明するためにコードをそのまま貼って、ちょっと並び替える。
ここで、`previousEl`とか`currentEl`とかなってるのが今回の獲物。

function cleanupLastView() {
  var _previousEl = previousEl;
  var _currentScope = currentScope;

  if (_currentScope) {
    _currentScope._willBeDestroyed = true;
  }

  if (currentEl) {
    // rendererってなんぞ
    renderer.leave(currentEl, function() {
      cleanOld();
      previousEl = null;
    });

    previousEl = currentEl;
  } else {
    cleanOld();
    previousEl = null;
  }

  currentEl = null;
  currentScope = null;


  function cleanOld() {
    if (_previousEl) {
      _previousEl.remove(); // これで消してるなら消えるはず・・
    }

    if (_currentScope) {
      _currentScope.$destroy();
    }
  }
}

rendererとはなんだ問題

ui-routerのstateが切り替わるときに、アニメーションで遷移させたい!需要があったんでしょうね。
その対応をするために、rendererってのを噛ませて、`**-leave`とか`**-enter`ってクラス名を捌いたり、アニメーションの処理をしてる。

ドキュメントこれ。

Frequently Asked Questions · angular-ui/ui-router Wiki · GitHub

そしてng-animate

今回の罠にハマるのは、おそらくng-animateを読み込んでるプロジェクト。
なんで突然ng-animateの話になるかというと、このrendererの処理がこいつの存在によって変わるから。

L:155あたりが正にそれで、ng-animateがある場合はコールバックがPromise.then()で処理されるようになってる。
このコールバックの処理がさっき見てた古いui-viewなDOMを削除してるとこ。

で、この削除のロジックを通ってるならDOMは消えるはずーってことでブレークポイント張ってみるも、待てども待てども処理されない!
どうやら直前のif文をかいくぐってるっぽい。

というわけで、古いDOMを`remove()`する処理の判定が走るはずのところが、ng-animateの非同期な処理のせいでズレて、古いui-viewな要素が消えず、どんどん増えていく、と。

対策

ようはng-animateがない場合の処理を通せばいいわけなので・・。

// [0] そもそもDIしない
angular.module('app', [/* 'ng-animate' */]);

// [1] まとめてOFF
$animate.enabled(false);

// [2] 個別にOFF
$animate.enabled(el, false); // el = ui-viewなやつ

もしくは、

<!-- [3] noanimation="true" -->
<div ui-view="foo" noanimation="true"></div>

この方式が一番シンプルで良いなーと思ったら、
この`noanimation`方式なんと2016-02-07リリースの、いわゆる最新バージョンの0.2.18で追加されたやつ。

おわりに

Angularガチ勢への道のりは遠いですね。