所谓watch,其本质就是观测一个响应式数据,当数据发生变化时通知并执行相应的回调函数。举个例子:
watch(obj,()=>{
console.log('数据变了')
})
//修改响应数据的值,会导致回调函数执行
obj.foo++
假设obj是一个响应数据,使用watch函数观测它,并传递一个回调函数,当修改响应式数据的值时,会触发该回调函数执行。
实际上,watch的实现本质上就是利用了effect以及options.scheduler选项,如以下代码所示:
effect(()=>{
console.log(obj.foo)
},{
scheduler(){
//当obj.foo的值变化时,会执行scheduler调度函数
}
})
在一个副作用函数中访问响应式数据obj.foo,通过前面的介绍,我们知道这会在副作用函数与响应式数据之间建立联系,当响应式数据变化时,会触发副作用函数重新执行。
但有一个例外,即如果副作用函数存在scheduler选项,当响应式数据发生变化时,会触发scheduler调度函数执行,而非直接触发副作用函数执行。
从这个角度来看,其实scheduler调度函数就是相当于一个回调函数,而watch的实现就是利用了这个特点。下面是最简单的watch函数的实现:
scss复制代码//watch函数接受两个参数,source是响应式数据,cb是回调函数
function watch(source,cb){
effect(
//触发读取操作,从而建立联系
()=> source.foo,
{
scheduler(){
//当数据变化时,调用回调函数cb
cb()
}
}
)
}
我们可以如下所示使用watch函数:
javascript复制代码const data = {foo:1}
const obj = new Proxy(data,{/*...*/})
watch(obj,()=>{
console.log('数据变化了')
})
obj.foo++
上面这段代码能正常工作, 但是我们注意到在watch函数的实现中,硬编码了对source.foo的读取操作。
scss复制代码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
}
如上面的代码所示,在watch内部的effect中调用traverse函数进行递归的读取操作,代替硬编码的方式,这样就能读取一个对象上的任意属性,从而当任意属性发生变化时都能触发回调函数执行。
watch函数除了可以观测响应式数据,还可以接受一个getter函数:
javascript复制代码watch(
//getter函数
()=> obj.foo,
//回调函数
()=>{
console.log('obj.foo 的值变了')
}
)
如以上代码所示,传递给watch函数的第一个参数不再是一个响应式数据,而是一个getter函数。在getter函数内部,用户可以指定该watch依赖哪些响应式数据,只有当这些数据变化时,才会触发回调函数执行。如下代码实现了这一功能:\
scss复制代码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()
}
)
}
首先判断source的类型,如果是函数类型,说明用户直接传递了getter函数, 这时直接使用用户的getter函数;如果不是函数类型,那么保留之前的做法,即调用traverse函数递归地读取。这样就实现了自定义getter的功能,同时使得watch函数更加强大。。
仔细观察你可能会注意到,现在的实现还缺少了一个非常重要的能力,即在回调函数中拿不到旧值与新值。通常我们在使用Vue.js中的watch函数时,能够在回调函数中得倒变化前后的值;
javascript复制代码watch(
() => obj.foo,
(newValue,oldValue) =>{
console.log(newValue,oldValue) // 2,1
}
)
obj.foo++
那么如何获得新值与旧值呢?这需要充分利用effect函数的lazy选项 , 如下代码所示:
scss复制代码function watch(source,obj){
let getter
if(typeof source === 'function'){
getter = source
}else{
getter = () => traverse(source)
}
//定义旧值与新值
let objValue, newValue
//使用effect注册副作用函数时,开启lazy选项,并把返回值存储到effectFn中以便后续手动调用
const effectFn = effect(
()=> getter(),
{
lazy:true,
scheduler(){
//在scheduler 中重新执行副作用函数,得到的是新值
newValue = effectFn()
// 将旧值和新值作为回调函数的参数
cb(newValue,oldValue)
//更新旧值,不然下一次会得到错误的旧值
oldValue = newValue
}
}
)
//手动调用副作用函数,拿到的值就是旧值
oldValue = effectFn()
}
在这段代码中, 最核心的改动就是使用lazy选项创建了一个懒执行的effect。注意上面代码中最下面的部分,我们手动调用effectFn函数得到的返回值就是旧值,即第一次执行的到的值。
当变化发生并触发scheduler调度函数执行时,会重新调用effectFn函数并得到新值,这样我们就拿到了旧值与新值,接着将它们作为参数传递给回调函数cb就可以了。最后一件非常重要的事情是,不要忘记使用新值更新旧值:oldValue = newValue,否则在下一次变更发生时会得到错误的旧值。