遇事不决nextTick,为什么​nextTick这么神奇?

16,066 阅读2分钟

场景说明

最近使用Vue全家桶做后台系统的时候,遇到了一个很奇葩的问题:有一个输入框只允许输入数字,当输入其它类型的数据时,输入的内容会被重置为null。为了实现这一功能,使用了一个父组件和子组件。为了方便陈述,这里将业务场景简化,具体代码如下:

// 父组件
<template>
  <Input v-model="form.a" @on-change="onChange"></Input>
</template>
<script type="javascript">
export default {
	data() {
    return {
      form: {
        a: null
      }
    }
  },
  methods: {
    async onChange(value) {
   		if (typeof value !== 'number') {
        // await this.$nextTick()
        this.form.a = null
      }   
    }
  }
}
</script>

// 子组件
<template>
  <input v-model="currentValue" @input="onInput" />
</template>
<script type="javascript">
export default {
	name: 'Input',
	props: {
    value: {
      type: [Number, Null],
      default: null 
    }
  },
	data() {
    return {
      currentValue: null
    }
  },
  methods: {
    onInput(event) {
      const value = event.target.value
      this.$emit('input', value)
      const oldValue = this.value
      if (oldValue === value) return
   		this.$emit('on-change', value)
    }
  },
  watch: {
    value(value, oldValue) {
      this.currentValue = value
    }
  }
}
</script>

将以上代码放到项目中去运行,你会很神奇地发现,在输入框输入字符串'abc'之后,输入框的值并没有被重置为空,而是保持为'abc'没有变化。在将注释的nextTick取消注释以后,输入框的值被重置为空。真的非常神奇。

其实之前有好几次同事也碰到了类似的场景:数据层发生了变化,dom并没有随之响应。在数据层发生变化以后,执行一次nextTick,dom就按照预期地更新了。这样几次以后,我们甚至都调侃:遇事不决nextTick。

代码执行顺序

那么,到底nextTick做了什么呢?这里以上面的代码为例,我们先来理一理我们代码是怎么执行的。具体来说,以上代码执行顺序如下:

  1. form.a初始值为null
  2. 用户输入字符串abc
  3. 触发input事件,form.a的值改为abc
  4. 触发on-change事件,form.a的值改为null
  5. 由于form.a的值到这里还是为null
  6. 主线程任务执行完毕,检查watch的回调函数是否需要执行。

这个顺序一理,我们就发现了输入框展示abc不置空的原因:原来form.a的值在主线程中间虽然发生了变化,但是最开始到最后始终为null。也就是说,子组件的props的value没有发生变化。自然,watch的回调函数也就不会执行。

但是这样一来,我们就有另外一个问题了:为什么触发input事件,form.a的值改为null的时候,没有触发watch的回调呢?为了说明这一点,我们需要深入Vue源码,看看$emit和watch的回调函数分别是在什么时候执行的。

$emit做了什么?

我们首先看看$emit对应的源码。由于Vue 2.X版本源码是使用flow写的,无形中增加了理解成本。考虑到这一点,我们直接找到Vue的dist包中的vue.js文件,并搜索emit函数

Vue.prototype.$emit = function (event) {
  var vm = this;
  {
    var lowerCaseEvent = event.toLowerCase();
    if (lowerCaseEvent !== event && vm._events[lowerCaseEvent]) {
      tip(
        "Event \"" + lowerCaseEvent + "\" is emitted in component " +
        (formatComponentName(vm)) + " but the handler is registered for \"" + event + "\". " +
        "Note that HTML attributes are case-insensitive and you cannot use " +
        "v-on to listen to camelCase events when using in-DOM templates. " +
        "You should probably use \"" + (hyphenate(event)) + "\" instead of \"" + event + "\"."
      );
    }
  }
  var cbs = vm._events[event];
  if (cbs) {
    cbs = cbs.length > 1 ? toArray(cbs) : cbs;
    var args = toArray(arguments, 1);
    var info = "event handler for \"" + event + "\"";
    for (var i = 0, l = cbs.length; i < l; i++) {
      invokeWithErrorHandling(cbs[i], vm, args, vm, info);
    }
  }
  return vm
};

function invokeWithErrorHandling (
  handler,
  context,
  args,
  vm,
  info
) {
  var res;
  try {
    res = args ? handler.apply(context, args) : handler.call(context);
    if (res && !res._isVue && isPromise(res) && !res._handled) {
      res.catch(function (e) { return handleError(e, vm, info + " (Promise/async)"); });
      // issue #9511
      // avoid catch triggering multiple times when nested calls
      res._handled = true;
    }
  } catch (e) {
    handleError(e, vm, info);
  }
  return res
}

