从Vue.nextTick探究事件循环中的线程协作机制
一、背景
对vue里的nextTick()方法理解不清晰,会导致api代码滥用的现象,我查看了vue官网的说明:
Vue.nextTick()用于在下次 DOM 更新循环结束之后执行延迟回调。
问题来了,怎么确定下次DOM更新循环结束的时间点呢?
二、Vue.nextTick源码探索
先看Vue.nextTick()源码[1]的实现方式。next-tick.js源码主要包含callbacks、pending、timerFunc、flushCallbacks四个变量:
callbacks,一个用于接收Vue.nextTick回调方法的队列 flushCallbacks,先入先出执行callbacks队列中所有回调,并清空队列 timerFunc,判断当前环境兼容性,选择对应方法执行flushCallbacks pending,控制flushCallbacks在callbacks队列清空前只执行一次
其中最关键的是timerFunc对于触发flushCallbacks的方法选择,这里贴出源码:
let timerFunc
// 1、优先采用原生Promise触发flushCallbacks
if (typeof Promise !== 'undefined' && isNative(Promise)) {
const p = Promise.resolve()
timerFunc = () => {
p.then(flushCallbacks)
// 在有问题的webView中添加空定时器强制刷新微任务队列
if (isIOS) setTimeout(noop)
}
isUsingMicroTask = true
}
// 2、在promise不可用时采用原生MutationObserver生成一个Dom元素触发flushCallbacks
else if (!isIE && typeof MutationObserver !== 'undefined' && (
isNative(MutationObserver) ||
MutationObserver.toString() === '[object MutationObserverConstructor]'
)) {
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
}
// 3、采用原生setImmediate来降级处理
else if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
timerFunc = () => {
setImmediate(flushCallbacks)
}
}
// 4、采用setTimeout兜底处理
else {
// Fallback to setTimeout.
timerFunc = () => {
setTimeout(flushCallbacks, 0)
}
}
上面的这段核心代码,优先采用了Promise保存回调,然后依次采用了MutationObserver、setImmediate、setTimeout兜底。下面是Vue.nextTick方法的流程图:
timerFunc这里的初始化方式利用了在不同环境下采用JavaScript的事件循环(eventLoop)机制做了触发回调的优雅降级。
三、事件循环机制
JavaScript运行时,按任务环境不同划分出了宏任务(macrotask)和微任务(microtask)。宏任务是由宿主环境发起的,宿主环境有浏览器、Node,常见的添加宏任务的方法为setTimeout、Ajax、I/O、UI交互事件等;微任务是由语言本身自带的,常见的添加方法有Promise.then、MutationObserver等。
事件循环的执行机制为:
1、当js执行栈中的所有任务的执行过程中若遇到微任务或宏任务,则将其添加到对应队列中;
2、执行栈中任务顺序执行完毕后去检查微任务队列是否为空,不为空则把任务按先入先出顺序依次拉取微任务队列中方法到js执行栈中运行;
3、执行栈以及微任务队列都清空后去检查宏任务队列是否为空,不为空把任务按先入先出顺序加入当前执行栈;
4、当执行栈执行完毕后,检查微任务队列是否为空,然后检查宏任务队列是否为空,以此循环至微任务队列、宏任务队列同时为空。
四、事件循环中的Dom渲染时机
结合上面nextTick的源码可以看出,Vue.nextTick将回调方法优先使用Promise.then放入了当前执行栈的微任务队列,采用了setTimeout放入宏任务队列兜底。那可以得出微任务是在dom更新循环结束后触发的,为什么有这样的规定呢,dom树更新后什么时候渲染呢?带着这个问题,我做了一个小测试。
document.body.style.background = 'blue';
console.log(1)
setTimeout(() => {
document.body.style.background = 'yellow';
console.log(2)
},0)
Promise.resolve().then(()=>{
document.body.style.background = 'red';
console.log(3)
})
console.log(4);
上面这段代码的输出结果是1,4,3,2,页面的变化是由红色转黄色,没有渲染为蓝色,以及没有由蓝转红的过程,可以证明渲染是在微任务之后,宏任务之前执行的。
然后我在每次打印时加上了对当前dom树的查询,代码如下:
document.body.style.background = 'blue';
console.log(1,document.body.style.background)
Promise.resolve().then(()=>{
document.body.style.background = 'red';
console.log(2,document.body.style.background)
})
setTimeout(() => {
document.body.style.background = 'yellow';
console.log(3,document.body.style.background)
},0)
console.log(4,document.body.style.background);
可以看到Dom树的变化是实时生效的,但对于Dom树的渲染是延迟生效的,并且晚于微任务,早于宏任务。这样不用频繁的触发渲染,而把一轮微任务队列中Dom树的变化收集起来统一渲染也节省了渲染性能消耗。
五、事件循环中的线程协作
主要负责Dom渲染部分的是与js线程同处于浏览器中渲染进程下的GUI渲染线程,下面结合浏览器运行机制来描述一下事件循环过程中的线程协作机制,本文大部分浏览器相关知识来源于李兵的《浏览器工作原理与实践》这门课。
首先,浏览器是多进程运行的,如常用的Chrome浏览器程序运行时包括:1个浏览器主进程、1个GPU进程、1个网络进程、多个渲染进程、多个插件进程。
其中,每个标签页配置了一个单独的渲染进程,而渲染进程中包含js引擎线程、事件触发线程、GUI渲染线程、异步HTTP请求线程、定时器触发线程。而事件循环就是通过渲染进程中各线程的协作,从而让单线程的JS能够执行异步任务。
1、JavaScript引擎线程,处理页面与用户的交互,以及操作DOM树、CSS样式树来给用户呈现一份动态而丰富的交互体验和服务器逻辑的交互处理,与GUI渲染引擎互斥。
2、GUI渲染线程,负责渲染浏览器界面, 与JavaScript引擎线程互斥,当界面需要重绘(Repaint)或由于某种操作引发回流(reflow)时,该线程就会执行。
3、事件触发线程,事件触发时负责把事件添加到待处理队列的队尾,等待JS引擎的处理。事件类型包括定时任务、AJAX异步请求、DOM事件如鼠标点击等,但由于JS的单线程关系所有这些事件都得排队等待JS引擎处理。
4、定时器线程,负责计时并触发定时。举例为SetTimeout的实现过程是在使用SetTimeout设置定时任务后,会将回调添加在延时执行队列中,然后用定时器开始计时,计时结束后将延时执行队列中的回调任务移出到js执行队列中,按js执行队列顺序执行。
5、异步http请求线程,在XMLHttpRequest在连接后是通过浏览器新开一个线程请求,将检测到状态变更时,如果设置有回调函数,异步线程就产生状态变更事件放到JS引擎的宏任务队列中等待处理。
将渲染进程中各线程功能和事件循环相结合,可以得到下图:
六、总结
探索源码发现,nextTick在不同环境下采用事件循环机制做了触发回调的优雅降级。
事件循环机制中,Dom树的变化是即时生效的,但Dom树的渲染晚于微任务,早于宏任务。而且把微任务队列中Dom树的变化收集起来统一渲染节省了渲染性能消耗。
结合浏览器相关知识,得出了事件循环的线程协作机制,其中包括了渲染线程的执行时机。
六、最佳实践
1、对于vue实例跟dom双向绑定的数据更新,需要在nexttick的回调后获取更新后的dom元素。
// vue官网api用法说明
// 修改数据
vm.msg = 'Hello'
// DOM 还没有更新
Vue.nextTick(function () {
// DOM 更新了
})
这里在修改vue实例的数据后没有立即更新dom,这里是由于vue数据的双向绑定机制导致的,在修改vm.msg后会按续触发setter()[Object.defineProperty] =》 notify() =》 update() =》 queueWatcher() =》 nextTick。
可以看到修改数据后最终是通过nextTick添加了微任务去添加dom更新事件,所以必须使用vue.nextTick才能获取到更新后的dom元素,并且这里是还没有渲染的。这里就不详细讲vue的双向绑定机制了,感兴趣的同学可以去阅读源码,上面提到的方法都标记了源文件地址。
2、对于非vue双向绑定的dom更新,在处理dom更新的语句后面可直接操作更新后的dom元素。
3、操作dom的多次更新(无论是否使用vue双向绑定)应该放在同一轮事件循环的当前js执行栈或微任务中,仅需调用一次渲染线程更新dom,避免放在下一轮宏任务中。这样处理可将多次处理dom优化为一次渲染,避免重复渲染,减少性能损失。
作者简介
杨亮,腾讯前端开发工程师,负责IEG健康系统相关前端业务,毕业于南京大学软件学院。
6月5日,Techo TVP 开发者峰会 ServerlessDays China 2021,即将重磅来袭!
扫码立即参会赢好礼👇