likes
comments
collection
share

Vue3源码学习4(上) | 响应系统的作用与实现

作者站长头像
站长
· 阅读数 15
Vue3源码学习4(上) | 响应系统的作用与实现

Vue3源码学习4(上) | 响应系统的作用与实现

响应系统是 Vue.js 的重要组成部分,了解过 Vue 的掘友们应该都知道 Vue3 采用 Proxy 实现响应式数据。老规矩我们还是先抛出几个问题:

  • 什么是响应式数据 和 副作用函数
  • 如何避免无限递归?
  • 为什么需要嵌套的副作用函数?
  • 两个副作用函数之间会产生哪些影响?

4.1 响应式数据 与 副作用函数

想必大家对 响应式数据 或多或少有听说过,但是面对 副作用函数 可能是闻所未闻。嗷!那么,咱就先来说一说何为 副作用函数

副作用函数

废话文学:副作用函数指的是会产生副作用的函数

先看一串代码:

function effect(){
    document.body.innerText = 'hello vue3'
}

上面的代码可以看出,当effect()的时候,它会设置body的文本内容,但是除了effect函数外可能其他函数有读取或设置body的文本内容。也就是说,effect函数的执行会直接或间接影响其他函数的执行。这个时候!就可以说effect函数产生了副作用

可能,你会觉得副作用好像在我们日常敲代码中很少出现,其实不然,当一个函数修改了全局变量,就可能成为了一个副作用函数,如下面代码所示:

// 全局变量
let val = 1
function effect() {
    val = 2 // 修改全局变量,产生副作用
}

嗷!副作用函数 大概是这么一个情况,现在来说说何为响应式数据吧。

响应式数据

假设在一个副作用函数中读取了某个对象的属性:

const obj = { text:'hello world' }
function effect() {
    // effect 函数的执行会读取 obj.text
    document.body.innerText = obj.text
}

上面代码的意思是:副作用函数effect会设置 body 元素的 innerText 属性,其值为 obj.text。为了响应式的实现 ,当 obj.text 的值发生变化时,希望副作用函数effect重新执行,例如:

obj.text = 'hello vue3' // 修改 obj.text 的值,希望副作用函数会重新执行

如果实现了上述中的希望,咱们是不是就得到了一个响应式数据了。但是显然!从上面的代码看来并不能实现这个目标,因为 obj 只是一个普通对象,即当我们修改它的值的时候,除了它本身外,不会产生任何其他反应。那么问题来了:

  • 我们该如何得到一个响应式数据

4.2 响应式数据的基本实现

从上面的代码中,我们可以稍稍微微的观察到:

  • 当副作用函数effect执行时,会触发字段 obj.text 的读取操作
  • 当修改 obj.text 的值时,会触发字段 obj.text 的设置操作

假设

如果我们能够拦截一个对象的读取设置操作,事情会变得很理所当然。假设,我们可以在读取字段 obj.text 的时候,把副作用函数存入一个变量里面,当来到设置环节的时候,再把副作用函数从设置的变量中提取出来并执行它。

流程图(存入):

Vue3源码学习4(上) | 响应系统的作用与实现

流程图(提取):

Vue3源码学习4(上) | 响应系统的作用与实现

那么,现在流程图已经给出来了,此时此刻,问题的关键变成了如何才能拦截一个对象属性的读取和设置操作。也就是说:

  • 代码是如何实现拦截对象并对其属性进行读取设置

代码实现

在ES2015之前,只能用过object.definePropertry函数实现,这也是 vue2 所采用的方式。在ES2015+中,我们可以使用代理对象Proxy来实现,这也是 Vue3 所采用的方式:

// 存储副作用函数的变量
const bucket = new Set()

// 原始数据
const data = {text:'hello world'}
// 对原始数据的代理
const obj = new Proxy(data,{
    // 拦截读取操作
    get(target,key) {
        // 将副作用函数 effect 添加到存储副作用函数的变量中
        bucket.add(effect)
        // 返回属性值
        return target[key]
    },
    // 拦截设置操作
    set(target,key,newVal) {
        // 设置属性值
        target[key] = newVal
        // 把副作用函数从变量中取出来并执行
        bucket.forEach(fn => fn())
        // 返回 true 代表设置操作成功
        return true
    }
})

上述代码的路子:首先声明了一个用于存储副作用函数的变量,它是Set类型。接着定义原始数据data,obj是原始数据的代理对象,我们分别设置了getset拦截函数,用于拦截读取设置操作。当读取属性时将副作用函数effect添加到变量中,即bucket.add(effect),然后返回属性值;当设置属性时先更新原始数据,再将副作用函数从变量中提取出来并重新执行,这样子就可以实现响应式了。

目前的实现还存在很多缺陷,例如我们直接通过名字(effect)来获取副作用函数,这是很不灵活的。副作用函数的名字可以任意取,我们完全可以把副作用函数命名为我们想要的,例如:myEffect,甚至是一个匿名函数。在此之前,我们只需要理解响应式数据的基本实现工作原理即可。

4.3 设计一个完善的响应式系统

这里为什么是说完善呢?很显然,上面的一切虽然已经实现了响应式数据,但是它还不够微妙,总的来说还不够完善,这里就尝试一下去构造一个比较完善的响应系统

变量的声明

一个响应系统的工作流程:

  • 读取操作发生时,将副作用函数存储到声明的变量中
  • 设置操作发生时,从变量中取出副作用函数并执行

阅读到这里的友友们都知道,上面我们都硬编码了副作用函数的名字(effect),导致一旦副作用函数的名字不叫effect,那么这段代码就不能正常地工作。而我们希望的是:

  • 哪怕副作用函数是一个匿名函数,也能够被正确地存储到变量中。

