Vue响应式原理(8)-watch侦听器实现原理

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 侦听器的功能,可以侦听响应式对象的属性变化,将旧值和新值传递给回调函数并执行,同时也对传入参数的不同类型进行了适配。

相关推荐
FlyWIHTSKY42 分钟前
vue2和vue3有什么区别,用法上有哪些区别
前端·javascript·vue.js
码云之上43 分钟前
10 颗一模一样的「Edit」:中后台 Web Agent 怎么找到对的那一行
前端·人工智能·浏览器
量化分析码农1 小时前
【Python量化多因子实战 04】因子暴露不是 alpha:市值与行业中性化实战
前端·后端
不可能片场2 小时前
Electron 发布 正文写入的三条通道与读回口径
前端·electron
jerrywus2 小时前
让 AI 帮你部署 Vercel,报错了也不用手动搬日志
前端·agent·mcp
补码缩写2 小时前
11 行 Python 拆 PNG 块,解压后的字节数能手算
前端
coding漫漫长路2 小时前
PNG压缩先看图是哪一类,先无损再量化多半白做
前端
用户257680763212 小时前
搜「洗牙」为什么搜不到「洁治」
前端
费曼学习法2 小时前
Agent 死循环的根因不在模型:错误分类、退避、熔断、快照四层实现
前端
修炼的dance2 小时前
同一张PNG换五种压法,体积差在颜色数上
前端