Vue响应式原理(2)-建立对象属性和副作用函数间依赖关系

1. 副作用函数注册

在上节中,简易实现了一个响应式系统,这种实现方式存在很多问题需要解决。 首先,对于副作用函数的收集,我们通过将变量名硬编码为effect来实现。这种硬编码方式存在很大局限,当我们的副作用函数命名变更时就无法正确收集,我们所期望的是即使副作用函数是一个匿名函数也能正确将其收集。 为了实现这一点,我们需要提供一个用来注册副作用函数的机制:

js 复制代码
// 用一个全局变量存储被注册的副作用函数
let activeEffect
// effect 函数用于注册副作用函数
function effect(fn) {
    // 当调用 effect 注册副作用函数时,将副作用函数 fn 赋值给activeEffect
    activeEffect = fn
    // 执行副作用函数
    fn()
}

在代码中,我们设置了一个全局变量activeEffect,其作用是指向实际执行的副作用函数fn。在effect函数中传递实际的副作用函数fn作为参数,在执行前,将activeEffect变量指向fn,随后执行副作用函数fn。

如下列代码所示,副作用函数以匿名函数的形式传递给effect函数,完成副作用函数的注册。

js 复制代码
effect(() => {
    document.body.innerText = obj.text
})

相应的,我们需要修改响应式数据的收集过程。

js 复制代码
const obj = new Proxy(data, {
    get(target, key) {
        // 将 activeEffect 中存储的副作用函数收集到"桶"中
        if (activeEffect) {
            bucket.add(activeEffect)
        }
        return target[key]
    },
    set(target, key, newVal) {
        target[key] = newVal
        bucket.forEach(fn => fn())
        return true
    }
})

整个依赖收集流程如下:在effect函数执行过程中,首先将全局变量activeEffect指向副作用函数fn,随后执行fn;在fn执行过程中,读取了obj.text属性,因此会触发代理对象的get拦截函数;在get函数中对activeEffect进行判断,此时应该指向的是副作用函数fn,因此实现了将当前正在执行的副作用函数fn收集到bucket中;后续的依赖触发过程如前所述。

2. 副作用函数与对象属性建立联系

js 复制代码
effect(
    // 匿名副作用函数
    () => {
        console.log('effect run') // 会打印 2 次
        document.body.innerText = obj.text
    }
)

setTimeout(() => {
    // 副作用函数中并没有读取 notExist 属性的值
    obj.notExist = 'hello vue3'
}, 1000)

在上面这段代码中,我们在副作用函数中读取了obj.text,我们希望实现的效果是当obj的text属性值发生变化时,会自动调用副作用函数,将document.body.innerText重新赋值。然而,在定时器中我们设定一秒后对obj的notExist属性值进行修改,按照预期副作用函数不应该重新执行。但是事实是副作用函数在一秒后重新执行了。

产生上述问题的主要原因是,在依赖的收集和触发过程中,我们并未在副作用函数和对象属性间建立联系。

解决方案

在上一节的例子中,我们使用一个 Set 数据结构作为存储副作用函数的"桶"。导致该问题的根本原因是,我们没有在副作用函数与被操作的目标字段之间建立明确的联系。例如当读取属性时,无论读取的是哪一个属性,其实都一样,都会把副作用函数收集到"桶"里;当设置属性时,无论设置的是哪一个属性,也都会把"桶"里的副作用函数取出并执行。副作用函数与被操作的字段之间没有明确的联系。解决方法很简单,只需要在副作用函数与被操作的字段之间建立联系即可,这就需要我们重新设计"桶"的数据结构,而不能简单地使用一个Set类型的数据作为"桶"了。

在依赖收集中存在三个角色: 被操作(读取)的代理对象 obj; 被操作(读取)的字段名 text; 使用 effect 函数注册的副作用函数 fn。

如果用 target 来表示一个代理对象所代理的原始对象,用 key来表示被操作的字段名,用 effectFn 来表示被注册的副作用函数,那么可以为这三个角色建立如下关系:

markdown 复制代码
target
    └── key
    └── fn

如果有两个副作用函数fn1和fn2同时读取同一个对象的属性值,那么关系如下:

js 复制代码
target
    └── text
    └── fn1
    └── fn2

如果有两个副作用函数fn同时读取同一个对象的属性值text1和text2,那么关系如下:

js 复制代码
target
    └── text1
        └── fn
    └── text2
        └── fn

如果在副作用函数fn中读取了两个不同对象的不同属性,那么关系如下:

js 复制代码
target1
    └── text1
        └── fn
target2
    └── text2
        └── fn

