从Vue.js源码看nextTick机制

80 阅读2分钟

操作DOM

在使用vue.js的时候,有时候因为一些特定的业务场景,不得不去操作DOM,比如这样:

<template>
  <div>
    <div ref="test">{{test}}</div>
    <button @click="handleClick">tet</button>
  </div>
</template>
export default {
    data () {
        return {
            test: 'begin'
        };
    },
    methods () {
        handleClick () {
            this.test = 'end';
            console.log(this.$refs.test.innerText);//打印“begin”
        }
    }
}

打印的结果是begin,为什么我们明明已经将test设置成了“end”,获取真实DOM节点的innerText却没有得到我们预期中的“end”,而是得到之前的值“begin”呢?

watcher队列

带着疑问,我们找到了Vue.js源码的Watch实现。当某个响应式数据发生变化的时候,它的setter函数会通知闭包中的Dep,Dep则会调用它管理的所有Watch对象。触发Watch对象的update实现。我们来看一下update的实现。

src/core/observe/watcher.js

update () {
    /* istanbul ignore else */
    if (this.lazy) {
        this.dirty = true
    } else if (this.sync) {
        /*同步则执行run直接渲染视图*/
        this.run()
    } else {
        /*异步推送到观察者队列中,下一个tick时调用。*/
        queueWatcher(this)
    }
}

我们发现Vue.js默认是使用异步DOM执行更新。 当异步执行update的时候,会调用queueWatcher函数。

/**
 * Push a watcher into the watcher queue.
 * Jobs with duplicate IDs will be skipped unless it's
 * pushed when the queue is being flushed.
 * 
 * 将一个观察者对象push进观察者队列,在队列中已经存在相同的id则该观察者对象将被跳过,除非它是在队列被刷新时推送。
 */

export function queueWatcher(watcher: Watcher) {
  /*获取watcher的id*/
  const id = watcher.id
  /*检验id是否存在,已经存在则直接跳过,不存在则标记哈希表has,用于下次检验*/
  if (has[id] == null) {
    has[id] = true
    if (!flushing) {
      /*如果没有flush掉,直接push到队列中即可*/
      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.
      let 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 (process.env.NODE_ENV !== 'production' && !config.async) {
        flushSchedulerQueue()
        return
      }
      nextTick(flushSchedulerQueue)
    }
  }
}

查看queueWatcher的源码我们发现,Watch对象并不是立即更新视图,而是被push进了一个队列queue,此时状态处于waiting的状态,这时候会继续会有Watch对象被push进这个队列queue,等待下一个tick时,这些Watch对象才会被遍历取出,更新视图。同时,id重复的Watcher不会被多次加入到queue中去,因为在最终渲染时,我们只需要关心数据的最终结果。

那么,什么是下一个tick?

nextTick

nextTick的实现比较简单,执行的目的是在microtask或者task中推入一个funtion,在当前栈执行完毕(也许还会有一些排在前面的需要执行的任务)以后执行nextTick传入的funtion,看一下源码:

src/core/util/next-tick.js

/* @flow */
/* globals MutationObserver */

import { noop } from 'shared/util'
import { handleError } from './error'
import { isIE, isIOS, isNative } from './env'

export let isUsingMicroTask = false

/*

   nextTick的实现比较简单,执行的目的是在microtask或者task中推入一个funtion,在当前栈执行完毕(也行还会有一些排在前面的需要执行的任务)以后执行nextTick传入的funtion。


   延迟一个任务使其异步执行,在下一个tick时执行,一个立即执行函数,返回一个function
   这个函数的作用是在task或者microtask中推入一个timerFunc,
   在当前调用栈执行完以后以此执行直到执行到timerFunc
   目的是延迟到当前调用栈执行完以后执行
*/


const callbacks = [] // 存放异步执行的回调
let pending = false  // 一个标记位,如果已经有timerFunc被推送到任务队列中去则不需要重复推送

function flushCallbacks() {
  pending = false
  const copies = callbacks.slice(0)
  callbacks.length = 0
  for (let i = 0; i < copies.length; i++) {
    copies[i]()
  }
}

// Here we have async deferring wrappers using microtasks.
// In 2.5 we used (macro) tasks (in combination with microtasks).
// However, it has subtle problems when state is changed right before repaint
// (e.g. #6813, out-in transitions).
// Also, using (macro) tasks in event handler would cause some weird behaviors
// that cannot be circumvented (e.g. #7109, #7153, #7546, #7834, #8109).
// So we now use microtasks everywhere, again.
// A major drawback of this tradeoff is that there are some scenarios
// where microtasks have too high a priority and fire in between supposedly
// sequential events (e.g. #4521, #6690, which have workarounds)
// or even between bubbling of the same event (#6566).


/*
这里我们有使用微任务的异步延迟包装器。
在2.5中,我们使用(宏)任务(与微任务结合使用)。
然而,它有微妙的问题时,状态改变之前重新绘制。
(例如#6813,out-in过渡)。
此外,在事件处理程序中使用(宏)任务会导致一些奇怪的行为。
这是无法回避的(例如#7109,#7153,#7546,#7834,#8109)。
所以我们现在到处都在使用微任务。

这种权衡的一个主要缺点是,在某些情况下,微任务有太高的优先级,
并在假定的连续事件之间(例如#4521,#6690,它们有变通方法)甚至在同一事件冒泡之间(#6566)触发。
*/

let timerFunc  // 一个函数指针,指向函数将被推送到任务队列中,等到主线程任务执行完时,任务队列中的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 */

