浏览器事件循环机制与Vue nextTick的实现

浏览器事件循环机制

先上一段简单的代码javascript

console.log('aa');
setTimeout(() => { 
    console.log('bb')}, 
0);
Promise.resolve().then(() => console.log('cc'));
复制代码

执行结果老是以下:html

aa
cc
bb
复制代码

为何呢?为何一样是异步,Promise.then 就是 比 setTimeout 先执行呢。前端

这就涉及到浏览器事件循环机制了。vue

  1. 之前浏览器只有一类事件循环,都是基于当前执行环境上下文, 官方用语叫 browsing-context连接在此。咱们能够理解为一个window就是一个执行环境上下文,若是有iframe, 那么iframe内就是另外一个执行环境了。
  2. 2017年新版的HTML规范新增了一个事件循环,就是web workers。这个暂时先不讨论。

事件循环机制涉及到两个知识点 macroTaskmicroTask,通常咱们会称之为宏任务微任务。不论是macroTask仍是microTask,他们都是以一种任务队列的形式存在。java

macroTask

script(总体代码), setTimeout, setIntervalsetImmediate(仅IE支持), I/O, UI-renderingreact

注:此处的 I/O 是一个抽象的概念,并非说必定指输入/输出,应该包括DOM事件的触发,例如click事件,mouseover事件等等。这是个人理解,若是有误,还请指出。ios

microTask

包括:Promises,process.nextTick, Object.observe(已废弃),MutationObserver(监听DOM改变)web

如下内容摘抄于知乎何幻的回答segmentfault

一个浏览器环境(unit of related similar-origin browsing contexts.)只能有一个事件循环(Event loop),而一个事件循环能够多个任务队列(Task queue),每一个任务都有一个任务源(Task source)。api

相同任务源的任务,只能放到一个任务队列中。

不一样任务源的任务,能够放到不一样任务队列中。

对上面的几句话进行总结:事件循环只有一个,围绕着调用栈,macroTaskmicroTaskmacroTaskmicroTask是一个大的任务容器,里面能够有多个任务队列。不一样的任务源,任务会被放置到不一样的任务队列。那任务源是什么呢,好比setTimeoutsetIntervalsetImmediate,这都是不一样的任务源,虽然都是在macroTask中,但确定是放置在不一样的任务队列中的。 最后,具体浏览器内部怎么对不一样任务源的任务队列进行排序和取数,这个目前我还不清楚,若是正在看文章的你知道的话,请告诉下我。

接下来咱们继续分析macroTaskmicroTask的执行顺序,这两个队列的行为与浏览器具体的实现有关,这里只讨论被业界普遍认同和接受的队列执行行为。

macroTaskmicroTask 的循环顺序以下:

注意: 总体代码算一个 macroTask

  1. 先执行一个 macroTask 任务(例如执行整个js文件内的代码)
  2. 执行完 macroTask 任务后,找到microTask队列内的全部任务,按前后顺序取出并执行
  3. 执行完microTask内的全部任务后,再从macroTask取出一个任务,执行。
  4. 重复:2,3 步骤。

如今,咱们来解释文章开始时的那串代码,为何Promise老是优先于setTimeout

console.log('aa');
setTimeout(() => { 
    console.log('bb')}, 
0);
Promise.resolve().then(() => console.log('cc'));
复制代码
  1. 浏览器加载总体代码并执行算一个macroTask
  2. 在执行这段代码的过程当中,解析到 setTimeout时,会将setTimeout内的代码添加到 macroTask 队列中。
  3. 接下来,又解析到Promise, 因而将 Promise.then()内的代码 添加到 microTask 队列中。
  4. 代码执行完毕,也就是第一个 macroTask 完成后,去 microTask 任务队列中,找出全部任务并执行, 此时执行了 console.log('cc');
  5. microTask 任务队列执行完毕后,又取出下一个 macroTask 任务并执行,也就是执行setTimeout内的代码console.log('bb')

从广义上一句话总结: 一个宏任务执行完后,会执行完全部的微任务,再又执行一个宏任务。依此循环,这也就是事件循环。

若是对事件循环机制仍是不怎么理解的话,能够看下这篇文章,图文并茂,讲的挺细的。

Vue nextTick函数的实现

调用 nextTick 的方式
// 第一种,Vue全局方法调用
Vue.nextTick(fn, context);

// 第二种,在实例化vue时,内部调用
this.$nextTick(fn);
复制代码

其实这两种方式都是调用的 Vue 内部提供的一个nextTick 方法,Vue内部对这个方法作了些简单的封装

