likes
comments
collection
share

一分钟早读系列:推演vue3响应式(一)effect基础篇

作者站长头像
站长
· 阅读数 15

vue中最出名的就是响应式变化,仅通过改变数据的方式即可改变视图,那么vue是如何做到的呢?

我们正常使用vue3中,通常是使用ref(str|num)reactive(obj),其中ref本质上也是调用reactive,在reactive中对入参进行了拦截处理,这里面的核心是effect的收集,执行能力。

为了通俗易懂,作者将拆分几个章节,按照顺序讲解响应式。本文的内容是基于一个小例子,一步步推演effect源码。

假设我们现在有一个obj,里面有一个keytext,我们期望当前body的内容与text绑定,可得出下面的代码

const obj = {text: 'hello'}
document.body.innerText = obj.text

可光是这样还不够,因为这段代码是死的——改变text的值无法同步给body,所以我们需要对obj的改变做拦截处理。目前在js中拦截的方式有两种,Object.definePropertyProxy,本文以Vue3为例,使用Proxy

const data = {text: 'hello'}
// 为了方便收集,将其转为函数,事实上正常使用中,也有watch和watchEffect函数
function effect() {
    document.body.innerText = obj.text
}
// 收集的桶,使用Set可避免重复
const bucket = new Set()

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())
        // 必须写这句,代表设置成功
        return true
    }
})
// 执行函数,触发读取text
effect()
// 延时修改响应式数据
setTimeout(() => {
    obj.text = 'hello Vue3'
}, 1000)

执行完延时中的修改操作后,可看到body的内容也自动变了。

其中的effect函数我们一般称之为副作用函数,所谓副作用函数是指那些影响外界或其他函数的函数。比如effect中修改了body的内容,如果其他函数中依赖了body内容,就会受到影响。再举个例子,有一个全局变量a,在effect1中改变a的值,那么effect1也是有个副作用函数。了解了什么是副作用函数之后,我们之后就称呼effect为副作用函数了。

其实上述代码还有很多缺陷,我们假定副作用函数的名字一定为effect,如果换了个名字,甚至是匿名函数怎么办呢?为了实现这点,我们需要一个注册副作用函数的机制

// 用一个全局变量存储副作用函数
let activeEffect
// effect函数用于注册副作用
function effect(fn) {
    // 储存副作用函数
    activeEffect = fn
    // 执行函数
    fn()
}

首先定义了一个全局变量activeEffect,作用是存储副作用函数。其次我们重新定义了函数effect,接收一个副作用函数,并将其储存并执行。我们可以这样调用effect

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

接下来对Proxy中的拦截也要做一些改动

