viola 是一个支持 Vue 的动态化框架,其 Vue 版本在 Vue 官方版本 2.5.7 上进行了少量改写,本文针对其进行具体分析。

最初,有使用者报告一个错误:在 iOS 系统,退出页面的时候,框架报错:

TypeError: undefined is not an object(evaluating 'e.isDestroyed"

接到这个错误之后,我首先进入 Vue 的 debug 版本,尝试获取更详细的信息:

TypeError: undefined is not an object(evaluating 'componentInstance.isDestroyed"

我们顺利地拿到了报错的变量名称,去 Vue 源代码中搜索,我们可以发现报错之处:

destroy: function destroy (vnode) {
    var componentInstance = vnode.componentInstance;
    if (!componentInstance._isDestroyed) { // 这里报错
      if (!vnode.data.keepAlive) {
        componentInstance.$destroy();
      } else {
        deactivateChildComponent(componentInstance, true /* direct */);
      }
    }
  }

这里是 componentInstanceundefined,这个实际上是 vnode 的实例,其为 undefined,说明该 vue 组件在之前的阶段就已经出错不正常了,这里并不是错误的根源所在,我们需要再次进行寻找报错原因。

于是我们查看业务代码的所有日志,又发现了这样一条报错:

[Vue warn]: Error in nextTick: "TypeError: undefined is not an object (evaluating 'vm.$options')"

初始化阶段出现这样一个错误,我们怀疑 vm 就是上文的 componentInstance ,于是,我们打印报错堆栈:

调用栈:
function updateChildComponent(
    vm,
    propsData,
    listeners,
    parentVnode,
    renderChildren
  ) {
        //...
        var hasChildren = !!(
              renderChildren ||
              vm.$options._renderChildren || // 这里报错
              parentVnode.data.scopedSlots ||
              vm.$scopedSlots !== emptyObject
            );
    }
function prepatch(oldVnode, vnode) {
      var options = vnode.componentOptions;
      var child = vnode.componentInstance = oldVnode.componentInstance;
      updateChildComponent(
        child,
        options.propsData,
        options.listeners,
        vnode,
        options.children
      );
    }
function patchVnode(oldVnode, vnode, insertedVnodeQueue, removeOnly) {}
function patch(oldVnode, vnode, hydrating, removeOnly) {}
function (vnode, hydrating) {}
function () {
        vm._update(vm._render(), hydrating);
      }
function get() {}
function getAndInvoke(cb) {}
function run() {}
function flushSchedulerQueue() {}
function flushCallbacks() {}

调用栈实际上有点冗长,不过我们还是能发现两个有用的信息:

  • 初始化阶段为 undefinedvm ,就是 componentInstance ,也就是和 destroy 阶段的报错属于同一个原因。
  • 根据调用栈发现,这是一个更新阶段的报错。

这引发了我们的思考:更新阶段找不到 componentInstance 报错。

这里实际上有点阻塞了,因为一般来说,Vue 的源代码经过测试,应该不会出现这种问题的,那是不是我们的问题呢,我们回归到业务代码:

created() {
    this.getFeedsListFromCache();
},
methods: {
    getFeedsListFromCache() {
        viola.requireAPI("cache").getItem(this.cacheKey_feeds, data => {
            this.processData(data.list);
        });
    },
    processData(list = [], opt = {}) {
        if (this.list.length < cacehFeedsLength) {
        }
        this.list = [];
    },
}

我们对业务代码进行了抽象简化,上面是我们的最小问题 Demo,实际上我们就做了这样一件事情:

  • 在 created 执行方法,调用端的接口,再回调函数里面更新某个 data 中声明的数据。

首先,我们可以梳理下对一般 vue 组件的初始化更新,vue 是如何做的:

  • created 时实际上 vnode 已经建立完成,这个时候还没有 mount,但是数据监听已经建立了,这个时候如果改动数据,会把相关 update 函数放在一个名为 flushCallbacks 的函数队列中。
  • 该函数队列会通过默认为 Promise.thenmicrotask 方式来调度,当前阶段的 mount 流程会继续,mount 结束后,会执行 flushCallbacks 队列中的更新操作。

从代码层面上来讲,这几个流程应该是这样的:

├── callHook(vm, 'created'); // 执行created 钩子
├── proxySetter(val); // 改变数据,调用 proxy
├── Watcher.prototype.update; // 调用 Watcher,将 update 操作入栈
├── vm.$mount(vm.$options.el); // 执行 mount 流程
├── callHook(vm, 'beforeMount');
├── callHook(vm, 'mounted'); // 依次调用 beforeMount 和 mounted
└── flushCallbacks // 执行 更新

然后我们分析我们这里的流程,首先值得强调的是这个函数 viola.requireAPI("cache").getItem ,这个函数是端注入的函数,但我们不能将其当作异步函数来对待,实际上,这是一个同步函数,(至于这个同步函数和 js 中的普通函数,是否有区别,还有待商榷,不过应该是有区别的,因为如果我们不用此函数的话,就不会出现该问题。)

接下来,我们打出详细的调用栈,根据顺序来分析实际的执行流程:

├── callHook(vm, 'created'); // 执行created 钩子
├── proxySetter(val); // 改变数据,调用 proxy
├── Watcher.prototype.update; // 调用 Watcher,将 update 操作入栈
├── flushCallbacks // 执行 更新
├── vm.$mount(vm.$options.el); // 执行 mount 流程 
├── callHook(vm, 'beforeMount');
└── callHook(vm, 'mounted'); // 依次调用 beforeMount 和 mounted

我们发现,我们的执行流程出现了很大问题:在 mount 阶段未完成的时候就执行了 flushCallbacks,先执行更新操作,这里的顺序错乱导致了后续问题

我们可看下调用 flushCallbacks 的代码:

if (typeof Promise !== 'undefined' && isNative(Promise)) {
  var p = Promise.resolve();
  microTimerFunc = function () {
    p.then(flushCallbacks);
    // in problematic UIWebViews, Promise.then doesn't completely break, but
    // it can get stuck in a weird state where callbacks are pushed into the
    // microtask queue but the queue isn't being flushed, until the browser
    // needs to do some other work, e.g. handle a timer. Therefore we can
    // "force" the microtask queue to be flushed by adding an empty timer.
    if (isIOS) { setTimeout(noop); }
  };
}

这里 microTimerFuncp.then ,被同步执行了,也就是说,这里的微任务优先于当前事件循环的函数执行了(此时由于 mount 流程是同步的,mount 流程的相关函数理应在该事件循环中,优先于微任务执行)。

我们找到了根源,接下来就是分析解决方案和根本原因。

由于我们的问题在于 update 流程执行太快了,所以采用一种方式放慢一点即可:

  • 将 vue 的微任务模式(默认)改成宏任务模式:var useMacroTask = false; => true。
  • 在 created 阶段的加一个 setTimeout (0)。

不过对于根本原因,实际上本次仍然没有完全分析透彻,还留有如下疑问:

  • viola.requireAPI("cache").getItem 这个函数到底做了什么?其对事件循环有什么影响?
  • 在执行 microTimerFunc 的时候,为什么 p.then 优先于 vm.$mount 执行了?
  • 该错误仅在 iOS 系统出现,iOS 系统是否会在某些情况将微任务的优先级变高?

对于这些疑问,Vue 源代码中也做了一些评论:

// Here we have async deferring wrappers using both microtasks and (macro) tasks.
// In < 2.4 we used microtasks everywhere, but there are some scenarios where
// microtasks have too high a priority and fire in between supposedly
// sequential events (e.g. #4521, #6690) or even between bubbling of the same
// event (#6566). However, using (macro) tasks everywhere also has subtle problems
// when state is changed right before repaint (e.g. #6813, out-in transitions).
// Here we use microtask by default, but expose a way to force (macro) task when
// needed (e.g. in event handlers attached by v-on).

不过,这里始终都没有找到最本质的原因,也许这和 iOS JSCore 的微任务 / 宏任务的处理机制有关,具体原因,待下次探究。