异步更新机制
是什么
在vue
中,数据更新引发页面更新时,页面并不会马上更新,而是将它们缓存在一个队列中,更新dom
会延迟到下一个事件循环中执行。这是因为,vue
组件更新,需要生成新虚拟dom
与旧的进行对比,再操作真实节点进行页面更新,若采取同步更新,将会非常损耗性能,还可能会导致UI闪烁的问题。
js
<template>
<div id="app">
<p>{{num}}</p>
<button @click="addNum">num++</button>
</div>
</template>
<script>
export default {
name: 'App',
data() {
return {
num: 1
}
},
methods: {
addNum() {
// 只会更新一次
for (let i = 0; i < 1000; i++) {
this.num++;
}
}
}
}
</script>
上面的例子中,点击num++
按钮,num
值一共改变了1000次,但是最终页面只会更新一次。
出现的问题
异步更新大大提高了页面更新的性能,但也带来了新的问题。如为一个响应式数组新增一项,添加后需要在代码中拿取新增项的真实节点。但按照下面的写法,是无法拿到的。
js
<template>
<div id="app">
<ul>
<li v-for="item of arr">{{ item }}</li>
</ul>
<button @click="addItem"></button>
</div>
</template>
<script>
export default {
name: 'App',
data() {
return {
arr: [1, 2, 3, 4]
}
},
methods: {
addItem() {
this.arr.push(5);
// 需要在这里拿取元素为5的li
console.log(this.$refs.nums.children[4]); // undefined
}
}
}
</script>
解决方法------nextTick出现原因
为了解决上述的问题,vue
提供了nextTick
这个api
。它可以在下次 DOM 更新完成后执行回调函数。
nextTick()
可以在状态改变后立即使用,以等待 DOM 更新完成。你可以传递一个回调函数作为参数,或者 await 返回的 Promise。
js
<template>
<div id="app">
<ul>
<li v-for="item of arr">{{ item }}</li>
</ul>
<button @click="addItem"></button>
</div>
</template>
<script>
export default {
name: 'App',
data() {
return {
arr: [1, 2, 3, 4]
}
},
methods: {
addItem() {
this.arr.push(5);
// 需要在这里拿取元素为5的li
this.$nextTick(() => {
console.log(this.$refs.nums.children[4]); // 5的真实dom
})
}
}
}
</script>
源码分析
Vue 页面更新和watch
回调执行是dep
通过调用Watcher
的update
方法通知数据的更新,在update
方法中会调用queueWatcher
方法。
queueWatcher
该方法主要作用就是收集需要update
的watcher
到queue
中。
- 如果更新队列存在了该
watcher
,则无需加入队列,直接返回; - 将
watcher
加入更新队列:如果更新队列未开始执行,则直接放在队列尾;反之,则根据id
按顺序插入相应位置。 - 判断是否有队列即将要执行(
waiting
状态),如果没有则调用nextTick
方法执行队列。
js
// watcher队列
const queue: Array<Watcher> = []
// 标记watcher是否已经存在于更新队列中 用watcher id作为key
let has: { [key: number]: true | undefined | null } = {}
// 判断是否已经在执行
let flushing = false
export function queueWatcher(watcher: Watcher) {
const id = watcher.id
// 在更新队列里面已经有了这个watcher 直接返回
if (has[id] != null) {
return
}
if (watcher === Dep.target && watcher.noRecurse) {
return
}
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.
// 开始执行了 需要按照id将其附加到合适的位置
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 (__DEV__ && !config.async) {
flushSchedulerQueue()
return
}
nextTick(flushSchedulerQueue)
}
}
flushSchedulerQueue
该方法主要作用是watcher
队列的执行。
- 对队列中的
watcher
,按照id
从小到大排序。排序能够保证:- 父组件先于子组件更新,因为父组件肯定先于子组件创建;
- 组件自定义的watcher将先于渲染watcher执行,因为自定义watcher先于渲染watcher创建。在
new Vue()
中先initRender()
、initWatcher()
再initRender()
。 - 如果组件在父组件执行watcher期间被销毁了,该watcher可以直接被跳过。
- 遍历队列中所有的
watcher
,按顺序执行watcher.run()
。开发环境下判断circular[id]是否超过最大限制的更新值(100),如果超过,抛出警告可能有循环更新。
js
function flushSchedulerQueue() {
currentFlushTimestamp = getNow()
flushing = true
let watcher, id
/**
* 排序能够保证:
* 1. 父组件先于子组件更新,因为父组件肯定先于子组件创建
* 2. 组件自定义的watcher将先于渲染watcher执行,因为自定义watcher先于渲染watcher创建
* 3. 如果组件在父组件执行watcher期间destroyed了,它的watcher可以直接被跳过
*/
// 为了让先创建的watcher先执行
queue.sort(sortCompareFn)
// 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) {
// 如是页面更新,通过before触发hook beforeUpdated
watcher.before()
}
id = watcher.id
has[id] = null
// 执行watcher更新 如是页面更新,最终执行vue._update()方法
watcher.run()
// in dev build, check and stop circular updates.
if (__DEV__ && 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
}
}
}
// keep copies of post queues before resetting state
const activatedQueue = activatedChildren.slice()
const updatedQueue = queue.slice()
// 队列完成更新,重置初始化参数
resetSchedulerState()
// call component updated and activated hooks
// 执行activted钩子
callActivatedHooks(activatedQueue)
// 执行updated钩子
callUpdatedHooks(updatedQueue)
// 清理dep 具体可看https://juejin.cn/post/7012885163344396324
cleanupDeps()
// devtool hook
/* istanbul ignore if */
if (devtools && config.devtools) {
devtools.emit('flush')
}
}
resetSchedulerState
重置相关数据为初始值。
js
function resetSchedulerState() {
index = queue.length = activatedChildren.length = 0
has = {}
if (__DEV__) {
circular = {}
}
waiting = flushing = false
}
nextTick源码分析
nextTick
- 将 nextTick 回调函数放入队列 callbacks 中;
- 若未进入任务队列,将其推进任务队列并改变 pending 状态;
- 若当前环境支持 promise,返回一个待定的 promise,当回调函数 cb 执行完毕后,调用返回 promise 的 resolve 方法。
ts
// vue2版本
// 源码地址 src\core\util\next-tick.ts
// 装nextTick所有回调函数
const callbacks: Array<Function> = [];
// 是否调用nextTick所有回调函数的方法已经进入任务队列
let pending = false;
export function nextTick(cb?: (...args: any[]) => any, ctx?: object) {
let _resolve
// 保存nextTick更新方法
callbacks.push(() => {
if (cb) {
try {
cb.call(ctx)
} catch (e: any) {
handleError(e, ctx, 'nextTick')
}
} else if (_resolve) {
// 已经执行完nexttick中的回调函数 nextTick().then(() => {/* 可执行 */})
_resolve(ctx)
}
})
// 若未进入任务队列 将其推进任务队列并改变pending状态
if (!pending) {
pending = true
timerFunc()
}
// $flow-disable-line
if (!cb && typeof Promise !== 'undefined') {
// 返回一个待定的promise
return new Promise(resolve => {
_resolve = resolve
})
}
}
timerFunc
这个函数主要就是根据当前环境采取不同的异步方法。从代码可以看出,顺序是promise
------>MutationObserver
------>setImmediate
------>setTimeout
。
js
// vue2版本
// 源码地址 src\core\util\next-tick.ts
// 是否是微任务
export let isUsingMicroTask = false
let timerFunc
// 如果支持promise
if (typeof Promise !== 'undefined' && isNative(Promise)) {
const p = Promise.resolve()
timerFunc = () => {
// 在.then()中执行所有的更新函数
p.then(flushCallbacks)
// ios系统 使用setTimeout
if (isIOS) setTimeout(noop)
}
isUsingMicroTask = true
} else if (/* 支持MutationObserver执行 */
!isIE &&
typeof MutationObserver !== 'undefined' &&
(isNative(MutationObserver) ||
// PhantomJS and iOS 7.x
MutationObserver.toString() === '[object MutationObserverConstructor]')
) {
// 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
// 支持setImmediate
} else if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
timerFunc = () => {
setImmediate(flushCallbacks)
}
} else {
// 最后setTimeout兜底
timerFunc = () => {
setTimeout(flushCallbacks, 0)
}
}
flushCallbacks
清空 callbacks,改变 pending 状态,执行所有 callbacks。
js
// vue2版本
// 源码地址 src\core\util\next-tick.ts
const callbacks: Array<Function> = []
let pending = false
// 清空callbacks 并执行所有callbacks
function flushCallbacks() {
// 开始执行了 所以pending改为false
pending = false
const copies = callbacks.slice(0)
callbacks.length = 0
for (let i = 0; i < copies.length; i++) {
copies[i]()
}
}
vue3的nextTick原理
vue3
的nextTick
主要就是通过了promise.then()
将待执行函数放入任务队列中。详细的介绍可以看一次弄懂 Vue2 和 Vue3 的 nextTick 实现原理 - 掘金 (juejin.cn)。
js
const resolvedPromise = /*#__PURE__*/ Promise.resolve() as Promise<any>
let currentFlushPromise: Promise<void> | null = null
export function nextTick<T = void, R = void>(
this: T,
fn?: (this: T) => R
): Promise<Awaited<R>> {
const p = currentFlushPromise || resolvedPromise
// fn回调函数放入.then执行 推入任务队列
return fn ? p.then(this ? fn.bind(this) : fn) : p
}
// 刷新任务队列
function queueFlush() {
if (!isFlushing && !isFlushPending) {
isFlushPending = true
// 创建微任务,把 flushJobs 推入任务队列等待执行
currentFlushPromise = resolvedPromise.then(flushJobs)
}
}
总结
vue
页面更新以及watch
的回调函数执行都是异步的。当数据发生变化时,会通过setter
劫持,遍历deps
中所有的watcher
,调用watcher
的update
方法,在update
方法中,会调用queueWatcher
方法,将watcher
添加到更新队列中,并通过nextTick
方法执行更新队列。
nextTick
是下一次DOM
更新完成后会执行该回调方法。便于我们在DOM
更新完后,立即拿到新的DOM
。在vue2
的nextTick
方法中,首先将nextTick
的回调方法保存到callbacks
中,并判断是否执行callbacks
的方法是否已经推入任务队列中了,如果还未则推入任务队列中。推入任务队列采用的异步方法,优先级如下:promise
、MutationObserver
、setImmediate
、setTimeout
。在vue3
中则是直接使用了promise.then()
方法,将待执行回调函数推入任务队列中。