Vue源码解析之 nextTick
前言
我们在 派发更新
文章中了解到,在执行 update
方法时,会执 nextTick(flushSchedulerQueue)
这段逻辑。而 nextTick
是 Vue 的核心实现,在介绍之前,我们需先了解下 JS 的运行机制。
JS 运行机制
JS 执行是单线程的,它基于事件循环。事件循环大致分为以下几个步骤:
- 所有同步任务都在主线上执行,形成一个执行栈
- 主线程外,存在一个任务队列
Event Queue
,每当有一个异步任务执行,都会插入该队列 执行栈
中所有同步任务执行完毕后会读取任务队列
,并依次执行- 主线程会重复上述三步骤

另外,任务队列中 task
又分为 macro task
宏任务 和 micro task
微任务。在浏览器环境中,常见的 macro task
有 setTimeout
、postMessage
、setImmediate
;常见的 micro task
有 MutationObsever
和 Promise.then
。而每个 macro task
执行完毕后,会执行所有的 micro task
。

nextTick 实现
nextTick
定义在 src/core/util/next-tick.js
文件中:
import { noop } from 'shared/util'
import { handleError } from './error'
import { isIOS, isNative } from './env'
const callbacks = []
let pending = false
function flushCallbacks () {
pending = false
const copies = callbacks.slice(0)
callbacks.length = 0
// 遍历执行传入的 cb
for (let i = 0; i < copies.length; i++) {
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).
let microTimerFunc
let macroTimerFunc
let useMacroTask = 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 */
// 判断是否支持原生 setImmediate
if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
macroTimerFunc = () => {
setImmediate(flushCallbacks)
}
} else if (typeof MessageChannel !== 'undefined' && ( // 判断是否支持原生 MessageChannel
isNative(MessageChannel) ||
// PhantomJS
MessageChannel.toString() === '[object MessageChannelConstructor]'
)) {
const channel = new MessageChannel()
const port = channel.port2
channel.port1.onmessage = flushCallbacks
macroTimerFunc = () => {
port.postMessage(1)
}
} else {
/* istanbul ignore next */
// 都不支持 执行 setTimeout
macroTimerFunc = () => {
setTimeout(flushCallbacks, 0)
}
}
// Determine microtask defer implementation.
/* istanbul ignore next, $flow-disable-line */
// 判断是否支持 Promise
if (typeof Promise !== 'undefined' && isNative(Promise)) {
const p = 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 macro
// 不支持 直接指向 宏任务
microTimerFunc = 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.
*/
export function withMacroTask (fn: Function): Function {
return fn._withTask || (fn._withTask = function () {
useMacroTask = true
const res = fn.apply(null, arguments)
useMacroTask = false
return res
})
}
export function nextTick (cb?: Function, ctx?: Object) {
let _resolve
// 不直接执行 cb回调,确保同一个 tick 执行多次 nextTick 不会开启多个异步任务
// 把这些异步任务 压成一个同步任务 下一次 tick 中执行 flushCallbacks 函数 遍历执行回调
callbacks.push(() => {
if (cb) {
try {
cb.call(ctx)
} catch (e) {
handleError(e, ctx, 'nextTick')
}
} else if (_resolve) {
// 调用nextTick 未传cb,直接nextTick.then(() => {})
_resolve(ctx)
}
})
if (!pending) {
pending = true
if (useMacroTask) {
macroTimerFunc()
} else {
microTimerFunc()
}
}
// $flow-disable-line
// nextTick.then(() => {}) 场景
if (!cb && typeof Promise !== 'undefined') {
return new Promise(resolve => {
_resolve = resolve
})
}
}
而我们常见的 vm.$nextTick
和 全局 Vue.nextTick()
,被定义在 src/core/instance/render.js
和 src/core/global-api/index.js
中:
// src/core/instance/render.js
export function renderMixin(Vue: Class<Component>) {
// install runtime convenience helpers
installRenderHelpers(Vue.prototype)
Vue.prototype.$nextTick = function (fn: Function) {
return nextTick(fn, this)
}
// 省略
}
// src/core/global-api/index.js
Vue.nextTick = nextTick
next-tick.js
逻辑先定义 microTimerFunc
微任务函数 和 macroTimerFunc
宏任务函数两个变量。 对于 macro task
实现,会先判断是否支持原生 setImmediate
,不支持再去判断是否支持原生 MessageChannel
,如果都不支持,最后执行 setTimeout 0
。对于 micro task
的实现,会先检测浏览器是否支持原生 Promise
,不支持直接指向 macro task
的实现。
另外,next-tick.js
还暴露了两个函数 nextTick
和 withMacroTask
。withMacroTask
函数它是对函数做一层包装,确保函数执行过程中对数据任意的修改,触发变化执行 nextTick
的时强制走 macroTimerFunc
。
我们重点关注下 nextTick
函数,在 派发更新
文章中,我们在执行 nextTick(flushSchedulerQueue)
所用到的函数。该函数会把传入的回调函数插入到 callbacks
数组,之后根据 useMacroTask
条件执行 macroTimerFunc
还是 microTimerFunc
,而它们都会在下一个 tick(事件循环) 执行 flushCallbacks
,flushCallbacks
函数主要对 callbacks
遍历,然后执行相应的回调函数。
这里使用 callbacks
而不是直接在 nextTick
中执行回调函数的原因是保证在同一个 tick (每一个事件循环) 内多次执行 nextTick
,不会开启多个异步任务,而把这些异步任务都压成一个同步任务,在下一个 tick 执行完毕。
// 下一次 tick 执行 flushCallbacks 函数 遍历执行每个回调
function flushCallbacks () {
pending = false
const copies = callbacks.slice(0)
callbacks.length = 0
for (let i = 0; i < copies.length; i++) {
copies[i]()
}
}
最后 nextTick
还有一段逻辑:
if (!cb && typeof Promise !== 'undefined') {
return new Promise(resolve => {
_resolve = resolve
})
}
该逻辑是调用 nextTick
不传入回调函数 cb 时,提供一个 Promise
化的调用,当 _resolve
函数执行,就会跳到 then
的逻辑中。
nextTick().then(() => {})
nextTick
应用场景及过程分析:
<div id="app">
<p ref="msg">{{msg}}</p>
<button @click="handleClick">点击</button>
</div>
<script src="../dist/vue.js"></script>
<script>
const app = new Vue({
el: '#app',
data() {
return {
msg: 'hello world',
}
},
methods: {
handleClick() {
this.msg = 'hello vue'
console.log('sync:', this.$refs.msg.innerText)
this.$nextTick(() => {
console.log('nextTick:', this.$refs.msg.innerText)
})
this.$nextTick().then(() => {
console.log('nextTick with promise:', this.$refs.msg.innerText)
})
}
}
})
</script>
// 输出结果
sync: hello world
nextTick: hello vue
nextTick with promise: hello vue
// 过程分析
1. 点击按钮 msg 值变更
2. 触发 setter 函数,调用 watcher 的 update 方法,最终执行 nextTick(flushSchedulerQueue)
3. 之后会把 flushSchedulerQueue 及 两个 nextTick 的回调都插入到 callbacks 中
4. 在下一个 tick 事件循环中会执行 flushCallbacks 函数,遍历调用每个回调函数并输出结果
// 注意点
由于第二个 nextTick 未传 cb 回调,而是直接 then 调用,根据 nextTick 函数中最后逻辑
if (!cb && typeof Promise !== 'undefined') {
return new Promise(resolve => {
_resolve = resolve
})
}
此时 _resolve 为 then 传入的回调,即
() => {
console.log('nextTick with promise:', this.$refs.msg.innerText)
}
在下一个 tick 会调用 flushCallbacks 函数,遍历 callbacks 依次执行每个 回调函数,此时 callbacks 存在三个回调
let _resolve
callbacks.push(() => {
if (cb) {
try {
cb.call(ctx)
} catch (e) {
handleError(e, ctx, 'nextTick')
}
} else if (_resolve) {
_resolve(ctx)
}
})
callbacks 前两个回调函数分别是 flushSchedulerQueue 和第一个 nextTick 传入的 cb,
由于第二个 nextTick 未传入回调函数,根据上文此时 _resolve 为 then 传入的 cb
那么在执行 callbacks 第三个回调函数时,直接走 else 逻辑,即:
else if (_resolve) {
_resolve(ctx)
}
之后整个过程执行完毕,并输出结果
总结
nextTick
是把要执行的任务推入到一个队列中,在下一个 tick 同步执行。- 数据的变化到 DOM 的重新渲染是一个异步过程,通过上文分析,数据变化会触发
setter
函数,调用 watcher 的update
方法,最终会执行nextTick(flushSchedulerQueue)
。但是watchers
的flush
是在nextTick
后,所以重新渲染是异步的。这也是为什么我们调用服务端接口获取数据,对数据修改后 DOM 变化,必须在nextTick
后执行。
参考
Vue 源码解析系列
转载自:https://juejin.cn/post/7189057409124401208