源码的内容其实很简单,就是把提前注册(或者说订阅)的函数放到一个数组中,执行$emit函数时就把数组中的函数一一取出并执行。可以看出,这是一个发布-订阅模式的使用。

也就是说,emit的执行是同步的。那么,watch是怎么执行的呢?相比之下,watch的执行会比较繁琐。理解了watch的流程,也就理解了Vue的核心。

首先,在初始化Vue组件时,有一个initWatch函数,我们来看看这个函数做了什么。

function initWatch (vm, watch) {
  for (var key in watch) {
    var handler = watch[key];
    if (Array.isArray(handler)) {
      for (var i = 0; i < handler.length; i++) {
        createWatcher(vm, key, handler[i]);
      }
    } else {
      createWatcher(vm, key, handler);
    }
  }
}

function createWatcher (
  vm,
  expOrFn,
  handler,
  options
) {
  if (isPlainObject(handler)) {
    options = handler;
    handler = handler.handler;
  }
  if (typeof handler === 'string') {
    handler = vm[handler];
  }
  return vm.$watch(expOrFn, handler, options)
}

Vue.prototype.$watch = function (
  expOrFn,
  cb,
  options
) {
  var vm = this;
  if (isPlainObject(cb)) {
    return createWatcher(vm, expOrFn, cb, options)
  }
  options = options || {};
  options.user = true;
  var watcher = new Watcher(vm, expOrFn, cb, options);
  if (options.immediate) {
    try {
      cb.call(vm, watcher.value);
    } catch (error) {
      handleError(error, vm, ("callback for immediate watcher \"" + (watcher.expression) + "\""));
    }
  }
  return function unwatchFn () {
    watcher.teardown();
  }
}

var Watcher = function Watcher (
  vm,
  expOrFn,
  cb,
  options,
  isRenderWatcher
) {
  this.vm = vm;
  if (isRenderWatcher) {
    vm._watcher = this;
  }
  vm._watchers.push(this);
  // options
  if (options) {
    this.deep = !!options.deep;
    this.user = !!options.user;
    this.lazy = !!options.lazy;
    this.sync = !!options.sync;
    this.before = options.before;
  } else {
    this.deep = this.user = this.lazy = this.sync = false;
  }
  this.cb = cb;
  this.id = ++uid$2; // uid for batching
  this.active = true;
  this.dirty = this.lazy; // for lazy watchers
  this.deps = [];
  this.newDeps = [];
  this.depIds = new _Set();
  this.newDepIds = new _Set();
  this.expression = expOrFn.toString();
  // parse expression for getter
  if (typeof expOrFn === 'function') {
    this.getter = expOrFn;
  } else {
    this.getter = parsePath(expOrFn);
    if (!this.getter) {
      this.getter = noop;
      warn(
        "Failed watching path: \"" + expOrFn + "\" " +
        'Watcher only accepts simple dot-delimited paths. ' +
        'For full control, use a function instead.',
        vm
      );
    }
  }
  this.value = this.lazy
    ? undefined
    : this.get();
}

function parsePath (path) {
  if (bailRE.test(path)) {
    return
  }
  var segments = path.split('.');
  return function (obj) {
    for (var i = 0; i < segments.length; i++) {
      if (!obj) { return }
      obj = obj[segments[i]];
    }
    return obj
  }
}

Watcher.prototype.get = function get () {
  pushTarget(this);
  var value;
  var vm = this.vm;
  try {
    value = this.getter.call(vm, vm);
  } catch (e) {
    if (this.user) {
      handleError(e, vm, ("getter for watcher \"" + (this.expression) + "\""));
    } else {
      throw e
    }
  } finally {
    // "touch" every property so they are all tracked as
    // dependencies for deep watching
    if (this.deep) {
      traverse(value);
    }
    popTarget();
    this.cleanupDeps();
  }
  return value
}

function defineReactive$$1 (
  obj,
  key,
  val,
  customSetter,
  shallow
) {
  var dep = new Dep();

  var property = Object.getOwnPropertyDescriptor(obj, key);
  if (property && property.configurable === false) {
    return
  }

  // cater for pre-defined getter/setters
  var getter = property && property.get;
  var setter = property && property.set;
  if ((!getter || setter) && arguments.length === 2) {
    val = obj[key];
  }

  var childOb = !shallow && observe(val);
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get: function reactiveGetter () {
      var value = getter ? getter.call(obj) : val;
      if (Dep.target) {
        dep.depend();
        if (childOb) {
          childOb.dep.depend();
          if (Array.isArray(value)) {
            dependArray(value);
          }
        }
      }
      return value
    },
    set: function reactiveSetter (newVal) {
      var value = getter ? getter.call(obj) : val;
      /* eslint-disable no-self-compare */
      if (newVal === value || (newVal !== newVal && value !== value)) {
        return
      }
      /* eslint-enable no-self-compare */
      if (customSetter) {
        customSetter();
      }
      // #7981: for accessor properties without setter
      if (getter && !setter) { return }
      if (setter) {
        setter.call(obj, newVal);
      } else {
        val = newVal;
      }
      childOb = !shallow && observe(newVal);
      dep.notify();
    }
  });
}

