响应式数据与副作用函数
副作用函数指的是会产生副作用的函数,如下面代码:
function effect(){
ducument.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
}
如上面代码所示,当obj.text的值发生变化时,希望副作用函数effect会重新执行,如果能实现这个目标,那么对象obj就是响应式数据。很显然现在还做不到,obj只是一个普通对象,值的变化不会产生其他反应。下面来看如何实现
响应式数据的基本实现 如何才能让obj变成响应式数据?目前有两条线索:
- 当副作用函数effect执行时,会触发字段obj.text的读取操作
- 当修改obj.text的值时,会触发字段obj.text的设置操作
如果能拦截一个对象的读取和设置操作,事情就简单了 当读取字段obj.text时,可以把副作用函数effect存储到一个“桶(bucket)”里。 接着,当设置obj.text时,再把副作用函数effect从“桶(bucket)”里去除执行即可。 总结: 读取obj.text -> 存储函数effect 到桶 设置obj.text -> 从桶里取出执行函数effect
那么如何才能拦截一个对象属性的读取和设置操作。在ES2015+中,可以使用代理对象Proxy来实现,
// 存储副作用函数的桶
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())
return true
}
})
这样就实现了响应式数据,可以用下面的代码测试:
// 副作用函数
function effect(){
document.body.innerText = obj.text
}
// 执行副作用函数,触发读取
effect()
// 1秒后修改响应式数据
setTimeout(()=>{
obj.text = 'hello vue3'
}, 1000)
在浏览器上运行这段代码,会得到预期效果。 但上面的代码还存在很多缺陷,例如这里是直接通过名字(effect)来获取副作用函数的,这种硬编码方式很不灵活。
设计一个完善的响应系统 1 不依赖副作用函数的名字 针对上面例子的来一步步进行优化。 首先上面硬编码了副作用函数的名字,其实最好的是哪怕副作用函数是一个匿名函数,也能够被正确地收集到“桶”中,这就需要提供一个用来注册副作用函数的机制,如下代码所示:
// 用一个全局变量存储被注册的副作用函数
let activeEffect
// effect 函数用于注册副作用函数
function effect(fn){
// 当调用 effect 注册副作用函数时,将副作用函数fn赋值给 activeEffect
activeEffect = fn
// 执行副作用函数
fn()
}
通过下面的代码使用effect函数
effect(
// 一个匿名的副作用阿含糊
() => {
document.body.innerText = 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
}
})
如上面代码所示,由于副作用函数已经存储到了activeEffect中,所以在get拦截函数内应该把activeEffect收集到“桶”中,这样响应式系统就不依赖副作用函数的名字了
如果在响应式数据obj上设置一个不存在的属性,如下:
effect(
() => {
console.log('effect run') //会打印两次
document.body.innerText = obj.text
}
)
setTimeout(()=>{
// 副作用函数中并没有读取notExist 属性的值
obj.noExist = 'hello vue3'
}, 1000)
如果为对象obj添加了新的notExist属性,理论上obj.notExist没有和匿名副作用函数建立得响应联系,因此定时器内语句的执行不应该触发匿名副作用函数重新执行。但如果执行上面的代码,会发现定时器到时后,匿名副作用函数却重新执行了,这是不正确的,要解决这个问题,需要重新设计“桶”的数据结构
其实导致这个问题的根本原因是没有在副作用函数与被操作的目标字段之间建立明确的联系,也就是当读取属性时,无论读取的是哪一个属性,都会被副作用函数收集到“桶”里,设置属性同理。解决方法很简单,只需要再副作用函数与被操作的字段之间建立联系即可,这样就不能简单地使用Set类型的数据作为“桶”
如果用target来表示一个代理对象所代理的原始对象,用key来表示被操作的字段名,用effectFn来表示被注册的副作用函数,那么就可以为这三个角色建立一种树型结构:
target
- key
- effectFn
如果有两个副作用函数同时读取同一个对象的属性值,那么关系如下
target
- key
- effectFn1
- effectFn2
如果一个副作用函数中读取了同一个对象的两个不同属性,那么关系如下
target
- text1
- effectFn
- text2
- effectFn
如果在不同的副作用函数中读取了两个不同对象的不同属性,那么关系如下
target1
- text1
- effectFn1
target2
- text2
- effectFn2
接下来尝试用代码来实现这个新的“桶”。首先,需要使用WeakMap代替Set作为桶的数据结构
const bucket = new WeakMap()
修改 get/set拦截器代码:
const obj = new Proxy(data, {
// 拦截读取操作
get(target, key){
// 没有 activeEffect, 直接return
if(!activeEffect) return
// 根据target从桶里获取depsMap, depsMap是一个Map类型 key --> effects
let depsMap = bucket.get(target)
// 如果不存在 depsMap,那么新建一个Map并与target关联
if(!depsMap){
bucket.set(target, {depsMap = new Map()})
}
// 根据key从depsMap中取得deps,deps是一个Set类型
// deps保存的是与当前key相关联的作用函数:effects
let deps = depsMap.get(key)
// 如果deps不存在,则新建
if(!deps){
depsMap.set(key, (deps = new Set()))
}
// 加入“桶”中
deps.add(activeEffect)
// 返回属性值
return target[key]
},
set(target, newVal){
target[key] = newVal
//根据target获取depsMap
const depsMap = bucket.get(target)
if(!depsMap) return
//根据key获取effects
const effects = depsMap.get(key)
effects && effects.forEach(fn => fn())
}
})
这里面的关系可以用json数据结构表示
{
target1:{
key1: [effect1, effect2]
},
target2:{
key2: [effect3, effect4]
},
}
下面解释下为什么要用WeekMap,先看一段代码:
const map = new Map();
const wekmap = new WeekMap();
(function(){
const foo = {foo: 1};
const bar = {bar: 2};
map.set(foo, 1);
weekmap.set(bar, 2)
})
上面的代码中,执行后,对于对象foo来说,它仍然作为map的key被引用着,因此垃圾回收器不会把它从内存中移除。然而对于对象bar来说,由于WeekMap的key是弱引用,它不影响垃圾回收器的工作,一旦表达式完毕,垃圾回收器会把bar从内存中移除,也就无法通过weakmap取得对象bar 简单来说,WeakMap对key是弱引用,不影响垃圾回收器的工作。所以一旦key被垃圾回收器回收,那么对应的键和值都找不到了,所以WeakMap经常用于存储那些只有当key所引用的对象存在时(没被回收)才有价值的信息。避免了内存溢出的可能。 最后对代码进行封装
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) {
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())
}
|