1. watch侦听器
Vue
中的侦听器 watch
主要功能是对响应式数据进行观测,当数据发生变化时,会将响应式数据的旧值和新值作为参数传递给用户提供的回调函数并执行。实际上,watch
的实现就是利用了调度器。
js
watch(obj, (newVal, oldVal) => {
console.log('changed')
})
obj.foo++;
这是 watch
使用的简单例子,第一个参数是响应式对象 obj
, 第二个参数是回调函数,函数中会获取到侦听对象的新值和旧值。下面我们来尝试实现 watch
函数:
js
function watch(source, cb) {
effect(
() => {
// 读取source对象foo属性,进行依赖收集,建立联系
source.foo
},
{
scheduler() {
// 触发依赖时执行cb回调函数
cb()
}
}
)
}
在 watch
函数内部,我们调用了副作用函数注册函数 effect
,在副作用函数中读取 source.foo
属性来建立副作用函数和响应式对象之间的依赖联系,因此在 source.foo
属性变化时会触发依赖,使得 scheduler
函数执行,而在 scheduler
函数中我们执行 watch
中传递的回调函数 cb
。从效果上看,就是在侦听的响应式对象发生变更时触发回调执行。
以上是 watch
的简单实现,但是很明显还存在几个问题:1. 在 effect
的副作用函数中硬编码了 source.foo
,只有在 foo
属性发生变化时才会触发侦听,这显然不符合 watch
的原本设计;2. watch
的第一个参数除了接受对象类型以外,可以接受函数类型来指定侦听对象的某一些属性,另外也可能传入原始值(没有侦听效果),但是我们目前的实现还不能适配这两种参数类型;3. 在回调函数中没有获取到响应式对象的新值和旧值。
2. 循环读取对象变量
首先我们来解决第一个问题,显然在副作用函数中我们不能以硬编码的形式读取对象的某一个属性,而是应该首先对侦听对象的类型进行判断,只有为对象形式的时候才进行侦听,并且需要遍历对象的全部属性来建立依赖联系。为此,我们需要写一个函数用来完成对象属性的遍历操作,并在副作用函数中调用这个函数:
js
function watch(source, cb) {
effect(
() => {
// 读取source对象foo属性,进行依赖收集,建立联系
traverse(source)
},
{
scheduler() {
// 触发依赖时执行cb回调函数
cb()
}
}
)
}
function traverse(value, seen = new Set()) {
// 如果要读取的数据已经被读取过了,那么什么都不做
if (seen.has(value)) return
// 将数据添加到 seen 中,代表遍历地读取过了,避免循环引用引起的死循环
seen.add(value)
// 假设 value 就是一个对象,使用 for...in 读取对象的每一个值,并递归地调用 traverse 进行处理
for (const k in value) {
// 递归调用
traverse(value[k], seen)
}
return value
}
在 watch
内部的 effect
中调用 traverse
函数进行递归的读取操作,可以读取一个对象上的全部属性,从而当任意属性发生变化时都能够触发回调函数执行。需要注意的是,在函数中设置了一个变量名为 seen
的 Set
集合来对当前已经遍历过的属性进行记录,主要是为了防止循环引用导致的死循环问题。
3. 接收函数类型参数和原始值类型参数
watch
函数除了可以观测响应式数据,还可以接收一个 getter
函数:
js
watch(
// getter 函数
() => obj.foo,
// 回调函数
() => {
console.log('obj.foo 的值变了')
}
)
在 getter
函数内部可以控制对响应式对象的某些属性进行侦听,只有当这些属性变化时才会触发回调函数执行。为此我们需要对 watch
函数进行一定修改:
js
function watch(source, cb) {
// 定义变量getter
let getter
if(typeof source === 'function') {
getter = () => source()
} else {
getter = () => traverse(source)
}
effect(
() => getter(),
{
scheduler() {
// 触发依赖时执行cb回调函数
cb()
}
}
)
}
首先判断 source
的类型,如果是函数类型,说明用户直接传递了 getter
函数,这时直接使用用户的 getter
函数;如果不是函数类型,则保持原来的处理方式,在 getter
函数中调用 traverse
函数递归地读取。
同时我们需要处理传入原始值的问题,此时需要在 traverse
中进行判断,如果传入的不是对象类型则直接返回不建立依赖联系:
js
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
}
4. 在回调函数中获取到新值和旧值
如何在回调函数中获得 getter
函数的新值和旧值呢?如前文所述,当我们在调度器中传入 lazy: true
时 effect
函数会返回一个 effectFn
函数交给用户自行执行;我们在全局设置旧值变量 oldValue
和新值变量newValue
;在 watch
中我们首次调用 effectFn
获取到的结果通过变量 oldValue
保存,在下一次侦听到更改时可以获取;当侦听到对象变化时,会触发 scheduler
函数执行,在 scheduler
中我们通过执行 effectFn
函数会获取到新值 newValue
,将新值和保存的旧值变量 oldValue
同时传递给回调函数执行,执行完后要记得用当前的新值覆盖旧值,那么在下一次侦听到变化时就能获取到正确的旧值:
js
function watch(source, cb) {
// 定义变量getter
let getter
if(typeof source === 'function') {
getter = () => source()
} else {
getter = () => traverse(source)
}
// 定义旧值与新值
let oldValue, newValue
effect(
() => getter(),
{
// 开启 lazy 选项,并把返回值存储到effectFn 中以便后续手动调用
lazy: true,
scheduler() {
// 在 scheduler 中重新执行副作用函数,得到的是新值
newValue = effectFn()
// 将旧值和新值作为回调函数的参数
cb(newValue, oldValue)
// 更新旧值,不然下一次会得到错误的旧值
oldValue = newValue
}
}
)
// 手动调用副作用函数,拿到的值就是旧值
oldValue = effectFn()
}
至此,我们基本实现了 watch 侦听器的功能,可以侦听响应式对象的属性变化,将旧值和新值传递给回调函数并执行,同时也对传入参数的不同类型进行了适配。