浅谈JavaScript事件循环与Vue的批量异步更新策略
在介绍事件循环之前,首先要明确以下几个关键概念。事件循环,同步和异步任务,宏任务,微任务。
一.事件循环
事件循环,是浏览器为了协调事件处理、脚本执行、网络请求和渲染等任务而制定的一套工作机制,它约束了各种任务的执行顺序。
二.同步任务与异步任务
JavaScript是单线程的,因此所有的任务都要排队执行。为了避免某些任务过于耗时而影响整体性能因此任务有同步和异步之分。同步任务按顺序执行,而异步任务的执行顺序则不一定是代码书写的顺序。例如:
console.log('a')
setTimeout(() => {
console.log('b')
})
console.log('c')
Promise.resolve().then(()=>{console.log('d')})
// a c d b
因此,为研究异步任务的执行顺序,还要引出宏任务和微任务的概念。
三.宏任务和微任务
宏任务:代表一个个离散的、独立工作单元。主要包括创建主文档对象、解析HTML、执行主线JS代码以及各种事件如页面加载、输入、网络事件和定时器等。浏览器完成一个宏任务,在下一个宏任务执行开始前,会对页面进行重新渲染。
微任务:是更小的任务,是在当前宏任务执行结束后立即执行的任务。如果存在微任务,浏览器会清空微任务之后再重新渲染。微任务的例子有 promise 回调函数、DOM发生变化(MutaionObserver),process.nextTick等。
四.事件循环的具体过程。
JavaScript引擎首先执行主线代码,区分出同步和异步任务。同步任务按代码顺序依次执行。而异步任务则按照宏任务和微任务推入不同的任务队列。前面说到,微任务是在当前宏任务执行结束后立即执行的任务。因此当主线代码执行完毕,微任务队列进入主线程依次执行。若微任务中又包含下属的宏任务或微任务,则继续推入相应的任务队列。当微任务队列的所有任务执行完毕,浏览器页面重新渲染,并再次进入宏任务队列依次执行。 下面是一个例子:
setTimeout(() => { // 宏任务1
new Promise(resolve => {
console.log('promise1')
resolve('setTimeout1')
}).then((res) =>{ // 微任务2
console.log(res)
})
});
console.log('main') // 主线的同步任务,直接打印
new Promise(resolve => {
console.log('promise2') // 直接打印
resolve()
}).then(() =>{ // 微任务1
console.log('then')
setTimeout(() =>{ // 宏任务2
console.log('setTimeout')
})
})
console.log('over') // 直接打印
上述代码的执行过程如下:
-
1 .执行主线代码,打印'main','promise2','over',微任务队列有微任务1,宏任务队列有宏任务1。
- script宏任务执行完毕,微任务队列开始执行,打印'then',宏任务2进入宏任务队列。此时微任务队列无任务,宏任务队列有宏任务1,宏任务2。
-
3 .此时微任务队列全部执行完毕,开始执行宏任务队列,队列有先入先出的性质,因此首先执行宏任务1,打印'promise1',微任务2 promise.then()进入微任务队列。前面说到,微任务是在当前宏任务执行结束后立即执行的任务,因此立即执行微任务2,打印'setTimeout1'。之后微任务队列无新任务,执行宏任务2,打印'setTimeout' .至此任务队列所有任务执行完毕,代码执行结束。
打印顺序为:
'main','promise2','over','then','promise1','setTimeout1',setTimeout'
五.Vue的批量异步更新策略
下面介绍事件循环在Vue中的实际应用。
Vue有一套批量,异步的更新策略。为什么是批量,异步的呢。我们知道,在Vue中,一个组件对应一个渲染Watcher。在一个更新周期当中,可能有多个组件的数据发生了变化,那么相应的每个组件对应的渲染Watcher都要执行一次。如果一个组件发现变化,立即执行对应的渲染watcher,则浏览器将会渲染一次。当多个组件的数据发生变化时,显然这样的执行逻辑会非常低效,且会影响用户体验。最高效的方式肯定是一次性的将所有发生变化的组件的Watcher一起执行。举个不恰当的例子。这个逻辑有些类似于洗衣服,每发现一件脏衣服就立即洗完显然是不明智的。更高效的方式是将脏衣服攒一攒,最后一起洗。那Vue是如何实现批量,异步更新的呢?
只要侦听到数据变化,Vue 将开启一个全局队列,该队列用于缓存在同一事件循环中发生数据变更的组件的渲染Watcher。如果同一个 watcher 被多次触发,只会被推入到队列中一次。前面说到,浏览器每执行完一个宏任务及其相关的微任务,就会刷新一次。因此我们将该队列放在一个微任务中执行,当执行完主线代码,再将微任务队列中的watcher队列依次执行。最后浏览器刷新且只刷新一次就完成所有组件的更新。Vue 在内部对异步队列尝试使用原生的Promise.then、MutationObserver和setImmediate,如果执行环境以上都不支持,则会采用setTimeout(fn,0)代替。
下面用伪代码实现一下批量异步更新。
1.首先创建一个全局队列,用于存放渲染Watcher。还要创建一个全局哈希结构用于标记Watcher,便于去重。
let hash = {} // 存储watcher队列的标识
let watcherQueue = []
/* 只要侦听到数据变化,将开启一个队列,并缓冲在同一事件循环中发生的所有数据变更。
批量更新时如果同一个watcher被多次触发,只会被推入到队列中一次。 */
2.实现缓存Watcher和清空Watcher队列的方法
function flusQueue() {
watcherQueue.forEach(watcher => watcher.run()) // 批量更新 更新完毕清空队列
hash = {}
watcherQueue = []
}
function queueWatcher(watcher) {
let id = watcher.id
if (!hash[id]) {
hash[id] = true
watcherQueue.push(watcher)
}
//nextTick方法将flusQueue放入微任务中执行
nextTick(flusQueue)
}
3.实现nextTick
export function nextTick(cb) {
let aysncFn = () => {
cb();
}
// 使用常见的微任务,要判断浏览器环境是否支持
if (Promise) {
Promise.resolve().then(aysncFn)
}
if (MutationObserver) {
let observe = new MutationObserver(aysncFn)
let textNode = document.createTextNode(1)
observe.observe(textNode, {
characterData: true
})
textNode.textContent = 2
return
}
if (setImmediate) {
return setImmediate(aysncFn)
}
setTimeout(aysncFn, 0)
}
4.最后实现Watcher的缓存,我们知道在响应式数据发生变化的派发更新过程中会触发Watcher的update方法,我们就在该方法中实现缓存。
export default class Watcher { // 观察者
...
update() {
queueWatcher(this)
}
...
}