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 的时候,把副作用函数
存入一个变量
里面,当来到设置
环节的时候,再把副作用函数
从设置的变量
中提取出来并执行它。
流程图(存入):
流程图(提取):
那么,现在流程图已经给出来了,此时此刻,问题的关键变成了如何才能拦截一个对象属性的读取和设置操作
。也就是说:
- 用
代码
是如何实现拦截
对象并对其属性进行读取
和设置
?
代码实现
在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是原始数据的代理对象
,我们分别设置了get
和set
拦截函数,用于拦截读取
和设置
操作。当读取属性
时将副作用函数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
构成
它们的关系如图:
从上图可以看出,其中WeakMap
的键是原始对象 target
,WeakMap
的值是一个Map
实例,而Map
的键是原始对象target
的key
,Map
的值是一个副作用函数组成的set
。也可以把Set
数据结构所存储的副作用函数函数集合称为key的 依赖集合
WeakMap
和 Map
的区别
嗷!看到这里的掘友或多或少会有点疑惑:
- 为什么使用
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())
}
分别封装到track
和trigger
函数内,带来了极大的灵活性
待续
转载自:https://juejin.cn/post/7156430516462288933