前言
这篇文章分析了 Vue 更新过程中使用的异步更新队列的相关代码。通过对异步更新队列的研究和学习,加深对 Vue 更新机制的理解
什么是异步更新队列
先看看下面的例子:
<div id="app">
<div id="div" v-if="isShow">被隐藏的内容</div>
<input @click="getDiv" value="按钮" type="button">
</div>
<script>
let vm = new Vue({
el: '#app',
data: {
//控制是否显示#div
isShow: false
},
methods:{
getDiv: function () {
this.isShow=true
var content = document.getElementById('div').innerHTML;
console.log('content',content)
}
}
})
</script>
- 上面的例子是,点击按钮显示被隐藏的 div,同时打印 div 内部 html 的内容。
- 按照我们一般的认知,应该是点击按钮能够显示 div 并且在控制台看到 div 的内部 html 的内容。
但是实际执行的结果确是,div 可以显示出来,但是打印结果的时候会报错,错误原因就是 innerHTML 为 null,也就是 div 不存在。
只有当我们再次点击按钮的时候才会打印出 div 里面的内容。这就是 Vue 的异步更新队列的结果
异步更新队列的概念
Vue 的 dom 更新是异步的,当数据发生变化时 Vue 不是立刻去更新 dom,而是开启一个队列,并缓冲在同一个事件中循环发生的所有数据变化。
在缓冲时,会去除重复的数据,避免多余的计算和 dom 操作。在下一个事件循环 tick 中,刷新队列并执行已去重的工作。
-
所以上面的代码报错是因为当执行 this.isShow=true 时,div 还未被创建出来,知道下次 Vue 事件循环时才开始创建
-
查重机制降低了 Vue 的开销
-
异步更新队列实现的选择:由于浏览器的差异,Vue 会根据当前环境选择 Promise.then 或者 MuMutationObserver,如果两者都不支持,则会用 setImmediate 或者 setTimeout 代替
异步更新队列解析
异步队列源码入口
通过之前对 Vue 数据响应式的分析我们知道,当 Vue 数据发生变化时,会触发 dep 的 notify() 方法,该方法通知观察者 watcher 去更新 dom,我们先看一下这的源码
- from src/core/observer/dep.js
//直接看核心代码
notify () {
//这是Dep的notify方法,Vue的会对data数据进行数据劫持,该方法被放到data数据的set方法中最后执行
//也就是通知更新操作
// stabilize the subscriber list first
const subs = this.subs.slice()
if (process.env.NODE_ENV !== 'production' && !config.async) {
subs.sort((a, b) => a.id - b.id)
}
for (let i = 0, l = subs.length; i < l; i++) {
// !!!核心:通知watcher进行数据更新
//这里的subs[i]其实是Dep维护的一个watcher数组,所以我们下面是执行的watcher中的update方法
subs[i].update()
}
}
- 上面的代码简单来说就是 dep 通知 watcher 尽心更新操作,我们看一下 watcher 相关的代码
from :src/core/observer/watcher.js
//这里只展示部分核心代码
//watcher的update方法
update () {
/* istanbul ignore else */
//判断是否存在lazy和sync属性
if (this.lazy) {
this.dirty = true
} else if (this.sync) {
this.run()
} else {
//核心:将当前的watcher放到一个队列中
queueWatcher(this)
}
}
- 上面 watcher 的 update 更新方法简单来说就是调用了一个 queueWatcher 方法,这个方法其实是将当前的 watcher 实例放入到一个队列中,以便完成后面的异步更新队列操作
异步队列入队
下面看看 queueWatcher 的逻辑 from src/core/observer/scheduler.js
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 的代码的主要作用就是对任务去重,然后启动异步任务,进行跟新操作。接下来我们看一线 nextTick 里面的操作
from src/core/util/next-tick.js
//cb:
export function nextTick (cb?: Function, ctx?: Object) {
let _resolve
//callbacks:这个方法维护了一个回调函数的数组,将回调函数添家进数组
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
})
}
- 这里的核心,其实就在 timerFunc 的函数上,该函数根据不同的运行时环境,调用不同的异步更新队列,下面看一下代码
from src/core/util/next-tick.js
/**这部分逻辑就是根据环境来判断timerFunc到底是使用什么样的异步队列**/
let timerFunc
//首选微任务执行异步操作:Promise、MutationObserver
//次选setImmediate最后选择setTimeout
// 根据当前浏览器环境选择用什么方法来执行异步任务
if (typeof Promise !== 'undefined' && isNative(Promise)) {
//如果当前环境支持Promise,则使用Promise执行异步任务
const p = Promise.resolve()
timerFunc = () => {
//最终是执行的flushCallbacks方法
p.then(flushCallbacks)
//如果是IOS则回退,因为IOS不支持Promise
if (isIOS) setTimeout(noop)
}
//当前使用微任务执行
isUsingMicroTask = true
} else if (!isIE && typeof MutationObserver !== 'undefined' && (
//如果当前浏览器支持MutationObserver则使用MutationObserver
isNative(MutationObserver) ||
MutationObserver.toString() === '[object MutationObserverConstructor]'
)) {
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)) {
//如果支持setImmediate,则使用setImmediate
timerFunc = () => {
setImmediate(flushCallbacks)
}
} else {
//如果上面的条件都不满足,那么最后选择setTimeout方法来完成异步更新队列
timerFunc = () => {
setTimeout(flushCallbacks, 0)
}
}
- 从上面代码可以看出,不论 timerFunc 使用的是什么样的异步更新队列,最终执行的函数还是落在了 flushCallbacks 上面,那么我们来看一看,这个方法到底是什么
from src/core/util/next-tick.js
function flushCallbacks () {
pending = false
//拷贝callbacks数组内容
const copies = callbacks.slice(0)
//清空callbacks
callbacks.length = 0
//遍历执行
for (let i = 0; i < copies.length; i++) {
//执行回调方法
copies[i]()
}
}
- 上面的这个方法就是遍历执行了我们 nextTick 维护的那个回调函数数组, 其实就是将数组的方法依次添加进异步队列进行执行。同时清空 callbacks 数组为下次更新作准备。
上面这几段代码其实都是 watcher 的异步队列更新中的入队操作,通过 queueWatcher 方法中调用的 nextTick(flushSchedulerQueue), 我们知道,其实是将 flushSchedulerQueue 这个方法入队
异步队列的具体更新方法
所以下面我们看一下 flushSchedulerQueue 这个方法到底执行了什么操作
from src/core/observer/scheduler.js
/**我们这里只粘贴跟本次异步队列更新相关的核心代码**/
//具体的更新操作
function flushSchedulerQueue () {
currentFlushTimestamp = getNow()
flushing = true
let watcher, id
//重新排列queue数组,是为了确保:
//更新顺序是从父组件到子组件
//用户的watcher先于render 的watcher执行(因为用户watcher先于render watcher创建)
//当子组件的watcher在父组件的watcher执行时被销毁,则跳过该子组件的watcher
queue.sort((a, b) => a.id - b.id)
//queue数组维护的一个watcher数组
//遍历queue数组,在queueWatcher方法中我们将传入的watcher实例push到了该数组中
for (index = 0; index < queue.length; index++) {
watcher = queue[index]
if (watcher.before) {
watcher.before()
}
id = watcher.id
//清空has对象里面的"id"属性(这个id属性之前在queueWatcher方法里面查重的时候用到了)
has[id] = null
//核心:最终执行的其实是watcher的run方法
watcher.run()
//下面是一些警告提示,可以先忽略
if (process.env.NODE_ENV !== 'production' && has[id] != null) {
circular[id] = (circular[id] || 0) + 1
if (circular[id] > MAX_UPDATE_COUNT) {
warn(
'You may have an infinite update loop ' + (
watcher.user
? `in watcher with expression "${watcher.expression}"`
: `in a component render function.`
),
watcher.vm
)
break
}
}
}
//调用组件updated生命周期钩子相关,先跳过
const activatedQueue = activatedChildren.slice()
const updatedQueue = queue.slice()
resetSchedulerState()
callActivatedHooks(activatedQueue)
callUpdatedHooks(updatedQueue)
if (devtools && config.devtools) {
devtools.emit('flush')
}
}
- 上面的一堆 flushSchedulerQueue 代码,简单来说就是排列了 queue 数组,然后遍历该数组,执行 watcher.run 方法。所以,异步队列更新当我们入队完以后,真正执行的方法其实是 watcher.run 方法
下面我们来继续看一下 watcher.run 方法,到底执行了什么操作
from src/core/observer/watcher.js
/**
* Scheduler job interface.
* Will be called by the scheduler.
* 上面这段英文注释 是官方注释,从这我们看出该方法最终会被scheduler调用
*/
run () {
if (this.active) {
//这里调用了watcher的get方法
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 {
this.cb.call(this.vm, value, oldValue)
} catch (e) {
handleError(e, this.vm, `callback for watcher "${this.expression}"`)
}
} else {
this.cb.call(this.vm, value, oldValue)
}
}
}
}
- 上述 run 方法最终要的操作就是调用了 watcher 的 get 方法,该方法我们在之前的源码分析有讲过,主要实现的功能是调用了 data 数据的 get 方法,获取最新数据。
至此,Vue 异步更新队列的核心代码我们就分析完了,为了便于理清思路,我们来一张图总结一下
关于 Vue.$nextTick
我们都知道 . n e x t T i c k 方 法 , 其 实 这 个 ∗ ∗ .nextTick 方法,其实这个 ** .nextTick 方法,其实这个∗∗nextTick** 方法就是直接调用的上面的 nextTick 方法
from src/core/instance/render.js
Vue.prototype.$nextTick = function (fn: Function) {
return nextTick(fn, this)
}
- 由上面的代码我们可以看出,$nextTick 是将我们传入的回调函数加入到了异步更新队列,所以它才能实现 dom 更新后回调
注意,$nextTick() 是会将我们传入的函数加入到异步更新队列中的,但是这里有个问题,如果我们想获得 dom 更新后的数据,我们应该把该逻辑放到更新操作之后
因为加入异步队列先后的问题,如果我们在更新数据之前入队的话 ,是获取不到更新之后的数据的
总结
总结起来就是,当触发数据更新通知时,dep 通知 watcher 进行数据更新,这时 watcher 会将自己加入到一个异步的更新队列中。然后更新队列会将传入的更新操作进行批量处理。
这样就达到了多次更新同时完成,提升了用户体验,减少了浏览器的开销,增强了性能。