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 的两个特性:

  • 立即执行的回调函数
  • 回调函数执行时机
相关推荐
用户47949283569151 分钟前
React 终于出手了:彻底终结 useEffect 的"闭包陷阱"
前端·javascript·react.js
程序员猫哥7 分钟前
前端开发,一句话生成网站
前端
Younglina28 分钟前
一个纯前端的网站集合管理工具
前端·vue.js·chrome
木头程序员30 分钟前
前端(包含HTML/JavaScript/DOM/BOM/jQuery)基础-暴力复习篇
开发语言·前端·javascript·ecmascript·es6·jquery·html5
卖火箭的小男孩31 分钟前
# Flutter Provider 状态管理完全指南
前端
小雨青年32 分钟前
鸿蒙 HarmonyOS 6|ArkUI(01):从框架认知到项目骨架
前端
Null15534 分钟前
浏览器唤起本地桌面应用(基础版)
前端·浏览器
pas1361 小时前
31-mini-vue 更新element的children
前端·javascript·vue.js
wordbaby1 小时前
TanStack Router 实战:如何构建经典的“左侧菜单 + 右侧内容”后台布局
前端·react.js
oMcLin1 小时前
如何在 RHEL 7 上优化 Nginx 与 PHP‑FPM 配置,确保高并发 Web 应用的稳定性与响应速度?
前端·nginx·php