Vue.js异步更新与nextTick机制深度解析(上篇)

连着改三次数据,页面只重渲染一次。setter 触发之后 watcher 去了哪里、nextTick 怎么把回调攒成一批一次冲掉,以及 Promise、MutationObserver、setImmediate、setTimeout 这条降级链为什么按这个顺序排。

位置
第 08 篇 / 共 12 篇
预计
13 分钟

赋值那一行返回的时候,DOM 一个字都没改

下面这段在完整版 Vue 2 的控制台里可以直接跑,页面上先准备一个 <div id="app"></div>

浏览器控制台 · 完整版 Vue 2
const vm = new Vue({
el: '#app',
template: '<p>{{ count }}</p>',
data: { count: 0 },
updated() { console.log('渲染了一次,现在是', this.$el.textContent); }
});
vm.count++; vm.count++; vm.count++;
console.log('赋值之后立刻读:', vm.$el.textContent);
vm.$nextTick(() => console.log('nextTick 里读:', vm.$el.textContent));

updated 只打印一次,内容是 3。改了三次数据,渲染函数只跑了一遍,中间的 12 从来没有出现在页面上。而紧跟在三行赋值后面的那次读取,拿到的还是 0

这两件事是同一个设计的两面。count 的 setter 里没有任何一行代码碰 DOM,它只是通知了 依赖这个数据的 watcher;watcher 也不渲染,它把自己塞进一个队列,然后请 nextTick 在当前这段同步代码跑完之后来清一次队。三次赋值推的是同一个 watcher,队列里只留一份, 所以最后只渲染一次。

代价写在第三行里:vm.$el.textContent 在同步代码里永远是旧的。想读改完之后的 DOM, 就得排到那次清队的后面去 —— 这正是 nextTick 存在的理由,而不是它顺带的一个功能。

setter 只是把 watcher 推进队列,渲染排在后面

sequenceDiagram
participant Data as 数据
participant Dep as 依赖
participant Watcher as 观察者
participant Queue as 更新队列
participant NextTick as nextTick
participant DOM as DOM
Data->>Dep: 数据变化
Dep->>Watcher: 通知更新
Watcher->>Queue: 加入队列
Queue->>Queue: 去重检查
Queue->>NextTick: 异步调度
NextTick->>Queue: 执行flushQueue
Queue->>Watcher: 批量执行run()
Watcher->>DOM: 更新视图

图上从「通知更新」到「加入队列」的那一步,源码里就是 Watcher.prototype.updatesrc/core/observer/watcher.js):

Vue 2 · Watcher.update
update () {
if (this.lazy) {
this.dirty = true
} else if (this.sync) {
this.run()
} else {
queueWatcher(this)
}
}

三条分支对应三种 watcher。lazy 是计算属性:不排队、不求值,只把自己标脏,等下次有人读它 再算。sync 是同步 watcher,this.$watch(expr, cb, { sync: true }) 或者 watch: { x: { handler, sync: true } } 走这里,当场执行,不进队列 —— 这条分支存在 说明「异步」是默认值,不是唯一选择。剩下的全部落到 queueWatcher,包括每个组件的渲染 watcher。

queueWatcher 里干的活(去重、排序、冲刷时插队、无限循环的计数)是下篇的正题。 这里只需要知道它最后那几行:队列从空变成非空的那一次,它调 nextTick(flushSchedulerQueue) 把「清队」这件事本身也变成了一个 nextTick 回调。

所以 nextTick 不是「等 DOM 更新完」的工具函数,它是渲染本身的载体。理解了它排队的规则, 就理解了为什么有的回调看得见新 DOM,有的看不见。

callbacks 攒一批,pending 保证只安排一次冲刷

src/core/util/next-tick.js 整个文件不到一百行,去掉降级判断之后剩这些:

Vue 2.6.14 · src/core/util/next-tick.js(节选)
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]()
}
}
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
})
}
}

