nextTick的回调函数一定在Dom更新后执行吗------nextTick原理解密
大家有没有思考过一个问题:就是大家都知道 nextTick** 它是一个异步任务,但是响应式数据的 Dom 更新也是异步的,那么怎么保证二者之间的执行顺序呢?nextTick的回调函数一定在 Dom 更新后执行吗?我将从 **nextTick 源码实现结合 dom 异步更新来帮大家解答这个问题。
写在前面
大家可以先思考下,下面这段代码里两次执行$nextTick
回调函数都能获取到最新的 message dom 值吗?
js
<template>
<div>
<div ref="contentDiv">{{ message }}</div>
<button @click="handlerClick">点击</button>
</div>
</template>
<script>
export default {
data() {
return {
message: '初始值'
}
},
methods: {
handlerClick() {
this.$nextTick(() => {
console.log(this.$refs.contentDiv.textContent, '$nextTick') // 第一次输出
})
this.message = '更新了'
this.$nextTick(() => {
console.log(this.$refs.contentDiv.textContent, '$nextTick') // 第二次输出
})
},
}
}
</script>
如果我修改一下,这段代码里两次执行$nextTick
回调函数都能获取到最新的message dom 值吗?
js
<template>
<div>
<div ref="contentDiv">{{ message }}</div>
<div>{{ text }}</div>
<button @click="handlerClick">点击</button>
</div>
</template>
<script>
export default {
data() {
return {
message: '初始值',
text: ''
}
},
methods: {
handlerClick() {
this.text = '测试'
this.$nextTick(() => {
console.log(this.$refs.contentDiv.textContent, '$nextTick') // 第一次输出
})
this.message = '更新了'
this.$nextTick(() => {
console.log(this.$refs.contentDiv.textContent, '$nextTick') // 第二次输出
})
},
}
}
</script>
在介绍整个原理之前,我先简单介绍下浏览器的事件循环机制。
1.浏览器事件循环机制
浏览器事件循环工作机制详解:
-
执行宏任务阶段
从宏任务队列中取出最旧的任务执行,常见宏任务类型包括:
- 页面主线程代码(
<script>
标签内容) - 定时器回调(
setTimeout
/setInterval
) - I/O操作回调(文件读写、网络请求)
- DOM事件回调(点击、滚动等)
requestAnimationFrame
(特殊类型宏任务)
- 页面主线程代码(
-
处理微任务队列
当前宏任务执行完毕后立即进入微任务处理阶段:
- 检查微任务队列是否为空
- 非空时 :依次执行全部微任务(先进先出)
- 常见微任务 :
Promise.then
/catch
/finally
回调MutationObserver
监听回调- Vue的DOM更新任务及
$nextTick
回调 queueMicrotask
API创建的任务
-
UI渲染阶段(可选)
当微任务队列清空后,浏览器根据需要执行:
- 样式计算 → 布局 → 绘制 的渲染流水线
- 执行
requestAnimationFrame
回调(若存在)
-
开启下一轮循环
重复上述过程,从宏任务队列中取下一个任务执行。整个机制的关键特点:
- 微任务具有最高优先级,必须在本轮循环中全部执行。什么意思呢?就是在一个事件循环开始时,会从宏任务队列取出一个宏任务执行,然后进入微任务处理阶段,会将微任务队列中所有的任务全部执行完,如果这时候有新的微任务加入,则会继续处理微任务,直到微任务队列为空。
- 宏任务按队列顺序执行,保证任务公平性
- UI渲染可能被跳过(当无视觉变化需求时)
2. $nextTick原理解密
2.1 创建异步执行器------timerFunc
在分析 nextTick 函数前需要先了解下 vue2 是如何创建异步执行器的,都用了哪些方法。
js
var timerFunc; // 异步执行器
// timerFunc = Promise.then
if (typeof Promise !== 'undefined' && isNative(Promise)) {
var p_1 = Promise.resolve();
timerFunc = function () {
p_1.then(flushCallbacks);
if (isIOS)
setTimeout(noop);
};
isUsingMicroTask = true;
}
// timerFunc = MutationObserver
else if (!isIE &&
typeof MutationObserver !== 'undefined' &&
(isNative(MutationObserver) ||
MutationObserver.toString() === '[object MutationObserverConstructor]')) {
var counter_1 = 1;
var observer = new MutationObserver(flushCallbacks);
var textNode_1 = document.createTextNode(String(counter_1));
observer.observe(textNode_1, {
characterData: true
});
timerFunc = function () {
counter_1 = (counter_1 + 1) % 2;
textNode_1.data = String(counter_1);
};
isUsingMicroTask = true;
}
// timerFunc = setImmediate
else if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
timerFunc = function () {
setImmediate(flushCallbacks);
};
}
// timerFunc = setTimeout
else {
timerFunc = function () {
setTimeout(flushCallbacks, 0);
};
}
这里Vue采用四级降级方案实现 timerFunc。
- 🌟 优先采用 Promise.then(微任务)
- 🐭 次选 MutationObserver(微任务)
- 🎯 再次使用 setImmediate(宏任务,Node.js 特有 Api)
- 🕒 兜底方案 setTimeout(宏任务)
有的同学可能会问,在一个事件循环里边,明明是先执行宏任务,这里为什么要优先使用微任务呢?
我们要明白的一点是,执行 $nextTick 函数本身就是宏任务的一部分,当整个宏任务执行完成后,下一步进入微任务处理阶段,将整个微任务队列清空 。一个循环里边只能处理一个宏任务,而微任务是需要全部清空的。所以这里使用微任务能够最快执行回调函数。
2.2 nextTick 函数实现
接下来我们来看看 nextTick 函数的实现。
js
var callbacks = [];
var pending = false;
function nextTick(cb, ctx) {
var _resolve;
// 逻辑分支1:存在回调函数
callbacks.push(function () {
if (cb) {
try {
cb.call(ctx);// 执行用户回调
}
catch (e) {
handleError(e, ctx, 'nextTick');// 错误边界处理
}
}
// 逻辑分支2:Promise模式
else if (_resolve) {
_resolve(ctx);// 触发Promise解析
}
});
// 异步队列控制锁
if (!pending) {
pending = true;
timerFunc();// 触发异步执行器
}
// Promise兼容处理
if (!cb && typeof Promise !== 'undefined') {
return new Promise(function (resolve) {
_resolve = resolve;
});
}
}
nextTick 函数核心逻辑可以分解为三步:
- 回调收集 :所有回调函数收集到 callbacks 数组中,等待执行。
- 异步调度 这里是整个 $nextTick 的核心,我详细解释一下:
- 通过 pending 状态锁,确保一次循环里只有一个异步任务。
- 执行 timerFunc 启动异步任务。里面会执行
Promise.resolve().then(flushCallbacks);
。而flushCallbacks
函数就会等到当前宏任务执行完后,在微任务处理阶段执行。 - 当 pending上锁后,后续所有的 $nextTick 回调函数都只会被收集,而不会触发异步任务。
- Promise兼容:未传入回调函数时,会返回一个 Promise 对象,相当于把 $nextTick 当成 Promise 来用。
2.3 执行回调------flushCallbacks
js
function flushCallbacks() {
pending = false;
var copies = callbacks.slice(0);
callbacks.length = 0;
for (var i = 0; i < copies.length; i++) {
copies[i]();
}
}
当进行微任务处理阶段时,就会执行 flushCallbacks
函数,将 callbacks
数组中的回调函数依次执行。同时将 pending 状态锁释放,并清空 callbacks
数组。
为什么要复制一份callbacks来做处理? 主要是防止递归调用造成死循环。
js// 假设回调中再次调用nextTick function callback() { nextTick(() => callback()); }
如果不复制的话回调会一直加载当前的 callback 里,从而导致死循环。这样也能保证批次的完整性,新的回调放到下一个批次里去执行。
2.4 $nextTick 小结
到这里想必大家都清楚 $nextTick 的原理了。就是收集回调函数 ,然后创建一个微任务异步执行。但是如果这是这样的话,怎么保证 $nextTick 回调函数执行顺序一定在dom更新完成之后呢?因为 dom 更新也是异步的呀。所以我们还需要结合 dom 异步更新原理来弄清楚这件事。
3. Dom 异步更新原理
了解响应式原理的同学应该知道数据变化后会触发 watcher 实例的 update 函数。不清楚的同学可以看我上一篇文章。
js
Watcher.prototype.update = function () {
if (this.lazy) {
this.dirty = true;
}
else if (this.sync) {
this.run();
}
else {
queueWatcher(this);
}
};
对于render watcher在 update 函数中,会调用 queueWatcher 函数将当前 watcher 实例加入到异步队列中。
js
var waiting = false;
var flushing = false;
function queueWatcher(watcher) {
var id = watcher.id;
if (has[id] != null) {
return;
}
if (watcher === Dep.target && watcher.noRecurse) {
return;
}
has[id] = true;
if (!flushing) {
queue.push(watcher);
}
else {
var i = queue.length - 1;
while (i > index$1 && queue[i].id > watcher.id) {
i--;
}
queue.splice(i + 1, 0, watcher);
}
if (!waiting) {
waiting = true;
if (process.env.NODE_ENV !== 'production' && !config.async) {
flushSchedulerQueue();
return;
}
nextTick(flushSchedulerQueue);
}
}
- 入队检测 :首先通过
watcher.id
来判断当前watcher
是否已经加入到异步队列中。防止同一数据变更触发多次相同更新 - 通过
flushing
变量来判断当前是否正在执行异步队列,如果否,则加入到 queue 队列中。如果正在执行,则按id排序插入,保障组件更新顺序从父到子。 - 异步队列:通过 nextTick 函数,将 flushSchedulerQueue 函数加入到微任务队列中。利用 waiting 变量来确保一次循环里只执行一次。
- flushSchedulerQueue: 执行 queue 队列中的所有 watcher 实例的 run 函数完成视图更新。
这里面需要注意的是,所有的 watcher 实例都添加到了 queue 队列中,然后调用 nextTick 函数完成视图更新。调用 nextTick ,调用 nextTick ,调用 nextTick ,重要的事说三遍。这意味着,它把所有视图更新的操作打包成了一个回调函数,然后通过 nextTick 函数,将这个回调函数加入到微任务队列中。
也就是说注册的 $nextTick 回调函数,和视图更新操作共用一个callback数组,这也就意味着它们二者之间是有明确的顺序,谁先注册,谁先执行。所以我们能够通过 nextTick 获取视图更新之后的 dom 元素,是因为我们代码里基本上都会先修改值,再调用nextTick。
4.总结
就是视图异步更新会打包成一个任务 ,用 $nextTick 注册回调 。和用户自己用 $nextTick 注册的回调函数共用同一个 callbacks 数组 。按先后注册顺序存放 (只要有任何一个 watcher 先注册了,那么整个视图更新的任务就会在第一个)。同时第一个注册回调的函数会启动一个微任务,等到宏任务执行完,执行微任务的时候会遍历执行 callbacks 数组中的所有回调函数。
写在最后
到这里想必大家对文章开头的两个例子都有了答案。
第一个例子里:第一个回调函数先于 message 注册,所以它拿不到更新后的值,第二个回调函数是可以的。
第二个例子里:先改变了 text 值,这时候视图更新的任务会先注册回调,text 更新和 message 更新都会先执行。所以两个回调函数都能拿到更新后的值。
如果大家觉得我写的还不错的话,欢迎大家关注下我的个人博客。里面会不定期更新新的文章。