watch的实现
所谓watch,本质上就是观察一个响应式数据,当响应式数据发生变化是执行指定的回调函数,其实就是观察者模式。
watch(obj, () => {
console.log('数据变了')
})
// 修改响应数据的值,会导致回调函数执行
obj.foo++
在上边的代码中,我们希望,当执行完foo的自增的时候,控制台也会打印“数据变了!”,这就是我们希望watch的执行效果。
watch的基本实现
还是从调度器说起
上边的测试代码中,其实从effect的角度来看就是利用effect的options参数的调度器就做相应的魔改:
effect(() => {
console.log(obj.foo)
}, {
scheduler() {
// 当 obj.foo 的值变化时,会执行 scheduler 调度函数
}
})
还是从上边代码来看,我们在传入的函数参数中,访问了obj.foo,那么其实在effect的初次执行中,会触发响应式数据的get拦截操作,从而触发track去追踪依赖的副作用函数,那么当前的effect会以activeEffect形式被收集。通常情况下,当响应式数据改变的时候,effect是会在trigger中被执行。然而,当effect函数带着scheduler这个属性的时候是一个例外情况,trigger的源码所写的,此时会执行scheduler而非effect本身。计算属性是利用这个特点,watch也不例外,那么简单的watch结构如下:
// watch 函数接收两个参数,source 是响应式数据,cb 是回调函数
function watch(source, cb) {
effect(
// 触发读取操作,从而建立联系
() => source.foo,
{
scheduler() {
// 当数据变化时,调用回调函数 cb
cb()
}
}
)
}
那么执行下边代码是可行的:
const data = { foo: 1 }
const obj = new Proxy(data, { /* ... */ })
watch(obj, () => {
console.log('数据变化了')
})
obj.foo++
但是可以看到依然是一个硬编码的操作,watch中只针对了foo这个属性值的侦测。
使用一个更通用的操作
至此,我们引入一个traverse函数,来访问传入的source的属性
function watch(source, cb) {
effect(
// 调用 traverse 递归地读取
() => traverse(source),
{
scheduler() {
// 当数据变化时,调用回调函数 cb
cb()
}
}
)
}
function traverse(value, seen = new Set()) {
// 如果要读取的数据是原始值,或者已经被读取过了,那么什么都不做
if (typeof value !== 'object' || value === null || seen.has(value)) return
// 将数据添加到 seen 中,代表遍历地读取过了,避免循环引用引起的死循环
seen.add(value)
// 暂时不考虑数组等其他结构
// 假设 value 就是一个对象,使用 for...in 读取对象的每一个值,并递归地调用 traverse 进行处理
for (const k in value) {
traverse(value[k], seen)
}
return value
}
traverse的逻辑,显而易见是对传入的参数去做一个递归的深度优先遍历,以此代替硬编码的操作。
拓展第一个参数的类型
watch 函数除了可以观测响应式数据,还可以结构一个getter函数:
watch(
// getter 函数
() => obj.foo,
// 回调函数
() => {
console.log('obj.foo 的值变了')
}
)
那么此时watch的第一个参数就不再是响应式数据,而是一个getter类型函数本身。在这个函数的内部,用户可以指定watch可以依赖哪些响应式数据,只有这些指定的数据的变化才会引起回调函数执行。
function watch(source, cb) {
// 定义 getter
let getter
// 如果 source 是函数,说明用户传递的是 getter,所以直接把 source 赋值给 getter
if (typeof source === 'function') {
getter = source
} else {
// 否则按照原来的实现调用 traverse 递归地读取
getter = () => traverse(source)
}
effect(
// 执行 getter
() => getter(),
{
scheduler() {
cb()
}
}
)
}
那么就是在内部封装了一个getter,根据传进来的第一个参数来做对应处理,但是最终都是转换成一个getter函数。当第一个参数是非函数类型时,直接将getter赋值为函数,而在函数内部去调用traverse第一个参数的结果。
获取新旧值
watch的回调函数有一个显著的特征,就是前两个参数分别是观察的数据的新值和旧值
watch(
() => obj.foo,
(newValue, oldValue) => {
console.log(newValue, oldValue) // 2, 1
}
)
obj.foo++
那么如何获得到旧值呢,其实旧值的获取本质上需要借助一个缓存,在计算属性中我们在函数体中用一个value来进行值的缓存,当然是借助effect中的懒执行来进行的。那么我们在watch中同样可以做一个懒执行来达到这个目的。
function watch(source, cb) {
let getter
if (typeof source === 'function') {
getter = source
} else {
getter = () => traverse(source)
}
// 定义旧值与新值
let oldValue, newValue
// 使用 effect 注册副作用函数时,开启 lazy 选项,并把返回值存储到 effectFn 中以便后续手动调用
const effectFn = effect(
() => getter(),
{
lazy: true,
scheduler() {
// 在 scheduler 中重新执行副作用函数,得到的是新值
newValue = effectFn()
// 将旧值和新值作为回调函数的参数
cb(newValue, oldValue)
// 更新旧值,不然下一次会得到错误的旧值
oldValue = newValue
}
}
)
// 手动调用副作用函数,拿到的值就是旧值
oldValue = effectFn()
}
在这里,我们依旧借助lazy和调度器scheduler。让我们来分析一下整个过程(从侦听到数据改变引发回调函数执行):
- 在使用watch监听一个响应式数据时,在watch内部getter中读取了对应的对象或者指定的值,触发响应式数据的track去进行依赖收集。需要注意的是,如果此时传进来的只是一个对象,那么内部会把对象的所有属性深度的遍历一遍,也就是说,对象内部的所有属性(包括对象内部的非源语数据的“成员”)都会跟watch建立一个依赖关系。
- 当监听的响应式数据发生变化时,由于lazy为true,trigger执行的调度器中,首先会将执行当前effectFn获得新值(计算属性那一节的effect中,如果lazy为true的话那么effect函数返回的会是内部的effectFn),然后执行回调函数,然后更新旧值。
立即执行的watch
关于watch主要是两个特性:立即执行和回调函数的执行时机。
一般情况下,watch的回调只会在响应式数据变化时才执行,但是,vue.js中可以指定选项参数immediate
来指定回调函数是否可以立即执行:
watch(obj, () => {
console.log('变化了')
}, {
// 回调函数会在 watch 创建时立即执行一次
immediate: true
})
当immediate
的选项存在且值为true的时候,回调函数会watch创建的时候立即执行一次。
仔细思考可以发现,立即执行的回调函数,逻辑上和后边执行的回调函数时的逻辑是一样的,那么我们可以把 scheduler调度函数的逻辑进一步抽象出来为通用函数,从而可以在初始化和变更的时候执行:
function watch(source, cb, options = {}) {
let getter
if (typeof source === 'function') {
getter = source
} else {
getter = () => traverse(source)
}
let oldValue, newValue
// 提取 scheduler 调度函数为一个独立的 job 函数
const job = () => {
newValue = effectFn()
cb(newValue, oldValue)
oldValue = newValue
}
const effectFn = effect(
// 执行 getter
() => getter(),
{
lazy: true,
// 使用 job 函数作为调度器函数
scheduler: job
}
)
if (options.immediate) {
// 当 immediate 为 true 时立即执行 job,从而触发回调执行
job()
} else {
oldValue = effectFn()
}
}
当immediate
为true时,立即执行一次,否则oldval直接赋值一次。
其他的执行时期
以flush选项为例
watch(obj, () => {
console.log('变化了')
}, {
// 回调函数会在 watch 创建时立即执行一次
flush: 'pre' // 还可以指定为 'post' | 'sync'
})
flush本质上是表明调度函数的执行时机。前文在减少过渡状态的时候使用微任务队列执行调度函数,和flush功能相同。当flush的值为‘’post“的时候,表示调度函数需要把副作用函数放到微任务队列中,并等待dom更新结束后再执行:
function watch(source, cb, options = {}) {
let getter
if (typeof source === 'function') {
getter = source
} else {
getter = () => traverse(source)
}
let oldValue, newValue
const job = () => {
newValue = effectFn()
cb(newValue, oldValue)
oldValue = newValue
}
const effectFn = effect(
// 执行 getter
() => getter(),
{
lazy: true,
scheduler: () => {
// 在调度函数中判断 flush 是否为 'post',如果是,将其放到微任务队列中执行
if (options.flush === 'post') {
const p = Promise.resolve()
p.then(job)
} else {
job()
}
}
}
)
if (options.immediate) {
job()
} else {
oldValue = effectFn()
}
}
当然当不指定flush的时候,同属性名称一样,当不指定flush的时候,等同与flush设置为“sync”。