响应系统也是 Vue.js 的重要组成部分,我们尝试实现一个相对完善的响应系统。接下来,我们就从认识响应式数据和副作用函数开始,初步了解响应系统的设计与实现。
一.副作用函数
概念
"副作用"名词解释是:随着主要作用而附带着发生的不好的作用。副作用函数指的是该函数的运行,会直接或间接影响其他函数或者变量的结果,那么这个函数就产生了副作用,称之为副作用函数。
举列说明
例 1:对某些函数产生的副作用
fuction effect(text){
document.body.innerText = text
}
function getText() {
return document.body.innerText
}
effect函数会修改 innerText 的内容。getText会返回innerText的值。如果我们使用effect修改了innerText内容,那么会影响到getText获取内容,那么这时effect就是个副作用函数。
例 2:函数的运行修改了全局变量的值
// 全局变量
let status = false
// effect函数执行,会影响status的值
function effect() {
status = true
}
二.响应式数据
响应式数据就是:数据变更时,所有依赖该数据的地方都发生变更,该数据称之为响应式数据。
举列说明
let obj = { name: "哈哈", age: "18" };
function effect () {
// effect 函数的执行会读取obj.name
document.body.innerText = obj.name
}
effect()
obj.name = '哈哈999' // 修改obj.name 的值,同时希望副作用函数会重
新执行,从而实现页面更新
数据 obj 更新,变更所有依赖 obj 的地方,那么obj就是响应式数据。很显然,上述代码是无法实现响应式的,那么如何让数据实现响应式呢?下面我们一步一步分析实现。
三.如何实现响应式数据
简单响应系统实现
通过观察,我们不难发现两点线索:
当函数执行时,会触发 obj 的读取操作;
当修改 obj 时,会触发 obj 的设置操作.
如果说我们可以拦截一个对象的读取和设置,从以下两点出发,我们可以实现简单的响应式。
当读取obj的值时,将读取数据的副作用函数effect存储起来;
当设置obj的值时,将 effect 取出并执行。
那么我们如何拦截一个数据的读取和设置呢?Vue.js3中采用代理对象Proxy来实现。
Proxy,原意为代理,Proxy 对象是 ES6 新出的一个特性,用于创建一个对象的代理,从而实现基本操作的拦截和自定义(如属性查找、赋值、枚举、函数调用等)。
Proxy 对象由两个参数组成:目标对象和处理程序对象。目标对象是我们要代理的对象,处理程序对象是一个包含拦截器方法的对象。拦截器方法是一个函数,它会在目标对象的属性或者方法被访问或者修改时调用。
具体拦截方法
get(target, property, receiver)方法用于拦截某个属性的读取操作,可以接收三个参数:
target:目标对象;
property:属性名;
receiver:proxy 实例本身(可选参数)
当读取代理对象属性值时,会触发get方法(不一定是get,这里以get为例),返回属性值,将读取数据的副作用函数effect存储起来。
set(targer,property,value,receiver)方法用来拦截某个属性的赋值操作,可以接收四个参数:
target:目标对象;
property:属性名;
value:属性值;
receiver:proxy 实例本身(可选参数)
当设置代理对象属性值时,会触发set方法,更新属性值,将 effect函数取出并执行。
ES6 提供了新的数据结构 Set。它类似于数组,但是成员的值都是唯一的,没有重复的值。我们使用Set 数据结构来存储数据的
副作用函数合集。
根据如上思路,采用 Proxy 来实现:
let data = { name: "哈哈", age: "18" };
function effect () {
// effect 函数的执行会读取obj.name
document.body.innerText = obj.name
}
// 存储副作用函数
const bucket = new Set()
// 给obj设置一个代理
let obj = new Proxy(data,{
get(target,property){
console.log("我读取了" + property);
console.log("它的值为" + target[property]);
// 将副作用函数 effect 添加到存储副作用函数的桶中
bucket.add(effect)
// 定义你要返回的值
return target[property];
},
set(target, property, value){
console.log("要设置对象属性?我拦截到了~");
console.log("要修改成" + value);
target[property] = value;
// 把副作用函数从桶里取出并执行
bucket.forEach(fn => fn())
// 返回 true 代表设置操作成功
return true
}
})
// 执行副作用函数,触发读取,注意定义代理后得用代理来调用属性或方法
effect()
// 1 秒后修改响应式数据,触发设置
setTimeout(() => {
obj.name = '哈哈999'
}, 1000)
在浏览器中运行上面这段代码,我们会发现,这样我们就实现了简单的响应式数据:
完善响应系统
上文实现的简易响应系统中,我们可以通过测试发现一下两个缺陷:
1.副作用函数的名字发生变化时,无法收集。
2.没有在副作用函数与被操作的目标字段之间建立明确的联系。
1.注册副作用函数
为了解决当函数名字发生变化时或者匿名函数,无法被正确收集这一问题。我们可以实现一个注册副作用函数的机制,专门收集副作用函数。
我们重新定义上文的effect函数,将其变成一个专门收集副作用函数的函数,然后使用一个其他名字的副作用函数作为effect的参数:
// 用一个全局变量存储被注册的副作用函数
let activeEffect
function effect(fn){
activeEffect = fn
fn()
}
effect(effectFn)
那么我们将函数的名字做一个修改:
let activeEffect
function effect(fn){
activeEffect = fn
fn()
}
function effectFn2(){
document.body.innerText = obj.name
}
effect(effectFn2)
可以发现:当函数的名字发生变化时,依然会收集。由于副作用函数已经存储到了activeEffect中,所以在 get 拦截函数内应该把activeEffect收集到bucket中,这样响应系统就不依赖副作用函数的名字了。
2.建立明确联系
由于副作用函数与被操作的字段没有明确的联系,无论读取的哪个属性,副作用函数都会被收集,无论设置哪个属性,副作用函数都会被执行。
那么如何将副作用函数与被操作的字段建立明确的联系呢?我们观察上文中的例子,可以发现3个角色:
被操作/读取的代理对象Obj;
被操作/读取的字段name;
使用effect函数注册的副作用函数effectFn
为了方便描述,我们用target来表示一个代理对象所代理的原始对象,用key来表示被操作的字段名,用effectFn来表示被注册的副作用函数。由上文的例子,我们可以给这3个角色建立如下关系图:
如果一个副作用函数读取了同一个对象中同一个属性?
如果多个副作用函数读取了同一个对象的不同属性?
如果多个副作用函数读取了不同对象的不同属性?
要想都满足以上几点,我们需要建立下图中的对应关系,如图所示:
由此图可以看出,target、key、effectFn三者的关系变得复杂,那么就需要我们重新设计
bucket 的数据结构,需要使用 WeakMap 代替 Set 作为桶的数据结构:
WeakMap是弱字典、弱映射,以键值对形式存放,键只能是对象,对对象的引用是弱引用,在没有其他引用和该键引用同一对象,这个对象将被垃圾回收机制回收。
// 存储副作用函数
const bucket = new WeakMap()
// 当前激活被注册的副作用函数
let activeEffect
当读取属性值时,会触发 get 拦截函数,将副作用函数收集到bucket中,我们将这部分逻辑单独封装到一个 track 函数中:
// 将副作用函数收集到bucket中
function track(target,key){
// 如果当前没有要注册的副作用函数,直接return
if(!activeEffect) return
// 根据 target(目标对象) 从“bucket”中取得 depsMap,它是一个 Map 类型:key -->effects
let depsMap = bucket.get(target)
// 如果不存在 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()))
}
// 最后将当前激活的副作用函数添加到“bucket”里
deps.add(activeEffect)
}
当读取属性值时,会触发 set 拦截函数,副作用函数重新执行,把这部分逻辑封装到 trigger 函数中:
function trigger(target, key) {
// 根据 target 从bucket中取得 depsMap,它是 key --> effects
const depsMap = bucket.get(target)
if (!depsMap) return
// 根据 key 取得所有副作用函数 effects
const effects = depsMap.get(key)
// 执行副作用函数
effects && effects.forEach(fn => fn())
}
执行:
// 给obj设置一个代理
const obj = new Proxy(data,{
get(target,key){
track(target,key)
// 定义你要返回的值
return target[key];
},
set(target, key, value){
target[key] = value;
// 把副作用函数从bucket里取出并执行
trigger(target, key)
}
})
四.遗留的副作用函数
遗留的副作用函数如何产生
我们修改一下data与副作用函数:
trigger函数添加日志:
obj.ok为true时,页面展示如图:
此时副作用函数 effectFn 与响应式数据之间建立的联系如下:
我们在控制台中将obj.ok修改为fase,页面展示就发生了修改:
从console中,我们可以发现:副作用函数 effectFn依然被obj.name所对应的依赖集合收集,当我们去修改obj.name的值,会执行副作用函数 effectFn。但此时obj.ok的值为false,不再会读取字段 obj.name 的值。
此时就产生了遗留的副作用函数,无论如何修改obj.name,都会执行副作用函数 effectFn。那么副作用函数 effectFn执行时,是否可以提前将它从obj.name所对应的依赖集合中删除?
避免产生遗留的副作用函数
当然可以,但我们需要知道哪些依赖集合中与该副作用函数有关联,将其收集起来,副作用函数执行的时候,将与其从有关联的依赖集合中删除。
在 effect 内部我们定义了新的 effectFn函数,并为其添加了 effectFn.deps 属性,该属性是一个数组,用来存储所有包含当前副作用函数的依赖集合:
在 track 函数中我们将当前执行的副作用函数activeEffect 添加到依赖集合 deps 中,这说明 deps 就是一个与 当前副作用函数存在联系的依赖集合,于是我们也把它添加到activeEffect.deps 数组中,这样就完成了对依赖集合的收集。
运行到浏览器:
有了这个联系后,我们就可以在每次副作用函数执行时,根据effectFn.deps 获取所有相关联的依赖集合,进而将副作用函数从依赖集合中移除。副作用函数的执行会导致其重新被收集到集合中,所以副作用函数从依赖集合中移除之后,需要重置 effectFn.deps 数组,避免重复收集。
运行到浏览器,修改obj的属性值,结果导致无限循环执行:
我们将其遍历删除的过程拆分处理,新建一个cleanup函数处理,并添加日志:
运行至浏览器:
从打印信息中,分析发现,修改obj.ok时,触发set,会取出obj.ok关联的依赖合集,循环该依赖合集,执行副作用函数。
当副作用函数执行时,会先从所有与该副作用函数有关联的依赖集合中,删除该副作用函数;然后再执行fn,代码如下:
函数fn执行时,会触发get,执行track函数,对应属性的依赖集合又重新收集了该副作用函数:
从而进入到一个"删除-收集-删除-收集..."的一个死循环,我们找到造成这一切的源头。该行代码执行时,就会进入死循环。
实际上cleanup函数中的清除,也会将 effects 集合中将当前执行的副作用函数剔除,因为他们的内存地址指向的是同一个值。等于说是此处循环未结束,effects的值就发生了更改。用简单例子模拟一下该现象:
const set = new Set([1])
set.forEach(item=>{
set.delete(1)
set.add(1)
console.log('遍历中')
})
我们在浏览器中执行这段代码,发现它会无限执行下去:
查询语言规范中,发现对此有明确的说明:在调用forEach遍历Set集合的时候,如果一个值已经被访问过了,但是该值被删除被重新添加到集合,如果此时forEach循环并没有结束 那么该值会被重新访问。
可以参照developer.mozilla.org/zh-CN/docs/…
因此,上面的代码会无限执行。解决办法很简单,重新申请一个内存空间,让他跟被删除又重新添加的Set隔离开。我们可以构造另外一个 Set:
这样就不会无限执行了。回到 trigger 函数,我们需要同样的手段来避免无限执行:
运行至浏览器,在控制台中修改obj.ok的值,可以发现页面发生了变化,也避免了无限循环:
完整代码:
// 存储副作用函数
const bucket = new WeakMap()
// 当前激活的副作用函数
let activeEffect
// 注册副作用函数
function effect (fn) {
const effectFn = () => {
// 调用 cleanup 函数完成清除工作
cleanup(effectFn)
// 当 effectFn 执行时,将其设置为当前激活的副作用函数
activeEffect = effectFn
fn()
}
// activeEffect.deps 用来存储所有与该副作用函数相关联的依赖集合
effectFn.deps = []
effectFn()
}
function cleanup (effectFn) {
for (let i = 0; i < effectFn.deps.length; i++) {
// deps 是依赖集合
const deps = effectFn.deps[i]
console.log('i', i)
// 将 effectFn 从依赖集合中移除
deps.delete(effectFn)
}
// 最后需要重置 effectFn.deps 数组
effectFn.deps.length = 0
console.log('deps',effectFn.deps)
}
// 将副作用函数收集到bucket中
function track (target, key) {
// 如果当前没有要注册的副作用函数,直接return
if (!activeEffect) return
// 根据 target(目标对象) 从“bucket”中取得 depsMap,它是一个 Map 类型:key -->effects
let depsMap = bucket.get(target)
if (!depsMap) {
// 如果不存在 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()))
}
// 最后将当前激活的副作用函数添加到“bucket”里
deps.add(activeEffect)
activeEffect.deps.push(deps)
console.log('所有依赖合集:', activeEffect.deps)
}
function trigger (target, key) {
// 根据 target 从bucket中取得 depsMap,它是 key --> effects
const depsMap = bucket.get(target)
console.log('depsMap', depsMap)
if (!depsMap) return
// 根据 key 取得所有副作用函数 effects
const effects = depsMap.get(key)
console.log('执行', key, '的依赖集合:')
console.log(effects)
// 执行副作用函数
// effects && effects.forEach(fn => fn())
const effectsToRun = new Set(effects)
effectsToRun.forEach(effectFn => effectFn())
}
const data = { ok: true, name: "哈哈" };
// 给obj设置一个代理
const obj = new Proxy(data, {
get (target, key) {
track(target, key)
// 定义你要返回的值
return target[key];
},
set (target, key, value) {
target[key] = value;
// 把副作用函数从bucket里取出并执行
trigger(target, key)
}
})
function effectFn () {
// effect 函数的执行会读取obj.name
document.body.innerText = obj.ok ? obj.name : 'not'
}
effect(effectFn)
小结
参考《Vue.js设计与实现 (霍春阳)》,文章中如果有错误,还望大家批评指正!