watcher 更新如何与 nextTick 协作
本文对应 Vue.js v2.6.11.
本文仅覆盖 Vue.js v2 中 watcher 更新以及 nextTick 部分。本文的目标是阐述在最新版本 Vue.js v2.6.11 中所有的 watcher 实例在触发 update 函数后,是如何借助 nextTick 特性实现:
-
当次事件循环中的多次修改仅有最后一次生效。
-
总是等到当前
tick事件循环task完成后,才会真正执行watcher.update函数。
为什么是 watcher
在 vue.js 内部实现中,watcher 不仅仅是 vm.$watch API 如此。watcher 本质上扮演了一个订阅数据更新 topic 的订阅者 subscriber。所有的数据更新后的回调触发逻辑都依赖于 watcher 实例。不同类型的 watcher 实例起到不同的回调效果。
-
每个视图组件都依赖于一个唯一与之对应的
renderWatcher实例,该实例始终接受一个用于视图更新的expOrFn函数作为renderWatcher的renderWatcher.getter函数。该函数在renderWatcher收到更新后,进行函数调用执行。当renderWatcher.getter触发时,即调用vnode的创建函数vm._render和DOM的Diff更新函数vm._patch。jsnew Watcher(vm, updateComponent) -
每一个
computed[computed]对应一个lazyWatcher实例,该实例始终接受一个用户传入的lazyWatcher.get函数来在 取值当前computed[computed]才进行惰性计算,并在依赖没有变化时,始终始终缓存的lazyWatcher.value值。因为lazyWatcher的计算过程是 同步的立即计算,即不依赖于nextTick,那么本文将忽略此类型watcher实例。 -
当开发者调用
vm.$watch或传入vm.$options.watch[watchKey]时,本质上是创建一个userWatcher。js// src/core/instance/state.js#L355-L356 options.user = true // 定义为 userWatcher new Watcher(vm, expOrFn, cb, options)额外的,每个用户传入的
userWatcher回调,都将作为userWatcher.cb注册。在每次userWatcher.get调用时,触发cb回调函数。
二者协作解决了什么问题
-
避免 无用计算,因为在单次事件循环
tick中,至多仅有一次UI更新机会,那么若在单次事件循环存在的多次renderWatcher更新视图操作时,就不能立即执行更新视图操作,而应该借助去重操作和nextTick调度延迟执行视图更新操作,实现最终仅有最后一次视图更新生效。
避免无用计算
首先,基于现行 html living standard 标准对浏览器事件循环章节 event loop processing model 对一次事件循环 tick 的定义:在每次执行完当前 task 并清空其附属 micro-task queue 后会因为性能 至多执行一次 UI 绘制(是否绘制取决于硬件刷新率)。
11: update the rendering
Rendering opportunities: Remove from docs all Document objects whose browsing context do not have a rendering opportunity.
A browsing context has a rendering opportunity if the user agent ivs currently able to present the contents of the browsing context to the user, accounting for hardware refresh rate constraints and user agent throttling for performance reasons, but considering content presentable even if it's outside the viewport.
Browsing context rendering opportunities are determined based on hardware constraints such as display refresh rates and other factors such as page performance or whether the page is in the background. Rendering opportunities typically occur at regular intervals.
即始终有一次事件循环 tick 对应 至多一次 UI 绘制。
-
对于
renderWatcher来说,在一次事件循环tick中,多次的renderWatcher.get触发,对应多次UI视图更新。显然这在一次事件循环tick中是多余的。对于当次UI绘制来说,始终仅有最后一次生效。那么为了避免多次无用的回调调用,就一定要在一次事件循环tick中保证 至多执行一次renderWatcher.get函数,进而始终 至多执行一次 组件视图更新。 -
对于
userWatcher来说,在一次事件循环tick中,可能存在多次依赖更新,那么也会存在如同renderWatcher一样的局面,为了避免多次无用的调用,故应该在一次事件循环tick中始终至多执行一次组件视图更新。
基于以上目标,Vue.js 内部借助 nextTick 机制实现在当次事件循环 tick 的 task 执行过程中,收集依赖的变化,但不立即执行回调函数,而是让 nextTick 延迟回调函数到一个特定的时机来触发回调。那么这就有了处理将多次高频调用处理为仅保留最终调用的机会。
减少强制同步布局和布局抖动
js run --> calculate style --> layout --> paint --> composite
在常规情况下,所有的 JS 样式修改都遵循以上渲染管道。仅当 JS 执行完成时,才会开始进行样式计算。但在 JS 中若提前获取当前事件循环 tick 的设置过样式的 DOM 节点的样式时(即使不是获取修改的样式),浏览器为了返回一个修改后的样式集合,不得不提前开始样式计算,并返回对应 DOM 更新后的样式值。在此期间,浏览器执行了 强制同步布局,多次的强制同步布局操作会造成当前页面的 布局抖动。
为什么布局在这里显得尤为重要?
因为布局总是作用于整个文档。
所以减少因多次更新意外造成的布局抖动显得尤为重要。在没有对单次事件循环 tick 进行更新合并时,当开发者代码多次更新状态导致其中一次视图更新意外触发强制同步布局时,那么多次相同更新会触发布局抖动,这种对渲染的影响会因为在单次事件循环 tick 中的多次更新而被无限放大。进而大幅降低当前事件循环 tick 渲染性能。若存在更新合并时,即使当前更新存在开发者代码造成的强制同步布局,也会因为仅有最后一次更新生效,而始终在一次事件循环 tick 中仅有一次开发者触发的强制同步布局,进而 尽可能 地维护了当前渲染管道流程,大幅提升渲染性能。
那么为了避免以上的问题,Vue.js 实现了一套基于事件循环模型的 nextTick 更新机制,所有的数据更新都 不是立即 应用到视图上的,那么结合前文视图由 renderWatcher 驱动的观点,后文阐述的部分核心点如下:
-
如何收集待执行的
watcher回调并进行去重; -
在何等时机触发当前事件循环
tick中,收集的所有待执行watcher回调函数。
此处,
watcher回调是泛指如renderWatcher对应的视图更新函数,lazyWatcher对应的computed计算函数等由发布者触发的回调函数,而不仅仅是开发者定义的watcher回调函数。
概述 nextTick
什么是 nextTick,其内部核心原理又是什么?这里以一次 vm.$nextTick 调用来简要阐述 nextTick 的核心原理。
API 挂载:在初始化 Vue global API 时,会在 renderMixin 中在 Vue 原型对象上经历以下 $nextTick 挂载:
Vue.prototype.$nextTick = function (fn: Function) {
return nextTick(fn, this)
}
那么以下调用:
vm.$nextTick(() => {
// do something you like
})
本质上是以下调用:
nextTick(() => {
// do something you like
}, this) // 此处 this 恒定指向 vue 实例
最终得到原始的 nextTick 函数实现:
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()
}
// $flow-disable-line
if (!cb && typeof Promise !== 'undefined') {
return new Promise(resolve => {
_resolve = resolve
})
}
}
不难看出 nextTick 本质上是在缓存传入的 cb 函数。其缓存容器是 es module 模块词法作用域内的 callbacks 变量。上文函数的职责如下:
-
缓存传参
cb函数; -
在
pending为 falsy 值时,调用模块词法作用域中的timerFunc函数; -
在没有传入
cb函数,且词法作用域支持Promise构造函数时,nextTick将返回一个Promise实例,而不是undefined。
那么我们要探究的 nextTick 的本质,可抽象为模块变量 callbacks 和 timerFunc 函数的功能组合体。在 src/core/util/next-tick.js 中,我们不难得到 timerFunc 是根据 JS 运行时进行实现。
以下按照运行时优先级进行排序:
-
在支持
Promise时,timerFunc对应:jstimerFunc = () => { p.then(flushCallbacks) if (isIOS) setTimeout(noop) } isUsingMicroTask = true -
在非
IE且支持MutationObserver时:jslet 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 -
在支持
setImmediate时:jstimerFunc = () => setImmediate(flushCallbacks) -
最后,使用以下实现进行兜底:
jstimerFunc = () => { setTimeout(flushCallbacks, 0) }
不难看出所有的实现有一个共同点是都包含了 flushCallbacks 函数,如下:
function flushCallbacks() {
pending = false
const copies = callbacks.slice(0)
callbacks.length = 0
for (let i = 0; i < copies.length; i++) {
copies[i]()
}
}
上文函数的主要职责在于取 callbacks 容器浅副本,逐个迭代执行 callbacks 中所有函数。以上所有的 timerFunc 实现核心差异点是使用不同的 task source 来实现调用 flushCallbacks 函数,即最终 nextTick 本质上是一个 回调函数调度器,优先借助 micro-task,否则使用 task 来实现清空 callbacks 列表。
那么结合 nextTick 的整体,不难得到以下结论:
-
本质上是依赖事件循环的
processing model实现回调函数延迟调用; -
优先使用
micro-task,否则回退至task来实现。
FAQ
-
Q: 为什么
callbacks是Array类型,而不是Function类型? -
A: 在当次事件循环
tick中,可能存在多次nextTick调用,例如:js// SomeComponent.vue export default { created() { this.$nextTick(doSomething) this.$nextTick(doOneMoreTime) } }那么使用
Array类型来缓存当前事件循环中tick多个 传入nextTick函数的回调函数。
视图与 renderWatcher
前文,笔者已经阐述了 nextTick 函数的核心原理和功能,目的是为了 批量 延迟函数调用。那么 watcher 的更新又是如何与 nextTick 关联的呢?将 watcher 的更新借助 nextTick 的延迟调用能力,那么我们就可以延迟 watcher 的更新,即有机会 在延迟期间 实现合并多次更新。下文以与视图唯一对应的 renderWatcher 为例。
在 Vue.js 中,每个视图组件都关联一个与组件自身唯一对应的 renderWatcher 实例。
// src/core/instance/lifecycle.js#L141-L213
function mountComponent(/* ... */) {
// ...
if (/* */) {
// ...
// 此处为开发环境的逻辑简化
updateComponent = () => {
vm._update(vm._render(), hydrating)
}
} else {
// 此处为生产环境逻辑
updateComponent = () => {
vm._update(vm._render(), hydrating)
}
}
// we set this to vm._watcher inside the watcher's constructor
// since the watcher's initial patch may call $forceUpdate (e.g. inside child
// component's mounted hook), which relies on vm._watcher being already defined
new Watcher(
vm,
updateComponent,
noop,
{
before() {
if (vm._isMounted && !vm._isDestroyed) {
callHook(vm, 'beforeUpdate')
}
}
},
true /* isRenderWatcher */
)
}
在 new Watcher 实例化一个 renderWatcher 时,在 Watcher 构造函数内部会使得当前 renderWatcher 成为当前 vm 实列的 vm._watcher 属性。否则 vm._watcher 为 null 值ref。
不难看出 updateComponent 就是 renderWatcher.getter 对应的函数,那么在 renderWatcher.update 调用时,本质上是调用的 renderWatcher.getter,进而调用 updateComponent 实现视图更新。updateComponent 中对应功能函数如下:
-
vm._render 如下:
js// src/core/instance/render.js#L69-L128 Vue.prototype._render = function (): VNode { // ... const { render, _parentVnode } = vm.$options // ... try { currentRenderingInstance = vm vnode = render.call(vm._renderProxy, vm.$createElement) } catch (e) { // ... } finally { currentRenderingInstance = null } // ... return vnode }不难得出,
vm._render的核心职责在于调用 $options.render 渲染函数之际,指定调用上下文为vm._renderProxy,并传参vm.$createElement函数,并最终vm._render产出vnode。 -
vm._update 如下:
js// src/core/instance/lifecycle.js#L59-L88 Vue.prototype._update = function (vnode: VNode, hydrating?: boolean) { //... if (!preVnode) { vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */) } else { vm.$el = vm.__patch__(prevVnode.vnode) } // ... }vm._update函数的核心在于以上代码,其中vm.__patch__函数的作用如同 snabbdom 库的patch函数。作用是根据vnode最小幅度的创建或修改真实的DOM。那么我们在某种程度上可认为
vm._update函数的核心功能就是将vm._render调用产生的vnode通过diff最小幅度的创建或修改真实的DOM nodes。
由以上对 updateComponent 函数的内涵的探讨,所以这是笔者说 renderWatcher.getter 在调用之际是在进行视图更新的原因。
那么,根据前文阐述,我们将 renderWatcher 的核心职责归纳如下:
-
在实例化
Watcher时,传入最后一个参数true,使得当前watcher实例为renderWatcher。使得vm._watcher值为renderWatcher,而不是null。 -
将
renderWatcher实例的epxOrFn参数定义为updateComponent函数,并成为renderWatcher.getter属性。updateComponent函数对应了视图更新函数。那么当renderWatcher.getter被调用时,即是进行diff比对,最终实现 最小幅度范围 的视图更新。
视图如何触发 watcher 更新
众所周知,所有的 vue template 都会被 vue-template-compiler 转换为 render 函数。在 render 函数中,所有的模板插值都对应了 vm 上对应的字段。
如下 vue template:
<div id="app">{{ msg }}</div>
将被编译为vue template explorer:
function render() {
with (this) {
return _c(
'div',
{
attrs: {
id: 'app'
}
},
[_v(_s(msg))]
)
}
}
上文中 with 语句起到的作用是在其块级作用域中拓展了作用域,使得 msg 的取值为 this.msg,那么以上渲染函数等价于:
function render() {
return _c(
'div',
{
attrs: {
id: 'app'
}
},
[_v(_s(this.msg))]
)
}
根据之前文章对 data 依赖收集及其触发原理的分析。我们不难得到,在 watcher 所订阅的依赖更新时,将通过 data[dataKey].__ob__.dep.notify 调用 dep.subs[i].update 方法来实现通知订阅者。
// src/core/observer#L37-L49
export default class Dep {
// ...
subs: Array<Watcher>
// ...
notify() {
// ...
for (let i = 0, l = subs.length; i < l; i++) {
subs[i].update()
}
}
}
根据类型定义,所有的 dep.subs[i] 均为 watcher 实例,那么 subs[i].update 调用,实际上是 watcher.update 调用:
// src/core/observer/watcher.js#L160-L173
export default class Watcher {
/**
* Subscriber interface.
* Will be called when a dependency changes.
*/
update() {
/* istanbul ignore else */
if (this.lazy) {
// 此 if 分支对应 lazyWatcher
this.dirty = true
} else if (this.sync) {
// 此分支对应 vuex 的 watcher
this.run()
} else {
// 此分支对应 renderWatcher 或 userWatcher
queueWatcher(this)
}
}
}
纵观整个 vue.js 源码,this.lazy 仅在定义 computed 的键值时,才会 true,this.sync 仅对 vuex 的 strict mode 下生效见 vuex v3.3.0 源码,而剩下的 if 分支是着重需要讨论的 watcher 与 nextTick 的协作分支。
如何避免重复更新
queueWatcher 如下,从函数语义来看,该函数就是为了队列化需要更新的 renderWatcher 和 userWatcher:
/**
* Push a watcher into the watcher queue.
* Jobs with duplicate IDs will be skipped unless it's
* pushed when the queue is being flushed.
*/
export function queueWatcher(watcher: Watcher) {
const id = watcher.id
if (has[id] == null) {
has[id] = true
if (!flushing) {
queue.push(watcher)
} else {
// if already flushing, splice the watcher based on its id
// if already past its id, it will be run next immediately.
let i = queue.length - 1
while (i > index && queue[i].id > watcher.id) {
i--
}
queue.splice(i + 1, 0, watcher)
}
// queue the flush
if (!waiting) {
waiting = true
if (process.env.NODE_ENV !== 'production' && !config.async) {
flushSchedulerQueue()
return
}
nextTick(flushSchedulerQueue)
}
}
}
queueWatcher 借助一个 key-value 数据结构和唯一的 watcher.id 做了一件非常重要的多次 watcher 更新 合并/去重操作。仅在 has[id] 为 falsy 值时,才会加入到 queue 中。
在初始时,waiting 标识为 false,那么进入到以下 if 语句中:
if (!waiting) {
waiting = true
// ...
nextTick(flushSchedulerQueue)
}
通过 nextTick 函数调度了 flushSchedulerQueue 函数的执行。
flushSchedulerQueue
函数如下:
/**
* Flush both queues and run the watchers.
*/
function flushSchedulerQueue() {
// ...
// Sort queue before flush.
// This ensures that:
// 1. Components are updated from parent to child. (because parent is always
// created before the child)
// 2. A component's user watchers are run before its render watcher (because
// user watchers are created before the render watcher)
// 3. If a component is destroyed during a parent component's watcher run,
// its watchers can be skipped.
queue.sort((a, b) => a.id - b.id)
// do not cache length because more watchers might be pushed
// as we run existing watchers
for (index = 0; index < queue.length; index++) {
watcher = queue[index]
if (watcher.before) {
watcher.before()
}
id = watcher.id
has[id] = null
watcher.run()
// in dev build, check and stop circular updates.
if (process.env.NODE_ENV !== 'production' && has[id] != null) {
circular[id] = (circular[id] || 0) + 1
if (circular[id] > MAX_UPDATE_COUNT) {
// ...
break
}
}
}
// keep copies of post queues before resetting state
const activatedQueue = activatedChildren.slice()
const updatedQueue = queue.slice()
resetSchedulerState()
// call component updated and activated hooks
callActivatedHooks(activatedQueue)
callUpdatedHooks(updatedQueue)
}
其职责在于:
-
根据
watcher创建的先后顺序排列watcher,根据以下:jsqueue.sort((a, b) => a.id - b.id)因为 Array.prototype.sort 属于原地排序,当回调函数返回值大于
0时,会在原数组中原地交换a,b顺序,故以上排序结果为小序在前的升序。又因为watcher实例化是 自增 ID。所以前文升序排列watcher表明父组件renderWatcher始终先于子组件renderWatcher。jsthis.id = ++uid // uid for batching -
迭代迭代调用
queue容器中的watcher.run方法,进而实现调用其watcher.get函数,进而实现调用watcher.getter函数(对应实例化Watcher时的expOrFn参数):-
在调用
watcher.run时可能会触发其他watcher,故迭代时,不会固定容器长度; -
对
renderWatcher来说,watcher.getter本质上调用的是的updateComponent函数,其本质对应了视图更新函数——vm._rendervnode创建函数和vm._patchDOM更新函数。 -
对于
userWatcher来说,watcher.getter对应了$options.watcher[watcherKey]的取值函数:jsexport default class Watcher { // ... constructor(/* ... */) { //... // parse expression for getter if (typeof expOrFn === 'function') { this.getter = expOrFn } else { // 此分支对应了 $options.watcher[watcherKey as string] this.getter = parsePath(expOrFn) if (!this.getter) { this.getter = noop process.env.NODE_ENV !== 'production' && warn( `Failed watching path: "${expOrFn}" ` + 'Watcher only accepts simple dot-delimited paths. ' + 'For full control, use a function instead.', vm ) } } } }即
userWatcher.getter对应调用获得的返回值是vm[watcherKey]的值。另外在调用
userWatcher.run时,因为watcher.user为true,那么会额外调用userWatcher.cb函数。并将watcher.getter的返回值和watcher.value作为新旧值传入userWatcher.cb函数。jsexport default class Watcher { // ... /** * Scheduler job interface. * Will be called by the scheduler. */ run() { if (this.active) { const value = this.get() if ( value !== this.value || // Deep watchers and watchers on Object/Arrays should fire even // when the value is the same, because the value may // have mutated. isObject(value) || this.deep ) { // set new value const oldValue = this.value this.value = value if (this.user) { try { // !! 调用用户定义的 $options.watch[watchKey] 回调函数 this.cb.call(this.vm, value, oldValue) } catch (e) { // ... } } else { this.cb.call(this.vm, value, oldValue) } } } } }
-
-
重置
watcher队列标识,表示当前队列已经由nextTick调度得到调用。jsresetSchedulerState() -
对于
<keep-alive>的缓存组件,激活activated钩子。js// call component activated hooks callActivatedHooks(activatedQueue) -
对当前队列
queue中的renderWatcher对应的 vm 实例,调用updated钩子。js// call component updated hooks callUpdatedHooks(updatedQueue)
至此,上文已经解释了 flushSchedulerQueue 背后的本质原理。
nextTick(flushSchedulerQueue)
结合前文对 nextTick 概述 和对 watcher 更新链路和 flushScheduleQueue 的分析,不难得出以下结论:
-
所有的
renderWatcher和userWatcher更新调用由queueWatcher驱动,此时所有的watcher更新并不会在当前事件循环tick的task执行上下文之上得到执行。 -
queueWatcher解决的核心思路是nextTick(flushSchedulerQueue)函数调用。 -
nextTick给予了flushSchedulerQueue函数延迟调用的能力。nextTick基于当前JS运行时以micro-task至task的优先级进行实现。所有的watcher更新函数的 调用时机完全取决于nextTick的运行时实现:-
当
nextTick以miro-task实现时,所有的watcher更新函数在当前事件循环tick的清空micro-task queue阶段得到执行。 -
当
nextTick以task实现时,所有的watcher更新函数基于一个全新的task(即作为setTimeout的回调函数的 flushSchedulerQueue)得到执行。
-
-
flushScheduleQueue函数是最终当前事件循环tick中收集的watcher更新的 真正执行者。
