侦听器是常用的 Vue API 之一,它用于监听一个数据并在数据变动时做一些自定义逻辑,本文将先列举侦听器在 Vue 中的使用方式,然后再分析源码讲述为什么可以这样使用、以及侦听器的实现原理。
侦听器用法
在 Vue3 中使用侦听器有两种方式,一种是传统的 Options API ,另一种则是提供给 Composition API 使用的 Function API。
注意:这里这是为了方便描述用法差异才将使用方式拆分,其实两种方式可以按需任意混用。
在 Options API 中使用
我是个怀旧的懒人,所以我一直喜欢 Options API 更多,那就先列举 watch
选项的用法(不涉及 WatchOptions
配置)。
对齐 Options API 中与 watch
相关的术语:
key
: 配置侦听选项时的属性,也就是被侦听的响应式变量,示例代码中的firstName
raw
: 配置侦听选项时key
对应的值,示例代码中的匿名函数handler/callback
: 侦听值改变时 Vue 要执行的回调函数
示例代码:
watch: {
firstName:function(nv) {
this.fullName = `${nv} ${this.lastName}`
}
函数
当 raw
是函数时,handler
就等于 raw
。
export default {
data() {
return {
firstName: 'Mo',
lastName: 'Deyan',
fullName: 'Mo Deyan'
}
},
watch: {
firstName(nv) {
this.fullName = `${nv} ${this.lastName}`
}
}
}
字符串
当 raw
是字符串时,Vue 会从上下文(当前组件实例)上获取同名的函数作为 handler
。
export default {
data() {
return {
firstName: 'Mo',
lastName: 'Deyan',
fullName: 'Mo Deyan'
}
},
methods: {
watchHandle(nv) {
console.log(this)
this.fullName = `${nv} ${this.lastName}`
},
},
watch: {
firstName: 'watchHandle'
}
}
对象
当 row
是对象时,需要在对象上显式配置 handler
。
-
如果配置的
handler
是函数,则使用该函数。export default { data() { return { firstName: 'Mo', lastName: 'Deyan', fullName: 'Mo Deyan' } }, watch: { firstName: { handler: function (nv) { this.fullName = `${nv} ${this.lastName}` } } } }
-
如果配置的
handler
是字符串,则从上下文(当前组件实例)上获取同名的函数作为handler
。export default { data() { return { firstName: 'Mo', lastName: 'Deyan', fullName: 'Mo Deyan' } }, methods: { watchHandle(nv) { console.log(this) this.fullName = `${nv} ${this.lastName}` }, }, watch: { firstName: { handler: 'watchHandle' } } }
数组
当 raw
是数组时,Vue 会遍历这个数组把当前配置拆分为多个侦听器,新创建的侦听器的 key
不变,raw
变为数组的项,这里有一个递归逻辑,所以数组的每一项不仅可以是函数、字符串、对象,还可以是数组,但一般不建议继续使用数组,因为层级上去了,即耗费内存又降低了代码的可维护性。
有些年轻人不讲码德,来绕,来套娃,欺负我一个刚出生的小框架,这好吗?这不好,我劝这些年轻人耗子尾汁,好好反思,以后不要再犯这样的聪明,小聪明,啊...码林要以和为贵,要讲码德,不要乱搞套娃,谢谢朋友们。
export default {
data() {
return {
firstName: 'Mo',
lastName: 'Deyan',
fullName: 'Mo Deyan'
}
},
methods: {
watchHandle(nv) {
console.log(this)
this.fullName = `${nv} ${this.lastName}`
},
},
watch: {
firstName: ['watchHandle', function (nv) {
console.log(this)
console.log(`firstName: ${nv}`)
}]
}
}
在 Composition API 中使用
虽然 Options API 很好用,但也无法忽视其逻辑复用能力较弱的缺点,或许这个不太好用的 Composition API 会成为主流。针对 Composition API ,Vue 提供了两个侦听函数:watchEffect
和 watch
,下面我们来一一列举他们的使用方式(不涉及 WatchOptions
配置)。
对齐 Composition API 中与 watch
相关的术语:
source
: 有如下两种解释- 使用
watchEffect
时,表示被侦听的函数,示例代码中的匿名函数 - 使用
watch
时,表示被侦听的响应式变量,示例代码中的count
- 使用
callback
: 侦听值变化时 Vue 要执行的回调函数,示例中的(count, prevCount) => {}
effect
: 侦听值变化时 Vue 要执行的副作用函数- 使用
watchEffect
时,可以等同于source
函数(被effect
包裹) - 使用
watch
时,可以等同于callback
参数(被effect
包裹)
- 使用
示例代码:
const count = ref(0)
watchEffect(() => {
console.log(count)
})
watch(count, (count, prevCount) => {})
watchEffect
一种相对简单的监听 API,你不用显式指定要监听的响应式数据,你只需传入一个函数 source
,然后 Vue 会在初始化时执行 source
,执行过程中会触发函数中响应式数据的 get
并收集当前 effect
为依赖。
setup() {
const count = ref(0)
watchEffect(() => console.log(count.value) // -> logs 0
setTimeout(() => {
count.value++ // -> logs 1
}, 100)
}
watch
watch
API 至少需要指定两个参数: source
和 callback
,其中 callback
被明确指定只能为函数,所以不同是用方式的差别其实只在 source
。
响应式数据
source
是由 ref/reactive
初始化的响应式数据时,使用方式如下:
setup() {
// ref
const count = ref(0)
watch(count, (nv, ov) => {
console.log(`watch count: ${nv}`)
})
// reactive
const info = reactive({
firstName: 'mo',
lastName: 'dy'
})
watch(info, (nv, ov) => {
console.log(`watch info fullName: ${nv.firstName} ${nv.lastName}`)
})
},
函数
source
是函数时,使用方式如下:
setup() {
const count = ref(0)
watch(() => count.value, (nv, ov) => {
console.log(`watch count: ${nv}`)
})
},
数组
source
是数组时,数组元素可以是函数和响应式数据之一(不能为数组,限制套娃),使用方式如下:
setup() {
const count = ref(0)
const info = reactive({
firstName: 'mo',
lastName: 'dy'
})
watch([() => count.value, info], ([newCount, newInfo], [oldCount, oldInfo]) => {
console.log(`watch array: \n newCount: ${newCount}, newFullName: ${newInfo.firstName} ${newInfo.lastName}`)
})
},
侦听器源码
由于在用法中我们区分了 Options API 和 Composition API ,所以在源码我们也做如下区分:
- Watch Options API 源码阅读
- Watch Function API 源码阅读
- Watch Common 源码阅读
Options API
在上一篇文章看懂 Hello World(Vue3)中我们说过,在 mountComponent
函数中,我们会调用 applyOptions
处理 Options API 的配置,所以我们来看看 applyOptions
函数中关于侦听器的处理。
applyOptions
packages/runtime-core/src/componentOptions.ts
function applyOptions(...) {
...
// 如果存在 watch 配置项,则将其 push 到 deferredWatch
if (watchOptions) {
deferredWatch.push(watchOptions)
}
// 如果当前不是在处理 mixin 的 Options API 配置项,则开始逐个处理 watch 配置
if (!asMixin && deferredWatch.length) {
deferredWatch.forEach(watchOptions => {
for (const key in watchOptions) {
createWatcher(watchOptions[key], ctx, publicThis, key)
}
})
}
...
}
可以看到,watchOptions
被 push
到了一个名为 deferredWatch
的数组中,然后等到确认当前处理的 Options 是不是 Mixins 提供的,就开始遍历 deferredWatch
逐个调用 createWatcher
处理之前收集的 Watch Options 。
Watch Options 包括两类:
- Mixins 中配置的侦听器
- 当前组件中配置的侦听器
createWatcher
packages/runtime-core/src/componentOptions.ts
function createWatcher(
raw: ComponentWatchOptionItem,
ctx: Data,
publicThis: ComponentPublicInstance,
key: string
) {
// 处理 key ,可以看到 key 是可以使用点号访问符的
const getter = key.includes('.')
? createPathGetter(publicThis, key)
: () => (publicThis as any)[key]
if (isString(raw)) {
// 如果 raw 是字符串,则从当前上下文上获取侦听器的回调函数
const handler = ctx[raw]
if (isFunction(handler)) {
watch(getter, handler as WatchCallback)
} else if (__DEV__) {
warn(`Invalid watch handler specified by key "${raw}"`, handler)
}
} else if (isFunction(raw)) {
// 如果 raw 是函数,则调用 watch API 执行侦听
watch(getter, raw.bind(publicThis))
} else if (isObject(raw)) {
if (isArray(raw)) {
// 如果 raw 是数组,则递归创建侦听器
raw.forEach(r => createWatcher(r, ctx, publicThis, key))
} else {
// 如果 raw 是非数组对象,则获取显示的 handler 配置,配置可以是函数也可以是字符串
const handler = isFunction(raw.handler)
? raw.handler.bind(publicThis)
: (ctx[raw.handler] as WatchCallback)
if (isFunction(handler)) {
watch(getter, handler, raw)
} else if (__DEV__) {
warn(`Invalid watch handler specified by key "${raw.handler}"`, handler)
}
}
} else if (__DEV__) {
warn(`Invalid watch option: "${key}"`, raw)
}
}
可以看到 createWatcher
函数主要是在处理 key
和 raw
配置,这也印证了上文 Options API 中侦听器的几种使用方式:
- 如果
raw
是字符串,则从当前上下文上获取侦听器的回调函数 - 如果
raw
是函数,则调用watch
API 执行侦听 - 如果
raw
是非数组对象,则获取显式配置的handler
选项- 如果配置的
handler
是函数,则使用该函数 - 如果配置的
handler
是字符串,则从上下文(当前组件实例)上获取同名的函数作为handler
- 如果配置的
- 如果
raw
是数组,Vue 会遍历这个数组把当前配置拆分为多个侦听器,新创建的侦听器的key
不变,raw
变为数组的项
实际执行侦听的 watch
函数,就是下面我们要继续阅读的 Watch Function API 之一。
Function API
Vue3 目前提供的 Watch Function API 有两个:watchEffect
和 watch
,用法差异在上文有所体现。
watchEffect
packages/runtime-core/src/apiWatch.ts
function watchEffect(
effect: WatchEffect,
options?: WatchOptionsBase
): WatchStopHandle {
return doWatch(effect, null, options)
}
函数 watchEffect
未做任何处理,直接调用了 doWatch
,它属于 Watch Common 所以我们之后再看。
watch
packages/runtime-core/src/apiWatch.ts
function watch<T = any, Immediate extends Readonly<boolean> = false>(
source: T | WatchSource<T>,
cb: any,
options?: WatchOptions<Immediate>
): WatchStopHandle {
if (__DEV__ && !isFunction(cb)) {
warn(
`\`watch(fn, options?)\` signature has been moved to a separate API. ` +
`Use \`watchEffect(fn, options?)\` instead. \`watch\` now only ` +
`supports \`watch(source, cb, options?) signature.`
)
}
return doWatch(source as any, cb, options)
}
函数 watch
在开发环境下,如果传入的 callback
不是函数会打印警告提示,然后也调用了 doWatch
函数。
Common
接下来我们来看 Watch 相关的公共逻辑,即不管开发者使用的是 Options API 还是 Composition API ,都会使用到的逻辑,从上文可以看到,它们之间的关系是这样的:
doWatch
function doWatch(
source: WatchSource | WatchSource[] | WatchEffect | object,
cb: WatchCallback | null,
{ immediate, deep, flush, onTrack, onTrigger }: WatchOptions = EMPTY_OBJ,
instance = currentInstance
): WatchStopHandle {
}
这个函数是实现侦听器的核心函数,所以内容也比较多,为了更好分析,我将函数功能拆分为以下五个:
- 生成取值函数
getter
,需要提醒的是响应式数据在取值时会收集依赖(依赖就是副作用函数runner
) - 生成调度器
scheduler
,它用于决定执行callback
的方式(上文说过callback
是侦听值变化时执行的回调函数) - 生成副作用函数
runner
(叫effect
或许更好) - 侦听器初始化
- 返回一个停止侦听的函数
生成取值函数
// 根据不同入参类型,生成统一标准的 getter 函数
let getter: () => any
let forceTrigger = false
if (isRef(source)) {
// 如果是 ref 则返回 ref.value
getter = () => (source as Ref).value
forceTrigger = !!(source as Ref)._shallow
} else if (isReactive(source)) {
// 如果是 reactive 则返回 reactive 并设置 deep 为 true
getter = () => source
deep = true
} else if (isArray(source)) {
// 如果是数组则则返回一个新的数组,新数组由 source 每一项取到的值
getter = () =>
source.map(s => {
if (isRef(s)) {
return s.value
} else if (isReactive(s)) {
return traverse(s)
} else if (isFunction(s)) {
return callWithErrorHandling(s, instance, ErrorCodes.WATCH_GETTER)
} else {
__DEV__ && warnInvalidSource(s)
}
})
} else if (isFunction(source)) {
if (cb) {
// 如果是函数,并且调用的是 watch API ,则给 source 包裹一个错误处理函数
// getter with cb
getter = () =>
callWithErrorHandling(source, instance, ErrorCodes.WATCH_GETTER)
} else {
// no cb -> simple effect
getter = () => {
if (instance && instance.isUnmounted) {
return
}
if (cleanup) {
cleanup()
}
return callWithErrorHandling(
source,
instance,
ErrorCodes.WATCH_CALLBACK,
[onInvalidate]
)
}
}
} else {
getter = NOOP
__DEV__ && warnInvalidSource(source)
}
// 如果 deep 为 true 则调用 traverse 递归访问 Array/Object/Set/Map 的子级
// 访问响应式对象(get trap)就会收集依赖
// 所以重点有两个:
// 1. getter 在何时调用的
// 2. 收集的依赖和谁关联
if (cb && deep) {
const baseGetter = getter
getter = () => traverse(baseGetter())
}
可以看到在生成 getter
时的一系列判断,也印证了上文侦听器在 Composition API 中使用的几种使用方式:
- 如果
source
是ref
则在生成getter
函数时返回source.value
- 如果
source
是reactive
则需要设置deep = true
- 如果
source
是数组,则getter
返回一个新的数组,新数组由source
每一项取到的值 - 如果
source
是函数,那就区分watchEffect
和watch
两个 API ,生成不同的取值函数 - 如过
deep
为true
,则调用traverse
函数返回一个新的getter
相信前四点都不会有疑问,唯有第五点处理 deep
的逻辑还需要更多一点的解释。让我们先思考一下 deep
的作用,是为了让响应式数据子级发生变动时通知执行 callback
,既然存在通知,就需要先让每个子级都能收集到依赖,那么 traverse
是如何实现的呢?
function traverse(value: unknown, seen: Set<unknown> = new Set()) {
if (!isObject(value) || seen.has(value)) {
return value
}
seen.add(value)
if (isRef(value)) {
traverse(value.value, seen)
} else if (isArray(value)) {
for (let i = 0; i < value.length; i++) {
traverse(value[i], seen)
}
} else if (isSet(value) || isMap(value)) {
value.forEach((v: any) => {
traverse(v, seen)
})
} else {
for (const key in value) {
traverse(value[key], seen)
}
}
return value
}
可以看到 traverse
函数的主要功能是递归取响应是对象的值,因为是响应是对象,所以在取值时就能完成依赖收集,并且维护一个 Set 来避免重复获取同一个对象的值。
生成调度器
let oldValue = isArray(source) ? [] : INITIAL_WATCHER_VALUE
const job: SchedulerJob = () => {
if (!runner.active) {
return
}
if (cb) {
// watch(source, cb)
const newValue = runner()
if (deep || forceTrigger || hasChanged(newValue, oldValue)) {
// cleanup before running cb again
if (cleanup) {
cleanup()
}
pauseTracking()
callWithAsyncErrorHandling(cb, instance, ErrorCodes.WATCH_CALLBACK, [
newValue,
// pass undefined as the old value when it's changed for the first time
oldValue === INITIAL_WATCHER_VALUE ? undefined : oldValue,
onInvalidate
])
resetTracking()
oldValue = newValue
}
} else {
// watchEffect
runner()
}
}
// important: mark the job as a watcher callback so that scheduler knows
// it is allowed to self-trigger (#1727)
job.allowRecurse = !!cb
let scheduler: ReactiveEffectOptions['scheduler']
// 判断 flush 选项绝对调度器行为
// sync: 数据变化时同步直接调用 cb
// post: 推迟调用 cb 到更新后
// pre: 默认行为,在更新前调用,如果组件不存在或没挂载则立即执行
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 {
// with 'pre' option, the first call must happen before
// the component is mounted so it is called synchronously.
job()
}
}
}
生成调度器这段代码也可以分为两段:
- 生成调度任务
- 生成调度器
生成调度任务没啥好说的,就是分别处理 watchEffect
和 watch
两个 API ,前者就是副作用函数 runner
本身(在内部执行上文生成的取值函数 getter
),后者则执行 callback
。
生成调度器时就和 watchOptions
中的 flush
配置项相关了:
flush === 'sync'
时,同步,表示直接执行调度任务job
flush === 'post'
时,异步,表示把调度任务塞到pendingPostFlushCbs
全局数组中,在本次渲染完成后才去执行它们flush === 'post'
时,默认值、异步,表示把调度任务塞到pendingPreFlushCbs
全局数组中,在更新前去执行它们。当然还有一种特殊情况,即组件还是未挂载的状态,那么就会同步执行调度任务,等同于sync
。
生成副作用函数
// 生成 effect 函数(对象)
const runner = effect(getter, {
lazy: true,
onTrack,
onTrigger,
scheduler
})
调用 effect
生成副作用函数,下一小节再针对 effect
函数作详细解析。
侦听器初始化
// initial run
if (cb) {
if (immediate) {
// 如果 immediate 为 true 则执行 watch 的 callback
job()
} else {
// 否则执行副作用函数
oldValue = runner()
}
} else if (flush === 'post') {
queuePostRenderEffect(runner, instance && instance.suspense)
} else {
runner()
}
可以看到初始化流程其实很简单:
- 如果存在
callback
且immediate === true
则直接执行一次调度任务(调度任务中也会执行副作用函数,获取最新的值) - 如果存在
callback
且immediate !== true
则执行副作用函数,副作用函数会执行上方生成的getter
方法,并收集当前的副作用函数 - 如果不存在
callback
(是watchEffect
API)且flush === 'post'
则把副作用函数添加到pendingPostFlushCbs
全局数组中 - 如果不存在
callback
(是watchEffect
API)且flush !== 'post'
则直接执行副作用函数
返回一个停止侦听的函数
return () => {
stop(runner)
if (instance) {
remove(instance.effects!, runner)
}
}
可以看到,停止侦听做了两件事:
- 调用
stop
处理副作用函数 - 把副作用函数从当前组件实例中移除
// 使副作用失效
function stop(effect: ReactiveEffect) {
if (effect.active) {
cleanup(effect)
if (effect.options.onStop) {
effect.options.onStop()
}
effect.active = false
}
}
// 从赖管理器中移除自身
// effect.deps 存放的是和自身相关的依赖管理器
// 当依赖管理器中存在这个副作用函数时,该副作用函数也会反向引用这个依赖管理器
function cleanup(effect: ReactiveEffect) {
const { deps } = effect
if (deps.length) {
for (let i = 0; i < deps.length; i++) {
deps[i].delete(effect)
}
deps.length = 0
}
}
可以看到 stop
函数的功能并不复杂:
- 改变
effect.active
的值为false
,使副作用函数失效 - 调用
cleanup
从赖管理器中移除自身 - 调用
onStop
回调
关于副作用函数上的 deps
属性,它存放的元素是 Set 集合 ,每个 Set 集合都是一个响应式数据的依赖收集器,它们又存放着和对应响应式数据相关的副作用函数,所以这里是一个双向引用的关系。那么这个关系是怎么形成的呢?在响应式数据收集这些副作用函数的同时,副作用函数也会把当前 Set 集合(响应式数据的依赖收集器)存放到它的 deps
数组中,这样做的意义是减少 cleanup
时的遍历移除依赖的性能损耗。
targetMap
是全局依赖管理器,deps{N}
是单个响应式数据关联的依赖收集器,effect
是副作用函数,他们之间的关系描述如下:
effect
Vue3 总是在说副作用,那么副作用究竟是什么呢?这里也分享一下我的浅薄之见,希望能对你理解 Vue3 的响应式有一定帮助,为了更好地解释,我将其拆分为两个点:
副作用函数的定义:副作用函数可以认定为是一个代理函数,它在执行原始函数前会记录一些运行时标记,并且把这些标记存放在代理函数上(比如 id
),而这些原始函数不相关的额外逻辑就是副作用。
副作用函数扮演的角色:副作用函数是响应式数据收集的依赖,即代表响的两大能力(依赖收集、派发更新),都只是在决定如何处置副作用函数,所以副作用函数承单了 Vue2 中的 Watcher
对象的部分角色。Watcher
在 Vue2 中的其他职责也被拆分成了独立的模块,比如依赖收集和派发更新在 Vue3 中被封装成了独立的 track/trigger
方法,再比如依赖收集器现在被存放在一个全局维护的 WeakMap 中。
接下来我们来分析生成副作用函数的 effect
函数实现。
packages/reactivity/src/effect.ts
function effect<T = any>(
fn: () => T,
options: ReactiveEffectOptions = EMPTY_OBJ
): ReactiveEffect<T> {
if (isEffect(fn)) {
fn = fn.raw
}
const effect = createReactiveEffect(fn, options)
// 如果不是滞后的、异步的副作用函数,则立即执行
if (!options.lazy) {
effect()
}
return effect
}
可以看到 effect
函数其实是调用 createReactiveEffect
函数生成的,并且如果没有配置 lazy
属性,就会立即执行生成的副作用函数。
function createReactiveEffect<T = any>(
fn: () => T,
options: ReactiveEffectOptions
): ReactiveEffect<T> {
const effect = function reactiveEffect(): unknown {
// 如果副作用函数失效就返回原函数执行结果,因为没有开始跟踪就不会进行依赖收集
// 这里还有个 scheduler 的判断,应该是为了处理特殊情况
if (!effect.active) {
return options.scheduler ? undefined : fn()
}
if (!effectStack.includes(effect)) {
cleanup(effect)
try {
enableTracking()
effectStack.push(effect)
activeEffect = effect
return fn()
} finally {
effectStack.pop()
resetTracking()
activeEffect = effectStack[effectStack.length - 1]
}
}
} as ReactiveEffect
effect.id = uid++
effect.allowRecurse = !!options.allowRecurse
effect._isEffect = true
effect.active = true
effect.raw = fn
effect.deps = []
effect.options = options
return effect
}
这里就可以看到副作用函数的真容了,也更能理解 Javascript 函数即对象了,它也拥有很多属性: id
、active
、deps
、 ... 回到函数本身:
- 如果副作用函数失效就返回原函数执行结果,抛弃副作用
- 如果副作用函数未被处理,则在执行原始函数前开启依赖追踪,这意味着如果原始函数中有使用响应式数据,就会收集当前的副作用函数
- 在副作用函数上存放一些运行时标记
trigger
通过上文对侦听器和副作用函数的介绍,我们知道 watch 在初始化时收集了关联的副作用函数,但这些函数在什么时候被执行呢?我们还知道如果侦听值发生变化,要重新执行的是 callback
函数,但我们收集的是副作用函数,它又是怎么关联执行 callback
的呢?
关于第一个问题,我们需要了解响应式的原理或者规则,即收集的依赖会在数据变化时被处理,所以先来看看在响应式数据的 set trap 吧。
packages/reactivity/src/ref.ts
set value(newVal) {
if (hasChanged(toRaw(newVal), this._rawValue)) {
this._rawValue = newVal
this._value = this._shallow ? newVal : convert(newVal)
trigger(toRaw(this), TriggerOpTypes.SET, 'value', newVal)
}
}
可以看到在给响应式数据设置新值时,如果新值有改变,则会调用 trigger
函数,会传入参数 TriggerOpTypes.SET
。
我们来继续阅读 trigger
函数,补上侦听流程的最后一环。
packages/reactivity/src/effect.ts
export function trigger(
target: object,
type: TriggerOpTypes,
key?: unknown,
newValue?: unknown,
oldValue?: unknown,
oldTarget?: Map<unknown, unknown> | Set<unknown>
) {
// 获取单个数据的依赖管理器,一个存放依赖的 Map
const depsMap = targetMap.get(target)
...
// 临时收集器,存放要本次 trigger 要处理的依赖
const effects = new Set<ReactiveEffect>()
// 向临时收集器中添加依赖
const add = (effectsToAdd: Set<ReactiveEffect> | undefined) => {
if (effectsToAdd) {
effectsToAdd.forEach(effect => {
if (effect !== activeEffect || effect.allowRecurse) {
effects.add(effect)
}
})
}
}
...
// 把依赖管理器中对应 key 值的依赖添加到临时收集器
if (key !== void 0) {
add(depsMap.get(key))
}
switch (type) {
...
case TriggerOpTypes.SET:
if (isMap(target)) {
add(depsMap.get(ITERATE_KEY))
}
}
...
const run = (effect: ReactiveEffect) => {
// 触发 onTrigger 回调
if (__DEV__ && effect.options.onTrigger) {
effect.options.onTrigger({
effect,
target,
key,
type,
newValue,
oldValue,
oldTarget
})
}
// 如果存在调度器,则执行调度器
// 不存在则执行副作用函数
if (effect.options.scheduler) {
effect.options.scheduler(effect)
} else {
effect()
}
}
// 遍历依赖并执行 run
effects.forEach(run)
}
可以看到 trigger
函数的主要职责就是执行副作用函数,有一点需要注意的是如果副作用函数有配置调度器 scheduler
,而本文讨论的侦听器在生成副作用函数时都是存在调度器的,所以上面提到的问题也有了答案:副作用函数通过调度器和 callback
产生关联。让我们回顾一下 scheduler
的实现,以默认值 flush === 'pre'
举例分析:
// default: 'pre'
scheduler = () => {
if (!instance || instance.isMounted) {
queuePreFlushCb(job)
} else {
// with 'pre' option, the first call must happen before
// the component is mounted so it is called synchronously.
job()
}
}
调度任务 job
就不再放代码了,忘记了的可以往上翻翻,总之就是一个执行 callback
的函数,调度器会在组件挂载的情况下,调用 queuePreFlushCb
把调度任务加入到 pendingPreFlushCbs
全局队列(数组)。
queuePreFlushCb
function queuePreFlushCb(cb: SchedulerCb) {
queueCb(cb, activePreFlushCbs, pendingPreFlushCbs, preFlushIndex)
}
// 把调度任务加入到预刷新队列、并触发队列刷新
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 {
// if cb is an array, it is a component lifecycle hook which can only be
// triggered by a job, which is already deduped in the main queue, so
// we can skip duplicate check here to improve perf
pendingQueue.push(...cb)
}
// 异步执行队列中的调度任务
queueFlush()
}
可以看到 queueCb
函数有两个职责:
- 把调度任务加入到队列(比如
pendingPreFlushCbs
) - 调用
queueFlush
异步执行队列中的调度任务
queueFlush
function queueFlush() {
if (!isFlushing && !isFlushPending) {
isFlushPending = true
// 异步执行队列中的调度任务
currentFlushPromise = resolvedPromise.then(flushJobs)
}
}
这里的异步,是把 flushJobs
添加到微任务队列中去,这里比较难理解的点是 isFlushing
和 isFlushPending
两个值得含义,这里解释下:
isFlushing
为true
表示目前处于执行flushJobs
阶段isFlushPending
为true
表示目前处于执行queueFlush
但还未进入flushJobs
(也就是说主线程还未执行完毕,还不到执行微任务) 之所以需要判断这两个时机,是为了确保flushJobs
只被执行一次。多次调用queueFlush
是很正常的情况(当调度任务中存在调度器时),没必要多次添加同一个微任务,当然即使执行多次目前看来也不会造成太多性能损耗,但调度器究竟会嵌套多少次也是无法预知的,避免添加无意义的微任务也让代码更具健壮性。
flushJobs
function flushJobs(seen?: CountMap) {
isFlushPending = false
isFlushing = true
if (__DEV__) {
seen = seen || new Map()
}
// 清空 pendingPreFlushCbs
flushPreFlushCbs(seen)
// Sort queue before flush.
// This ensures that:
// 1. Components are updated from parent to child. (because parent is always
// created before the child so its render effect will have smaller
// priority number)
// 2. If a component is unmounted during a parent component's update,
// its update can be skipped.
queue.sort((a, b) => getId(a) - getId(b))
// 清空 queue
try {
for (flushIndex = 0; flushIndex < queue.length; flushIndex++) {
const job = queue[flushIndex]
if (job) {
if (__DEV__) {
checkRecursiveUpdates(seen!, job)
}
callWithErrorHandling(job, null, ErrorCodes.SCHEDULER)
}
}
} finally {
flushIndex = 0
queue.length = 0
// 清空 pendingPostFlushCbs
flushPostFlushCbs(seen)
isFlushing = false
currentFlushPromise = null
// some postFlushCb queued jobs!
// keep flushing until it drains.
if (queue.length || pendingPostFlushCbs.length) {
flushJobs(seen)
}
}
}
本函数会执行所有调度任务,包括三个队列:
pendingPreFlushCbs
queue
pendingPostFlushCbs
组件更新的副作用函数的调度器就是 queueJob
,即关于组件更新的调度任务是放到 queue
这个队列的,这里也印证了上文说明的 pre
和 post
顺序问题, pre -> component update -> post
。其实在组建更新流程中、挂载前,还会执行 flushPreFlushCbs
清空一次 pendingPreFlushCbs
队列,因为在更新过程中或许会触发侦听器(props update),这里不细说。
创建组件更新副作用函数时使用的配置项如下:
const prodEffectOptions = {
scheduler: queueJob,
allowRecurse: true
}
至此,Vue3 侦听器流程已经完成了闭环。
总结
关联PR
此文在创作期间提交的 PR
结语
写文章真的是耗费时间的一件事,并且即使我花了大量时间去描述(有时候一周都写不完一篇),也并不一定能帮助到别人,我认为这其中的阻碍有两点:
- 思维差异
- 理解不足
但这两点都不是很容易解决的问题,所以我虽然尽量在写,但前几篇文章也反馈寥寥,我想或许应该有点改变。所以希望看到这里的朋友,能针对本文给点建议,希望最终它能成为一篇合格的侦听器原理入门文章。