响应式数据的基本实现
我们先了解一个副作用函数,什么是副作用函数呢?
副作用函数:一个会产生副作用的函数,副作用是指函数在正常工作任务之外对外部环境所施加的影响。
副作用很容易产生,例如有一个函数修改了全局变量,这其实也是一个副作用,如:
let text = '1’ // 全局变量
function effect () {
text = '2’ // 修改全局变量,产生副作用
}
我们再来了解什么是响应式数据?
比如 我们有一个副作用函数,其内部读取了某个对象的属性,当前该属性发生变化时,我们能重复执行副作用函数:
const obj = { text: 'hello world!' }
function effect () { // 副作用函数中 读取了 obj对象的text值,同时将该值设置为body的文本
document.body.innerText = obj.text
}
// 当前 obj.text 的值 发生变化时,我们需要能再次执行上面的effect 副作用函数
obj.text = 'hello vue3'
上面代码中,我们如何才是实现响应式呢?
如果我们能拦截obj对象的 get(读取) 与 set(设置)操作,这样当我们监测到obj.text 读取值的时候,我们可以将副作用函数 effect 存储到一个“桶”里面,如下面所示:
上图 存储副作用函数
当 obj.text 值 发生变化时,我们再从“桶”里面取出对应的副作用函数并执行,如图所示:
上图 触发副作用函数
基于 代理对象 Proxy,实现对象值的读取及设置拦截, 下面我们就根据 Proxy 来实现响应式:
// “桶” 用于存储 副作用函数
const bucket = new Set()
// 原数据
const data = { text: 'hello world!' }
// 对原数据的代理
const obj = new Proxy(data, {
get (target, key) { // 拦截读取
// 将副作用函数添加到 “桶”中
bucket.add(effect)
return target[key]
},
set (target, key, newValue) { // 拦截设置
target[key] = newValue
// 执行“桶”中的副作用函数
bucket.forEach(fn => fn())
}
})
// 重点看上面代码
function effect () { // 副作用函数中 读取了 obj对象的text值,同时将该值设置为body的文本
document.body.innerText = obj.text
}
effect() // 执行副作用函数 触发读取
// 1s后修改 obj.text 值
setTimeout(() => obj.text = 'hello vue3', 1000)
上面代码中,我们先创建了 bucket 且是一个 Set 类型的值,它是用于存储副作用函数的桶。还定义了 原数据 data 及 代理对象 obj,设置里 get 和 set 拦截函数,用于拦截读取与设置。当读取属性的时候 我们将 effect 副作用函数添加到bucket中,同时返回属性值,当设置属性值时先更新原数据,然后再遍历 bucket 并执行里面的副作用函数。然后,定义 effect 函数,其将obj.text 属性值设置为body的文本,我们又执行了 effect 函数,后面设置了一个定时器 1s 后执行 将 obj.text 修改为 hello vue3。在控制台中执行了上面代码,来验证我们响应式的实现是成功了的。
当然在我们上面代码的响应式监听中还是不完善的,有很多bug,我们来继续完善它。
微型响应式系统
副作用函数当然不能是我们写死的一个函数,我们需要设置个桶 来 存储 副作用函数,然后提供一个用于注册副作用函数的机制,如下所示:
// 设置一个变量用于存储被注册的副作用函数
let activeEffect
// effect 函数哟哦那个鱼注册副作用函数
function effect (fn) {
// 将副作用函数 fn 赋值给 activeEffect
activeEffect = fn
fn() // 执行 副作用函数
}
// 示例 注册副作用函数:掉用effect 方法 并传入 对应的副作用函数
effect(() => document.body.innerText = obj.text)
我们使用一个匿名的副作用函数作为 effect 函数的参数。当 effect 函数执行时,首先会把匿名的副作用函数 fn 赋值给全局变量 activeEffect 。接着执行被注册的匿名副作用函数 fn,这将会触发响应式数据 obj.text 的读取操作,进而触发代理对象 Proxy 的 get 拦截函数,我们在get 拦截函数中 修改 副作用函数 读取,从以前的 effect 修改成 activeEffect,判断activeEffect是否有值,如果有我们将这个副作用函数添加到 bucket 中去,这样响应系统就不依赖副作用函数名字,代码如下所示 :
// 下面我来完善 Proxy
// 对原数据的代理
const obj = new Proxy(data, {
get (target, key) { // 拦截读取
if (activeEffect) {
// 将副作用函数添加到 “桶”中
bucket.add(activeEffect)
}
return target[key]
},
set (target, key, newValue) { // 拦截设置
target[key] = newValue
// 执行“桶”中的副作用函数
bucket.forEach(fn => fn())
}
})
如果我们在响应式数据 obj 上设置一个不存在的属性(新增属性)时,我们会发现我们的副作用函数也会执行,按照理论上来说,新增一个属性,同时这个属性与副作用函数没有简历响应关系,我们修增或修改与副作用函数无关的值,不应该出发副作用函数重新执行。为了解决这个问题我们现在需要重新设计一个 bucket 的数据结构。
我们上面设置的 bucket 是一个 Set 数据结构,我们没有在副作用函数与被操作的目标字段之间建立明确的联系。现在问题很明确就是副作用函数与被操作的字段之间没有建立联系,如果需要建立联系,我们就得重新设计 bucket 的数据结构,上面使用 Set 类型 作用"桶"的数据结构已经满足不了我们的需求来。
那么我们应该怎么样来设计这个数据结构才能符合我们的需求呢?我们需要仔细观察一下我们副作用函数代码,上面的副作用函数来说:
// 示例 注册副作用函数:掉用effect 方法 并传入 对应的副作用函数(这里使用 effectFn 来表示被注册的副作用函数)
effect(fucntion effectFn() { document.body.innerText = obj.text })
在这段代码中存在三个角色:
- 代理对象 obj
- 代理对象字段名 text
- 使用 effect 函数注册的副作用函数 effectFn
如果用 target 来表示一个代理对象所代理的原始对象,用 key 来表示被操作的字段名,用 effectFn 来表示被注册的副作用函数,那么可以为这三个角色建立如下关系:
target
└── key
└── effectFn
下面举几个例子来对其进行补充说明:
// 如果有两个副作用函数同时读取同一个对象的属性值:
effect(function effectFn1() {
obj.text
})
effect(function effectFn2() {
obj.text
})
// 关系如下:
target
└── text
└── effectFn1
└── effectFn2
// 一个副作用函数中读取了同一个对象的两个不同属性:
effect(function effectFn3() {
obj.text1
obj.text2
})
// 关系如下:
target
└── text1
└── effectFn3
└── text2
└── effectFn3
// 在不同的副作用函数中读取了两个不同对象的不同属性:
effect(function effectFn4() {
obj1.text1
})
effect(function effectFn5() {
obj2.text2
})
// 关系如下:
target1
└── text1
└── effectFn4
target2
└── text2
└── effectFn5
了解了上面案例,我相信你是理解了新的桶数据结构,其实就是一个树型数据结构。接下来我们就尝试实现这个新的“桶”,首先需要先将bucket 变更为 WeakMap 类型:
// “桶” 用于存储 副作用函数
const bucket = new WeakMap() // 后面会解释为什么用WeakMap 类型 而不是其他类型
然后我们再修改 代理拦截get、set代码:
// 给原数据、属性、副作用函数 建立关系
// 对原数据的代理
const obj = new Proxy(data, {
get (target, key) { // 拦截读取
// 如果没有 activeEffect,不需要建立关系,直接返回对应属性值
if (!activeEffect) {
return target[key]
}
// 根据 target 从 bucket 桶中 获取 depsMap
let depsMap = bucket.get(target)
// 如果 depsMap 不存在,那么就新建一个 Map 与 target 关联
if (!depsMap) {
bucket.set(target, (depsMap = new Map()))
}
// 根据 key 从 depsMap 中取得 deps 值, 它是一个 Set 类型
// 里面存储所有与当前 key 相关的副作用函数
let deps = depsMap.get(key)
// 如果 deps 不存在,那么就新建一个 Set 与 key 关联
if (!deps) {
depsMap.set(key, (deps = new Set()))
}
// 将当前副作用函数添加到 deps 中
deps.add(activeEffect)
return target[key]
},
set (target, key, newValue) { // 拦截设置
target[key] = newValue
// 这里 就是 负责取值同时执行
// 根据 target 从 bucket 桶中 获取 depsMap
const depsMap = bucket.get(target)
// 如果没有直接 return
if (!depsMap) return
// 根据 key 取得对应的 副作用函数集
const effects = depsMap.get(key)
// 执行副作用函数
effects?.forEach(fn => fn && fun())
}
})
根据上面段代码可以看出构建数据结构的方式,我们分别使用了 WeakMap、Map 和 Set:
- WeakMap 由 target --> Map 构成
- Map 由 key --> Set 构成
WeakMap、Map 和 Set 之间的关系图:
搞清了它们之间的关系,我们有必要解释一下这里为什么要使用 WeakMap,这其实涉及 WeakMap 和 Map 的区别。
我们引用一下《JavaScript高级程序设计(第四版)》的原话:
ECMAScript6新增的”弱映射“(WeakMap)是一种新的集合类型,为这门语言带来了增强的键值对存储机制。WeakMap是Map的”兄弟“类型,其API也是Map的子集。WeakMap中的”weak“(弱),描述的是JavaScript垃圾回收程序对待的”弱映射“中键的方式。 ---- 《JavaScript高级程序设计(第四版)》6.5
Map与WeakMap简单区别
- Map的键值可以是原始数据类型和引用类型,WeakMap的键值只能说引用类型(object)
- Map可以迭代遍历键,WeakMap不可迭代遍历键
WeakMap中的”weak“表示弱映射的键是弱引用,意思就是这些键不属于正式的引用。换言之,WeakMap所构建的实例中,其key键所对应引用地址的引用断开或不属于指向同一个内存地址的时候,其对应value值就会被加入垃圾回收队伍。
如果使用 Map 来代替 WeakMap,那么即使用户侧的代码对 target 没有任何引用,这个 target 也不会被回收,最终可能导致内存溢出。
我们用一段代码来讲解:
const map = new Map()
const weakmap = new WeakMap()
(function(){
const foo = {foo: 1}
const bar = {bar: 2}
map.set(foo, 1)
weakmap.set(bar, 2)
})()
我们定义了 map 和 weakmap 常量,分别对应 Map 和 WeakMap 的实例。接着定义了一个立即执行的函数表达式(IIFE),在函数表达式内部定义了两个对象:foo 和 bar,这两个对象分别作为 map 和 weakmap 的 key。当该函数表达式执行完毕后,对于对象 foo 来说,它仍然作为 map 的 key 被引用着,因此垃圾回收器(grabage collector)不会把它从内存中移除,我们仍然可以通过map.keys 打印出对象 foo。然而对于对象 bar 来说,由于 WeakMap 的 key 是弱引用,它不影响垃圾回收器的工作,所以一旦表达式执行完毕,垃圾回收器就会把对象 bar 从内存中移除,并且我们无法获取 weakmap 的 key 值,也就无法通过 weakmap 取得对象 bar。
最后,我们对上面代码做一些封装处理:
// 对原数据的代理
const obj = new Proxy(data, {
get (target, key) { // 拦截读取
// 调用 track 处理函数 将 activeEffect 添加到 bucket 副作用函数关联桶里
track(target, key)
return target[key]
},
set (target, key, newValue) { // 拦截设置
target[key] = newValue
// 把 关联的副作用函数 从 bucket 桶中取出并执行
trigger(target, key)
}
})
// 在 get 拦截函数内掉用 track 函数追踪变化
function track (target, key) {
// 如果没有 activeEffect,不需要建立关系
if (!activeEffect) {
return
}
// 根据 target 从 bucket 桶中 获取 depsMap
let depsMap = bucket.get(target)
// 如果 depsMap 不存在,那么就新建一个 Map 与 target 关联
if (!depsMap) {
bucket.set(target, (depsMap = new Map()))
}
// 根据 key 从 depsMap 中取得 deps 值, 它是一个 Set 类型
// 里面存储所有与当前 key 相关的副作用函数
let deps = depsMap.get(key)
// 如果 deps 不存在,那么就新建一个 Set 与 key 关联
if (!deps) {
depsMap.set(key, (deps = new Set()))
}
// 将当前副作用函数添加到 deps 中
deps.add(activeEffect)
}
// 在 set 拦截函数内调用 trigger 函数触发变化
function trigger (target, key) {
// 这里 就是 负责取值同时执行
// 根据 target 从 bucket 桶中 获取 depsMap
const depsMap = bucket.get(target)
// 如果没有直接 return
if (!depsMap) return
// 根据 key 取得对应的 副作用函数集
const effects = depsMap.get(key)
// 执行副作用函数
effects?.forEach(fn => fn && fun())
}
我们将 get 拦截函数里编写把副作用函数收集到“桶”里的这部分逻辑单独封装到一个 track 函数中,函数的名字叫 track 是为了表达追踪的含义。同样,我们也可以把触发副作用函数重新执行的逻辑封装到trigger 函数中,这能为我们带来极大的灵活性。