Ionic中不合理的view层级导致afterEnter没有被调用
文章原链接:http://blog.csdn.net/zzxiang1985/article/details/66970321
在公司的ionic项目中我们定义了如下状态:
- $stateProvider
- .state('A', {
- abstract: true,
- views: {
- root: {
- template: '<ion-nav-view id="ViewA"></ion-nav-view>'
- }
- }
- })
- .state('A.B', {
- url: '/A/B',
- templateUrl: 'A/B.tpl.html',
- controller: 'ABCtrl'
- })
- .state('A.C', {
- abstract: true,
- url: '/A/C'
- })
- .state('A.C.D', {
- url: '/D',
- views: {
- 'root@': {
- templateUrl: 'A/C/D.tpl.html',
- controller: 'ACDCtrl'
- }
- }
- })
- .state('E', {
- url: '/E',
- views: {
- root: {
- templateUrl: 'E.tpl.html'
- }
- }
- })
其中views里面的root是在index.html里定义的ion-nav-view:
- <html>
- ...
- <body ng-app="starter">
- ...
- <ion-nav-view name="root"></ion-nav-view>
- </body>
- </html>
并且ABCtrl和ACDCtrl的代码中都注册监听了afterEnter事件。
按理说从状态A.B跳转到状态A.C.D时,ACDCtrl里的afterEnter会被执行,可实际运行的时候却没有。但是从E跳转到A.C.D则没有问题,ACDCtrl里的afterEnter会如期被调用。从E跳到A.B也没有问题,ABCtrl里的afterEnter也会执行。
公司项目的ionic lib版本是1.3.1:
- $ ionic lib
- Local Ionic version: 1.3.1 (/Users/zhixiangzhu/my-ionic-project/www/lib/ionic/version.json)
- Latest Ionic version: 1.3.3 (released 2017-02-24)
- * Local version is out of date
本文末尾附上了我自己写的一个ionic小项目专用于重现这个问题。该项目的ionic lib版本是1.3.3:
- $ ionic lib
- Local Ionic version: 1.3.3 (/Users/zhixiangzhu/ionic-afterEnter-test/www/lib/ionic/version.json)
- Latest Ionic version: 1.3.3 (released 2017-02-24)
- * Local version up to date
于是我钻进了ionic的代码里研究了一番。afterEnter是在ionicViewSwitcher的transitionComplete函数中,也就是在状态跳转完成时触发的:
- function transitionComplete() {
- ...
- // the most recent transition added has completed and all the active
- // transition promises should be added to the services array of promises
- if (transitionId === transitionCounter) {
- ...
- // emit that the views have finished transitioning
- // each parent nav-view will update which views are active and cached
- switcher.emit('after', enteringData, leavingData); // ionic在这里触发afterEnter
- ...
- }
- ...
- }
可以看到afterEnter触发的条件是transitionId === transitionCounter。ACDCtrl的afterEnter没有被调用,正是因为这个条件没有被满足。
于是需要理解transitionId和transitionCounter分别是什么。两者的定义在如下代码中:
- IonicModule.factory('$ionicViewSwitcher', [
- ...,
- function(...) {
- ...
- var transitionCounter = 0;
- ...
- var ionicViewSwitcher = {
- create: function(navViewCtrl, viewLocals, enteringView, leavingView, renderStart, renderEnd) {
- // get a reference to an entering/leaving element if they exist
- // loop through to see if the view is already in the navViewElement
- var enteringEle, leavingEle;
- var transitionId = ++transitionCounter;
可以看到transitionCounter是一个全局变量。状态跳转时每创建一次ionViewSwitcher,transitionCounter计数就会加1。上面代码里的create函数是从ionNavView的$stateChangeSuccess响应函数一路调用进来的。
- IonicModule
- .directive('ionNavView', [
- ...,
- function(...) {
- // IONIC's fork of Angular UI Router, v0.2.10
- // the navView handles registering views in the history and how to transition between them
- return {
- ...
- // listen for $stateChangeSuccess
- $scope.$on('$stateChangeSuccess', function() {
- updateView(false);
- });
- ...
- function updateView(firstTime) {
- // get the current local according to the $state
- var viewLocals = $state.$current && $state.$current.locals[viewData.name];
- // do not update THIS nav-view if its is not the container for the given state
- // if the viewLocals are the same as THIS latestLocals, then nothing to do
- if (!viewLocals || (!firstTime && viewLocals === latestLocals)) return;
- // update the latestLocals
- latestLocals = viewLocals;
- viewData.state = viewLocals.$$state;
- // register, update and transition to the new view
- navViewCtrl.register(viewLocals); // ionicViewSwitcher的create函数是从这里一路调用进去的
- }
- ...
而$stateChangeSuccess事件是在状态跳转完成时在$rootScope上广播触发的:
- var transition = $state.transition = resolved.then(function () {
- ...
- $rootScope.$broadcast('$stateChangeSuccess', to.self, toParams, from.self, fromParams);
- ...
经过一番研究,我发现故事正是可以从$stateChangeSuccess事件开始讲起。
我们先以状态E跳转到状态A.B这个没有出现问题的流程为例。
在状态跳转成功,也就是$stateChangeSuccess在$rootScope上广播触发的时候,其实跳转目标状态(A.C.D)和目标状态的祖先状态(A.C和A)对应的view还没有创建好,或是还处于非活跃状态。此时以$rootScope为根结点的scope树可以简化如下:
- ion-nav-view name="root"
- |
- |-------- ion-view state="E"
$broadcast的算法是深度遍历,所以首先被遍历到的是位于根部的名为root的ion-nav-view。当$stateChangeSuccess在ion-nav-view的$scope上触发时,ionic会检查当前的ion-nav-view是否跳转目标状态或其祖先状态的其中任意一个view所在的容器。如果不是,那么ionic就会跳过这个ion-nav-view,遍历下一个。见上文updateView函数中的注释:
// do not update THIS nav-view if its is not the container for the given state
但是在E -> A.B这个例子中,ion-nav-view name="root"是状态A的view所在的容器。因此ionic会在当前的ion-nav-view中创建或唤醒相应状态(A)的view,并将transitionCounter计数器加1,赋值给相应view的transitionId。因为在状态A的定义中,A的view本身也含有一个ion-nav-view,所以现在scope树变成了这样(假设状态跳转之前transitionCounter为0):
- ion-nav-view name="root"
- |
- |-------- ion-view state="E"
- |
- |-------- ion-nav-view id="ViewA" transitionId = 1
当新的ion-nav-view被创建的时候,它对自身也会执行一次updateView的流程,判断自己是否为目标状态或目标祖先状态的任意一个view所在的容器。在这里ion-nav-view id="ViewA"是A.B的view所在的容器,因此它会在自己的view中创建A.B的view,并再次增加transitionCounter计数,赋值给A.B的view的transitionId。此时的scope树如下所示:
- ion-nav-view name="root"
- |
- |-------- ion-view state="E"
- |
- |-------- ion-nav-view id="ViewA" transitionId = 1
- |
- |-------- ion-view view-title="A.B" transitionId = 2
由于A.B的view中没有ion-nav-view(详见文章末尾附件中的代码),且scope树中已没有未遍历的ion-nav-view,所以$stateChangeSuccess的广播到此结束。此时transitionCounter的值为2,而transitionId为2的正是A.B的view,于是在transitionComplete的时候afterEnter在ABCtrl上被触发。
上面的过程可以小结如下:在状态跳转成功的时候,ionic在$rootScope上广播$stateChangeSuccess事件,从scope树的根节点开始按深度遍历所有的ion-nav-view。如果当前正在遍历的ion-nav-view是目标状态或其祖先状态的view所在的容器,那么就会在其中创建或唤醒相应状态的view,增加transitionCounter计数并赋值view的transitionId。在$stateChangeSuccess广播完成之后,ionic会在transitionId最大(即等于transitionCounter)的view上,也就是最后创建或唤醒的view上触发afterEnter。
那么从状态A.B跳转到状态A.C.D时,为什么没有在ACDCtrl上触发afterEnter呢?让我们跟踪一下这个过程。
在A.B -> A.C.D的$stateChangeSuccess广播之前,scope树是这样的:
- ion-nav-view name="root"
- |
- |-------- ion-nav-view id="ViewA"
- |
- |-------- ion-view view-title="A.B"
和之前一样,首先遍历到的是ion-nav-view name="root"。这里要注意,在A.C.D及其祖先状态中,以root为容器的既有状态A的view,又有状态A.C.D的view(见A.C.D定义的views)。在决定在ion-nav-view中创建或唤醒哪个状态的view这个问题上,ionic会优先考虑子状态的view。所以在ion-nav-view name="root"中,ionic只会创建A.C.D的view,而不会创建A的view。
*注:如果想深究这个优先级是如何实现的话,可研究ionic的transitionTo函数中的如下代码:
- ...
- // We also set up an inheritance chain for the locals here. This allows the view directive
- // to quickly look up the correct definition for each view in the current state.
- ...
- for (var l = keep; l < toPath.length; l++, state = toPath[l]) {
- locals = toLocals[l] = inherit(locals);
- resolved = resolveState(state, toParams, state === to, resolved, locals, options);
- }
从中可见ionic是通过javascript的继承与原型链实现这种优先级的,子状态的view数据(locals)作为子类覆盖了父状态的数据。
于是scope树变成了这样(假设状态跳转之前transitionCounter为0):
- ion-nav-view name="root"
- |
- |-------- ion-nav-view id="ViewA"
- | |
- | |-------- ion-view view-title="A.B"
- |
- |-------- ion-view view-title="A.C.D" transitionId = 1
接下来注意了!A.C.D的view创建之后,遍历还没结束。scope树里还有一个$stateChangeSuccess广播之前就存在的ion-nav-view id="ViewA",而它正好是目标状态A.C.D的父状态A.C的view的容器!因此ionic会继续在ion-nav-view id="ViewA"上创建A.C的view:
- ion-nav-view name="root"
- |
- |-------- ion-nav-view id="ViewA"
- | |
- | |-------- ion-view view-title="A.B"
- | |
- | |-------- div transitionId = 2 (这是A.C的view。因为A.C没有定义template,所以它的view只是一个空的div。)
- |
- |-------- ion-view view-title="A.C.D" transitionId = 1
这时可以发现,最大的transitionId已经不是A.C.D的view,而是A.C的view了。这就是为什么ACDCtrl上没有触发afterEnter的原因。(注:这样说下来,按照流程afterEnter似乎会在A.C的view上触发,但实际上也没有。这是因为afterEnter的触发除了transitionId的判断以外,还有其它更多条件,这些就不在本文中阐述了。)
为什么从状态E跳转到状态A.C.D又没问题呢?因为这种情况下在A.C.D的view创建之后,scope树如下:
- ion-nav-view name="root"
- |
- |-------- ion-view view-title="E"
- |
- |-------- ion-view view-title="A.C.D" transitionId = 1
这时scope树中已经没有其它还未遍历的ion-nav-view了,遍历到此结束。此时transitionId最大的正是A.C.D的view,因此afterEnter也就在ACDCtrl上触发。其实即便scope树中还有其它未遍历的ion-nav-view,只要它们不是A、A.C或A.C.D的容器,那么它们之中就不会创建或唤醒新的view,transitionCounter也就不会增大,afterEnter也还是会在ACDCtrl上触发。
上面阐述的整个遍历和创建view的过程都发生在ionic.bundle.js的transitionTo函数中。这个函数在跳转开始前会调用resolveState记录下目标状态及其祖先状态的各个view需要的容器,然后在跳转成功后会广播$stateChangeSuccess事件,遍历scope树,在最后创建或唤醒的view上触发afterEnter。
因此,如果我们希望A.B -> A.C.D时afterEnter能在ACDCtrl上触发,那么可以更改A.C.D的views定义,将view放在ion-nav-view id="ViewA"上:
- .state('A.C.D', {
- url: '/D',
- views: {
- '@A': { // 'root@'改为'@A'
- templateUrl: 'A/C/D.tpl.html',
- controller: 'ACDCtrl'
- }
- }
- })
这样scope树遍历之后的结果如下:
- ion-nav-view name="root"
- |
- |-------- ion-nav-view id="ViewA"
- |
- |-------- ion-view view-title="A.B"
- |
- |-------- ion-view view-title="A.C.D" transitionId = 1 (A.C.D的view覆盖了A.C的view)
transitionId最大的就是A.C.D的view——实际上也只创建了这一个新view,于是afterEnter就会在ACDCtrl上触发。
或者也可以给A.C的view添加一个ion-nav-view:
- .state('A.C', {
- abstract: true,
- url: '/A/C',
- template: '<ion-nav-view></ion-nav-view>'
- })
然后将A.C.D的view放在A.C的view中:
- .state('A.C.D', {
- url: '/D',
- views: {
- '@A.C': {
- templateUrl: 'A/C/D.tpl.html',
- controller: 'ACDCtrl'
- }
- }
- })
这样scope树遍历之后的结果如下:
- ion-nav-view name="root"
- |
- |-------- ion-nav-view id="ViewA"
- |
- |-------- ion-view view-title="A.B"
- |
- |-------- ion-nav-view transitionId = 1 (A.C的view)
- |
- |--------- ion-view view-title="A.C.D" transitionId = 2
transitionId最大的仍然是A.C.D的view。
注:要控制一个view放在哪个ion-nav-view需要理解view的命名法则,参见ui-router的文档。
总而言之,如果我们希望在状态跳转时afterEnter在目标状态的view上触发,那么必须合理安排view的层级,以保证在scope树的深度遍历中,目标状态的view(而不是目标状态的祖先状态的view)是最后一个被创建或唤醒的view。
不过我仍然不太理解为何ionic要用这种方法决定在哪个view上触发afterEnter。为何不在创建view的过程中记下目标状态的view,然后在跳转完成后直接在那个view上触发呢?
最后附上专用于重现该问题的ionic小项目。下载项目解压后,在项目目录下执行ionic serve(需要本机安装ionic)即会弹出界面。初始状态是E,可以点击按钮在A.B和A.C.D之间跳转。
本文在我的独立博客上的地址:https://zxtechart.com/2017/03/26/irrational-view-hierarchy-causes-afterenter-not-firing-in-ionic/