var Dep = function Dep () {
  this.id = uid++;
  this.subs = [];
}

Dep.prototype.addSub = function addSub (sub) {
  this.subs.push(sub);
};

Dep.prototype.removeSub = function removeSub (sub) {
  remove(this.subs, sub);
};

Dep.prototype.depend = function depend () {
  if (Dep.target) {
    Dep.target.addDep(this);
  }
};

Dep.prototype.notify = function notify () {
  // stabilize the subscriber list first
  var subs = this.subs.slice();
  if (!config.async) {
    // subs aren't sorted in scheduler if not running async
    // we need to sort them now to make sure they fire in correct
    // order
    subs.sort(function (a, b) { return a.id - b.id; });
  }
  for (var i = 0, l = subs.length; i < l; i++) {
    subs[i].update();
  }
}

Watcher.prototype.update = function update () {
  /* istanbul ignore else */
  if (this.lazy) {
    this.dirty = true;
  } else if (this.sync) {
    this.run();
  } else {
    queueWatcher(this);
  }
}

Dep.target = null;
var targetStack = [];

function pushTarget (target) {
  targetStack.push(target);
  Dep.target = target;
}

function popTarget () {
  targetStack.pop();
  Dep.target = targetStack[targetStack.length - 1];
}

我们看到,watch相关联的函数接近20个。这么多函数在来回跳的时候,很容易把逻辑弄丢了。这里我们来讲一讲整个流程。

在初始化Vue实例时,执行initWatch,initWatch函数往下走,创建了一个watcher实例。watcher实例执行了getter函数,getter函数读取了data某个属性的值,因此触发了Object.defineProperty中的get函数。get函数执行了dep.depend函数,这个函数用于收集依赖。所谓的依赖其实就是回调函数。在我们说的这个例子中,就是value的watch回调函数。

讲到这里,我们发现watch的回调函数只是在这里进行了注册,还没有执行。那么,watch真正的执行是在哪里呢?我们回到最开始代码的执行顺序来看。在第3步的时候,form.a=abc,这里有一个设置的操作。这个操作触发了Object.defineProperty的set函数,set函数执行了dep.notify函数。执行了update函数,update函数的核心就是queueWatcher函数。为了更好地说明,我们把queueWatcher函数单独列出来看看。

function queueWatcher (watcher) {
  var id = watcher.id;
  if (has[id] == null) {
    has[id] = true;
    if (!flushing) {
      queue.push(watcher);
    } else {
      // if already flushing, splice the watcher based on its id
      // if already past its id, it will be run next immediately.
      var i = queue.length - 1;
      while (i > index && queue[i].id > watcher.id) {
        i--;
      }
      queue.splice(i + 1, 0, watcher);
    }
    // queue the flush
    if (!waiting) {
      waiting = true;

      if (!config.async) {
        flushSchedulerQueue();
        return
      }
      nextTick(flushSchedulerQueue);
    }
  }
}

function flushSchedulerQueue () {
  currentFlushTimestamp = getNow();
  flushing = true;
  var watcher, id;

  // Sort queue before flush.
  // This ensures that:
  // 1. Components are updated from parent to child. (because parent is always
  //    created before the child)
  // 2. A component's user watchers are run before its render watcher (because
  //    user watchers are created before the render watcher)
  // 3. If a component is destroyed during a parent component's watcher run,
  //    its watchers can be skipped.
  queue.sort(function (a, b) { return a.id - b.id; });

  // do not cache length because more watchers might be pushed
  // as we run existing watchers
  for (index = 0; index < queue.length; index++) {
    watcher = queue[index];
    if (watcher.before) {
      watcher.before();
    }
    id = watcher.id;
    has[id] = null;
    watcher.run();
    // in dev build, check and stop circular updates.
    if (has[id] != null) {
      circular[id] = (circular[id] || 0) + 1;
      if (circular[id] > MAX_UPDATE_COUNT) {
        warn(
          'You may have an infinite update loop ' + (
            watcher.user
              ? ("in watcher with expression \"" + (watcher.expression) + "\"")
              : "in a component render function."
          ),
          watcher.vm
        );
        break
      }
    }
  }

  // keep copies of post queues before resetting state
  var activatedQueue = activatedChildren.slice();
  var updatedQueue = queue.slice();

  resetSchedulerState();

  // call component updated and activated hooks
  callActivatedHooks(activatedQueue);
  callUpdatedHooks(updatedQueue);

  // devtool hook
  /* istanbul ignore if */
  if (devtools && config.devtools) {
    devtools.emit('flush');
  }
}

