基于Proxy从0到1实现响应式数据,读完本文你会收获:
get和set都分别做了什么在本文开始前我们先理解一个概念副作用函数
副作用函数是什么?
副作用的函数不仅仅只是返回了一个值,而且还做了其他的事情:
通俗点理解就是副作用函数就是会产生副作用的函数如下所示:
function effect() {
document.getElementById('text').innerHTML = obj.text
}除了effect 函数之外的任何函数都可以读取或设置body的文本内容,也就是说,effect函数的执行会直接或间接影响其他函数的执行,这时就可以说effect函数产生了副作用
假设在一个副作用函数中读取了某个对象的属性:
const obj = { text: 'hello anju' }
function effect() {
document.getElementById('text').innerHTML = obj.text
}这时我们希望当obj.text 发生变化时,副作用函数 effect会重新执行
obj.text = 'hello world'如果能实现这个目标,那对象obj就是响应式数据
观察如下代码:
const obj = { text: 'hello anju' }
function effect() {
document.getElementById('text').innerHTML = obj.text
}1、当副作用函数effect执行时,会触发字段 obj.text的 读取 操作;
2、当修改obj.text的值时,会触发字段obj.text的 设置 操作
那么如果我们能拦截一个对象的
读取和设置操作是不是就可以让事情变得简单? 如何拦截?
这里我们看一下vue的拦截方式:

1、vue2:Object.defineProperty()
2、vue3:Proxy
目标对象之前架设一层拦截,外部所有的访问都必须先通过这层拦截,因此提供了一种机制,可以对外部的访问进行过滤和修改因为Object.defineProperty()存在一定的缺陷,所以这里我们采用Proxy来实现
首先我们定义一个存储副作用函数的桶
// 存储副作用函数的桶
const bucket = new Set()// 原始数据
const data = { text: 'hello anju' }使用Proxy代理原始数据
// 代理原始数据
const obj = new Proxy(data, {
// 拦截读取操作
get(target, key) {
// 将副作用函数存储至桶中
bucket.add(effect)
// 返回属性值
return target[key]
},
// 拦截设置操作
set(target, key, newVal) {
// 设置属性值
target[key] = newVal
// 把副作用函数从桶里取出并执行
bucket.forEach(fn => fn())
// 返回true 代表设置成功
return true
}
})我们执行下实例代码(因为是在vue项目里写的实例代码,所以这里用onMounted):
function effect() {
document.getElementById('text').innerHTML = obj.text
}
onMounted(() => {
effect()
setTimeout(() => {
obj.text = 'hello vue3'
}, 2000)
})看下执行效果:

成功(。◝‿◜。),至此,一个基础版的响应式系统就实现了
我们目前实现的只是一个基础版的响应式系统,那跟完善的响应式系统相比我们还差哪些东西?
首先,我们可以看到我们刚实现的基础版的响应式系统存在一个硬编码的问题,耦合度高,过度依赖副作用函数的名称(effect)
所以我们要优先解决下硬编码的问题,这里我们再次的观察一下我们刚实现的基础版响应式数据,想一想一个响应式系统的工作流程是什么?
// 存储副作用函数的桶
const bucket = new Set()
// 原始数据
const data = { text: 'hello anju' }
// 代理原始数据
const obj = new Proxy(data, {
// 拦截读取操作
get(target, key) {
// 将副作用函数存储至桶中
bucket.add(effect)
// 返回属性值
return target[key]
},
// 拦截设置操作
set(target, key, newVal) {
// 设置属性值
target[key] = newVal
// 把副作用函数从桶里取出并执行
bucket.forEach(fn => fn())
// 返回true 代表设置成功
return true
}
})响应式系统的工作流程:
所以这里我们就要提供一个机制,能去注册副作用函数:
// 用一个全局变量存储被注册的副作用函数
let activeEffect
// effect 函数用于注册副作用函数
function effect(fn) {
// 调用effect 注册副作用函数时,将副作用函数fn 赋值给activeEffect
activeEffect = fn
// 执行副作用函数
fn()
}加入此机制,重新改善下我们基础版的响应式系统:
// 触发响应式数据obj.text 的读取操作,
// 进而触发代理对象Proxy的 get 拦截函数
effect(
// 匿名的副作用函数
() => {
document.getElementById('text').innerHTML = obj.text
}
)
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
}
})(。◝‿◜。),至此,一个解决了硬编码问题的响应式系统就实现了
但是,到这里我们的响应式系统还是不够完善,如果我们给响应式数据obj上设置一个不存在的属性时,会发生什么呢?
effect(
() => {
console.log('effect run')
document.getElementById('text').innerHTML = obj.text
}
)
setTimeout(() => {
obj.notExist = 'hello vue3'
})执行结果:

