在 Vue 3.x 的 Composition API 中,我们可以用近似 React Hooks 的方式组织代码的复用;ref/reactive/computed 等好用的响应式 API 函数可以摆脱组件的绑定,抽离出来被随处使用了。
传统上在 Vue 2.x Options API 的实践中,不太推荐过多使用组件定义中的 watch 属性 -- 理由是除了某些新旧值比较和页面副作用以外,用 computed 值会更“合理”。
而随着新 API 的使用,由于“生命周期”概念大幅收窄为“副作用”,故新的独立 watch/watchEffect 函数使用频率大大增加了,并且其更灵活的函数形式也让它使用起来愈加方便;不过或许正是这种“不习惯”和过度灵活,让我们在读过一遍官网文档后仍会有所疑问。
本文就将尝试聚焦于 Composition API 中的 watch/watchEffect,希望通过对相应模块的单元测试进行解读和归纳,并结合适度解析一部分源码,大抵上能够达到对其有更直观全面的了解、使用起来心中有数的效果;至于更深层次、更全面的框架层面原理等,请读者老爷们根据需要自行了解罢。
我们将要观察三个代码仓库,分别是
vue
- Vue 2.x 项目@vue/composition-api
- 结合 Vue 2.x “提前尝鲜” Composition API 的过渡性项目vue-next
- Vue 3.x 项目,本文分析的是其 3.0.0-beta.15 版本@vue/composition-api
是 Vue 3.x 尚不可用时代的替代产物,选择从该项目入手分析的主要原因有:
此次谈论的主要是使用在 vue 组件 setup() 入口函数中的 watch/watchEffect 方法;涉及文件包括 test/apis/watch.spec.js
、src/apis/watch.ts
等。
"watch API 完全等效于 2.x this.$watch (以及 watch 中相应的选项)。watch 需要侦听特定的数据源,并在回调函数中执行副作用。默认情况是懒执行的,也就是说仅在侦听的源变更时才执行回调。"
这里先适当考察一下源码中暴露的 watch() 函数相关的几种签名形式和参数设置,有利于理解后面的用例调用
(目标数组 sources, 回调 cb, 可选选项 options) => stopFn
function watch<
T extends Readonly<WatchSource<unknown>[]>,
Immediate extends Readonly<boolean> = false
>(
sources: T,
cb: WatchCallback<MapSources<T>, MapOldSources<T, Immediate>>,
options?: WatchOptions<Immediate>
): WatchStopHandle
(单一基本类型目标 source, 回调 cb, 可选选项 options) => stopFn
function watch<T, Immediate extends Readonly<boolean> = false>(
source: WatchSource<T>,
cb: WatchCallback<T, Immediate extends true ? T | undefined : T>,
options?: WatchOptions<Immediate>
): WatchStopHandle
(响应式对象单目标 source, 回调 cb, 可选选项 options) => stopFn
function watch<
T extends object,
Immediate extends Readonly<boolean> = false
>(
source: T,
cb: WatchCallback<T, Immediate extends true ? T | undefined : T>,
options?: WatchOptions<Immediate>
): WatchStopHandle
(回调 effect, 可选选项 options) => stopFn
⚠️注意:这时就需要换成调用 watchEffect() 了
"立即执行传入的一个函数,并响应式追踪其依赖,并在其依赖变更时重新运行该函数"
function watchEffect(
effect: WatchEffect,
options?: WatchOptionsBase
): WatchStopHandle
options
为 { immediate: true }
的情况下,cb
立即执行一次,观察到从旧值 undefined 变为默认值的过程cb(newV, oldV, onCleanup)
中的第三个参数 onCleanup 并不执行source
为单一的基本类型,且 options
为 { flush: 'post', immediate: true }
的情况下,cb
立即执行一次,观察到从旧值 undefined 变为默认值的过程source
为 () => a.value
且 options
为 { immediate: true }
的情况下const a = ref(1)
进行 watch()cb(2, 1)
的参数被执行{ lazy: true }
的情况下,cb
并不会执行const a = ref({ b: 1 })
,options 为 { lazy: true, deep: true }
vm.a.b = 2
的形式对 a 赋值,此时由于是 lazy 模式所以 cb
仍并不会执行cb({b: 2}, {b: 2})
的参数被调用,显然以上赋值方式未达到预期vm.a = { b: 3 }
的形式对 a 赋值,在下一个 nextTick 中,回调正确地以 cb({b: 3}, {b: 2})
的参数被调用{ lazy: true }
cb
只运行一次且新旧参数正确,模板中也正确渲染出新值{ immediate: true }
cb
立即观察到从 undefined 到默认值的变化cb
再次运行且新旧参数正确,模板中也正确渲染出新值{ lazy: true, flush: 'pre' }
cb
首次运行且新旧参数正确,但在 cb
内部访问到的模板渲染值仍是旧值 -- 说明 cb
在模板重新渲染之前被调用了{ lazy: true, flush: 'sync' }
cb
由于指定了 lazy: true 而不会被默认调用cb
cb
调用次数,恰为 n{ flush: 'sync', immediate: true })
,观察响应式对象 const count = ref(0)
count.value++
cb
就被调用了两次cb(0, undefined)
cb(1, 0)
watchEffect(() => { void x.value; _echo('sync1'); }, { flush: 'sync' });
watchEffect(() => { void x.value; _echo('pre1'); }, { flush: 'pre' });
watchEffect(() => { void x.value; _echo('post1'); }, { flush: 'post' });
watch(x, () => { _echo('sync2') }, { flush: 'sync', immediate: true })
watch(x, () => { _echo('pre2') }, { flush: 'pre', immediate: true })
watch(x, () => { _echo('post2') }, { flush: 'post', immediate: true })
const inc = () => {
result.push('before_inc')
x.value++
result.push('after_inc')
}
before_inc
-> sync1
-> sync2
-> after_inc
-> pre1
-> pre2
-> post1
-> post2
effect
回调被立即执行effect()
函数中,能访问到目标值registerCleanup(fn) => void
effect
回调中能访问到目标的初始值effect
回调中能访问到目标的新值{ flush: 'sync' }
的情况下effect
回调被立即执行并访问到目标值effect
回调能立即执行并访问到新值{ immediate: true }
时cb
被立即调用一次,观察到值从 undefined 到 sources 初始值数组的变化cb
又被调用一次,观察到最后一次赋值的变化cb
又被调用一次,观察到最后一次赋值的变化{ flush: 'post', immediate: true }
时cb
被立即调用一次,观察到值从 undefined 到 sources 初始值数组的变化cb
,并没有新的调用cb
又被调用一次,并观察到目标值的变化cb
又被调用一次,并观察到目标值的变化{ flush: 'post', immediate: false }
时cb
并未被立即调用cb
被调用一次,并观察到目标值最新的变化cb
又被调用一次,并观察到目标值的变化{ flush: 'sync', immediate: true }
时cb
被立即调用一次,观察到值从 undefined 到 sources 初始值数组的变化cb
,应又被调用一次,并观察到目标值新的变化cb
,应被调用了 n 次,且每次都能正确观察到值的变化{ lazy: true, flush: 'sync' }
时cb
并未被立即调用cb
,应又被调用一次,并观察到目标值新的变化cb
,应被调用了 n 次,且每次都能正确观察到值的变化{ immediate: true }
时cb
被立即调用一次,观察到目标值从 undefined 到初始值的变化cb
又被调用一次,并观察到目标值新的变化effect
被立即调用一次effect
没有新的调用,且此时 effect
中访问到的是目标初始值effect
有一次新的调用,且此时 effect
中访问到的是目标新值effect
的形式为 (onCleanup: fn => void) => voideffect
应被调用watch(source, cb: (newV, oldV, onCleanup) => void, { immediate: true }) => stop
观察一个响应式对象cb
立即被调用watchEffect(effect: (onCleanup) => void) => stop
观察响应式对象 ref1{ immediate: true }
cb
的形式为 (newV, oldV, onCleanup: fn => void) => void无论是 watch() 还是 watchEffect() 最终都是利用 vue 2 中的 Watcher 类构建的。
watcher.lazy = false
在 Watcher 类构造函数中,lazy 属性会赋给实例本身,也会影响到 dirty 属性:
if (options) {
this.lazy = !!options.lazy
...
}
...
this.dirty = this.lazy // for lazy watchers
...
this.value = this.lazy
? undefined // 懒加载,实例化后不立即取值
: this.get()
以及 Watcher 类相关的一些方法中:
update () {
if (this.lazy) {
this.dirty = true
} else if (this.sync) {
this.run()
} else {
queueWatcher(this)
}
}
...
/**
* Evaluate the value of the watcher.
* This only gets called for lazy watchers.
*/
evaluate () {
this.value = this.get()
this.dirty = false
}
而后,会异步地通过 Vue.prototype._init
--> initState
--> initComputed
--> defineComputed
--> createComputedGetter()
--> watcher.evaluate()
--> watcher.get()
取值:
// src/core/instance/state.js
if (watcher.dirty) {
watcher.evaluate()
}
这里把 get() 稍微单说一下,同样是 Watcher 类中:
// src/core/observer/watcher.js
import { traverse } from './traverse'
import Dep, { pushTarget, popTarget } from './dep'
...
/**
* Evaluate the getter, and re-collect dependencies.
*/
get () {
pushTarget(this)
let value
const vm = this.vm
try {
value = this.getter.call(vm, vm)
} catch (e) {
...
} finally {
if (this.deep) {
// 深层遍历
traverse(value)
}
popTarget()
this.cleanupDeps()
}
return value
}
watcher 依靠 deps、newDeps 等数组维护依赖关系,如添加依赖就是通过 dep.depend()
--> watcher.addDep()
。
这里涉及到的几个主要函数(pushTarget、popTarget、cleanupDeps)都和 Dep 依赖管理相关,由其管理监听顺序、通知 watcher 实例 update() 等。
// src/apis/watch.ts
const createScheduler = <T extends Function>(fn: T): T => {
if ( isSync || fallbackVM ) {
return fn
}
return (((...args: any[]) =>
queueFlushJob(
vm,
() => {
fn(...args)
},
flushMode as 'pre' | 'post'
)) as any) as T
}
...
function installWatchEnv(vm: any) {
vm[WatcherPreFlushQueueKey] = []
vm[WatcherPostFlushQueueKey] = []
vm.$on('hook:beforeUpdate', flushPreQueue)
vm.$on('hook:updated', flushPostQueue)
}
Vue.prototype.$watch
中,逻辑很简单,只要是 true 就在实例化 Watcher 后立即执行一遍就完事了;其相关部分的源码如下:const watcher = new Watcher(vm, expOrFn, cb, options)
if (options.immediate) {
try {
cb.call(vm, watcher.value)
} catch (error) {
handleError(error, vm, `callback for immediate watcher "${watcher.expression}"`)
}
}
"watch 和 watchEffect 在停止侦听, 清除副作用 (相应地 onInvalidate 会作为回调的第三个参数传入),副作用刷新时机 和 侦听器调试 等方面行为一致" -- Composition API 文档
// src/apis/watch.ts
// 即 watch() 的参数二 `cb` 的参数三(前俩是 newValue、oldValue)
// 或 watchEffect() 的参数一 `effect` 的唯一参数
const registerCleanup: InvalidateCbRegistrator = (fn: () => void) => {
cleanup = () => {
try {
fn()
} catch (error) {
logError(error, vm, 'onCleanup()')
}
}
}
// 下文中运行时间点中真正被执行的
const runCleanup = () => {
if (cleanup) {
cleanup()
cleanup = null
}
}
在 watch 的情况下,cb
回调中的 cleanup 会在两个时间点被调用:
一个是每次 cb
运行之前:
const applyCb = (n: any, o: any) => {
// cleanup before running cb again
runCleanup()
cb(n, o, registerCleanup)
}
// sync 立即执行 cb,或推入异步队列
let callback = createScheduler(applyCb)
二是 watcher 卸载时:
// src/apis/watch.ts
function patchWatcherTeardown(watcher: VueWatcher, runCleanup: () => void) {
const _teardown = watcher.teardown
watcher.teardown = function (...args) {
_teardown.apply(watcher, args)
runCleanup()
}
}
在 watchEffect 的情况下,cb
回调中的 cleanup (这种情况下也称为 onInvalidate,失效回调)同样会在两个时间点被调用:
// src/apis/watch.ts
const watcher = createVueWatcher(vm, getter, noopFn, {
deep: options.deep || false,
sync: isSync,
before: runCleanup,
})
patchWatcherTeardown(watcher, runCleanup)
首先是遍历执行每个 watcher 时, cleanup 被注册为 watcher.before,文档中称为“副作用即将重新执行时”:
// vue2 中的 flushSchedulerQueue()
for (index = 0; index < queue.length; index++) {
watcher = queue[index]
if (watcher.before) {
watcher.before()
}
...
}
其次也是 watcher 卸载时,文档中的描述为:“侦听器被停止 (如果在 setup() 或 生命周期钩子函数中使用了 watchEffect, 则在卸载组件时)”。
cb
函数在这里称为 effect
,成为了首个参数,而该回调现在只包含 onCleanup 一个参数options
默认的 options:
function getWatchEffectOption(options?: Partial<WatchOptions>): WatchOptions {
return {
...{
immediate: true,
deep: false,
flush: 'post',
},
...options,
}
}
调用 watchEffect() 时和传入的 options 结合:
export function watchEffect(
effect: WatchEffect,
options?: WatchOptionsBase
): WatchStopHandle {
const opts = getWatchEffectOption(options)
const vm = getWatcherVM()
return createWatcher(vm, effect, null, opts)
}
实际能有效传入的只有 deep 和 flush:
// createWatcher
const flushMode = options.flush
const isSync = flushMode === 'sync'
...
// effect watch
if (cb === null) {
const getter = () => (source as WatchEffect)(registerCleanup)
const watcher = createVueWatcher(vm, getter, noopFn, {
deep: options.deep || false,
sync: isSync,
before: runCleanup,
})
...
}
function createVueWatcher( vm, getter, callback, options ): VueWatcher {
const index = vm._watchers.length
vm.$watch(getter, callback, {
immediate: options.immediateInvokeCallback,
deep: options.deep,
lazy: options.noRun,
sync: options.sync,
before: options.before,
})
return vm._watchers[index]
}
关于这点也可以参考下文的 2.1 - test 23、test 24
Vue 3.x beta 中 watch/watchEffect 的签名和之前 @vue/composition-api
中一致,在此不再赘述。
对比、结合前文,该部分将主要关注其单元测试的视角差异,并列出其实现方面的一些区别,希望能加深对本文主题的理解。
主要涉及文件为 packages/runtime-core/src/apiWatch.ts
和 packages/runtime-core/__tests__/apiWatch.spec.ts
等。
因为函数的用法相比 @vue/composition-api 中并无改变,Vue 3 中相关的单元测试覆盖的功能部分和前文的版本差不多,写法上似乎更偏重于对 ref/reactive/computed 几种响应式类型的考察。
const src = reactive({ count: 0 })
对象src.count++
watchEffect(effect: onCleanup: fn => void) => stop
观察一个响应式对象watch(source, cb: onCleanup: fn => void) => stop
观察一个响应式对象{ deep: true }
的情况下const state = reactive({
nested: {
count: ref(0)
},
array: [1, 2, 3],
map: new Map([['a', 1], ['b', 2]]),
set: new Set([1, 2, 3])
})
{ immediate: false }
watch() "immediate" option is only respected when using the watch(source, callback, options?) signature.
{ deep: true }
watch() "deep" option is only respected when using the watch(source, callback, options?) signature.
const obj = reactive({ foo: 1, bar: 2 })
obj.foo
、'bar' in obj
、Object.keys(obj)
// 1st
{
target: obj,
type: TrackOpTypes.GET,
key: 'foo'
}
// 2nd
{
target: obj,
type: TrackOpTypes.HAS,
key: 'bar'
}
// 3rd
{
target: obj,
type: TrackOpTypes.ITERATE,
key: ITERATE_KEY
}
const obj = reactive({ foo: 1 })
obj.foo++
后,options.onTrigger 参数为:{
type: TriggerOpTypes.SET,
key: 'foo',
oldValue: 1,
newValue: 2
}
delete obj.foo
后,options.onTrigger 参数为:{
type: TriggerOpTypes.DELETE,
key: 'foo',
oldValue: 2
}
// vue-next/packages/runtime-core/src/apiWatch.ts
function doWatch(
source: WatchSource | WatchSource[] | WatchEffect,
cb: WatchCallback | null,
{ immediate, deep, flush, onTrack, onTrigger }: WatchOptions = EMPTY_OBJ
): WatchStopHandle {
...
}
// packages/reactivity/src/effect.ts
export interface ReactiveEffectOptions {
...
onTrack?: (event: DebuggerEvent) => void
onTrigger?: (event: DebuggerEvent) => void
onStop?: () => void // 也就是 watcher 中的 onCleanup
}
export type DebuggerEvent = {
effect: ReactiveEffect
target: object
type: TrackOpTypes | TriggerOpTypes
key: any
} & DebuggerEventExtraInfo
export function track(
target: object,
type: TrackOpTypes,
key: unknown
) {
...
}
export function trigger(
target: object,
type: TriggerOpTypes,
key?: unknown,
newValue?: unknown,
oldValue?: unknown,
oldTarget?: Map<unknown, unknown> | Set<unknown>
) {
...
}
// packages/reactivity/src/operations.ts
export const enum TrackOpTypes {
GET = 'get',
HAS = 'has',
ITERATE = 'iterate'
}
export const enum TriggerOpTypes {
SET = 'set',
ADD = 'add',
DELETE = 'delete',
CLEAR = 'clear'
}
在前文中我们看到了 watch/watchEffect 对 effect() 的间接调用。实际上除了名称相近,其调用方式也差不多:
// packages/reactivity/src/effect.ts
export function effect<T = any>(
fn: () => T,
options: ReactiveEffectOptions = EMPTY_OBJ
): ReactiveEffect<T>
export function stop(effect: ReactiveEffect)
二者区别主要在于:
对于 Vue 传统的 Options API 组件写法:
const App = {
data() {
return {
message: "Hello"
};
},
watch: {
message: {
handler() {
console.log("Immediately Triggered")
}
}
}
}
yyx990803
的说法,这种改动其实是和组合式 API 中的行为一致的 -- watch() 默认不会立即执行,而 watchEffect() 相反同样有别于 Vue 2.x 的一点是,在传统写法中:
const App = {
data() {
foo: {
bar: {
qux: 'val'
}
}
},
watch: {
'foo.bar.qux' () { ... }
}
}
或者在 Vue 2.x + @vue/composition-api 中,也可以写成:
const App = {
props: ['aaa'],
setup(props) {
watch('aaa', () => { ... });
return { ... };
}
}
Vue 2.x 中对以 .
分割的 magic strings 实际上做了特别解析:
// vue/src/core/util/lang.js
/**
* Parse simple path.
*/
export function parsePath (path: string): any {
...
const segments = path.split('.')
return function (obj) {
for (let i = 0; i < segments.length; i++) {
if (!obj) return
obj = obj[segments[i]]
}
return obj
}
}
而如果回过头看 1.1 中的 watch 函数签名,并结合以下定义:
export type WatchSource<T = any> = Ref<T> | ComputedRef<T> | (() => T)
也就不难理解,新式的 source 目前只支持三种类型:
所以,
yyx990803
称为 “magic strings” 的字符串 source 也不再支持() => this.foo.bar.baz
或 () => props.aaa
代替