Watcher.prototype.run = function run () {
  if (this.active) {
    var value = this.get();
    if (
      value !== this.value ||
      // Deep watchers and watchers on Object/Arrays should fire even
      // when the value is the same, because the value may
      // have mutated.
      isObject(value) ||
      this.deep
    ) {
      // set new value
      var oldValue = this.value;
      this.value = value;
      if (this.user) {
        try {
          this.cb.call(this.vm, value, oldValue);
        } catch (e) {
          handleError(e, this.vm, ("callback for watcher \"" + (this.expression) + "\""));
        }
      } else {
        this.cb.call(this.vm, value, oldValue);
      }
    }
  }
}

function nextTick (cb, ctx) {
  var _resolve;
  callbacks.push(function () {
    if (cb) {
      try {
        cb.call(ctx);
      } catch (e) {
        handleError(e, ctx, 'nextTick');
      }
    } else if (_resolve) {
      _resolve(ctx);
    }
  });
  if (!pending) {
    pending = true;
    timerFunc();
  }
  // $flow-disable-line
  if (!cb && typeof Promise !== 'undefined') {
    return new Promise(function (resolve) {
      _resolve = resolve;
    })
  }
}

var timerFunc;

// The nextTick behavior leverages the microtask queue, which can be accessed
// via either native Promise.then or MutationObserver.
// MutationObserver has wider support, however it is seriously bugged in
// UIWebView in iOS >= 9.3.3 when triggered in touch event handlers. It
// completely stops working after triggering a few times... so, if native
// Promise is available, we will use it:
/* istanbul ignore next, $flow-disable-line */
if (typeof Promise !== 'undefined' && isNative(Promise)) {
  var p = Promise.resolve();
  timerFunc = 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); }
  };
  isUsingMicroTask = true;
} else if (!isIE && typeof MutationObserver !== 'undefined' && (
  isNative(MutationObserver) ||
  // PhantomJS and iOS 7.x
  MutationObserver.toString() === '[object MutationObserverConstructor]'
)) {
  // Use MutationObserver where native Promise is not available,
  // e.g. PhantomJS, iOS7, Android 4.4
  // (#6466 MutationObserver is unreliable in IE11)
  var counter = 1;
  var observer = new MutationObserver(flushCallbacks);
  var textNode = document.createTextNode(String(counter));
  observer.observe(textNode, {
    characterData: true
  });
  timerFunc = function () {
    counter = (counter + 1) % 2;
    textNode.data = String(counter);
  };
  isUsingMicroTask = true;
} else if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
  // Fallback to setImmediate.
  // Technically it leverages the (macro) task queue,
  // but it is still a better choice than setTimeout.
  timerFunc = function () {
    setImmediate(flushCallbacks);
  };
} else {
  // Fallback to setTimeout.
  timerFunc = function () {
    setTimeout(flushCallbacks, 0);
  };
}

在queueWatcher函数中,我们看到了熟悉的面孔:nextTick。我们发现,nextTick就是一个微任务的平稳降级:它将根据所在环境,依次使用Promise、MutationObserver、setImmediate以及setTimeout执行任务。我们看到,执行form.a=abc时,实际上是先注册了一个微任务,在这里我们可以理解为watch回调函数的包裹函数。这个微任务将在主线程任务走完以后执行,因此它将被先挂起。

随后主线程执行了form.a=null,再次触发了setter。由于都是form.a注册的,在推入微任务队列前会去重,避免watch的回调多次执行。到这里,主线程任务执行完成,微任务队列中watcher回调函数的包裹函数被推出执行,由于form.a的值始终都为null,因此不会执行回调函数。

在加入$nextTick函数以后,在form.a=null之前先执行了nextTick函数,nextTick函数执行了watcher的回调函数的包裹函数,此时form.a的值为abc,旧的值和新的值不一样,因此执行了watch回调函数。至此,整个逻辑就理顺了。

后话

没想到,一个简简单单nextTick的使用居然关系到了Vue的核心原理!