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._render
vnode
创建函数和vm._patch
DOM
更新函数。 -
对于
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
更新的 真正执行者。