# $nextTick 的浅析
# nextTick
在下次 DOM 更新循环结束之后执行延迟回调。在修改数据之后立即使用这个方法,获取更新后的 DOM。
由于 Vue 中,修改数据后对应的 DOM 不会立即更新,而是会被推入到一个任务队列中异步执行(这个异步实现就是nextTick),所以当我们想要获取更新后的 DOM 的话一般使用$nextTick来异步获取。对应的代码在src/core/util/next-tick.js里,我们来看下这段代码
export let isUsingMicroTask = false; //标记当前是否是使用 microTask 来实现
const callbacks = [];
let pending = false;
function flushCallbacks() {
pending = false;
const copies = callbacks.slice(0);
callbacks.length = 0;
for (let i = 0; i < copies.length; i++) {
copies[i]();
}
}
let timerFunc;
if (typeof Promise !== 'undefined' && isNative(Promise)) {
const p = Promise.resolve();
timerFunc = () => {
p.then(flushCallbacks);
};
isUsingMicroTask = true;
} else if (
!isIE &&
typeof MutationObserver !== 'undefined' &&
(isNative(MutationObserver) ||
MutationObserver.toString() === '[object MutationObserverConstructor]')
) {
let counter = 1;
const observer = new MutationObserver(flushCallbacks);
const textNode = document.createTextNode(String(counter));
observer.observe(textNode, {
characterData: true,
});
timerFunc = () => {
counter = (counter + 1) % 2;
textNode.data = String(counter);
};
isUsingMicroTask = true;
} else if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
timerFunc = () => {
setImmediate(flushCallbacks);
};
} else {
timerFunc = () => {
setTimeout(flushCallbacks, 0);
};
}
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);
}
});
if (!pending) {
pending = true;
timerFunc();
}
if (!cb && typeof Promise !== 'undefined') {
return new Promise((resolve) => {
_resolve = resolve;
});
}
}
首先定义了callbacks变量来维护待执行的函数,以及一个flushCallbacks方法来循环执行callbacks里面的方法,这个方法会拷贝callbacks变量,防止callbacks在执行过程中的再次被添加入回调函数。
接着定义了timerFunc这是异步方法的具体实现。
- 如果当前环境支持
Promise的话,则将异步执行函数flushCallbacks放入微任务队列中用,Promise.resolve().then()来实现。并将isUsingMicroTask设置为true - 接着判断当前浏览器是否支持
MutationObserver,如果支持则将异步执行函数作为MutationObserver回调,并创建一个textNode,通过修改textNode.data来实现异步函数,同时将isUsingMicroTask设置为true - 接着判断当前环境是否支持
setImmediate,支持则使用setImmediate来实现一个宏任务队列 - 最后使用
setTimeout来作为一个兜底的异步实现 这样就完成了对一步函数timerFunc初始化
接着我们看具体的nextTick的实现。首先定义了一个_resolve方法,接着向callbackspush 了一个方法,方法很简单,如果有回调函数则执行回调函数,没有回调函数则执行_resolve方法。接着判断当前异步方法是否在执行中,如果没有执行则执行异步方法timerFunc,因为我们callbacks方法的执行是在timerFunc异步方法的回调里,所以当一轮同步任务执行完时,callbacks里面已经收集了这轮同步任务产生的所有通过nextTick添加进来的异步任务,包括 DOM 更新的Watchr函数。在同步任务结束后,在依次执行callbacks里面的回调函数。因为是依次执行的,如果我们在修改数据前就去获取 DOM 元素是获取不到的,比如:
this.$nextTick(() => {
//获取 a 属性对应的DOM是获取不到的
});
this.a = 'b';
最后,判断当前环境是否支持Promise并且cb参数为假值,是的话则返回一个Promise实例,并将内部的resolve函数赋值给_resolve变量。这样当我们调用$nextTick并且没有传递回调函数是就可以使用Promise的语法了
# 微任务与事件冒泡
上面的代码有一个全局变量isUsingMicroTask标记了当前nextTick的实现方式是一个微任务还是宏任务,这个变量的作用是什么呢?我们先来看下这样一个例子
new Vue({
template:`<div @click="flag?onDivClick:()=>{}">
<button @click="flag = true">Button</button>
</div>`
data:{
flag:false
},
methods:{
onDivClick(){
console.error(666)
}
}
})
当我们点击 button 的时候,onDivClick方法会执行吗?按我们的预期应该是不会执行的,因为 DOM 的更新在微任务队列里,是异步的。然而事实却是onDivClick会执行。因为微任务的执行太快了,比事件的冒泡还要快。所以当事件传递到外层 div 的时候,外层 div 的事件绑定已经更新过了。有什么办法能够规避这个问题呢,其实很简单,只需要比较事件执行的时间与事件挂载的事件,如果发生时间比挂载时间还要早,则不执行这个方法。
事件的挂载时间的定义,在Watcher调度器的src/core/observer/scheduler.js下
执行事件与挂载时间的对比相关代码,在 Web 平台的事件绑定相关的代码中src/platforms/web/runtime/modules/events.js
function add(name, handler) {
if (useMicrotaskFix) { //是否是微任务环境下
const attachedTimestamp = currentFlushTimestamp;
const original = handler;
handler = original._wrapper = function (e) {
if (e.timeStamp >= attachedTimestamp) { // 比较2者时间大小
return original.apply(this, arguments);
}
};
}
target.addEventListener(
name,
handler
);
}
以上是核心的判断代码,在微任务环境下去比较时间的发生时间与挂载时间。而宏任务的优先级比较微任务的低,所以不会出现这样的情况
# 总结
- nextTick 内部根据执行环境先后尝试使用
PromiseMutationObserversetImmediate和setTimeout来实现一个异步调度函数 - 将一次同步任务产生的所有通过
nextTick函数添加的回调放在一个队列里,在异步回调中一次性依次执行完毕 - 当
nextTick执行环境是微任务时,防止因微任务优先级大于时间冒泡,导致的事件回调提前执行的问题。需要判断DOM回调执行时比较下事件的发生时间与挂载时间