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())
}

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

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

相关推荐
程序员凡尘1 小时前
完美解决 Array 方法 (map/filter/reduce) 不按预期工作 的正确解决方法,亲测有效!!!
前端·javascript·vue.js
Bug缔造者7 小时前
Element-ui el-table 全局表格排序
前端·javascript·vue.js
xnian_7 小时前
解决ruoyi-vue-pro-master框架引入报错,启动报错问题
前端·javascript·vue.js
罗政8 小时前
[附源码]超简洁个人博客网站搭建+SpringBoot+Vue前后端分离
vue.js·spring boot·后端
阿树梢8 小时前
【Vue】VueRouter路由
前端·javascript·vue.js
随笔写9 小时前
vue使用关于speak-tss插件的详细介绍
前端·javascript·vue.js
陈小唬11 小时前
html页面整合vue2或vue3
前端·vue.js·html
花下的晚风11 小时前
Vue实用操作篇-1-第一个 Vue 程序
前端·javascript·vue.js
我是Superman丶13 小时前
【前端UI框架】VUE ElementUI 离线文档 可不联网打开
前端·vue.js·elementui
growdu_real13 小时前
pandoc自定义过滤器
vue.js