// src/core/instance/render.js ---- line 57
// 这里调用 nextTick 时自动把当前vue实例对象做为第二个参数传入,因此咱们调用 this.$nextTick时,不须要传第二个参数
Vue.prototype.$nextTick = function (fn) {
    return nextTick(fn, this)
};

// src/core/global-api/index.js ---- line 45
// 直接将 nextTick 暴露出去,做为Vue全局方法
Vue.nextTick = nextTick;
复制代码

也就是说,这两种调用方式,都是执行的Vue内部提供的nextTick方法。这个nextTick方法,Vue用了一个单独的文件维护。

文件在vue项目下 src/core/util/next-tick.js

flushCallbacks - 执行回调

首先文件头部,定义了一个触发回调的函数 flushCallbacks

这个flushCallbacks 永远是被异步执行的。至于为何,接下来会讲到。

const callbacks = []
let pending = false

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

这部分代码的意思,就是依次触发 callbacks内的函数。那么 callbacks 数组是存放什么的?其实就是存放咱们调用this.$nextTick(fn) 是传入的fn,只不过对它作了一层做用域包装和异常捕获。

nextTick 函数的定义

nextTick 函数 定义在文件的末尾,代码以下。注意看我加的注释。

export function nextTick (cb?: Function, ctx?: Object) {
    let _resolve
    // 将传入的函数包装一层,绑定做用域,并try-catch捕获错误
    // 若是没传入函数,且浏览器原生支持 Promise 的状况下,让 Promise resolve;
    callbacks.push(() => {
        if (cb) {
            try {
                cb.call(ctx)
            } catch (e) {
                handleError(e, ctx, 'nextTick')
            }
        } else if (_resolve) {
            _resolve(ctx)
        }
    })
    // pending 是一个开关,每次执行 flushCallbacks 后,会将 pending 重置为 fasle
    if (!pending) {
        pending = true
        if (useMacroTask) {
            // 以 macroTask 的方式,执行 flushCallbacks
            // 这里虽然代码是执行了,可是 macroTimerFunc 内部的代码是异步执行,这个点很关键
            macroTimerFunc()
        } else {
            // 以 microTask 的方式,执行 flushCallbacks
            // 这里虽然代码是执行了,可是 microTimerFunc 内部的代码是异步执行,这个点很关键
            microTimerFunc()
        }
    }
    // $flow-disable-line
    // 这里返回一个 Promise, 因此咱们能够这样调用,$this.nextTick().then(xxx)
    if (!cb && typeof Promise !== 'undefined') {
        return new Promise(resolve => {
            _resolve = resolve
        })
    }
}
复制代码

上面的代码的 pending 有点意思, 它是Vue作的一个性能优化吧。用来处理同时调用多个 nextTick 的业务场景, 例如

new Vue({
    // 省略
    created() {
        // 执行第一个时,首先 fn1 会被 push 进 callbacks,再往下走
        // pending 为 false, 因此会进入 if (!pending),而后 pending 被设为true, 执行 macroTimerFunc 或 microTimerFunc
        this.$nextTick(fn1);
        // 执行第二 个时,pending为true,这时就不会进入 if (!pending) 了,
        // 可是 callbacks.push 是会执行的,也就是说会把 fn2 push进 callbacks 数组
        this.$nextTick(fn2);
        // 同第二个
        this.$nextTick(fn3);
    }
})
复制代码

若是是这样调用, 那么Vue会怎么作呢,Vue是会将这三个fn所有pushcallbacks,在下次执行macroTaskmicroTask的任务时,一块儿执行的。 缘由是由于第一次执行 this.$nextTick时,不管是执行的macroTimerFunc仍是microTimerFunc, flushCallbacks都是被异步执行,macroTimerFunc是用macroTask的方式,而microTimerFunc是用microTask的方式。例如:

macroTimerFunc = () => {
    setTimeout(flushCallbacks, 0)
}

microTimerFunc = () => {
    Promise.resolve().then(flushCallbacks);
}
复制代码

因此这三个this.$nextTick执行完后,其实就至关于往callbackspush了三个fn。在下次执行macroTaskmicroTask的任务时,flushCallbacks内的代码才会执行,也就是执行咱们传入的fn

由于

一个宏任务执行完后,会执行完全部的微任务,再又执行一个宏任务。依此循环

看到这里的同窗估计会有个疑问点,useMacroTask是什么,macroTimerFunc 是什么, microTimerFunc又是什么。接下来会一一解开。

useMacroTask
// 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).
let microTimerFunc
let macroTimerFunc
let useMacroTask = false
复制代码

这里的注释须要仔细看下,翻译摘抄于这里, 大体意思以下

