使用场景:
在我们开发项目的时候,总会碰到一些场景:当我们使用vue操作更新dom后,需要对新的dom做一些操作时,但是这个时候,我们往往会获取不到跟新后的DOM.因为这个时候,dom还没有重新渲染,所以我们就要使用vm.$nextTick方法。
用法:
nextTick接受一个回调函数作为参数,它的作用将回调延迟到下次DOM跟新周期之后执行。
methods:{
example:function(){
//修改数据
this.message='changed'
//此时dom还没有跟新,不能获取新的数据
this.$nextTick(function(){
//dom现在跟新了
//可以获取新的dom数据,执行操作
this.doSomeThing()
})
}
}
小思考:
在用法中,我们发现,什么是下次DOM更新周期之后执行,具体是什么时候,所以,我们要明白什么是DOM更新周期。 在Vue当中,当视图状态发生变化时,watcher会得到通知,然后触发虚拟DOM的渲染流程,渲染这个操作不是同步的,是异步。Vue中有一个队列,每当渲染时,会将watcher推送这个队列,在下一次事件循环中,让watcher触发渲染流程。
为什么Vue使用异步更新队列?
简单来说,就是提升性能,提升效率。 我们知道Vue2.0使用虚拟dom来进行渲染,变化侦测的通知只发送到组件上,组件上的任意一个变化都会通知到一个watcher上,然后虚拟DOM会对整个组件进行比对(diff算法,以后有时间我会详细研究一下),然后更新DOM.如果在同一轮事件循环中有两个数据发生变化了,那么组件的watcher会收到两次通知,从而进行两次渲染(同步跟新也是两次渲染),事实上我们并不需要渲染这么多次,只需要等所有状态都修改完毕后,一次性将整个组件的DOM渲染到最新即可。
如何解决一次事件循环组件多次状态改变只需要一次渲染更新?
其实很简单,就是将收到的watcher实例加入队列里缓存起来,并且再添加队列之前检查这个队列是否已存在相同watcher。不存在时,才将watcher实例添加到队列中。然后再下一次事件循环中,Vue会让这个队列中的watcher触发渲染并清空队列。这样就保证一次事件循环组件多次状态改变只需要一次渲染更新。
什么是事件循环?
我们知道js是一门单线程非阻塞的脚本语言,意思是执行js代码时,只有一个主线程来处理所有任务。非阻塞是指当代码需要处理异步任务时,主线程会挂起(pending),当异步任务处理完毕,主线程根据一定的规则去执行回调。事实上,当任务执行完毕,js会将这个事件加入一个队列(事件队列)。被放入队列中的事件不会立刻执行其回调,而是当前执行栈中所有任务执行完毕后,主线程会去查找事件队列中是否有任务。
异步任务有两种类型,微任务和宏任务。不同类型的任务会被分配到不同的任务队列中。
执行栈中所有任务执行完毕后,主线程会去查找事件队列中是否有任务,如果存在,依次执行所有队列中的回调,只到为空。然后再去宏任务队列中取出一个事件,把对应的回调加入当前执行栈,当前执行栈中所有任务都执行完毕,检查微任务队列是否有事件。无线循环此过程,叫做事件循环。
常见的微任务
- Promise.then
- Object.observe
- MutationObserver
常见的宏任务
- setTimeout
- setInterval
- setImmediate
- UI交互事件
在我们使用vm.$nextTick中获取跟新后DOM时,一定要在更改数据的后面使用nextTick注册回调。
methods:{
example:function(){
//修改数据
this.message='changed'
//此时dom还没有跟新,不能获取新的数据
this.$nextTick(function(){
//dom现在跟新了
//可以获取新的dom数据,执行操作
this.doSomeThing()
})
}
}
如果是先使用nextTick注册回调,然后修改数据,在微任务队列中先执行使用nextTick注册的回调,然后才执行跟新DOM的回调,所以回调中得不到新的DOM,因为还没有更新。
methods:{
example:function(){
//此时dom还没有跟新,不能获取新的数据
this.$nextTick(function(){
//dom没有跟新,不能获取新的dom
this.doSomeThing()
})
//修改数据
this.message='changed'
}
}
我们知道,添加微任务队列中的任务执行机制要高于宏任务的执行机制(下面代码必须理解)
methods:{
example:function(){
//先试用setTimeout向宏任务中注册回调
setTimeout(()=>{
//现在DOM已经跟新了,可以获取最新DOM
})
//然后修改数据
this.message='changed'
}
}
setTimeout属于宏任务,使用它注册回调会加入宏任务中,宏任务执行要比微任务晚,所以即便是先注册,也是先跟新DOM后执行setTineout中设置回调。
理解nextTick的作用后,我们以下来介绍实现原理
实现原理剖析:
由于nextTick会将回调添加到任务队列中延迟执行,所以在回调执行之前,如果反复使用nextTick,Vue并不会将回调添加到任务队列中,只会添加一个任务。Vue内部有一个列表来存储nextTick参数中提供的回调,当任务触发时,以此执行列表里的所有回调并清空列表,其代码如下(简易版):
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 microTimeFun
const p=Promise.resolve()
microTimeFun=()=>{
p.then(flushCallBacks)
}
export function nextTick(cb,ctx){
callbacks.push(()=>{
if(cb){
cb.call(ctx)
}
})
if(!pending){
pending=true
microTimeFun()
}
}
理解相关变量:
- callbacks:用来存储用户注册的回调函数(获得了更新后DOM所进行的操作)
- pending:用来标记是否向任务队列添加任务,pending为false,表示任务队列没有nextTIck任务,需要添加nextTick任务,当添加一个nextTick任务时,pending为ture,在回调执行之前还有nextTick时,并不会重复添加任务到任务队列,当回调函数开始执行时,pending为flase,进行新的一轮事件循环。
- flushCallbacks:就是我们所说的被注册在任务队列中的任务,当这个函数执行,callbacks中所有函数依次执行,然后清空callbacks,并重置pending为false,所以说,一轮事件循环中,flushCallbacks只会执行一次。
- microTimerFunc:它的作用就是使用Promise.then将flushCallbacks添加到微任务队列中。
下图给出nextTick内部注册流程和执行流程。
官方文档里面还有这么一句话,如果没有提供回调且支持Promise的环境下,则返回一个Promise。也就是说。可以这样使用nextTickthis.$nextTick().then(function(){
//dom跟新了
})
要实现这个功能,只需要在nextTIck中判断,如果没有提供回调且当前支持Promise,那么返回Promise,并且在callbacks中添加一个函数,当这个函数执行时,执行Promise的resolve,即可,代码如下
function nextTick (cb, ctx) {
var _resolve;
callbacks.push(function () {
if (cb) {
cb.call(ctx);
} else if (_resolve) {
_resolve(ctx);
}
});
if (!pending) {
pending = true;
timerFunc();
}
if (!cb && typeof Promise !== 'undefined') {
return new Promise(function (resolve) {
_resolve = resolve;
})
}
}
nextTick源码查看
到此,nextTick原理基本上已经讲完了。那我们现在可以看看真正vue中关于nextTick中的源码,大概我们都能理解的过来了,源码如下。
var timerFunc;
// The nextTick behavior leverages the microtask queue, which can be accessed
// via either native Promise.then or MutationObserver.
// MutationObserver has wider support, however it is seriously bugged in
// UIWebView in iOS >= 9.3.3 when triggered in touch event handlers. It
// completely stops working after triggering a few times... so, if native
// Promise is available, we will use it:
/* istanbul ignore next, $flow-disable-line */
if (typeof Promise !== 'undefined' && isNative(Promise)) {
var p = Promise.resolve();
timerFunc = function () {
p.then(flushCallbacks);
// 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); }
};
isUsingMicroTask = true;
} 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
// (#6466 MutationObserver is unreliable in IE11)
var counter = 1;
var observer = new MutationObserver(flushCallbacks);
var textNode = document.createTextNode(String(counter));
observer.observe(textNode, {
characterData: true
});
timerFunc = function () {
counter = (counter + 1) % 2;
textNode.data = String(counter);
};
isUsingMicroTask = true;
} else if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
// Fallback to setImmediate.
// Technically it leverages the (macro) task queue,
// but it is still a better choice than setTimeout.
timerFunc = function () {
setImmediate(flushCallbacks);
};
} else {
// Fallback to setTimeout.
timerFunc = function () {
setTimeout(flushCallbacks, 0);
};
}
function nextTick (cb, ctx) {
var _resolve;
callbacks.push(function () {
if (cb) {
try {
cb.call(ctx);
} catch (e) {
handleError(e, ctx, 'nextTick');
}
} else if (_resolve) {
_resolve(ctx);
}
});
if (!pending) {
pending = true;
timerFunc();
}
// $flow-disable-line
if (!cb && typeof Promise !== 'undefined') {
return new Promise(function (resolve) {
_resolve = resolve;
})
}
}
总结
这篇文章大概花了两天时间才写出来的,充分的参考了<深入浅出vue.js>这本书,充分了理解书上关于vm.$nextTick中的每一句话,同时也对js中的事件循环有了进一步认识,对js运行机制也进一步加深。作为前端小白,不想只局限于调用各种API,更要知道其原理,每天进步一小步。希望大家能多多与我讨论交流。