Vue.js 3 渐进式实现之响应式系统——第十二节:watch 的基本实现原理

往期回顾

  1. 系列开篇与响应式基本实现
  2. effect 函数注册副作用
  3. 建立副作用函数与被操作字段之间的联系
  4. 封装 track 和 trigger 函数
  5. 分支切换与 cleanup
  6. 嵌套的 effect 与 effect 栈
  7. 避免无限递归循环
  8. 调度执行
  9. 懒执行的 effect
  10. 计算属性与缓存
  11. 计算属性的 track 和 trigger

watch 的基本实现原理

上一节中我们实现了完整的计算属性功能,这一节开始我们来实现 watch。

思路

watch

所谓 watch,本质就是观测一个响应式数据,数据发生变化时通知并执行相应的回调函数。watch 的实现本质上就是利用了 effect 以及 options.scheduler 选项,下面是最简单的实现:

js 复制代码
function watch(source, cb) {
    effect(
        // 读取 source,从而建立联系
        () => source.foo,
        {
            scheduler() {
                // source 有变化时,执行cb
                cb()
            }
        }
    )
}

递归读取完整对象

然而现在的实现有一个问题,我们硬编码了对 source.foo 的读取,也就是说现在只能监听 obj.foo 的改变。实际上我们希望的是监听的是整个 source 对象的改变,也就是说当 source 对象的任意属性发生变化时,都会触发 cb 的执行。

因此我们需要封装一个通用的读取操作:

js 复制代码
function watch(source, cb) {
    effect(
        () => traverse(source),
        {
            scheduler() {
                cb()
            }
        }
    )
}

function traverse(value, seen = new Set()) {
    // 如果 value 是原始值,或者已经被读取过,则什么都不做
    if (typeof value !== 'object' || value === null || seen.has(value)) {
        return
    }

    // 将 value 添加到 seen 中代表读取过了,避免循环引用引起的死循环
    seen.add(value)

    // 暂时只考虑对象,遍历读取对象每一个属性,递归调用 traverse
    for (const key in value) {
        traverse(value[key], seen)
    }

    return value
}

如上述代码所示,在 watch 内部 的 effect 中调用 traverse 函数递归读取对象的每一个属性,使得任意属性发生变化时都能触发回调函数执行。

支持接收 getter

watch 函数除了可以观测响应式数据,还支持接收一个 getter 函数。在 getter 函数内部,用户可以指定该 watch 监听哪些响应式数据。:

js 复制代码
watch(
    () => source.foo,
    () => {
        console.log('source.foo changed')
    }
)

实现这一功能的代码如下:

js 复制代码
function watch(source, cb) {
    let getter
    if (typeof source === 'function') {
        getter = source
    } else {
        getter = () => traverse(source)
    }

    effect(
        () => getter(),
        {
            scheduler() {
                cb()
            }
        }
    )
}

获取新值和旧值

在使用 Vue.js 的 watch 时,有一个非常重要的功能,那就是能够在回调函数中得到变化前后的值:

js 复制代码
watch(
    () => source.foo,
    (newVal, oldVal) => {
        console.log('source.foo changed', newVal, oldVal)
    }
)

实现这一功能,需要充分利用 effect 函数的 lazy 选项。每次监听的数据更新时手动调用 effect 函数的返回值 effectFn 获取更新后的响应式数据的值,而上一次调用 effectFn 的结果则作为旧值:

js 复制代码
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()
}

上述代码最下面的部分,我们手动调用 effectFn 得到的返回值就是旧值,即第一次执行得到的值。当变化发生并触发 scheduler 调度函数执行时,会重新调用 effectFn 并得到新值。这样我们就拿到了新值和旧值,进而传给回调函数。最后不要忘了用新值更新旧值:oldValue = newValue,毕竟本次更新后的新值也是下一次更新后的旧值。

已实现

目前我们实现了 watch 的基本功能,支持监听对象的任意属性变化,也支持接收 getter 函数,并且回调函数中也能获取到监听的数据的新值和旧值。

缺陷/待实现

下一节我们将继续实现关于 watch 的两个特性:

  • 立即执行的回调函数
  • 回调函数执行时机
相关推荐
程序员的世界你不懂1 小时前
【Flask】测试平台开发,重构提测管理页面-第二十篇
vue.js·重构·flask
IT_陈寒1 小时前
Python异步编程的7个致命误区:90%开发者踩过的坑及高效解决方案
前端·人工智能·后端
猫猫村晨总1 小时前
整理了几道前端面试题
前端·vue.js·面试
江拥羡橙1 小时前
【目录-多选】鸿蒙HarmonyOS开发者基础
前端·ui·华为·typescript·harmonyos
你的电影很有趣1 小时前
lesson55:CSS导航组件全攻略:从基础导航条到动态三级菜单与伸缩菜单实现
前端·css
蔗理苦1 小时前
2025-09-05 CSS4——浮动与定位
开发语言·前端·css·html·css3
浊浪载清辉2 小时前
《Html泛型魔法学院:用霍格沃茨风格网页教授集合框架》
前端·javascript·学习·html
Want5952 小时前
HTML元素周期表
前端·html
一只一只妖5 小时前
突发奇想,还未实践,在Vben5的Antd模式下,将表单从「JS 配置化」改写成「模板可视化」形式(豆包版)
前端·javascript·vue.js
悟能不能悟8 小时前
js闭包问题
开发语言·前端·javascript