Vue2.4以前的版本中,nextTick几乎都是基于microTask实现的,可是因为microTask的执行优先级很是高,在某些场景之下它甚至要比事件冒泡还要快,就会致使一些诡异的问题;可是若是所有都改为macroTask,对一些有重绘和动画的场景也会有性能的影响。因此最终nextTick采起的策略是默认走microTask,对于一些DOM的交互事件,如v-on绑定的事件回调处理函数的处理,会强制走macroTask

useMacroTask 表示是否启用 macroTask 的方式执行回调。

macroTimerFunc

接下来,macroTimerFunc 的定义是,在 下一个macroTask 中执行 flushCallbacks

// 优先 setImmediate, 
// 而后是 MessageChannel
// 最后才是 setTimeout
if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
  macroTimerFunc = () => {
    setImmediate(flushCallbacks)
  }
} else if (typeof MessageChannel !== 'undefined' && (
  isNative(MessageChannel) ||
  MessageChannel.toString() === '[object MessageChannelConstructor]'
)) {
  const channel = new MessageChannel()
  const port = channel.port2
  channel.port1.onmessage = flushCallbacks
  macroTimerFunc = () => {
    port.postMessage(1)
  }
} else {
  macroTimerFunc = () => {
    setTimeout(flushCallbacks, 0)
  }
}
复制代码

为何采用的顺序是 setImmediate --> MessageChannel --> setTimeout ? 缘由是由于:

在支持MessageChannelsetImmediate的状况下,他们的执行顺序是优先于setTimeout的(在IE11/Edge中,setImmediate延迟能够在1ms之内,而setTimeout有最低4ms的延迟,因此setImmediatesetTimeout(0)更早执行回调函数。

MessageChannel的延迟也是会小于setTimeout的, 有人比较过。 至于 MessageChannelsetImmediate 谁快谁慢,这个我不清楚。

microTimerFunc

再是microTimerFunc 的定义是,若是浏览器支持原生 Promise 的话,在 下一个microTask 中执行 flushCallbacks

// 若是浏览器支持原生 Promise 的话,把 flushCallbacks 放入 microTask 中执行
if (typeof Promise !== 'undefined' && isNative(Promise)) {
    const p = Promise.resolve()
    microTimerFunc = () => {
    p.then(flushCallbacks)
    // 这时为了处理 iOS microtask 没有被刷新的bug
    if (isIOS) setTimeout(noop)
    }
} else {
    // 若是没有Promise,就把 macroTimerFunc 赋值 给 microTimerFunc, 也就是在 `macroTask` 中执行 `flushCallbacks`
    microTimerFunc = macroTimerFunc
}
复制代码
withMacroTask

withMacroTask 是DOM事件函数的一个包装器, Vue给DOM添加事件时,会用到它。

这个方法就是为了解决 Vue 2.4 版本以前 nextTick 的bug。

/** * Wrap a function so that if any code inside triggers state change, * the changes are queued using a (macro) task instead of a microtask. */
export function withMacroTask (fn: Function): Function {
    return fn._withTask || (fn._withTask = function () {
        // 注意这里,这里开启了useMacroTask,
        // 也就是说,若是是经过DOM事件添加的代码,代码内就算有nextTick,那nextTick内的代码也会被强制走 macroTask 方式
        useMacroTask = true
        const res = fn.apply(null, arguments)
        useMacroTask = false
        return res
    })
}

// 在这里会被调用
// src/platforms/web/runtime/modules/events.js ---- line41
function add ( event: string, handler: Function, once: boolean, capture: boolean, passive: boolean ) {
  handler = withMacroTask(handler)
  if (once) handler = createOnceHandler(handler, event, capture)
  target.addEventListener(
    event,
    handler,
    supportsPassive
      ? { capture, passive }
      : capture
  )
}
复制代码

至此,Vue nextTick的流程算是分析完了。

这些分析都是我看了源码和一些文章后的我的理解,若是有误的话,请道友指出。谢谢。

最后上一段代码,出自Google 2018GDD大会,欢迎探讨并说出缘由。

button.addEventListener('click', () => {
  Promise.resolve().then(() => console.log('microtask 1'))
  console.log('listener 1')
})

button.addEventListener('click', () => {
  Promise.resolve().then(() => console.log('microtask 2'))
  console.log('listener 2')
})

1. 手动点击,结果是什么
2. 用测试代码 button.click() 触发,结果是什么
复制代码

答案在这篇文章

参考并推荐几篇好文:

event-loop

vue技术内幕

深刻浏览器的事件循环 (GDD@2018)

【Vue源码】Vue中DOM的异步更新策略以及nextTick机制

前端基础进阶(十二):深刻核心,详解事件循环机制