浏览器事件循环机制
先上一段简单的代码
console.log('aa');
setTimeout(() => {
console.log('bb')},
0);
Promise.resolve().then(() => console.log('cc'));
复制代码
执行结果总是如下:
aa
cc
bb
复制代码
为什么呢?为什么同样是异步,Promise.then 就是 比 setTimeout 先执行呢。
这就涉及到浏览器事件循环机制了。
- 以前浏览器只有一类事件循环,都是基于当前执行环境上下文, 官方用语叫
browsing-context
链接在此。我们可以理解为一个window
就是一个执行环境上下文,如果有iframe
, 那么iframe
内就是另一个执行环境了。- 2017年新版的HTML规范新增了一个事件循环,就是web workers。这个暂时先不讨论。
事件循环机制涉及到两个知识点 macroTask
和 microTask
,一般我们会称之为宏任务
和微任务
。不管是macroTask
还是microTask
,他们都是以一种任务队列
的形式存在。
macroTask
script
(整体代码),setTimeout
,setInterval
,setImmediate
(仅IE支持),I/O
,UI-rendering
注:此处的 I/O 是一个抽象的概念,并不是说一定指输入/输出,应该包括DOM事件的触发,例如click事件,mouseover事件等等。这是我的理解,如果有误,还请指出。
microTask
包括:
Promises
,process.nextTick
,Object.observe
(已废弃),MutationObserver
(监听DOM改变)
以下内容摘抄于知乎何幻的回答
一个浏览器环境(unit of related similar-origin browsing contexts.)只能有一个事件循环(Event loop),而一个事件循环可以多个任务队列(Task queue),每个任务都有一个任务源(Task source)。
相同任务源的任务,只能放到一个任务队列中。
不同任务源的任务,可以放到不同任务队列中。
对上面的几句话进行总结:事件循环只有一个,围绕着调用栈,
macroTask
,microTask
。macroTask
和microTask
是一个大的任务容器,里面可以有多个任务队列。不同的任务源
,任务会被放置到不同的任务队列
。那任务源是什么呢,比如setTimeout
,setInterval
,setImmediate
,这都是不同的任务源,虽然都是在macroTask
中,但肯定是放置在不同的任务队列中的。 最后,具体浏览器内部怎么对不同任务源的任务队列进行排序和取数,这个目前我还不清楚,如果正在看文章的你知道的话,请告诉下我。
接下来我们继续分析macroTask
和 microTask
的执行顺序,这两个队列的行为与浏览器具体的实现有关,这里只讨论被业界广泛认同和接受的队列执行行为。
macroTask
和 microTask
的循环顺序如下:
注意: 整体代码算一个 macroTask
- 先执行一个
macroTask
任务(例如执行整个js文件内的代码) - 执行完
macroTask
任务后,找到microTask
队列内的所有
任务,按先后顺序取出并执行 - 执行完
microTask
内的所有任务后,再从macroTask
取出一个
任务,执行。 - 重复:2,3 步骤。
现在,我们来解释文章开始时的那串代码,为什么Promise
总是优先于setTimeout
console.log('aa');
setTimeout(() => {
console.log('bb')},
0);
Promise.resolve().then(() => console.log('cc'));
复制代码
- 浏览器加载整体代码并执行算一个
macroTask
- 在执行这段代码的过程中,解析到
setTimeout
时,会将setTimeout内的代码
添加到macroTask
队列中。 - 接下来,又解析到
Promise
, 于是将Promise.then()内的代码
添加到microTask
队列中。 - 代码执行完毕,也就是第一个
macroTask
完成后,去microTask
任务队列中,找出所有任务并执行, 此时执行了console.log('cc')
; 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
全部push
到callbacks
,在下次执行macroTask
或microTask
的任务时,一起执行的。 原因是因为第一次执行 this.$nextTick
时,无论是执行的macroTimerFunc
还是microTimerFunc
, flushCallbacks
都是被异步执行,macroTimerFunc
是用macroTask
的方式,而microTimerFunc
是用microTask
的方式。例如:
macroTimerFunc = () => {
setTimeout(flushCallbacks, 0)
}
microTimerFunc = () => {
Promise.resolve().then(flushCallbacks);
}
复制代码
所以这三个this.$nextTick
执行完后,其实就相当于往callbacks
内push
了三个fn
。在下次执行macroTask
或microTask
的任务时,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 ? 原因是因为:
在支持
MessageChannel
和setImmediate
的情况下,他们的执行顺序是优先于setTimeout
的(在IE11/Edge
中,setImmediate
延迟可以在1ms
以内,而setTimeout
有最低4ms
的延迟,所以setImmediate
比setTimeout(0)
更早执行回调函数。
MessageChannel
的延迟也是会小于setTimeout
的, 有人比较过。 至于MessageChannel
和setImmediate
谁快谁慢,这个我不清楚。
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() 触发,结果是什么
复制代码
答案在这篇文章
参考并推荐几篇好文: