背景
说来惭愧,笔者作为一名有好几年开发经验的高级前端,却一直对Vue的执行细节一知半解。 最近拜读霍大的《Vue.js设计与实现》,发现自己之前对vue事件流程和nextTick方法一直存在误解,而且就目前从网上看到的关于nextTick的讨论,貌似有类似误解的同学还不少。 所以趁现在还有印象,赶紧记录一下,也希望能给有类似困惑的同学有所启发。
《Vue.js设计与实现》是一本难得的好书,豆瓣评分9.5, 推荐每个Vuer都看看!
为了方便讲解,本文先澄清几个nextTick误区,然后自己尝试手动"肢解"一个最小化的Vue demo,转化成原生JS代码,以辅助理解。因为文章比较长,分拆成两个部分以方便阅读:
误区一:❌ nextTick是用于获取DOM渲染完成后的最终属性
Vue官方文档中,对于nextTick有如下一句解释:
如果不看上下文,对此容易产生一个误会:nextTick是用于获取dom更新后的DOM属性。 那实际情况真是如此吗?
首先,对dom的更新有所了解的同学应该知道,在JS线程的一次事件循环的更新流程如下:
同步任务
> 微任务
> DOM渲染
> 宏任务(下一次事件循环)
然而,一个容易混淆的概念是,上述的"DOM渲染"实际指的是浏览器的GUI渲染进程,实际上DOM操作本身其实是同步的,其属性可以直接在下一行获得,并不需要等待DOM渲染完成才能获取属性。
我们先写个实践代码测试一下:
<!DOCTYPE html>
<html lang="en">
<body>
<button>change</button>
<script>
document.querySelector('button').onclick = function(){
// 同步任务
document.body.style = 'background: blue';
console.log('同步任务: 当前背景色: ', document.body.style.background);
alert('同步任务: 当前背景色: ' + document.body.style.background);
// 微任务
Promise.resolve().then(() => {
document.body.style = 'background: green';
console.log('微任务: 当前背景色: ', document.body.style.background);
alert('微任务: 当前背景色: ' + document.body.style.background);
});
// 宏任务
setTimeout(() => {
document.body.style = 'background: red';
console.log('宏任务: 当前背景色: ', document.body.style.background);
alert('宏任务: 当前背景色: ' + document.body.style.background);
}, 0)
}
</script>
</body>
</html>
在浏览器运行☝️上述html代码,使用alert中断渲染进程,效果如下:
观察上述Deom的运行效果,修改DOM背景色时,同步任务和微任务的背景色并不会立刻渲染,而是会集中到宏任务执行之前,body的背景色才会渲染出来; 而宏任务的背景色则会立刻渲染(因为已经进入了下一个事件循环了). 这个符合上述同步任务 > 微任务 > DOM渲染 > 宏任务
的预期。 但是不管同步任务、微任务、宏任务,alert中断执行时,控制台都能正确打印出当前背景色的值:
事实上,浏览器的对待页面的正确“姿势”是先计算后渲染。 即使在同一个事件循环中,DOM操作本身也是同步的,只是DOM渲染会推迟到一次事件循环的最后再执行。
在本质上,DOM也只是一个对象而已,在修改DOM对象时,UI的修改(包括添加节点、修改颜色、修改节点内容等)会立即更新应用程序的状态,然后等到微任务队列清空后,事件循环会检查当前是否需要重新渲染UI,如果需要则渲染UI视图。 所以在数据层面上,你的下一行代码就可以拿到DOM的修改结果,在数据层面上获取DOM更新后的属性并不需要nextTick的参与。
上述Demo为方便演示,更新方式使用了background,只涉及重绘(repaint); 不过笔者实测重排(reflow)的效果也是一样,有兴趣的同学可以自行测试。
题外话:上述例子不能使用debugger来中断,因为debugger只中断JS引擎的执行,而GUI渲染线程并不会中断。 必须使用alert才能中断GUI渲染线程。 如果使用debugger替换alert,在断点处也能看到UI被重新渲染。
误区二:❌ nextTick可将传入的回调函数推迟到一次事件循环后再执行
这貌似是个比较普遍的误区。 在网上搜索nextTick相关文章,时不时会看到这样的表述:
那么实际情况又是如何?
nextTick实现方法
在Vue中,nextTick
的实现原理,在浏览器支持的情况下,基本上是尽量基于微任务实现的。 Vue2.0中nexTick经历了多次调整,在微任务和宏任务中反复横跳,但优先考虑微任务; 而在Vue3.0中,因为不存在兼容性问题了,所以统一使用Promse实现,简化代码如下:
// Vue3.0中的nextTick
function nextTick(fn) {
return Promse.resolve().then(fn);
}
Vue2的nextTick实现的变化可以看看这篇文章。
微任务情况下nextTick的执行时机
在上面误区一已经提到,微任务的执行时机是在DOM渲染之前的,所以nextTick理论上是无法将代码加入下一个事件循环的。 有同学可能会问:如果微任务中又产生了新的微任务,那执行顺序会是怎么样呢? 是在当前事件循环继续执行,还是将新的微任务放到下一个事件循环的微任务队列呢?
还是老习惯,我们尽量使用栗子来验证:
<!DOCTYPE html>
<html lang="en">
<body>
<script>
console.log('同步任务1')
Promise.resolve().then(() => {
console.log('微任务1');
Promise.resolve().then(() => {
console.log('微任务2');
Promise.resolve().then(() => {
console.log('微任务3');
})
})
})
setTimeout(() => {
console.log('宏任务1');
}, 0)
console.log('同步任务2')
</script>
<script>
// 一个script相当于一个新的宏任务, 这里用来模拟下一次事件循环
console.log('下一个宏任务')
</script>
</body>
</html>
☝️运行结果:
查看控制台的打印结果可知,无论我们嵌套多少层,微任务中添加的微任务,都必定会在当前的事件循环中被执行。 即使在微任务执行中又有新的微任务入列,JS线程也始终会将微任务队列清空,然后才会执行后续的DOM渲染,以及下一个事件循环。 所以只要nextTick是基于微任务方法实现的,那就无法把其回调函数的代码推迟到下一个事件循环!
如果上面的实例还无法让你信服, 我们还可以用一个更直接的栗子来验证:
<template>
<div @click="modify">{{name}}</div>
</template>
<script setup>
import {ref, nextTick} from 'vue';
const name = ref("111");
const modify = () => {
nextTick(() => {
const text = document.querySelector("div").innerText;
alert(text);
});
name.value = "333";
};
</script>
假设nextTick
的作用是将代码加入到下一个事件循环,那么点击div时,上面的alert弹框预期应该显示修改后的值333
,但在vue演练场中测试可知,实际弹框显示的却是修改前的值111
:
可见,nextTick只能对发生在它"前面"的数据变化做出响应,而不能对发生在它"后面"的数据变化做出响应。
这也不是那也不是,那么nextTick到底是干嘛用的?
先断章了。因为文章比较长,分拆成两个部分以方便阅读。下半部分我们将尝试自己实现一个vue的事件流程。文章路径如下:
下半部分涉及Vue的响应式原理,对Vue的执行原理有所了解的同学可能看起来会更容易理解。我会尽量用栗子和图片来解释清楚。