这里我们会发现匿名副作用函数执行了两遍。在副作用函数里我们并没有读取 obj.notExist 的值,理论上 obj.notExist 并没有与副作用建立响应关系,因此定时器内的语句执行不应该触发匿名副作用函数重新执行,所以造成这样的原因是什么呢?
首先我们看下我们设计存储副作用函数的桶用的是什么数据结构:
const bucket = new Set()所以问题的根本原因是:我们没有在副作用函数与被操作的目标字段之间建立明确的关系
当读取属性时,无论读取的是哪一个属性,其实都一样,都会把副作用函数收集到桶中
当设置属性时,无论设置的是哪一个属性,也都会把桶里的副作用函数取出并执行
副作用函数与被操作的字段直接没有明确的联系,所以我们要在副作用函数与被操作字段之间建立联系即可,我们需要重新设计我们的桶的数据结构,不能简单的使用一个Set类型的数据作为桶
那我们应该设计一个什么样的数据结构呢?
首先,观察如下代码:
effect(
effectFn(()=> {
document.getElementById('text').innerHTML = obj.text
})
)上述代码存在三个角色:
那么这三者的关系是什么?
这里我们
用 target 来表示一个代理对象所代理的原始对象
用 key 来表示被操作的字段名
用 effectFn 来表示被注册的副作用函数关系如下:

如果有两个副作用函数同时读取了同一个对象的属性值
effect(
effectFn1(()=>{
obj.text
})
)
effect(
effectFn2(()=>{
obj.text
})
)关系如下:

一个副作用函数读取了同一个对象的两个不同属性值
effect(
effectFn(()=>{
obj.text1
obj.text2
})
)关系如下:

综上所述,这其实就是一个树形数据结构,建立起这个关系,就可以解决我们的问题
首先我们需要 使用 weakMap代替 Set 作为桶的数据结构(weakMap对key是弱引用,不影响垃圾回收器的工作)
// 存储副作用函数的桶
const bucket = new WeakMap()然后修改 get/set拦截器的代码
const obj = new Proxy(data, {
// 拦截读取操作
get(target, key) {
//没有 activeEffect 直接return
if(!activeEffect) return
// 根据 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) {
// 设置属性值
target[key] = newVal
// 根据target 从桶中取得 depsMap, 它是 key --> effects
const depsMap = bucket.get(key)
if(!depsMap) return
// 根据key 取得所有副作用函数 effects
const effects = depsMap.get(key)
// 执行副作用函数
effect && effect.forEach(fn => fn())
}
})在上述代码内,我们分别使用了 WeakMap、Map、Set
target ---> Map 构成key---> Set 构成其中 WeakMap 的键是原始对象 target, 值是一个Map实例
Map的键是原始对象 target 的key,值是一个由副作用函数组成的Set
三者关系如下:

最后,我们在提取封装下我们的代码:
将把副作用函数收集至桶中的逻辑封装至 track(追踪) 函数
把触发副作用函数重新执行的逻辑封装至 trigger(触发)函数
const obj = new Proxy(data, {
get(target, key) {
// 将副作用函数 activeEffect 添加到存储副作用函数的桶中
tarck(target, key)
// 返回属性值
return target[key]
},
set(target, key, newVal) {
// 设置属性值
target[key] = newVal
// 取出副作用函数并执行
trigger(target, key)
}
})track
function track(target, key) {
if(!activeEffect) return
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)
}trigger
function trigger(target, key) {
const depsMap = bucket.get(target)
if(!depsMap) return
const effects = depsMap.get(key)
effects && effects.forEach(fn => fn())
}(。◝‿◜。),至此我们本篇《基于Proxy从0到1实现响应式数据》就结束了,感谢大家阅读(。◝‿◜。),如果有任何问题欢迎在评论区指出
参考内容:
[1] 霍春阳《Vue.js 设计与实现》