代码实现:

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

定义了一个全局变量activeEffect,初始值是undefined,它的作用是存储被注册的副作用函数。接着重新定义了effect函数,让它变成了一个用来注册副作用函数的函数,effect函数接收一个参数fn,即要注册的副作用函数。

使用方式:

effect(
// 一个匿名的副作用函数
() => {
    document.body.innerText = obj.text
 }
)

将一个匿名函数作为 effect 函数的实参传入,会把匿名的副作用函数fn赋值给全局变量activeEffect。接着执行被注册的匿名副作用函数fn,这将会触发响应式数据 obj.text 读取操作,进而触发代理对象Proxy的拦截:

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

可以看出,在get拦截函数内把activeEffect收集到变量中,这样响应系统就不依赖副作用函数的名字了。

到了这里,你会不会有一种无敌的感觉了,但如果再对这个系统稍加测试,例如在响应式数据 obj 上设置一个不存在的属性时:

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

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

可以看到,匿名副作用函数内部读取了字段 obj.text 的值,于是匿名副作用函数字段 obj.text 之间会建立响应联系。但是,上面的代码中我们却为对象添加新的notExist属性,我们又知道,匿名副作用函数中并没有读取obj.notExist 属性的值。所以在我们目前的认知范围内:字段 obj.notExist 并没有与副作用建立响应联系,因此,定时器内语句的执行不应该触发匿名副作用的重新执行。但是,事与愿违,当定时器到时,匿名副作用函数却被重新执行,这并不是我们想要的结果.

改变变量的数据结构

产生上述问题的根本原因:没有在副作用函数与被操作的目标字段之间建立明确的联系

渣男到专一的改变

在此之前,我们使用一个Set数据结构作为存储副作用函数的变量,所以当读取属性时,无论读取的是哪一个属性,其实都一样,都会把副作用函数收集到变量中;当设置属性时,无论设置的是哪一个属性,也会把变量中的副作用函数取出并执行,也就是上面所说的 副作用函数与被操作的字段之间没有明确的联系

没有联系嘛,那解决办法也就简单:让他们哥俩建立联系,这就需要我们来重新设计存储副作用函数变量的数据结构了。那么问题来了:

  • 那应该设计怎样的数据结构呢?

在解决这个问题之前,观察一下下面的代码:

effect(function effectFn() {
    document.body.innerText = obj.text
})

在这段代码中存在三个角色:

  • 被操作(读取)的代理对象obj
  • 被操作(读取)的字段名text
  • 使用 effect 函数注册的副作用函数effectFn

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

target
  └─ key
        └─ effectFn

总之,就是一个树形结构。也就是说 副作用函数操作哪几个字段,该副作用函数就会跟相应的字段建立联系。或者说,只有我们设置了对应的key值,有且只会导致effectFn函数执行,它不会影响其他副作用函数影响,也不会去影响其他字段名。心里有谁才会受谁的影响

彻底蜕变

既然数据结构set不够专一,咱就把它换了,使用WeakMap代替它作为变量的数据结构:

// 存储副作用函数的变量
const bucket = new WeakMap()

然后修改get/set拦截代码:

const obj = new Proxy(data,{
    // 拦截读取操作
    get(tart,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) {
        // 设置属性值
        tartget[key] = newVal
        // 根据 target 从变量中取得 depsMap ,它是 key --> effects
        const depsMap = bucket.get(target)
        if(!depsMap) return
        // 根据 key 取得所有副作用函数 effects
        const depsMap = bucket.get(target)
        // 执行副作用函数
        effects && effects.forEach(fn => fn())
    }
})

上面这段代码中构建数据结构的方式:使用了 WeapMap + Map + Set:

  • WeapMap 由 target --> Map 构成
  • Map 由 key --> Set 构成

它们的关系如图:

Vue3源码学习4(上) | 响应系统的作用与实现 从上图可以看出,其中WeakMap的键是原始对象 targetWeakMap的值是一个Map实例,而Map的键是原始对象targetkey,Map的值是一个副作用函数组成的set。也可以把Set数据结构所存储的副作用函数函数集合称为key的 依赖集合

WeakMapMap 的区别

嗷!看到这里的掘友或多或少会有点疑惑:

  • 为什么使用WeakMap替代Map就可以达到我们想要效果呢?

这就关系到垃圾回收器的问题了,简单的说,WeakMap对 key 是弱引用,不影响垃圾回收器的工作。根据这个特性可知,一旦 key 被垃圾回收期回收,那么对应的键和值就访问不到了。所有WeakMap经常用于存储那些只有当 key 所引用的对象存在时(没有被回收)才有价值的信息。

例如上面的场景:如果target对象没有任何引用了。说明用户侧不再需要它了,这时垃圾回收器会完成任务。但如果使用Map来代替WeakMap,那么用户侧的代码对target没有任何引用,这个target也不会被回收,最后可以能导致内存溢出。

最后的黑夜

最后,对上文上的代码做一些封装处理,当读取属性值时,我们将部分逻辑单独封装到一个track函数中,同样,也可以把触发副作用函数重新执行的逻辑封装到trigger函数中:

const obj = new Proxy(data,{
    // 拦截读取操作
    get(target,key) {
        // 将副作用函数 activEffect 添加到存储副作用函数的变量中
        tarck(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 target[key]
        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) {
        tartget[key] = newVal
        const depsMap = bucket.get(target)
        if(!depsMap) return
        const depsMap = bucket.get(target)
        effects && effects.forEach(fn => fn())
}

分别封装到tracktrigger函数内,带来了极大的灵活性

待续