总而言之,我们需要建立一个树形结构,最上层为对象,下一层是对象属性,最下层是收集到的副作用函数。为实现这种结构,使用Set作为桶显然不能满足要求,我们很容易想到用多层Map来实现替换。再进一步思考,我们可以发现最上层的Map需要用Object类型作为key来实现收集,对于key是Object形式,使用WeakMap显然比Map更为合适,其弱引用的特性可以更好地实现垃圾回收机制。

基于以上思路,我们可以对依赖收集和触发的过程进行一定改进:

js 复制代码
// 存储副作用函数的桶
const bucket = new WeakMap()

const obj = new Proxy(data, {
    // 拦截读取操作
    get(target, key) {
        // 没有 activeEffect,直接 return
        if (!activeEffect) return target[key]
        // 根据 target 从"桶"中取得 depsMap,它也是一个 Map 类型:key -->effects
        let depsMap = bucket.get(target)
        // 如果不存在 depsMap,那么新建一个 Map 并与 target 关联
        if (!depsMap) {
            bucket.set(target, (depsMap = new Map()))
        }
        // 再根据 key 从 depsMap 中取得 deps,它是一个 Set 类型,
        // 里面存储着所有与当前 key 相关联的副作用函数:effects
        let deps = depsMap.get(key)
        // 如果 deps 不存在,同样新建一个 Set 并与 key 关联
        if (!deps) {
            depsMap.set(key, (deps = new Set()))
        }
        // 最后将当前激活的副作用函数添加到"桶"里
        deps.add(activeEffect)
        // 返回属性值
        return target[key]
    },
    // 拦截设置操作
    set(target, key, newVal) {
        // 设置属性值
        target[key] = newVal
        // 根据 target 从桶中取得 depsMap,它是 key --> effects
        const depsMap = bucket.get(target)
        if (!depsMap) return
        // 根据 key 取得所有副作用函数 effects
        const effects = depsMap.get(key)
        // 执行副作用函数
        effects && effects.forEach(fn => fn())
    }
})

最终我们建立的关系如下图所示,WeakMap 的键是原始对象 target,WeakMap 的值是一个Map 实例,而 Map 的键是原始对象 target 的 key,Map 的值是一个由副作用函数组成的 Set。

可以看到,相比于最初的简易版响应式系统,目前的get拦截函数和set拦截函数的内容已经增加了不少,为例代码的易读性,同时降低耦合性,我们需要将get和set拦截函数中的部分代码进行抽离,分装成依赖收集函数track和依赖触发函数trigger。

js 复制代码
const obj = new Proxy(data, {
    // 拦截读取操作
    get(target, key) {
        // 将副作用函数 activeEffect 添加到存储副作用函数的桶中
        track(target, key)
        // 返回属性值
        return target[key]
    },
    // 拦截设置操作
    set(target, key, newVal) {
        // 设置属性值
        target[key] = newVal
        // 把副作用函数从桶里取出并执行
        trigger(target, key)
    }
})

// 在 get 拦截函数内调用 track 函数追踪变化
function track(target, key) {
    // 没有 activeEffect,直接 return
    if (!activeEffect) return
    let depsMap = bucket.get(target)
    if (!depsMap) {
        bucket.set(target, (depsMap = new Map()))
    }
    let deps = depsMap.get(key)
    if (!deps) {
        depsMap.set(key, (deps = new Set()))
    }
    deps.add(activeEffect)
}

// 在 set 拦截函数内调用 trigger 函数触发变化
function trigger(target, key) {
    const depsMap = bucket.get(target)
    if (!depsMap) return
    const effects = depsMap.get(key)
    effects && effects.forEach(fn => fn())
}

至此,我们已经实现了一个基础版响应式系统,可以建立不同对象不同属性和副作用函数的依赖关系,在修改对象属性时可以执行对应的副作用函数。

在后续文章中我们将继续对一些其它问题进行优化,继续完善响应式系统。

相关推荐
2401_868534786 小时前
常见的 vue面试题目
前端·javascript·vue.js
向日的葵0067 小时前
vue路由(二)
前端·javascript·vue.js·vue
xkxnq8 小时前
第八阶段:工程化、质量管控与高级拓展(130天),Vue端到端测试:Cypress自动化测试(登录流程+表单提交+页面跳转)
前端·vue.js·flutter
老毛肚8 小时前
jeecgboot vue API 拆分02
前端·javascript·vue.js
爱因斯坦乐19 小时前
Vue项目整合
前端·javascript·vue.js
ct97820 小时前
组件间的通信
前端·javascript·vue.js
左手吻左脸。20 小时前
Vue 全栈面试题大全(2026 最新版最详细)
前端·javascript·vue.js
小新11021 小时前
最简单但完整的 Vue 响应式示例(一个简单的计数器按钮)
前端·javascript·vue.js
刘海不能乱161 天前
Java JUC源码分析系列笔记-Synchronized
vue.js
whatever who cares1 天前
Vue3中vue文件和composables的分工
前端·javascript·vue.js