在 Vue 开发中,深入理解和正确运用 nextTick 函数有利于网页性能以及稳定性的提升。本文将全面且深入地剖析 nextTick 函数的作用,并且附带上代码示例帮助理解。
一、nextTick 函数的核心作用
(一)实现基于更新后 DOM 状态的精确交互
在很多时候,我们需要依据更新后的 DOM 状态来执行后续操作。比如,我们有一个根据响应式数据显隐的 DOM,如果直接在状态变化后获取可能为 undefined,如果使用上 nextTick 将万无一失。
<template>
<div>
<button @click="showMessage">显示消息</button>
<div v-if="showMsg" ref="msg">消息</div>
</div>
</template>
<script>
export default {
data() {
return {
showMsg: false,
};
},
methods: {
showMessage() {
this.showMsg = true;
console.log(this.$refs.msg); // undefined
// 使用 $nextTick 确保 DOM 更新后再执行操作
this.$nextTick(() => {
console.log(this.$refs.msg); // 消息
});
},
},
};
</script>
原理是因为 nextTick 是将函数添加到异步队列里,等到下一个异步队列再执行。而 DOM 是同步操作。
(二)避免多次更新 DOM - 优化页面性能
如果每次数据变化都立即更新 DOM,会导致频繁的 DOM 操作,可能会导致重绘(Repaint)或重排(Reflow),性能开销较大。
重排(Reflow): 当元素的几何属性(如元素的位置、大小、布局等)发生改变时,浏览器需要重新计算元素的几何属性,并且重新布局页面,这个过程称为重排。例如,改变元素的宽度、高度、添加或删除元素、修改元素的位置,定位等都会触发重排。
重绘(Repaint): 当元素的样式属性发生改变,但不影响其几何属性时,浏览器会重新绘制元素,这个过程称为重绘。例如,改变元素的颜色、背景颜色、字体等属性会触发重绘。
- 重排是一个相对昂贵的操作,因为浏览器需要重新计算页面的布局,涉及到整个文档流的重新计算,会影响页面的性能,特别是当页面布局复杂时,大量的重排操作会导致页面卡顿。
- 重绘的性能开销比重排小,但如果频繁发生重绘,也会影响页面的性能,尤其是在复杂页面上。
<template>
<div>
<div ref="item" class="item">{{ message }}</div>
<button @click="updateMessage">更新消息</button>
</div>
</template>
<script>
export default {
data() {
return {
message: "初始消息",
};
},
methods: {
updateMessage() {
// 先更新数据
this.message = "更新后的消息";
// 直接修改样式,可能导致多次重排重绘
const item = this.$refs.item;
item.style.backgroundColor = "lightblue";
},
},
};
</script>
<style>
.item {
background-color: white;
padding: 5px;
transition: all 0.3s ease;
}
</style>
上面的示例中在 nextTick 之前改变 DOM 属性,就可能导致多次更新 DOM,因为此时 DOM 第一次的更新可能已经完成,立马发出了下一个 DOM更新。
三、源码示例深度剖析
以下是经过简化的 nextTick 底层源码:
let callbacks = [];
let pending = false;
function nextTick(cb) {
callbacks.push(cb);
if (!pending) {
pending = true;
Promise.resolve().then(flushCallbacks);
}
}
function flushCallbacks() {
pending = false;
const copies = callbacks.slice(0);
callbacks.length = 0;
for (let i = 0; i < copies.length; i++) {
copies[i]();
}
}
// 示例使用
nextTick(() => {
console.log('Callback executed in next tick');
});
在上述源码中, nextTick 函数首先将传入的回调函数包装并推入 callbacks 队列。然后用 Promise 把回调推入微队列,等待执行。
当然,这只是最简单的版本,万一浏览器不支持 Promise 呢?在 Vue2 里的源码中,会退而用 MutationObserver api 去把回调添加到异步队列。如果再没有,就继续往下降级,具体可以看代码。
let timerFunc;
// 用于存储需要执行的回调函数
const callbacks = [];
let pending = false;
// 定义 flushCallbacks 函数,用于执行存储在 callbacks 中的回调
function flushCallbacks() {
pending = false;
const copies = callbacks.slice(0);
callbacks.length = 0;
for (let i = 0; i < copies.length; i++) {
copies[i]();
}
}
// 尝试使用 Promise 作为首选的微任务执行方式
if (typeof Promise !== "undefined") {
const p = Promise.resolve();
timerFunc = () => {
p.then(flushCallbacks);
};
} else if (typeof MutationObserver !== "undefined") {
let counter = "1";
const observer = new MutationObserver(flushCallbacks);
const textNode = document.createTextNode(counter);
observer.observe(textNode, {
characterData: true,
});
timerFunc = () => {
counter = counter === "1" ? "2" : "1";
textNode.data = counter;
};
} else if (typeof setImmediate !== "undefined") {
timerFunc = () => {
setImmediate(flushCallbacks);
};
} else {
timerFunc = () => {
setTimeout(flushCallbacks, 0);
};
}
function nextTick(cb) {
callbacks.push(cb);
if (!pending) {
pending = true;
timerFunc();
}
}
// 示例使用
nextTick(() => {
console.log("Callback executed in next tick");
});
可以看到,如果 MutationObserver 还没有,会使用 setImmediate 加入宏队列里面。最后会有 setTimeout 兜底。
但是在实际的项目中,我们不止可以使用回调函数的方式,我们还能使用 await 的方式。
但是很明显,上面的代码并不支持,所以 Vue 还有有一段的处理。
function nextTick(cb) {
return new Promise((resolve) => {
// 将回调函数和 resolve 封装到一个新函数中
const _cb = () => {
if (cb) {
cb();
}
resolve();
};
callbacks.push(_cb);
if (!pending) {
pending = true;
timerFunc();
}
});
}
这里的nextTick 会返回一个 Promise ,在 flushCallbacks 执行的时候才 resolve。这样就能兼容 await 的模式了。
👆上面都是 Vue2 的源码,在 Vue3 中会有一些优化:把 MutationObserver 改为 queueMicrotask api。并且优先使用
原因有以下两点:
更加标准:queueMicrotask api 是一个更标准化的 API,它的设计目的就是为了将回调添加到微任务队列中,相比 MutationObserver 更符合现代 JavaScript 标准
更加简洁:queueMicrotask 的使用更加简洁,无需像 MutationObserver 那样创建和管理观察对象,简化了代码结构和实现逻辑。
// Vue3 中会直接使用 queueMicrotask 来推入异步队列
export function queueMicrotask(cb) {
if (typeof queueMicrotask === 'function') {
queueMicrotask(cb)
} else if (typeof Promise === 'function') {
Promise.resolve().then(cb)
} else {
setTimeout(cb, 0)
}
}
当然,实际的 Vue 源码会复杂得多。会考虑很多其他东西,这里我们掌握核心逻辑就好。
三、总结
nextTick 函数最大的作用就是等获取到 Vue 更新后的 Dom,这个在实际项目中非常常见。一些不起眼的 bug 就是因为你没有操作到更新后的 Dom。所以在涉及到 Dom 操作的时候,要想清楚是否需要使用 nextTick。在不必要的地方也避免使用,因为过度依赖或滥用可能会导致代码逻辑复杂度过高且难以维护。在一些简单场景下,如果能够通过合理的 Vue 响应式数据绑定和生命周期钩子处理 DOM 相关操作,应优先选择这些方式,而非一味地使用 nextTick。同时,由于 nextTick 的回调执行时机依赖于异步任务队列和浏览器事件循环,在一些极端情况下(如浏览器性能极低或存在大量异步任务积压),可能会出现延迟执行时间过长的问题,我们需要对此有清晰的认识并进行适当的错误处理和性能优化。