简介
在 vue 的官方文档中有一个 API 叫做 nextTick,将回调延迟到下次 DOM 更新循环之后执行。在修改数据之后立即使用这个方法,获取更新后的 DOM。 语法
vm.$nextTick([callback]);复制代码
参数:
{Function;}[callback];复制代码
用法 放在Vue.nextTick()回调函数中的执行的应该是涉及 DOM操作的 JavaScript 代码。
Vue 的响应式原理:在 data 选项里所有属性都会被watcher监控,当修改了data的某一个值,并不会立即反映到视图中。Vue 会将我们对data的更改放到watcher的一个队列中(异步),只有在当前任务空闲时才会去执行watcher队列任务。这就有一个延迟时间,所以对 dom 的操作要放在$nextTick中来操作,才能获取到最新的dom。
响应式对象 Observer > 依赖收集 Dep > 派发更新 Watcher
nextTick 是 Vue 的一个核心实现,如果还不了解 js 运行机制,可以看一下另一篇文章js 运行机制,这里就不多赘述了。
在浏览器环境中常见的 macro task 和 micro task 如下: macro task:
setTimeout、setTimeInterval
MessageChannel
postMessage
setImmediate
requestAnimationFrame
I/O
UI 渲染a
micro task:
MutationObsever
Promise.then
process.nextTick
vue 源码解析
在派发更新 Watcher里面有用到nextTick(flushScheduerQueue),其实就是vue对派发更新的一个优化。下面直接看源码,在 src/core/util/next-tick.js 中:
// nextTick 中执行回调函数的原因是保证在同一个 tick 内多次执行 nextTick,不会开启多个异步任务,而把这些异步任务都压成一个同步任务,在下一个 tick 执行完毕。import{ noop }from"shared/util";import{ handleError }from"./error";import{ isIOS, isNative }from"./env";// flushScheduerQueue/*存放异步执行的回调*/constcallbacks = [];//一个标记位,如果已经有timerFunc被推送到任务队列中去则不需要重复推送letpending =false;/*下一个tick时的回调*/functionflushCallbacks(){ pending =false;//复制callbackconstcopies = callbacks.slice(0);//清除callbackscallbacks.length =0;for(leti =0; i < copies.length; i++) {//触发callback的回调函数copies[i](); }}// Here we have async deferring wrappers using both microtasks and (macro) tasks.// In < 2.4 we used microtasks everywhere, but there are some scenarios where// microtasks have too high a priority and fire in between supposedly// sequential events (e.g. #4521, #6690) or even between bubbling of the same// event (#6566). However, using (macro) tasks everywhere also has subtle problems// when state is changed right before repaint (e.g. #6813, out-in transitions).// Here we use microtask by default, but expose a way to force (macro) task when// needed (e.g. in event handlers attached by v-on)./**
其大概的意思就是:在Vue2.4之前的版本中,nextTick几乎都是基于microTask实现的,
但是由于microTask的执行优先级非常高,在某些场景之下它甚至要比事件冒泡还要快,
就会导致一些诡异的问题;但是如果全部都改成macroTask,对一些有重绘和动画的场
景也会有性能的影响。所以最终nextTick采取的策略是默认走microTask,对于一些DOM
的交互事件,如v-on绑定的事件回调处理函数的处理,会强制走macroTask。
**/letmicroTimerFunc;letmacroTimerFunc;letuseMacroTask =false;// Determine (macro) task defer implementation.// Technically setImmediate should be the ideal choice, but it's only available// in IE. The only polyfill that consistently queues the callback after all DOM// events triggered in the same loop is by using MessageChannel./* istanbul ignore if */// 而对于macroTask的执行,Vue优先检测是否支持原生setImmediate(高版本IE和Edge支持),// 不支持的话再去检测是否支持原生MessageChannel,如果还不支持的话为setTimeout(fn, 0)。if(typeofsetImmediate !=="undefined"&& isNative(setImmediate)) { macroTimerFunc =()=>{ setImmediate(flushCallbacks); };}elseif(typeofMessageChannel !=="undefined"&&/**
在Vue 2.4版本以前使用的MutationObserver来模拟异步任务。
而Vue 2.5版本以后,由于兼容性弃用了MutationObserver。
Vue 2.5+版本使用了MessageChannel来模拟macroTask。
除了IE以外,messageChannel的兼容性还是比较可观的。
**/(isNative(MessageChannel) ||// PhantomJSMessageChannel.toString() ==="[object MessageChannelConstructor]")) {/**
可见,新建一个MessageChannel对象,该对象通过port1来检测信息,port2发送信息。
通过port2的主动postMessage来触发port1的onmessage事件,
进而把回调函数flushCallbacks作为macroTask参与事件循环。
**/constchannel =newMessageChannel();constport = channel.port2; channel.port1.onmessage = flushCallbacks; macroTimerFunc =()=>{ port.postMessage(1); };}else{/* istanbul ignore next */macroTimerFunc =()=>{ setTimeout(flushCallbacks,0); };}// Determine microtask defer implementation./* istanbul ignore next, $flow-disable-line */if(typeofPromise!=="undefined"&& isNative(Promise)) {constp =Promise.resolve(); microTimerFunc =()=>{ 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); };}else{// fallback to macromicroTimerFunc = macroTimerFunc;}/**
* Wrap a function so that if any code inside triggers state change,
* the changes are queued using a (macro) task instead of a microtask.
*//*
推送到队列中下一个tick时执行
cb 回调函数
ctx 上下文
*/exportfunctionwithMacroTask(fn: Function):Function{return( fn._withTask || (fn._withTask =function(){ useMacroTask =true;constres = fn.apply(null,arguments); useMacroTask =false;returnres; }) );}exportfunctionnextTick(cb?: Function, ctx?: Object){let_resolve; callbacks.push(()=>{if(cb) {try{ cb.call(ctx); }catch(e) { handleError(e, ctx,"nextTick"); } }elseif(_resolve) { _resolve(ctx); } });if(!pending) { pending =true;if(useMacroTask) { macroTimerFunc(); }else{ microTimerFunc(); } }// $flow-disable-lineif(!cb &&typeofPromise!=="undefined") {returnnewPromise(resolve=>{ _resolve = resolve; }); }}复制代码
nextTick这就是我们在上一节执行 nextTick(flushSchedulerQueue) 所用到的函数。它的逻辑也很简单,把传入的回调函数 cb 压入 callbacks 数组,最后一次性地根据 useMacroTask 条件执行 macroTimerFunc 或者是 microTimerFunc,而它们都会在下一个 tick 执行 flushCallbacks。 flushCallbacks 这个方法就是挨个同步的去执行callbacks中的回调函数,callbacks中的回调函数是在调用 nextTick 的时候添加进去的; 这里使用 callbacks 而不是直接在 nextTick 中执行回调函数的原因是保证在同一个 tick 内多次执行 nextTick,不会开启多个异步任务,而把这些异步任务都压成一个同步任务,在下一个 tick 执行完毕。 注意这里有个比较难理解的地方,第一次调用 nextTick 的时候 pending 为false。 此时已经push到浏览器event loop中一个宏任务或微任务的task,如果在没有flush掉的情况下继续往callbacks里面添加。 那么在执行这个占位queue的时候会执行之后添加的回调,所以macroTimerFunc、microTimerFunc 相当于task queue的占位。 以后 pending 为true则继续往占位queue里面添加,event loop轮到这个task queue的时候将一并执行。 执行 flushCallbacks 时 pending 置false,允许下一轮执行 nextTick 时往event loop占位。
macroTimerFunc、microTimerFunc next-tick.js 申明了 microTimerFunc 和 macroTimerFunc 2 个变量,它们分别对应的是 micro task 的函数和 macro task 的函数。对于 macro task 的实现,优先检测是否支持原生 setImmediate,这是一个高版本 IE 和 Edge才支持的特性,不支持的话再去检测是否支持原生的 MessageChannel,如果也不支持的话就会降级为 setTimeout 0;而对于 micro task 的实现,则检测浏览器是否原生支持 Promise,不支持的话直接指向 macro task 的实现。
nextTick 实现
首先 nextTick 把传入的 cb 回调函数用 try-catch 包裹后放在一个匿名函数中推入callbacks数组中。 这么做是因为防止单个 cb 如果执行错误不至于让整个JS 线程挂掉。 每个 cb 都包裹是防止这些回调函数如果执行错误不会相互影响,比如前一个抛错了后一个仍然可以执行。
然后检查 pending 状态,这个跟之前介绍的 queueWatcher 中的 waiting 是一个意思。 它是一个标记位,一开始是 false 在进入macroTimerFunc、microTimerFunc方法前被置为 true。因此下次调用 nextTick 就不会进入macroTimerFunc、microTimerFunc方法。 这两个方法中会在下一个 macro/micro tick 时候 flushCallbacks 异步的去执行callbacks队列中收集的任务,而 flushCallbacks 方法在执行一开始会把 pending 置 false。 因此下一次调用 nextTick 时候又能开启新一轮的 macroTimerFunc、microTimerFunc,这样就形成了 vue 中的 event loop。
最后检查是否传入了 cb。因为 nextTick 还支持 Promise 化的调用:nextTick().then(() => {})。所以如果没有传入 cb 就直接return了一个Promise实例,并且把resolve传递给_resolve。这样后者执行的时候就跳到我们调用的时候传递进 then 的方法中。
示例
代码如下:
执行结果如下图所示:
>need-to-insert-img
同步方式: 当把data中的name修改之后,此时会触发name的 setter 中的 dep.notify 通知依赖本data的render watcher去 update,update 会把 flushSchedulerQueue 函数传递给 nextTick,render watcher在 flushSchedulerQueue 函数运行时 watcher.run 再走 diff -> patch 那一套重渲染 re-render 视图,这个过程中会重新依赖收集,这个过程是异步的;所以当我们直接修改了name之后打印,这时异步的改动还没有被 patch 到视图上,所以获取视图上的 DOM 元素还是原来的内容。
setter 前: setter前为什么还打印原来的是原来内容呢,是因为 nextTick 在被调用的时候把回调挨个push进callbacks数组,之后执行的时候也是 for 循环出来挨个执行,所以是类似于队列这样一个概念,先入先出;在修改name之后,触发把render watcher填入 schedulerQueue 队列并把他的执行函数 flushSchedulerQueue 传递给 nextTick ,此时callbacks队列中已经有了 setter前函数 了,因为这个 cb 是在 setter前函数 之后被push进callbacks队列的,那么先入先出的执行callbacks中回调的时候先执行 setter前函数,这时并未执行render watcher的 watcher.run,所以打印 DOM 元素仍然是原来的内容。
setter 后: setter后这时已经执行完 flushSchedulerQueue,这时render watcher已经把改动 patch 到视图上,所以此时获取 DOM 是改过之后的内容。
Promise 方式: 相当于 Promise.then 的方式执行这个函数,此时 DOM 已经更改。
setTimeout 方式: 最后执行macro task的任务,此时 DOM 已经更改。
注意,在执行 setter前函数 这个异步任务之前,同步的代码已经执行完毕。异步的任务都还未执行,所有的 $nextTick 函数也执行完毕。所有回调都被push进了callbacks队列中等待执行,所以在setter前函数执行的时候。此时callbacks队列是这样的:
[setter前函数, flushSchedulerQueue, setter后函数,Promise方式函数];复制代码
它是一个micro task队列,执行完毕之后执行macro task setTimeout,所以打印出上面的结果。
总结
nextTick是把要执行的任务推入到一个队列中,在下一个tick同步执行
数据改变后触发渲染watcher的update,但是watchers的flush是在nextTick后,所以重新渲染是异步的