yinanchen
前端开发工程师
yinanchen 2022-06-29
本文意在介绍vue3响应式的设计思路,没有过多对源码进行解读,主要从另一个角度来认识vue3的响应式的设计与实现,对于没有看过vue3源码的同学可以初步认识vue3的响应式原理,有利于对源码观看和解读起到更好的理解作用,对于已经观看过源码的同学,能让你对vue3有更深层次的一个理解
正式介绍响应式设计之前,我们需要先了解什么是副作用函数。文本字义上看,副作用函数就是能产生副作用的函数,举个例子
fuction effect() {
document.body.innerText = 'xiaohei'
}
effect函数的执行,会设置body的文本内容,但有可能有fn1,fn2等其他函数也会读取或者设置到body的文本内容。也就是,effect的执行直接或者间接影响其他函数的执行,这是就说effect产生了副作用
我们知道在vue中我么写在template里面的变量都是响应式的,当我们修改了这个变量,页面中的数据也就是template里面的数据也会跟着变,其实通过刚刚介绍的副作用就可以知道,数据改变我们要驱使template里面的数据也改变,这个过程就有点类似我们刚刚的effect函数,也就是我们修改了一个值,这个值的副作用函数要跟着执行
有过一定vue基础的人都知道,vue2是通过Object.defineProperty去实现,vue3则是使用了proxy;通过上文的讲述之后你可以猜想到其实就是利用了这2个api中的拦截读取和设置的属性
当然这里还有考虑一点,effect副作用函数不止一个的情况呢?有可能我们改一个变量值,会触发很多副作用的执行。这个时候我们就需要一个桶bucket,用这个桶在读取操作的时候来存放所有的副作用,当触发设置操作的时候,从桶中依次取出副作用执行即可,流程变化=>
根据上面的思路,我们还需要考虑一点细节问题,如何保证代码灵活性?这个时候我们的effect就需要变换一下,我们需要为effect提供一个注册副作用函数的函数,用来保证不管effect是什么函数,哪怕是一个匿名函数,都能被桶收集到,代码实现如下:
// 用一个全局变量来存放被注册的副作用函数
let activeEffect: Function | undefined = undefined;
function effect(fn: Function) {
// fn就是我们上面讲到的effect副作用
activeEffect = fn;
// 执行副作用
fn();
}
// 存放effect的桶
const bucket: Set<Function> = new Set();
const data = { text: "xiaohei" };
const obj = new Proxy(data, {
// 拦截读取操作
get(target, key) {
if (activeEffect) {
bucket.add(activeEffect);
}
return target[key];
},
// 拦截设置操作
set(target, key, newVal) {
target[key] = newVal;
bucket.forEach((fn) => fn());
return true;
},
});
从上面的代码执行来看,我们确实可以初步简单的实现修改data中text的数据,让对于effect执行,但是细心的人会发现,如果我们设置data的其他属性,比如data.xiaohei,其实也会触发所有effect执行
为什么会出现这种情况呢?其实不难发现,我们的桶是一个数组去存放副作用的,而对于data数据来说,不管你设置什么值,都对被proxy代理,也就是我们没有把副作用和data具体的key绑定上关系。所以我们需要重新设计一下这个桶bucket的数据结构
很多人来想到用map数据结构,即:key - set结构来设计桶;只能说对了一半,因为你只考虑了对单一个数据data的结构封装,而我们这一封装是针对于多个数据可使用的,所以应当采用map-map数据结构
这里桶的设计,vue3用了es6的weekmap来实现,为什么使用weakmap呢?原因是weakmap对key是弱引用,不影响垃圾回收器工作,一旦key被垃圾回收器回收,对应的值也就访问不到了
所以真正的桶的数据结构设计应该为:
基于上面的分析,我们再做一成封装,将读取收集依赖的过程封装成track函数,将触发副作用函数封装到trigger里面,代码封装如下:
// 用一个全局变量来存放被注册的副作用函数
let activeEffect: Function | undefined = undefined;
function effect(fn: Function) {
// fn就是我们上面讲到的effect副作用
activeEffect = fn;
// 执行副作用
fn();
}
// 存放effect的桶
const bucket: WeakMap<Object, Map<string, Set<Function>>> = new WeakMap();
const data = { text: "xiaohei" };
const obj = new Proxy(data, {
// 拦截读取操作
get(target, key: string) {
// 将副作用函数添加到桶
track(target, key)
return target[key];
},
// 拦截设置操作
set(target, key: string, newVal) {
target[key] = newVal;
// 触发副作用
trigger(target, key)
return true;
},
});
function track(target: Object, key: string) {
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)
}
function trigger(target, key) {
const depsMap = bucket.get(target)
if(!depsMap) return
const effects = depsMap.get(key)
effects && effects.forEach(fn => fn())
}
有这么一种情况,我们在副作用中定义了一个三元运算符,分别读取某个对象的2个属性
比如这样 obj.ok ? obj.text : 'last'
,可以先思考下会发生什么情况?
根据上面的响应式系统,好像没什么区别,不管你读取那个属性,都会依赖收集,触发。原因正是如此,我们可以假设一下,如果我们把obj.ok
设置为false,那么这个三元就会一直读取到后面的last
,那么如果我此时读取obj.text
那是不是对应的这个副作用依赖不应该被收集呢?
事实上根据我们上面的代码是会的,我可以解释一下执行的思路
obj.ok = false
,这会触发ok的trigger函数更新视图,执行副作用函数这时我们可以看到,中间貌似少了个步骤,没错就是对依赖的删除,所谓有增有减
那么怎么删除对应依赖呢?很简单,我们在track的时候不是已经收集了依赖了吗?我们现在需要的就是在副作用执行的时候先清楚依赖即可,这样我们就需要重新设计我们的effect函数了
let activeEffect: Function | undefined = undefined;
function effect(fn: Function) {
// fn就是我们上面讲到的effect副作用
const effectFn = () => {
cleanup(effectFn) // 删除操作,只要遍历deps去删除即可,不贴代码了
activeEffect = effectFn // 等于本身,以便可执行
fn()
}
effectFn.deps = [] // 存放相关副作用的依赖
effectFn()
}
function track(target: Object, key: string) {
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)
activeEffect.deps.push(deps) // 存放到上面effectFn的依赖容器中
}
function trigger(target, key) {
const depsMap = bucket.get(target)
if(!depsMap) return
const effects = depsMap.get(key)
const effectsToRun = new Set(effects) // 开辟一个新变量避免在遍历中删除或新增元素导致无限循环
effectsToRun.forEach(item => item()) // 执行副作用重新读取,但是副作用的执行中又有删除
// effects && effects.forEach(fn => fn())
}
effect是可以深层嵌套的 !但是我们上面的逻辑并没有避免这种情况,原因就在activeEffect
中,因为这只是一个变量存放当前的活跃effect,但effect里面有effect,这个变量没法存储另一个深层的effect,所以导致代码错误
解决方法其实有点了解数据结构的人员就可以猜到,我们可以搞个栈来存放运行的effect,将当前运行的effect压入栈底,等待执行完毕再推出,保证activeEffect
始终指向当前栈的最顶上指针即可解决
obj.text++
有的时候副作用中可能会是这句魔性的自增语句,其实这个语句等同于obj.text = obj.text + 1
,这个时候就有可能引起无线递归循环了,读取和触发一直循环
解决也很容易,在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(activeEffect !== effectFn) {
effectsToRun.add(effectFn)
}
})
effectsToRun.forEach(item => item())
}