Vue 和React都实现了异步更新策略。虽然实现的方式不尽相同, 但都达到了减少DOM操作 避免过度渲染的目的。通过研究框架的运行机制, 其设计思路将深化我们对DOM优化的理解, 拓宽我们对DOM实践的认知。
Event Loop
Micro-Task与Macro-Task
事件循环中的异步队列由两种:macro(宏任务)队列和micro(微任务队列)。
常见的macro-task比如:setTimeout setInterval setImmediate script(整体代码)I/O操作 UI渲染等
常见的micro-task比如: process.nextTick Promise MutationObserver等
Event Loop过程解析
基于对micro和macro的认知,我们来走一遍完整的时间循环过程。
一个完整的Event Loop过程,可以概括为以下阶段:
- 初始状态,调用栈空。micro队列空, macro队列里有且只有一个script脚本
- 全局上下文(script 标签)被推入调用栈,同步代码执行。在执行过程中, 通过对一些接口的调用, 可以产生新的macro-task与micro-task,他们会分别被推入任务队列。同步代码执行完了,script脚本会被移出macro队列,这个过程本质上是队列的macro-task的执行和出队的过程。
- 上一步我们出队的是一个macro-task,这一步我们处理的是micro-task。但需要注意的是:当macro-task出队时,任务是一个一个执行的;而micro-task出队时,任务是一队一队执行的。因此,我们处理micro队列这一步,会逐个执行队列中的任务并把它出队,直到队列被清空。
- 执行渲染操作,更新界面
- 检查是否存在web worker任务, 如果由 则对其进行处理
- 上述过程循环往复, 直到两个队列都清空
总结一下,每一次循环都是一个这样的过程:
渲染的时机
先思考一个这样的问题:加入我想要在异步任务里进行DOM更新, 我该把它包装成micro还是macro呢?
先假设他是一个macro任务,比我我在script脚本中用setTimeout来处理它
setTimeout(task, 0)// task 是一个用于修改DOM的回调
现在task被推入macro队列。 但因为script脚本本身是一个macro任务,所以本次执行完script脚本之后,下一个不走就要去处理micro队列了, 再往下就去执行了一次render。
但本次render我的目标task其实并没有执行,想要修改DOM也没有修改,因此这一次的render其实是一次无效的render。
macro不ok, 我们转向micro试试看。我用Promise来把task包装成是一个micro任务:
Promise.resolve().then(task)
那么我们结束了对script脚本的执行, 是不是紧接着就去处理了micro-task队列了?micro-task处理完,DOM修改好了, 紧接着就可以走render流程了——不需要消耗多余一次渲染,不需要等待一轮事件循环,直接为用户呈现最即时的更新结果。
因此,我们更新DOM的时间点,应该尽可能靠近渲染的时机。当我们需要在异步任务中实现DOM修改时,把它包装成micro任务是相对明智的选择。
异步更新策略(Vue)
什么是异步更新?
当我们使用Vue或React提供的接口去更新数据时, 这个更新并不会立即生效,而是会被推入到一个队列里,待到适当的时机,队列中的更新任务会被批量触发, 这就是异步更新。
异步更新可以帮助我们避免过度渲染,是让JS为DOM分压的典范之一。
异步更新的优越性
异步更新的特性在于它只看结果, 因此渲染引擎不需要问过程买单。
最典型的例子, 比如有时我们会遇到这样的情况:
// 任务一
this.content = '第一次测试'
// 任务二
this.content = '第二次测试'
// 任务三
this.content = '第三次测试'
我们三个更新任务中对同一个状态修改了三次,如果我们采取传统的同步更新策略, 那么就要操作三次DOM。但本质上需要呈现给用户的目标内容其实只是第三次的结果,也就是说只有第三次的操作是有意义的——我们白白浪费了两次计算。
但如果我们把这三个任务塞进异步更新队列里,他们会现在js的层面上被批量执行完毕。当流程走到渲染这一步时,它仅仅需要针对有意义的计算结果操作一次DOM——这就是异步更新的好处
Vue状态更新手法:nextTick
Vue每次想要更新一个状态的时候,他先把这个更新操作给包装成一个异步操作派发出去。这件事情,在源码中是有一个叫做nextTick的函数来完成的:
export function nextTick (cb?:Function,ctx?: Object) {
let _resolve
callbacks.push(() => {
if (cb) {
try {
cb.call(ctx)
} catch (e) { handleError(e, ctx, 'nextTick') }
} else if (_resolve) {
_resolve(ctx)
}
})
// 检查上一个异步任务队列(即名为callbacks的任务数组)是否派发和执行完毕了。pending此处相当于一个锁
if (!pending) {
pending = true
// 是否要求一定要派发macro任务
if (useMacroTask) {
macroTimerFunc()
} else { microTimerFunc() }// 如果不是macro那么就全都是micro
}
if (!cb && typeof Promise !== 'undefined') {
return new Promise(resolve => {
_resolve = resolve
})
}
}
我们看到, Vue的异步任务默认情况下都是用Promise来包装的,也就是说他们都是micro-task。
为了熟悉一下常见的macro和micro派发方式 加深对Event Loop的理解, 我们继续细化解析一下macroTimeFunc() 和microTimeFunc()这两个方法
macro Time Func() 是这么实现的:
// macro首选setImmediate这个兼容性最差
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
macroTimeFunc = () => {
port.postMessage(1)
}
} else {
//兼容性最好的派发方式是setTimeout
macroTImerFunc = () => {
setTimeout(flushCallbacks)
}
}
microTimeFunc()是这么实现的:
// 简单粗暴 不是ios全都去Promise 如果不兼容promise 那么你只能将就一下变成macro了
if (typeof Promise !== 'undefiend' && isNative(Promise)) {
const p = Promise.resolve()
microTimerFunc = () => {
p.then(flushCallbacks)
if (isIOS) { setTimeout(noop) }
}
} else {
microTimerFunc = macroTimerFunc
}
我们注意到无论是派发macro任务还是micro任务, 派发的任务对象都是一个叫做flushCallbacks的东西,这个东西做了什么
flushCallbacks源码如下:
function flushCallbacks () {
pending = false
// callbacks在nexttick中出现过, 他是任务数组
const copies callbacks.slice(0)
callbacks.length = 0
for (let i = 0; i < copies.length; i++) {
copies[i]()
}
}
Vue中每产生一个状态更新任务,它就会被塞进一个叫callbacks的数组(此处是任务队列的实现形式)中, 这个任务队列再被丢进micro或macro队列之前,会先去检查当前是否有异步更新任务正在执行(即检查pending锁)。如果确认pending锁是开着的(false),就把他设置锁上(true),然后对当前callbacks数组的任务进行派发(丢尽micro或macro队列)和执行。设置pending锁的意义在于保证状态更新任务的有序进行,避免发生混乱。