浅析Vue.nextTick
什么是Vue.nextTick?
Vue实现响应式并不是当数据发生变化,DOM就立刻变化,而是创建一个队列,缓冲在同一时间循环中发生的所有数据改变。就如一个外卖员接订单,在不同的店家,接到了送往同一个地方的单子,并不是接一个就送一个,而是一个个拿完之后先放入外卖箱,再一起配送,避免多次配送。如果同一个watcher被多次触发,只会依次推入到队列中。依靠这种缓冲去除重复数据引起的不必要计算及DOM操作,Vue.nextTick就是在DOM更新后自动执行该回调函数。
应用场景
-
在Vue的生命周期created钩子函数进行DOM操作,我们知道created时DOM并未生成,此时DOM操作一定要放入Vue.nextTick函数中。
-
数据变化后,当需要操作DOM时,操作放入Vue.nextTick回调中
事件循环及UI Render
首先我们应先了解事件循环,一次macrotasks + microtasks 称为一次ticket,ticket结束之后会触发浏览器的重绘操作(不一定每次都执行),换言之,执行任务的耗时会影响视图渲染的时机,通常浏览器以每秒60帧(60fps)的速率刷新页面,据说这个帧率最适合人眼交互,大概16.7ms渲染一帧,所以如果要让用户觉得顺畅,单个macrotask及它相关的所有microtask最好能在16.7ms内完成。但也不是每轮事件循环都会执行视图更新,浏览器有自己的优化策略,例如把几次的视图更新累积到一起重绘,重绘之前会通知requestAnimationFrame执行回调函数,也就是说requestAnimationFrame回调的执行时机是在一次或多次事件循环的UI render阶段。
<body>
<button onclick="change()">change</button>
<div id="xml">1</div>
</body>
<script>
window.onload = () => {
function test2() {
setTimeout(() => {
document.getElementById("xml").style.color = "red"
document.getElementById("xml").innerText = "red"
})
}
function test3() {
Promise.resolve().then(()=>{
document.getElementById("xml").style.color = "red"
document.getElementById("xml").innerText = "red"
})
}
function test4() {
const time = new Date().getTime();
while(true) {
if(new Date().getTime() - time > 20) {
break
}
}
}
function test1() {
document.getElementById("xml").style.color = "blue"
document.getElementById("xml").innerText = "blue"
test4()
test3()
// test2()
}
setTimeout(test1)
}
</script>
我们从performance面板来解读上述代码渲染机制,首先我们了解下Timings:
(1)DCL(DOMContentLoaded)表示 HTML 文档加载完成事件。当初始 HTML 文档完全加载并解析之后触发,无需等待样式、图片、子 frame 结束。作为明显的对比,load 事件是当个页面完全被加载时才触发
(2)FP(First Paint)首屏绘制,页面刚开始渲染的时间。
(3)FCP(First Contentful Paint)首屏内容绘制,首次绘制任何文本,图像,非空白canvas 或 SVG 的时间点。
(4)FMP(First Meaningful Paint)首屏有意义的内容绘制,这个“有意义”没有权威的规定,本质上是通过一种算法来猜测某个时间点可能是 FMP。有的理解为是最大元素绘制的时间,即同LCP(Largest Contentful Paint )。其中 FP、FCP、FMP 是同一条虚线,三者时间不一致。比如首次渲染过后,有可能出现 JS 阻塞,这种情况下 FCP 就会大于 FP。
(5)L(Onload)页面所有资源加载完成事件。
(6)LCP(Largest Contentful Paint )最大内容绘制,页面上尺寸最大的元素绘制时间
各种颜色柱子代表的事件分类:
Loading 事件
| 内容 | 说明 |
|---|---|
| Parse HTML | 浏览器解析HTML |
| Finish Loading | 网络请求完成 |
| Receive Data | 请求的响应数据到达事件,如果响应数据很大(拆包),可能会多次触发该事件 |
| Receive response | 响应头报文到达时触发 |
| Send Request | 发送网络请求时触发 |
Script 事件
| 内容 | 说明 |
|---|---|
| Animation Frame Fired | 一个定义好的动画帧发生并开始回调处理时触发 |
| Cancel Animation Frame | 取消一个动画帧触发 |
| GC Event | 垃圾回收时触发 |
| DOMContentLoaded | 当页面中的DOM内容加载并解析完触发 |
| Evaluate Script | A script was evaluated |
| Event | JS事件 |
| Function Call | 浏览器进入js引擎时触发 |
| Install Timer | 创建定时器(调用setTimeout或者setInterval)时触发 |
| Timer Fired | 定时器回调 |
| Request Animation Frame | A requestAnimationFrame() call scheduled a new frame |
| Remove Timer | 清除计时器时触发 |
Rendering 事件
| 内容 | 说明 |
|---|---|
| Layout | Layout |
| Update Layer Tree | 更新 Layer Tree |
Painting 事件
| 内容 | 说明 |
|---|---|
| paint | 绘制 |
| Schedule Style Recalculation | 样式更改 |
先执行onload事件,然后执行setTimeout,创建了一个定时器,然后进行layout(视图渲染)。
执行timer fired,执行test1()
test1函数里,调用getElementById,更改了样式,调用test4()
在test4()后render前执行了test3中的微任务
可以注意一个现象,因为test4中有一个20ms的循环,阻塞了render的渲染,导致两次render间隔时间是大于16.7ms
假设我们把test4和test3注释掉,然后执行test2,不在同一事件循环中更改样式,中间间隔时间小于16.7ms只会引起一次render,验证不是每轮事件循环都会执行视图更新。
原理解析
数据变化时,会执行Watcher的update方法执行数据更新,Watcher的update方法也是通过Vue.nextTick方法进行遍历执行,最终完成视图更新。
那Vue.nextTick是什么呢?实际上也是一个异步函数,每次传进来的事件都会添加到callback回调中
return function queueNextTick (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)
}
})
if (!pending) {
pending = true
// 任务执行函数
timerFunc()
}
if (!cb && typeof Promise !== 'undefined') {
return new Promise((resolve, reject) => {
_resolve = resolve
})
}
}
那timerFunc又是什么呢?
// 如果支持promise
if (typeof Promise !== 'undefined' && isNative(Promise)) {
var p = Promise.resolve()
var logError = err => { console.error(err) }
timerFunc = () => {
p.then(nextTickHandler).catch(logError)
// in problematic UIWebViews, Promise.then doesn't completely break, but
// it can get stuck in a weird state where callbacks are pushed into the
// microtask queue but the queue isn't being flushed, until the browser
// needs to do some other work, e.g. handle a timer. Therefore we can
// "force" the microtask queue to be flushed by adding an empty timer.
if (isIOS) setTimeout(noop)
}
} else if (!isIE && typeof MutationObserver !== 'undefined' && (
isNative(MutationObserver) ||
// PhantomJS and iOS 7.x
MutationObserver.toString() === '[object MutationObserverConstructor]'
)) {
// use MutationObserver where native Promise is not available,
// e.g. PhantomJS, iOS7, Android 4.4
var counter = 1
// 创建一个MutationObserver去监听任务的结束
var observer = new MutationObserver(nextTickHandler)
var textNode = document.createTextNode(String(counter))
observer.observe(textNode, {
characterData: true
})
timerFunc = () => {
counter = (counter + 1) % 2
// 用于监听dom更改
textNode.data = String(counter)
}
} else {
// fallback to setTimeout
/* istanbul ignore next */
timerFunc = () => {
setTimeout(nextTickHandler, 0)
}
}
从上面可以看出,在不同环境下对使用哪种异步方法做了处理,优先使用promise及MutationObserver(可以监听dom变化),这个两都是添加到微任务中。就好比我们排队买包子,包子是现做的,当有一笼包子好了,需要排队取,微任务是包子出炉就可以取,而宏任务却需要到下一笼包子。中间等时间可以看作UI Render,是在每次任务的最后,所以优先使用微任务,避免重复进行渲染。
我们可以看到在使用MutationObserver时是自己创建了一个新DOM,然后去执行DOM更新,但是这跟我们的DOM更新有什么关系呢?因为js是单线程,任务队列中有着先进先出的规则。就如,我们去餐厅排队(任务队列),这时候需要取号(创建一个新dom的任务,监听dom的更改,添加到任务队列),当服务员叫号了我们(在我们创建的dom更之前的任务都执行完了,轮到dom更新完了), 我们可以进餐(轮到nextTic任务执行了),所以能保证DOM更新之后去执行这个回调。
因为兼容问题,只能进行降级操作,使用setTimeout进行异步回调,此时是添加到了下一次任务队列中,会多进行一次UI Render。
总结
vue为了确保性能问题,会把DOM修改添加到异步队列中,在所有的同步代码执行完后再统一修改DOM,一次事件循环中只会触发一次Watcher的Updete,也是通过nextTick进行异步创建,会优先使用microTask创建任务队列,如果要获取修改后的DOM,也是通过nextTick创建一个异步任务跟在DOM更新任务之后。