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

  • 立即执行的回调函数
  • 回调函数执行时机
相关推荐
christine-rr4 分钟前
征文投稿:如何写一份实用的技术文档?——以软件配置为例
运维·前端·网络·数据库·软件构建
_骁5 分钟前
记两次谷歌浏览器升级引起的bug
前端
风之舞_yjf1 小时前
Vue基础(14)_列表过滤、列表排序
前端·javascript·vue.js
BillKu1 小时前
scss(sass)中 & 的使用说明
前端·sass·scss
疯狂的沙粒1 小时前
uni-app 项目支持 vue 3.0 详解及版本升级方案?
前端·vue.js·uni-app
Jiaberrr2 小时前
uniapp Vue2 获取电量的独家方法:绕过官方插件限制
前端·javascript·uni-app·plus·电量
Lhuu(重开版2 小时前
Vue:Ajax
vue.js·ajax·okhttp
谢尔登2 小时前
【React】React 18 并发特性
前端·react.js·前端框架
Joker`s smile2 小时前
使用React+ant Table 实现 表格无限循环滚动播放
前端·javascript·react.js
国家不保护废物2 小时前
🌟 React 魔法学院入学指南:从零构建你的第一个魔法阵(项目)!
前端·react.js·架构