pending 是这个模块唯一的状态。一次同步代码里调一百次 $nextTickcallbacks 长一百, 但 timerFunc() 只在第一次被调用 —— 后面九十九次撞上 if (!pending) 直接跳过。 一个 tick 只安排一次冲刷,攒下来的回调在那一次里全部跑掉,这就是「批量」的全部含义。

flushCallbacks 里那两行值得看清楚。它先把数组复制一份,再把原数组清空,然后遍历副本。 如果不复制:某个回调在执行过程中又调了 nextTick(比如在 updated 里改数据, 又触发一轮渲染排队),新回调会追加到正在遍历的数组尾部,for 循环当场把它也跑了, 一轮死循环就在同一次微任务里转起来。复制之后,新来的回调进的是下一批, 下一批会再走一次 pendingfalsetrue 的流程。

最后那个 if (!cb && ...)await this.$nextTick() 这种写法的出处。不传回调时它 return 一个 Promise,_resolve 被闭包捕获,等轮到自己的时候由 callbacks 里那个包装函数 调用 _resolve(ctx)。两种写法进的是同一个数组、同一个位置,只是出口不同。 注意包装函数里有 try/catch_resolve 那条分支没有 —— 回调写法里抛出的错误会被 handleError 接住走全局错误处理,Promise 写法里抛出的错误则由 .then 之后的链路负责。

实例上的 $nextTick 只是一层绑定:Vue.prototype.$nextTick = function (fn) { return nextTick(fn, this) }, 第二个参数就是让回调里的 this 指向组件的那个 ctx

降级链有四级,前两级是微任务

timerFunc 是模块加载时一次性决定的,之后不再变:

graph TD
A[选择异步策略] --> B{Promise可用?}
B -->|| C[使用Promise最优选择]
B -->|| D{MutationObserver可用?}
D -->|| E[使用MutationObserver次优选择]
D -->|| F{setImmediate可用?}
F -->|| G[使用setImmediate宏任务]
F -->|| H[使用setTimeout降级方案]
Vue 2.6.14 · timerFunc 的四级降级
if (typeof Promise !== 'undefined' && isNative(Promise)) {
const p = Promise.resolve()
timerFunc = () => {
p.then(flushCallbacks)
if (isIOS) setTimeout(noop)
}
isUsingMicroTask = true
} else if (!isIE && typeof MutationObserver !== 'undefined' && (
isNative(MutationObserver) ||
// PhantomJS and iOS 7.x
MutationObserver.toString() === '[object MutationObserverConstructor]'
)) {
9 collapsed lines
// Use MutationObserver where native Promise is not available,
// e.g. PhantomJS, iOS7, Android 4.4
// (#6466 MutationObserver is unreliable in IE11)
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)) {
// Fallback to setImmediate.
// Technically it leverages the (macro) task queue,
// but it is still a better choice than setTimeout.
timerFunc = () => {
setImmediate(flushCallbacks)
}
} else {
timerFunc = () => {
setTimeout(flushCallbacks, 0)
}
}

顺序是 PromiseMutationObserversetImmediatesetTimeout,前两级把 isUsingMicroTask 置为 true,后两级不置 —— 这个导出的标记后面还会用到一次。

微任务排在前面,理由是它的执行时机。微任务队列在当前这段同步代码(当前宏任务)结束时 就被清空,而且是在浏览器渲染之前;宏任务要等到下一轮事件循环。用微任务意味着数据改完、 同步代码一收尾,DOM 立刻就更新了,用户看不到任何中间帧。换成 setTimeout, 中间就多了一次「浏览器有机会画一帧旧内容」的窗口。

MutationObserver 那一级的写法有点绕:它监听一个游离的文本节点,timerFunc 每次把 textNode.data'0''1' 之间来回切,靠这次改动触发观察者回调。这个文本节点 不在文档里,纯粹是拿来当微任务发射器用的。

