一分钟早读系列:推演vue3响应式(一)effect基础篇
vue中最出名的就是响应式变化,仅通过改变数据的方式即可改变视图,那么vue是如何做到的呢?
我们正常使用vue3中,通常是使用ref(str|num)
和reactive(obj)
,其中ref
本质上也是调用reactive
,在reactive
中对入参进行了拦截处理,这里面的核心是effect
的收集,执行能力。
为了通俗易懂,作者将拆分几个章节,按照顺序讲解响应式。本文的内容是基于一个小例子,一步步推演effect
源码。
假设我们现在有一个obj
,里面有一个key
为text
,我们期望当前body
的内容与text
绑定,可得出下面的代码
const obj = {text: 'hello'}
document.body.innerText = obj.text
可光是这样还不够,因为这段代码是死的——改变text的值无法同步给body
,所以我们需要对obj
的改变做拦截处理。目前在js中拦截的方式有两种,Object.defineProperty
和Proxy
,本文以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
})
其中有三个角色
- 代理对象obj
- 读取属性text
- 副作用函数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())
}
现在我们对比下源码,轮廓上已经大致相似了
由于篇幅问题,我们基础篇就先到这里。与源码不同的地方在于,源码还要做更多的兼容处理,比如清理失效的副作用函数,嵌套effect等,在进阶篇会基于基础篇的代码继续推演,直到与源码相同。
转载自:https://juejin.cn/post/7279041076273643532