持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第1天,点击查看活动详情
前言
最近在读霍春阳大佬的vuejs设计与实现,看了之后直呼好书!我希望通过分享的方式巩固自己的知识体系,也希望能让大家对vue3的响应式原理更加通透。
知识储备
defineProperty(vue2实现方式)
Object.defineProperty()
方法会直接在一个对象上定义一个新属性,或者修改一个对象的现有属性,并返回此对象。
使用方法:
Object.defineProperty(obj, prop, descriptor)
vue2中使用defineProperty作为响应式的实现方式,核心总结就是通过get和set函数以分别实现依赖的收集和数据的更新。但是也有对应的短处,一是对于数组类型的更新无法检测到,需要通过重写数组改变方法以实现依赖收集目的;二是对于data中的数据都是基于递归遍历去实现依赖的收集。
了解了vue2设计的短处,我们就有了升级和优化的方向,那就是proxy。
proxy(vue3实现方式)
Proxy 对象用于创建一个对象的代理,从而实现基本操作的拦截和自定义(如属性查找、赋值、枚举、函数调用等)。
使用方法:
const p = new Proxy(target, handler)
// `target` 要使用 `Proxy` 包装的目标对象(可以是任何类型的对象,包括原生数组,函数,甚至另一个代理)。
// `handler 一个通常以函数作为属性的对象,各属性中的函数分别定义了在执行各种操作时代理 `p` 的行为。
proxy
有效的的解决了需要递归处理数据和数组的变化无法监听的问题,并且会返回一个全新的响应式对象。
什么是副作用函数和响应式
副作用函数简单说就是他的执行会直接或间接的影响其他函数的执行
//比如
const data = {msg:'good morning'}
const effect = ()=.{
document.body.innerHtml = data.msg
}
//data.msg可能在其他任意函数中访问,effect这个函数的执行就会产生相关的副作用
响应式就是当我们的数据发生改变,和它相关的副作用函数需要重新执行
const data = {msg:'good morning'}
const effect = ()=.{
document.body.innerHtml = data.msg
}
setTimeout(()=>{
data.msg = 'good afternoon'
},1000)
//我们希望当msg值发生改变,effect函数也能执行,这就是一个响应式数据
实现
一、简版响应式
我们在读取某个对象值的时候,将对应的副作用函数收集起来,等到未来某一刻设置更新了对象值,我们再将副作用桶里的函数拿出来依次执行。实例代码如下:
// 存储副作用函数的桶
const bucket = new Set();
// 原始数据
const data = { msg: "good morning" };
// 对原始数据的代理
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());
},
});
function effect() {
document.body.innerText = obj.msg;
}
effect();
setTimeout(() => {
obj.text = "goode afternoon ";
}, 1000);
这样我们就实现了最简单响应式,但是还有非常多需要考虑处理的情况。首先我们需要处理匿名副作用函数的情况,以实现任何名字的副作用函数都能得到执行。
二、实现匿名函数副作用可执行
因为副作用函数可能是任意名字,所以我们这里写一个副作用函数注册函数
// 用一个全局变量存储当前激活的 effect 函数
let activeEffect
function effect(fn) {
// 当调用 effect 注册副作用函数时,将副作用函数复制给 activeEffect
activeEffect = fn
// 执行副作用函数
fn()
}
//bucket.add(effect);//以前的effect是固定的副作用函数,现在activeEffect可以试任意函数
// 将副作用函数 activeEffect 添加到存储副作用函数的桶中
bucket.add(activeEffect)
我们发现现在是整个对象和副作用建立了联系,然而这却不是我们想要的,这会导致
//任意属性的改变会导致所有副作用函数执行一遍
obj.val1 => effect1 effect2
obj.val2 => effect1 effect2
// 我们理想的情况是对象的属性和相关的副作用函数绑定,而不是所有的
obj.val1 => effect1
obj.val2 => effect2
三、副作用函数与目标字段建立联系
这里建议大家先了解WeakMap、Map、Set的数据结构,以方便更能理解设计者的初心。 通过刚才的分析,我们知道欠缺的是属性和副作用之间的绑定,即:
整理好关系后用代码实现如下:
//存储副作用的桶
const bucket = new WeakMap();
const obj = new Proxy(data, {
// 拦截读取操作
get(target, key) {
// 将副作用函数 activeEffect 添加到存储副作用函数的桶中
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)
// 返回属性值
return target[key]
},
// 拦截设置操作
set(target, key, newVal) {
// 设置属性值
target[key] = newVal
// 把副作用函数从桶里取出并执行
const depsMap = bucket.get(target)
if (!depsMap) return
const effects = depsMap.get(key)
effects && effects.forEach(fn => fn())
}
})
四、实现分支切换与cleanup
如果副作用函数中有三元运算的场景,就需要我们去处理分支切换情况,并且清除不需要的副作用函数
const effect = (()=>{
document.body.innerHtml = data.show ? data.msg : 'no data'
})
/*
这个时候页面展示的值取决于data.show,如果值为true,就取msg的值
如果值为false,则永远为'no data',这个时候如果msg的值改变,我们不需要执行这个副作用函数去更新
*/
因此我们需要在执行副作用的时候,先去把它从所有与之关联的依赖集合中删除,当副作用函数执行完毕后,会重新建立联系,但是新的联系中不会包含遗留的副作用函数。
要将一个副作用函数从与之关联的依赖集合中删除,我们需要知道哪些依赖集合中包含它,因此我们需要重新设计一下副作用函数。如下所示
// 用一个全局变量存储当前激活的 effect 函数
let activeEffect
function effect(fn) {
const effectFn = () => {
cleanup(effectFn)
// 当调用 effect 注册副作用函数时,将副作用函数复制给 activeEffect
activeEffect = effectFn
fn()
}
// activeEffect.deps 用来存储所有与该副作用函数相关的依赖集合(副作用函数集合)(浅拷贝)
effectFn.deps = []
// 执行副作用函数
effectFn()
}
//get依赖收集
deps.add(activeEffect)
activeEffect.deps.push(deps)//在这里实现收集
function cleanup(effectFn) {
// 遍历effectFn.deps数组(副作用函数集合)
for (let i = 0; i < effectFn.deps.length; i++) {
//deps是依赖集合
const deps = effectFn.deps[i]
//将effectFn从依赖集合中移除
deps.delete(effectFn)
}
//最后需要重置effectFn.deps数组
effectFn.deps.length = 0
}
至此我们的响应式系统就可以避免副作用函数产生遗留了。
五、处理无限执行
这个时候如果运行代码,会发现目前的实现会导致无限执行,原因出在effects && effects.forEach(fn => fn())
。effects是一个set结构,我们在执行fn时,会先将当前副作用函数从依赖中移除,然后副作用的执行又会将当前副作用函数放到依赖中,就会导致循环调用副作用的时候不断重复执行。语言规范中对此有明确的说明:在调用forEach遍历Set时,如果一个值已经被访问过了,但该值被删除并重新添加到集合,如果此时forEach遍历没有结束,那么该值会重新被访问。
解决方法就是构造另外一个set集合并遍历它
const effectsToRun = new Set()// 新增
effects && effects.forEach(effectFn => effectsToRun.add(effectFn))// 新增
effectsToRun.forEach(effectFn => effectFn())// 新增
// effects && effects.forEach(effectFn => effectFn())// 删除
至此我们可以将依赖收集和触发分别抽成函数track和trigger
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)
activeEffect.deps.push(deps)
}
function trigger(target, key) {
const depsMap = bucket.get(target)
if (!depsMap) return
const effects = depsMap.get(key)
const effectsToRun = new Set()
effects && effects.forEach(effectFn => effectsToRun.add(effectFn))
effectsToRun.forEach(effectFn => effectFn())
// effects && effects.forEach(effectFn => effectFn())
}
六、处理嵌套的effect和effect栈
当我们遇到嵌套的副作用函数时,会出现有的副作用未执行的问题。
effect(function effectFn1() {
console.log('effectFn1 执行')
effect(function effectFn2() {
console.log('effectFn2 执行')
temp2 = obj.bar
})
temp1 = obj.foo
})
不管我们是修改obj.foo,还是修改obj.bar,最终打印都是
effectFn1 执行 effectFn2 执行effectFn2 执行
但是我们期望的却是修改foo打印1212,修改bar打印122
原因出在acctiveEfect上,由于我们只有所存储的当前副作用函数只能有一个,所以当我们执行到内层的副作用函数时,会直接覆盖掉外层的副作用函数,所以出现了上面的场景。
我们需要引入effecStack栈,在对应的时候实现入栈和出栈,当执行到内层的副作用函数时,需要将当前副作用函数入栈,执行完成后出栈,并且将activeEffect设置为外层的副作用函数。
// 用一个全局变量存储当前激活的 effect 函数
let activeEffect
// effect 栈
const effectStack = []
function effect(fn) {
const effectFn = () => {
cleanup(effectFn)
// 当调用 effect 注册副作用函数时,将副作用函数复制给 activeEffect
activeEffect = effectFn
// 在调用副作用函数之前将当前副作用函数压栈
effectStack.push(effectFn)
fn()
// 在当前副作用函数执行完毕后,将当前副作用函数弹出栈,并还原 activeEffect 为之前的值
effectStack.pop()
activeEffect = effectStack[effectStack.length - 1]
}
// activeEffect.deps 用来存储所有与该副作用函数相关的依赖集合
effectFn.deps = []
// 执行副作用函数
effectFn()
}
这样就能解决嵌套的副作用函数场景了。
七、避免无限递归循环
实现一个完善的响应式系统有诸多需要考虑的细节。看下边的代码
effect(() => obj.foo++)
。
这个代码会导致副作用函数无限执行,我们将上面代码拆解一下你就会知道为什么了。
effect(()=>obj.foo = obj.foo +1)
,它会首先读取foo的值,会触发track操作,将当前副作用函数收集到依赖中,接着加一后再赋值给foo,此时会触发trigger操作,即执行副作用函数。但问题是该副作用函数正在执行中,还没执行完就要开始下一次执行。就会导致无限递归调用自己,接着就会产生栈溢出。
基于此,我们可以在trigger增加守卫条件:即如果trigger触发执行的副作用函数与当前正在执行的副作用函数相同,则不触发执行:
effects && effects.forEach(effectFn => {
if (effectFn !== activeEffect) {//新增守卫条件
effectsToRun.add(effectFn)
}
})
八、执行调度
一个完善的响应式系统肯定得有可调度性,即trigger动作触发副作用函数重新执行时,我们可以决定副作用函数的调用时机以及次和方式。
我们可以为effect函数设计一个选项参数options,允许用户指定调度器。当用户传了调度器,我们在trigger函数中触发副作用函数重新执行时,就可以直接调用用户传递的调度器函数没从而把控制权给用户。
function trigger(target, key) {
const depsMap = bucket.get(target)
if (!depsMap) return
const effects = depsMap.get(key)
const effectsToRun = new Set()
effects && effects.forEach(effectFn => {
if (effectFn !== activeEffect) {
effectsToRun.add(effectFn)
}
})
effectsToRun.forEach(effectFn => {
// 如果一个副作用函数存在调度器,则调用该调度器,并且吧副作用函数作为参数传递
if (effectFn.options.scheduler) {
effectFn.options.scheduler(effectFn)
} else {
// 否则直接调用副作用函数
effectFn()
}
})
// effects && effects.forEach(effectFn => effectFn())
}
effect(() => {
console.log(obj.foo)
}, {
scheduler(fn) {
setTimeout(fn)
}
})
这样我们就能顺利的控制打印顺序了。
总结
至此就实现了一个简洁版的响应式系统,并且从实际场景去一步步增强。
互联网时代最大的意义就是让我们能够站在巨人的肩膀上去看世界,通过业界大佬的分析,也能让我们对于响应式的理解更加透彻。在这里再次给大家分享一下霍春阳大佬的书《Vue.js的设计与实现》,里边内容真的非常丰富,对于我们深入理解原理绝对非常有益。