在vue3中,修改一个响应式对象,像是这样:
<template>
<span class="my-name">name: {{ name }}</span>
</template>
<script lang="ts" setup>
import { ref, watchEffect } from 'vue'
const name = ref("zxfan")
name.value += ' yes!'
</script>
当执行name.value += ' yes!',vue3并不会立即触发当前组件的更新。而是将更新任务放到任务队列中。这一点与vue2一致。
任务队列
调度相关的源码位于runtime-core/src/schduler.ts中
在任务调度过程中,任务会被放入三类任务队列中:
-
preFlushCbs:要在执行queue之前执行的队列
-
queue:任务队列
-
postFlush:要在执行queue之后执行的队列
每一次flush的过程(或者说一个Tick),都是按照清空preFlushCbs队列->清空queue队列->清空postFlush队列的顺序
flush 指刷新任务队列,或者说 执行完任务队列中所有的任务
入队queue的时机
queue存放的都是组件渲染任务
组件更新
先提一下vue中的响应式:
import {effect, ref} from '@vue/reactivity'
const age = ref(20)
effect(() => {
console.log(age.value) // 会输出两次,分别是 20 和 21
})
age.value = 21
effect的回调函数会被响应式得重写触发执行。
那么回到本文最开始的例子中,当执行name.value += ' yes!'。响应式数据变化了,触发(trigger)其关联到的effect重新执行。那么需要重新执行的effect在哪里声明的呢?
// runtie-core/src/renderer.ts
const setupRenderEffect: SetupRenderEffectFn = (
instance,
initialVNode,
container,
anchor,
parentSuspense,
isSVG,
optimized
) => {
// create reactive effect for rendering
instance.update = effect(function componentEffect() {
// 省略。。。创建或更新 VNode
}, __DEV__ ? createDevEffectOptions(instance) : prodEffectOptions)
}
setupRenderEffect给组件套上了个effect。这个函数是组件实例创建的时候调用的。
当修改组件内的响应式数据时,会触发该effect重新执行。
当然不会直接就执行了,注意到了吗,effect还有第二个参数。第二个参数的类型如下:
export interface ReactiveEffectOptions {
lazy?: boolean // 该effect是否
scheduler?: (job: ReactiveEffect) => void // 调度
onTrack?: (event: DebuggerEvent) => void
onTrigger?: (event: DebuggerEvent) => void
onStop?: () => void
allowRecurse?: boolean
}
其它先不关注,我们只看scheduler属性。
如果有这个属性,就不会直接执行effect,而是调用这个scheduler函数,并且将effect作为参数传给它
export function trigger(
target: object,
type: TriggerOpTypes,
key?: unknown,
newValue?: unknown,
oldValue?: unknown,
oldTarget?: Map<unknown, unknown> | Set<unknown>
) {
// 省略...
const run = (effect: ReactiveEffect) => {
// 有scheduler 调用 scheduler
if (effect.options.scheduler) {
effect.options.scheduler(effect)
} else {
// 否则直接执行 effect
effect()
}
}
effects.forEach(run)
}
那么在给组件实例套上effect的时候,传给effect的第二个参数是什么呢?
// runtie-core/src/renderer.ts
const prodEffectOptions = {
scheduler: queueJob,
// #1801, #2043 component render effects should allow recursive updates
allowRecurse: true
}
function createDevEffectOptions(
instance: ComponentInternalInstance
): ReactiveEffectOptions {
return {
scheduler: queueJob,
allowRecurse: true,
onTrack: instance.rtc ? e => invokeArrayFns(instance.rtc!, e) : void 0,
onTrigger: instance.rtg ? e => invokeArrayFns(instance.rtg!, e) : void 0
}
}
__DEV__ ? createDevEffectOptions(instance) : prodEffectOptions)根据不同的环境传递的参数有一些差异性。但是我们只关注scheduler,scheduler的值都是queueJob这个函数
queueJob的作用就是将effect放入queue任务队列。
稍后在讨论queueJob具体都做了些什么,先总结一下组件更新的调度流程
-
组件实例创建时,套上了effect方法
-
修改响应式数据,触发effect重新执行(先调用trigger函数)
-
effect的第二个参数中有
scheduler属性,所以不会直接执行effect方法,而是将调用scheduler方法即queueJob -
将组件更新effect放入
queue队列中
调用forceUpdate
在vue2中可以使用this.$forceUpdate强制组件重新渲染。这一API在vue3 Options API中保留了下来
// runtime-core/src/componentPublicInstance.ts
const publicPropertiesMap: PublicPropertiesMap = extend(Object.create(null), {
// 省略...
$forceUpdate: i => () => queueJob(i.update),
// 省略...
} as PublicPropertiesMap)
可以观察到,forceUpdate就是直接调用queueJob,并将effect(i.update就是effect)传入。
hmr
开发环境下,热更新也会触发组件重新渲染。也是调用queueJob将更新任务加入任务队列
入队preFlushCbs和postFlushCbs的时机
preFlushCbs存放的是组件渲染前需要完成的任务
postFlushCbs存放的是组件渲染完成后要完成的任务
watchEffect和watch
以watchEffect为例,看一下它的类型声明:
function watchEffect(
effect: (onInvalidate: InvalidateCbRegistrator) => void,
options?: WatchEffectOptions
): StopHandle
interface WatchEffectOptions {
flush?: 'pre' | 'post' | 'sync' // 默认:'pre'
onTrack?: (event: DebuggerEvent) => void
onTrigger?: (event: DebuggerEvent) => void
}
watchEffect的第二个参数中有个flush属性,有三种取值
pre(默认):在组件渲染前异步调用,会将任务放入preFlushCbs队列中post:在组件渲染后异步调用,会将任务放入postFlushCbs队列中sync:同步调用
分析源码,来看一下具体放入队列的操作:
export function watchEffect(
effect: WatchEffect,
options?: WatchOptionsBase
): WatchStopHandle {
// 调用doWatch方法
return doWatch(effect, null, options)
}
function doWatch(
source: WatchSource | WatchSource[] | WatchEffect | object,
cb: WatchCallback | null,
{ immediate, deep, flush, onTrack, onTrigger }: WatchOptions = EMPTY_OBJ,
instance = currentInstance
): WatchStopHandle {
// 省略...
const job: SchedulerJob = () => {
// 省略
// 会执行传入的回调 cb
}
// 赋值scheduler
let scheduler: ReactiveEffectOptions['scheduler']
if (flush === 'sync') {
scheduler = job
} else if (flush === 'post') {
scheduler = () => queuePostRenderEffect(job, instance && instance.suspense)
} else {
// default: 'pre'
scheduler = () => {
if (!instance || instance.isMounted) {
queuePreFlushCb(job)
} else {
// 第一次执行,组件未渲染的话,同步执行
job()
}
}
}
// 创建effect
const runner = effect(getter, {
lazy: true, // true的话, effect第一次不会自动执行,而是直接返回effect
onTrack,
onTrigger,
scheduler
})
// 省略...
}
总结一下:
-
响应式都是通过
effect来实现的 -
先赋值
sheduler,有三种情况flush: sync:直接赋值job,也就是直接触发effect执行flush: post:赋值一个函数,函数会执行queuePostRenderEffect,即将job放入postFlushCbs队列flush: pre:赋值一个函数,函数会执行queuePreRenderEffect,即将job放入preFlushCbs队列
-
创建
effect,以实现响应式 并将scheduler传入。当响应式数据发生变化,就会调用scheduler函数
除此之外,在Vnode创建、更新和销毁的过程中,许多环节需要在组件更新完之后再去执行相应操作。vue调用了queuePostFlushCb。
任务调度
现在来具体看一下这些任务进入任务队列中都做了什么:
queueJob
// runtime-core/src/schduler.ts
export function queueJob(job: SchedulerJob) {
// 判断队列中是否已有该job,没有才会添加到任务队列中
if (
(!queue.length ||
!queue.includes(
job,
isFlushing && job.allowRecurse ? flushIndex + 1 : flushIndex
)) &&
job !== currentPreFlushParentJob
) {
// 找的任务在任务队列中合适的位置
const pos = findInsertionIndex(job)
if (pos > -1) {
queue.splice(pos, 0, job)
} else {
queue.push(job)
}
// 清空任务队列
queueFlush()
}
}
先进行重复判断。这是确保任务队列中不会出现多个相同组件的effect,防止组件重复渲染。
最终调用queueFlush来执行清空任务队列(执行完所有任务)
queuePreFlushCb
// runtime-core/src/schduler.ts
function queueCb(
cb: SchedulerCbs,
activeQueue: SchedulerCb[] | null,
pendingQueue: SchedulerCb[],
index: number
) {
if (!isArray(cb)) {
if (
!activeQueue ||
!activeQueue.includes(
cb,
(cb as SchedulerJob).allowRecurse ? index + 1 : index
)
) {
pendingQueue.push(cb)
}
} else {
pendingQueue.push(...cb)
}
// 清空任务队列
queueFlush()
}
export function queuePreFlushCb(cb: SchedulerCb) {
queueCb(cb, activePreFlushCbs, pendingPreFlushCbs, preFlushIndex)
}
export function queuePostFlushCb(cb: SchedulerCbs) {
queueCb(cb, activePostFlushCbs, pendingPostFlushCbs, postFlushIndex)
}
queuePreFlushCb和queuePostFlushCb都会再去调用queueCb函数,这个函数做两件事
- 将任务放入响应的任务队列(当然 先判断重复)
- 执行
queueFlush,来清空任务队列
queueFlush
// runtime-core/src/schduler.ts
const resolvedPromise: Promise<any> = Promise.resolve()
function queueFlush() {
if (!isFlushing && !isFlushPending) {
isFlushPending = true
currentFlushPromise = resolvedPromise.then(flushJobs)
}
}
function flushJobs(seen?: CountMap) {
// 省略...
// 1. 清空preFlushCb队列
flushPreFlushCbs(seen)
// 省略...
try {
// 2. 清空queue队列
for (flushIndex = 0; flushIndex < queue.length; flushIndex++) {
const job = queue[flushIndex]
if (job) {
// 执行job,也就是前面传入的effect
callWithErrorHandling(job, null, ErrorCodes.SCHEDULER)
}
}
} finally {
// 省略...
// 3. 清空postFlushCbs队列
flushPostFlushCbs(seen)
// 省略...
}
}
清空队列主要流程:
- 异步执行
flushJob,这里使用了resolvedPromise.then(flushJobs)。将清空队列的操作变成了异步。 - 清空preFlushCb队列
- 清空queue队列
- 清空postFlushCbs队列
这里的“清空”是执行队列中所有任务的意思
nextTick原理?
先看一段demo:
<template>
<span class="my-name">name: {{name}}</span>
</template>
<script lang="ts" setup>
import { nextTick, onMounted, ref } from 'vue'
const name = ref("zxfan")
onMounted(() => {
name.value += ' yes!'
const dom = document.querySelector(".my-name")
console.log(dom?.innerHTML); // 输出:name: zxfan
nextTick(() => {
console.log(dom?.innerHTML); // 输出:name: zxfan yes!
})
})
</script>
由于修改响应式数据,不会立即触发组件重渲染,所以第一个console.log输出的内容是上一次渲染的内容。而nexTick注册的回调会等到在渲染完毕后执行。
我们看一下nextTick是如何实现的
// runtime-core/src/schduler.ts
export function nextTick(
this: ComponentPublicInstance | void,
fn?: () => void
): Promise<void> {
const p = currentFlushPromise || resolvedPromise
return fn ? p.then(this ? fn.bind(this) : fn) : p
}
注意到,它是等promise执行完毕后在去执行回调函数。那么这个promise是什么呢?
currentFlushPromise:这个promise我们刚刚遇到过,是在queueFlush中赋值的。这个promise会在本轮任务队列全部清空后才会变成fullfilled。这是再去调用nextTick的回调,就能获取到最新的domresolvedPromise:说明现在任务队列为空,那就包装一层promise并返回
要理解nextTick,还需要结合浏览器的事件循环来分析,我们先明确几个知识点:
- 浏览器的事件循环将事件放到两个队列——宏任务队列和**微任务队列
- 每一次事件循环,会优先先清空微任务队列
- promise属于微任务
以下面的demo为例,解释一下从修改响应式数据到nextTick中回调执行的过程
name.value += ' yes!'
nextTick(() => {
console.log(dom?.innerHTML); // 输出:name: zxfan yes!
})
- 当执行
name.value += ' yes!'时,响应式数据发生了改变。- 触发Proxy的setter,将更新组件任务的effect放入vue任务队列中(调用queueJob)
- 调用queueFlush,这里调用了
promise.resolve,所以将flushJobs放入微任务队列 - 此时微任务队列= [flushJobs]
- 接着执行
nextTick。等待currentFlushPromise执行完毕 - 开始清空微任务队列。先执行flushJobs,执行更新组件任务的effect,这个过程会操作DOM,一旦操作DOM。浏览器会进行线程切换。由JS线程切换到渲染线程,浏览器去更新DOM。当DOM更新完毕,在切换回JS线程。回到JS线程后,
currentFlushPromise状态变成fullfilled。将nextTick的回调cb放入微任务队列。此时微任务队列= [cb] - 继续清空微任务队列,执行cb。此时如果在cb中访问dom,就是渲染完成后的dom内容。
总结
Vue2中的表现
任务调度
vu2的任务调度原理也是类似。
- 组件的响应式数据发生改变,会触发setter方法。
- 触发
dep.notify()。 - 遍历dep对象上的
watcher,逐一调用其update方法,(Watcher对象是在渲染组件时创建的,用来更新组件) - update再调用
queueWatcher,将更新任务加入任务队列 - 调用
nextTick(flushSchedulerQueue),用来清空任务队列。nextTick是为了让组件更新变成异步。
nextTick
vue2中的nextTick比vue3多了些内容。
nextTick也模拟了一个回调任务队列callbacks(注意这不是调度的任务队列)
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)
}
})
// 如果队列没有正在执行(flush),就去清空队列
if (!pending) {
pending = true
timerFunc() // 清空队列
}
// $flow-disable-line
if (!cb && typeof Promise !== 'undefined') {
return new Promise(resolve => {
_resolve = resolve
})
}
}
nextTick调用了timeFunc来清空队列
function flushCallbacks () {
// 省略... 就是清空callbakcs任务队列
}
let timerFunc
// 1 支持promise,就用promise来模拟异步
if (typeof Promise !== 'undefined' && isNative(Promise)) {
const p = Promise.resolve()
timerFunc = () => {
p.then(flushCallbacks)
if (isIOS) setTimeout(noop)
}
isUsingMicroTask = true
}
// 2. 不支持promise 就用MutationObserver来模拟异步
else if (!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
}
// 3.用setImmediate来模拟异步
else if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
timerFunc = () => {
setImmediate(flushCallbacks)
}
} else {
// Fallback to setTimeout.
timerFunc = () => {
setTimeout(flushCallbacks, 0)
}
}
为了更多的兼容浏览器,timerFunc模拟异步判断了三种情况
- 支持Promise,就直接用Promise来模拟异步。Promise属于微任务
- 不支持Promise,支持MutationObserver。MutationObserver也属于微任务
- 创建一个空的文本节点,监听这个文本节点的字符变化。变化了就调用
flushCallbacks来清空回调任务队列 - 调用
timerFunc,会修改文本节点的内容,从而触发flushCallbacks函数的执行
- 创建一个空的文本节点,监听这个文本节点的字符变化。变化了就调用
MutationObserver和Promise都不支持,就用setImmediate来模拟异步,后者属于宏任务- 最后方案就是使用setTimeout来模拟异步
可见vue2为了兼容性,下足了功夫。而vue3直接就使用了Promise