按兼容性排,MutationObserver 支持的浏览器其实比 Promise 更广,它却排在第二级。 源码注释给了理由:它在 iOS 9.3.3 及以上的 UIWebView 里坏得很厉害 —— 在 touch 事件处理函数里 触发几次之后就彻底不工作了,所以只要有原生 Promise 就用 Promise。这一级实际服务的是 PhantomJS、iOS 7、Android 4.4 这类没有原生 Promise 的环境。分支开头那个 !isIE 是另一件事:MutationObserver 在 IE11 上不可靠(注释里标着 #6466),干脆整个跳过。

setImmediate 只在 IE 和 Node 里有,源码注释说得很实在:技术上它走的是宏任务队列, 但仍然比 setTimeout 好。好在哪 —— setTimeout(fn, 0) 在浏览器里有最小延迟钳制 (嵌套层数超过 5 层之后按 4 毫秒起跳),setImmediate 没有这层等待。

isIOS 那一行是给一类具体机型打的补丁:iOS 的 UIWebView 里 Promise.then 会卡在一个 古怪的状态 —— 回调进了微任务队列,队列却不被清空,要等浏览器有别的活干(比如处理一个定时器) 才恢复。加一个空的 setTimeout 就是给它一件别的活干。

2.5 用过宏任务,2.6 改回全微任务,代价是事件冒泡里的那个坑

next-tick.js 顶上有一段长注释,讲的是这个模块被改回来过一次,注释里那个「again」 是关键词:2.4 以前全走微任务,2.5 改成两种混用,2.6 又改了回去。

2.5 的模块里同时维护着 microTimerFuncmacroTimerFunc 两条路,外加一个 useMacroTask 开关。默认走微任务,但 v-on 挂上去的事件处理函数会被 withMacroTask 包一层,在里面触发的状态改动强制走宏任务。那条宏任务链首选 setImmediate, 其次是 MessageChannel,最后才轮到 setTimeout —— 注释里说 MessageChannel 是唯一能 稳定把回调排在同一轮里所有 DOM 事件之后的 polyfill。2.6 把这套整个拆了:withMacroTask 删掉,两个 timer 合成一个 timerFuncMessageChannel 在 2.6 的这个文件里一个字都找不到, 换上来的是 MutationObserver

有意思的是两版注释引的是同一批 issue 编号(#4521#6690#6566#6813), 结论却是反的。不是发现了新 bug,是同一笔账重新算了一遍:2.5 那版写「默认微任务, 需要时强制宏任务」,2.6 那版写「所以现在全部走微任务,again」。压倒天平的是 状态在重绘前一刻被改动时会出问题(点名了 out-in 过渡),以及在事件处理函数里用宏任务 会引出一批绕不过去的怪行为 —— 后者一口气列了五个 issue 编号。

放弃的那一半也写在同一段注释里,用词很直白 —— 这个取舍有一个主要的坏处: 某些场景下微任务的优先级太高了,它会在两个本该连续发生的事件之间插进来执行 (这几个「有办法绕开」),甚至会插在同一个事件的冒泡过程中间。

后一种最值得记住,因为它能复现成一句话:父元素上有一个 v-if,子元素的点击处理函数把 条件改成 true。点击子元素 → 处理函数跑完 → 事件还在往上冒泡的间隙,微任务插了进来, Vue 把父元素上那个刚出现的元素渲染了出来,并且给它挂上了事件监听器 → 冒泡继续走到父元素 → 那个刚刚才被创建出来的监听器,收到了这次点击。用户只点了一下,却触发了两处。

Vue 2.6 的应对不是把渲染推迟回宏任务,而是在事件监听器这一侧加了个时间戳判定 (src/platforms/web/runtime/modules/events.js):

Vue 2.6.14 · src/platforms/web/runtime/modules/events.js(节选)
const useMicrotaskFix = isUsingMicroTask && !(isFF && Number(isFF[1]) <= 53)
function add (name, handler, capture, passive) {
// async edge case #6566: inner click event triggers patch, event handler
// attached to outer element during patch, and triggered again. This
// happens because browsers fire microtask ticks between event propagation.
if (useMicrotaskFix) {
const attachedTimestamp = currentFlushTimestamp
const original = handler
handler = original._wrapper = function (e) {
if (
// no bubbling, should always fire.
e.target === e.currentTarget ||
// event is fired after handler attachment
e.timeStamp >= attachedTimestamp ||
// bail for environments that have buggy event.timeStamp implementations
e.timeStamp <= 0 ||
e.target.ownerDocument !== document
) {
return original.apply(this, arguments)
}
}
}
// 省略:真正的 addEventListener 调用
}

思路是:挂监听器的时候记下当前这一轮冲刷的时间戳,收到事件时比一下 —— 事件比监听器还老, 说明这个监听器是在事件已经开始传播之后才被挂上去的,那这次就不该由它来处理,直接不执行。 e.target === e.currentTarget 那条放行的是没有冒泡的情形(事件就发生在自己身上), 永远要执行;后面两条是给 e.timeStamp 本身不可靠的环境留的退路 —— iOS 9 在 history.pushState 之后时间戳变成 0,QtWebEngine 给的是负数,跨文档的事件用的又是另一套 起点。

useMicrotaskFix 这个名字说明了护栏的适用范围。isUsingMicroTask 为假, 也就是降级到 setImmediatesetTimeout 的环境,本来就不会在冒泡中间插队, 不必付这份判断的成本。后半截排除的是 Firefox 53 及以下:它不在事件传播之间跑微任务, 同样不需要(顺带,它的 Event.timeStamp 实现本身也是错的)。

包装函数被存在 original._wrapper 上,不是随手挂的 —— 解绑时 removeEventListener 传的是 handler._wrapper || handler,靠这个字段才找得回真正注册进去的 那个函数。

currentFlushTimestamp 是每次清队时取一次的时间戳,不是每挂一个监听器就取一次 —— 源码注释里给的理由是 performance.now() 本身有开销,页面上有几千个监听器时这笔开销不小。

回调排在渲染前面还是后面,取决于你哪一行先写

nextTick 的回调看不看得见新 DOM,不由「它是不是 nextTick 回调」决定, 由它在 callbacks 数组里的下标决定。而清队这件事本身也是数组里的一项。

浏览器控制台 · 同一个数组里的先后
vm.$nextTick(() => console.log('A:', vm.$el.textContent)); // 0
vm.count = 1;
vm.$nextTick(() => console.log('B:', vm.$el.textContent)); // 1

A 先入队,此时数据还没改,队列里只有它一个。第二行改了 count,渲染 watcher 排队, queueWatchernextTick(flushSchedulerQueue),「清队」排在了 A 后面。第三行的 B 再排到清队后面。数组最终是 [A, flushSchedulerQueue, B],跑起来就是:A 看到旧值, 中间渲染,B 看到新值。

平时写代码不会踩这个顺序,因为惯例是先改数据再 $nextTick。真正会踩的是另一种写法: 在 created$nextTick 一个「读 $refs」的回调,而模板里那个 refv-if 后面 —— 挂载和这个回调的先后不是靠直觉能排出来的,$refs 拿到 undefined 就是这么来的。

有了这条规则,再回头看那三行 vm.count++:它们推的是同一个渲染 watcher,队列里只留一份, 清队时只渲染一次。queueWatcher 是怎么把重复的挡在门外、多个组件的 watcher 又是按什么顺序 跑的,下篇拆这个函数。

这篇是 Vue.js 内部机制深度解析的第 8 篇。前一篇是 Diff 算法深度剖析 - Vue.js 的 DOM 更新魔法,后一篇是 Vue.js 异步更新与 nextTick 机制深度解析(下篇)