const obj = new Proxy(data, {
    get(target, key) {
        // 将effect收集起来
        if (activeEffect) {
            bucket.add(activeEffect)
        }
        
        return target[key]
    },
    set(target, key, newVal) {
        // 设置属性值
        target[key] = newVal
        // 将收集到的函数从桶里取出来并执行
        bucket.forEach((fn => fn())
        // 必须写这句,代表设置成功
        return true
    }
})

在这个Proxy中,将收集好的副作用函数activeEffect填充进桶中,这样响应系统就可以不依赖函数名字了。但如果仔细深究,就会发现即便设置一个不存在属性时,也会触发副作用函数:

effect(() => {
    // 会打印两次
    console.log('effect run')
    document.body.innerText = obj.text
})
setTimeout(() => {
    // 设置一个非text的全新属性
    obj.noop = 'hello'
})

我们首先在匿名副作用函数中读取text,建立了副作用函数与text之间的响应关系。然后再定时器中设置了noop,理论上noop没有与副作用函数建立响应关系,所以副作用函数不应执行,但执行代码发现,副作用函数是被执行的,这显然是不正确的,为此我们需要重新设计桶的结构

首先我们先观察这点代码

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

其中有三个角色

  1. 代理对象obj
  2. 读取属性text
  3. 副作用函数effectFn

可以建立如下关系:代理对象target->属性key->副作用函数effectFn,我们粗略的描述下结构,大概是这样子的

const targetMap: WeakMap<target, Map<key, Set<effectFn>>>

为什么要用Map呢?因为我们收集的属性是对象,普通对象的属性只能为字符串。但我们可以进一步优化,在最外层使用WeakMap代替Map,也就是下面这个样子

// 储存副作用的桶
const targetMap = mew WeakMap()
const obj = new Proxy(data, {
    get(target, key) {
        // 如果没有activeEffect直接return
        if (!activeEffect) {
            return target[key]
        }
        // 根据target从桶中获取depsMap,也就是WeakMap里面的那个Map
        let depsMap = targetMap.get(target)
        if (!depsMap) {
            // 如果没有depsMap,则新建一个进行关联
            targetMap.set(target, (depsMap = new Map()))
        }
        // 取出当前key关联的所有副作用函数
        let deps = depsMap.get(key)
        if (!deps) {
            // 同理,如果没有则新建并关联
            depsMap.set(key, (deps = new Set()))
        }
        // 将副作用函数收集进桶中
        deps.add(activeEffect)
        
        return target[key]
    },
    set(target, key, newVal) {
        // 设置属性值
        target[key] = newVal
        // 取出depsMap,里面收集了收集过的key和对应的副作用函数集合
        const depsMap = targetMap.get(target)
        // 如果没有,则说明完全没收集过,直接返回即可
        if (!depsMap) return true
        // 取出key对应的全部副作用函数
        const effects = depsMap.get(key)
        // 将收集到的函数取出来并执行
        effects && effects.forEach((fn => fn())
        // 必须写这句,代表设置成功
        return true
    }
})

这里解释一下为什么使用WeakMap以及和Map的区别。WeakMap属于弱引用数据类型,这种类型的特征是不允许遍历,如果WeakMap中的key的外界引用结束了的话,会触发垃圾回收机制。反观Map,就算key的外界引用结束了,由于有可能被遍历,所以key作为Map的属性引用还在,导致垃圾回收机制不会回收这个key,最终可能会引起内存溢出。再回到上文的代码中,WeakMap收集的是target,如果target没有任何外界引用的话,说明用户并不需要这个对象了,可以回收掉。

最后,我们将上文代码进行简单封装:将get函数中收集的逻辑封装成track函数,将set中触发副作用的逻辑封装成trigger函数

const obj = new Proxy(data, {
    get(target, key) {
        // 将target和key传入函数中用以收集副作用函数
        track(target, key)
        return target[key]
    },
    set(target, key, newVal) {
        // 设置属性值
        target[key] = newVal
        // 根据target和key取出对应的全部副作用函数并执行
        trigger(target, key)
        // 必须写这句,代表设置成功
        return true
    }
})
// 追踪变化
function track(target, key) {
    if (!activeEffect) {
        return target[key]
    }
    let depsMap = targetMap.get(target)
    if (!depsMap) {
        targetMap.set(target, (depsMap = new Map()))
    }
    let deps = depsMap.get(key)
    if (!deps) {
        depsMap.set(key, (deps = new Set()))
    }
    deps.add(activeEffect)
}
// 触发变化
function trigger(target, key) {
    const depsMap = targetMap.get(target)
    if (!depsMap) return
    const effects = depsMap.get(key)
    effects && effects.forEach((fn => fn())
}

现在我们对比下源码,轮廓上已经大致相似了

一分钟早读系列:推演vue3响应式(一)effect基础篇

一分钟早读系列:推演vue3响应式(一)effect基础篇

一分钟早读系列:推演vue3响应式(一)effect基础篇

一分钟早读系列:推演vue3响应式(一)effect基础篇 由于篇幅问题,我们基础篇就先到这里。与源码不同的地方在于,源码还要做更多的兼容处理,比如清理失效的副作用函数,嵌套effect等,在进阶篇会基于基础篇的代码继续推演,直到与源码相同。