/*
nextTick行为利用了微任务队列,该队列可以通过 Promise.then 或 MutationObserver实现。
MutationObserver有更广泛的支持,但它在iOS >= 9.3.3的UIWebView中被严重bug触发触摸事件处理程序。
触发几次后完全停止工作…,因此如果 Promise 是可用的,我们将使用它。
*/

/*
  这里解释一下,一共有Promise、MutationObserver以及setTimeout三种尝试得到timerFunc的方法
  优先使用Promise,在Promise不存在的情况下使用MutationObserver,这两个方法都会在microtask中执行,会比setTimeout更早执行,所以优先使用。
  如果上述两种方法都不支持的环境则会使用setTimeout,在task尾部推入这个函数,等待调用执行。
  参考:https://www.zhihu.com/question/55364497
*/

if (typeof Promise !== 'undefined' && isNative(Promise)) {
  /*使用Promise*/
  const p = Promise.resolve()
  timerFunc = () => {
    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)
  let counter = 1
  const observer = new MutationObserver(flushCallbacks)
  const textNode = document.createTextNode(String(counter))
  observer.observe(textNode, {
    characterData: true
  })
  timerFunc = () => {
    counter = (counter + 1) % 2
    textNode.data = String(counter)
  }
  isUsingMicroTask = true
} else if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
  // Fallback to setImmediate.
  // Techinically it leverages the (macro) task queue,
  // but it is still a better choice than setTimeout.
  timerFunc = () => {
    setImmediate(flushCallbacks)
  }
} else {
  // Fallback to setTimeout.
  /*使用setTimeout将回调推入任务队列尾部*/
  timerFunc = () => {
    setTimeout(flushCallbacks, 0)
  }
}

/*
  推送到队列中下一个tick时执行
  cb 回调函数
  ctx 上下文

它是一个立即执行函数,返回一个nextTick接口。
传入的cb会被push进callbacks中存放起来,然后执行timerFunc(pending是一个状态标记,保证timerFunc在下一个tick之前只执行一次)。
*/


export function nextTick(cb?: Function, ctx?: Object) {
  let _resolve
  callbacks.push(() => {
    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(resolve => {
      _resolve = resolve
    })
  }
}


/*
看了源码发现timerFunc会检测当前环境而不同实现,其实就是按照Promise,MutationObserver,setImmediate,setTimeout优先级,哪个存在使用哪个,最不济的环境下使用setTimeout。
*/

看了源码发现timerFunc会检测当前环境而不同实现,其实就是按照Promise,MutationObserver,setImmediate,setTimeout优先级,哪个存在使用哪个,最不济的环境下使用setTimeout。

Promise,MutationObserver这两个方法的回调函数都会在microtask中执行,它们会比setImmediate,setTimeout更早执行,所以优先使用。

nextTick的最佳选择就是微任务,但是promise是es6的,就会涉及兼容问题,所以vue求其次选择宏任务作为 降级。 setTimeout和setImmediate属于宏任务,setTimeout所有浏览器都兼容,但是执行有延迟,是最后的兜底方 案,setImmediate可以兼容ie浏览器。

Promise

首先是Promise,(Promise.resolve()).then()可以在microtask中加入它的回调

MutationObserver

MutationObserver新建一个textNode的DOM对象,用MutationObserver绑定该DOM并指定回调函数,在DOM变化的时候则会触发回调,该回调会进入microtask,即textNode.data = String(counter)时便会加入该回调。

至于 MutationObserver 如何模拟 nextTick 这点,直接看源码,其实就是创建一个 TextNode 并监听内容变化,然后要 nextTick 的时候去改一下这个节点的文本内容: var counter = 1

  let counter = 1
  const observer = new MutationObserver(flushCallbacks)
  const textNode = document.createTextNode(String(counter))
  observer.observe(textNode, {
    characterData: true
  })
  timerFunc = () => {
    counter = (counter + 1) % 2
    textNode.data = String(counter)
  }
  isUsingMicroTask = true

setImmediate

setTimeOut

setTimeOut默认有4ms延时,setTimeout延时0不会老老实实立即执行:

setTimeout(function(){
    console.log("我不是立即执行的,一般我会延时4ms,哈哈");
},0);

为什么要异步更新视图

<template>
  <div>
    <div>{{test}}</div>
  </div>
</template>
export default {
    data () {
        return {
            test: 0
        };
    },
    created () {
      for(let i = 0; i < 1000; i++) {
        this.test++;
      }
    }
}

现在有这样的一种情况,created的时候test的值会被++循环执行1000次。
每次++时,都会根据响应式触发setter->Dep->Watcher->update->patch。
如果这时候没有异步更新视图,那么每次++都会直接操作DOM更新视图,这是非常消耗性能的。
所以Vue.js实现了一个queue队列,在下一个tick的时候会统一执行queue中Watcher的run。同时,拥有相同id的Watcher不会被重复加入到该queue中去,所以不会执行1000次Watcher的run。最终更新视图只会直接将test对应的DOM的0变成1000。
保证更新视图操作DOM的动作是在当前栈执行完以后下一个tick的时候调用,大大优化了性能。

访问真实DOM节点更新后的数据

所以我们需要在修改data中的数据后访问真实的DOM节点更新后的数据,只需要这样,我们把文章第一个例子进行修改。

<template>
  <div>
    <div ref="test">{{test}}</div>
    <button @click="handleClick">tet</button>
  </div>
</template>
export default {
    data () {
        return {
            test: 'begin'
        };
    },
    methods () {
        handleClick () {
            this.test = 'end';
            this.$nextTick(() => {
                console.log(this.$refs.test.innerText);//打印"end"
            });
            console.log(this.$refs.test.innerText);//打印“begin”
        }
    }
}

使用Vue.js的global API的$nextTick方法,即可在回调中获取已经更新好的DOM实例了。