soulcm / blog

2017开通
6 stars 0 forks source link

从event loop看vue的nextTick #11

Open soulcm opened 7 years ago

soulcm commented 7 years ago

从event loop看vue的nextTick

先看一段代码,请使用chrome验证你所得出的答案

console.log('script start');

setTimeout(function() {
  console.log('setTimeout');
}, 0);

Promise.resolve().then(function() {
  console.log('promise1');
}).then(function() {
  console.log('promise2');
});

console.log('script end');

要答对上面的问题,就需要理解js本身的event loop。我们都知道js是单线程的,同一时间只能执行一个任务,那怎么才能做到异步的感觉呢。这就需要引入几个概念了, task、microtask、macrotask。

所有同步任务都将在主线程上执行,形成一个task队列和microtask的队列,然后先将task按顺序压入执行栈执行,task队列清空后就将microtask压入执行栈执行,在一次event loop中都清空后,就会进行一次视图的渲染,然后执行macrotask。

microtask紧跟着task,一旦没有task压入执行栈,microtask就会被压入而执行。常见的microtask有process.nextTick、Promise、MutationObserver等。

那macrotask又是什么呢,它其实就是setTimeout以及node中的setImmediat的回调,它们都是创建一个异步任务,会在event loop的末尾才执行,它其实也属于一个task。至于它与microtask的区别就是microtask会影响IO回调,microtask不执行完的话,界面会一直卡住,macrotask就不会有这个问题。

回到最开始,我们看下那段代码的执行过程

  1. 代码执行,同步代码全部放入在task或microtask中

    tasks: [concole.log, setTimeout cb, concole.log]
    stack: [console.log]
    microtask: [promise then]
  2. task的代码依次执行,先打印出script start,然后在打印出script end,此时剩余一个macrotask和microtask

    tasks: [setTimeout cb]
    stack: [promise then]
    microtask: [promise then]
  3. 然后promise执行,打印promise 1,然后接着另一个promise打印promise 2

    tasks: [setTimeout cb]
    stack: [setTimeout cb]
    microtask: []
  4. 打印setTimeout,至此队列全部清空,一个event loop完成。

    tasks: []
    stack: []
    microtask: []

接着上面的知识,我们可以来看vue的nextTick方法了。在vue中,数据监测都是通过Object.defineProperty来重写里面的set和get方法实现的,vue更新DOM是异步的,每当观察到数据变化时,vue就开始一个队列,将同一事件循环内所有的数据变化缓存起来,等到下一次event loop,将会把队列清空,进行dom更新,内部使用的microtask MutationObserver来实现的。

虽然数据驱动建议避免直接操作dom,但有时也不得不需要这样的操作,这时就该Vue.nextTick(callback)出场了,它接受一个回调函数,在dom更新完成后,这个回调函数就会被调用。不管是vue.nextTick还是vue.prototype.$nextTick都是直接用的nextTick这个闭包函数

export const nextTick = (function () {
  const callbacks = []
  let pending = false
  let timerFunc

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

  //other code
})()

callbacks就是缓存的所有回调函数,nextTickHandler就是实际调用回调函数的地方。让这个函数延迟执行,vue优先用promise来实现,其次是html5的MutationObserver,然后是setTimeout。前两者属于microtask,后一个属于macrotask。都是达到一个异步的过程。

if (typeof Promise !== 'undefined' && isNative(Promise)) {
    var p = Promise.resolve()
    var logError = err => { console.error(err) }
    timerFunc = () => {
        p.then(nextTickHandler).catch(logError)
        if (isIOS) setTimeout(noop)
    }
} else if (typeof MutationObserver !== 'undefined' && (
    isNative(MutationObserver) ||
    MutationObserver.toString() === '[object MutationObserverConstructor]'
)) {
    var counter = 1
    var observer = new MutationObserver(nextTickHandler)
    var textNode = document.createTextNode(String(counter))
    observer.observe(textNode, {
        characterData: true
    })
    timerFunc = () => {
        counter = (counter + 1) % 2
        textNode.data = String(counter)
    }
} else {
    timeFunc = () => {
        setTimeout(nextTickHandle, 0)
    }
}

来看最后一部分

return function queueNextTick(cb?: Function, ctx?: Object) {
    let _resolve
    callbacks.push(() => {
        if (cb) cb.call(ctx)
        if (_resolve) _resolve(ctx)
    })
    if (!pending) {
        pending = true
        timerFunc()
    }
    if (!cb && typeof Promise !== 'undefined') {
        return new Promise(resolve => {
            _resolve = resolve
        })
    }
}

这就是我们正在调用的nextTick函数,在一个event loop内它会将调用nextTick的cb回调函数都放入callbacks中,pending用于判断是否有队列正在执行回调,例如有可能在nextTick中还有一个nextTick,此时就应该属于下一个循环了。最后几行代码是promise化,可以将nextTick按照promise方式去书写(暂且用的较少)。

参考资料

event loop

Tasks microtasks queues and schedules

MutationObserver