源码下载
对应的源码在demo下的vue下的响应式设计与实现文件夹下
副作用函数
1-什么是副作用函数
当一个函数的执行产生了某种结果,而该结果不会随着函数销毁而销毁时,该函数就是有副作用的,如下,effect的执行会在页面上设置文本内容,且该文本会一直被保留,当其他js程序访问document.body时,拿到的是被effect篡改后的结果
const person = {
name: "sp",
age: 28,
};
function effect() {
document.body.innerText = person.name;
}
2-为什么从副作用函数说起
因为vue本质上可以看作是一个副作用函数,其内部的运行流程会帮助我们对web页面做出修改,而这些修改会真实反映到我们的js代码中
什么是响应式
现在,我们对前文的示例加上应用场景,每当点击span标签的时候,我们都将标签内容展示到 document.body上
<div>
<span>张三</span>
<span>李四</span>
<span>王五</span>
</div>
为此,我们需要先获取div,再为其绑定事件,最后通过事件对象拿到span的内容后修改person.name的值后重新调用effect函数
const elementDiv = document.getElementsByTagName('div')[0]
if(elementDiv){
elementDiv.addEventListener('click',(e)=>{
if(e.target.nodeName === 'SPAN'){
person.name = e.target.innerText
effect()
}
})
}
那我现在觉得每次都手动调用一次比较麻烦,我想在修改person.name时就自动调用effect,如果能达到这一目标,那么我们认为person就是响应式的
基础版实现
观察我们在前文列举的示例,不难发现:
- 当副作用函数effect执行时,会触发字段person.name的读取操作
- 当elementDiv的回调执行时,会触发字段person.name的设置操作
那么,如果我们能够拦截到person对象的读和写这两个操作的话,不就有可能自动触发effect了嘛。显然,Proxy很擅长做这件事,看如下代码,我们在get时将effect收集到bucket中,当set时取出运行
const bucket = new Set()
new Proxy(person,{
get(target,key){
bucket.add(effect)
return target[key]
},
set(target,key,value){
bucket.forEach(v=>v())
target[key] = value
return true
}
})
迭代
1-effect通用化
目前,我们的effect函数是写死的,能且只能用来向document.body输出文本,实际上,函数体应该是任意的,可由开发者指定的
为此,我们将effect进行下改造,使其接受一个函数并存储到全局
function effect(func){
actEffect = func
func()
}
然后在bucket中添加的改为用户传递的
new Proxy(obj, {
get(target, key) {
actEffect && bucket.add(actEffect);
...
},
...
});
2-为用户注册的func设置响应关系
目前,用户提供的func是与整个响应式对象关联的,无论修改了响应式对象的哪一个key值都会触发(运行demo\vue\响应式设计与实现\01.js查看效果)
proxyObj.someKey = (proxyObj.someKey || 0)+1
因此,我们需要为副作用函数与被操作的目标字段之间建立明确的响应关系,那么怎么设计呢?
我们先来思考一下:
- 响应关系的主体都有谁?
响应对象、响应对象的key、副作用函数
- 它们之间是什么关系?
由于一个响应对象的key不确定,所以响应对象与key之间是一对多的关系;由于一个key可能注册多个副作用函数,所以key与副作用函数之间也是一对多
- 如何使用js来进行表达?
既然是对象,则首选Map和WeakMap,但是由于用户侧改变路由或者触发v-if导致组件销毁时对应的响应式数据也应该被垃圾回收,所以我们选择弱引用关系的WeakMap;而key则选择Map,最后对于注册的副作用函数,我们使用Set来保证其唯一性
想明白了之后,我们重新设计的数据结构如下所示:
![](https://img.haomeiwen.com/i22517122/744dbca29f494d1d.png)
最后进行代码实现,只需要按照设计的结构的层级在get时进行对应的存储在set时取回执行即可
new Proxy(obj, {
get(target, key) {
if(!actEffect) return target[key]
// 获取响应对象
let reactiveObj = bucket.get(target)
if(!reactiveObj) bucket.set(target,(reactiveObj = new Map()))
// 获取响应对象中的key注册的副作用函数列表
let effects = reactiveObj.get(key)
if(!effects) reactiveObj.set(key,(effects = new Set()))
// 建立关联
effects.add(actEffect)
return target[key];
},
set(target, key, value) {
target[key] = value;
const reactiveObj = bucket.get(target)
if(reactiveObj){
const effects = reactiveObj.get(key) || []
effects.forEach(v=>v())
}
return true;
},
});
3-消除dead code
所谓dead code指的是那些用不到的代码,这部分代码在webpack构建工程中会帮我们剔除掉,就是tree shaking的概念。对于我们的响应式,如果一个副作用函数只在特定条件下生效,则不应该总是触发它,如下,当obj.isOk为false后,后续对obj.text的操作不再应该重新触发副作用函数的执行(运行demo\vue\响应式设计与实现\02.js查看效果)
effect(()=>{
document.body.innerText = obj.isOk?obj.text:''
})
故,我们要想办法将这部分遗留的副作用函数剔除掉:
我们知道,一个副作用函数可能注册在多个key上,一个key又可能有多个不同的副作用函数,但是有且只有一个正处于激活中的副作用函数,因此,我们以此为切入点,在effect内部创建一个私有函数,并添加deps属性
function effect(func){
function _effect(){
actEffect = _effect
func()
}
_effect.deps = []
_effect()
}
在get时,把key值注册的副作用函数更新到deps中
const proxyObj = new Proxy(obj, {
get(target, key) {
...
actEffect.deps.push(effects)
...
},
});
这样我们就能通过actEffect.deps拿到所有key值注册的副作用函数了,因为我们保存的是引用关系,所以可以通过effect引用切断在对应key值上的注册关系
function reset(_effect) {
_effect.deps.forEach((v) => {
const effects = v
effects.delete(_effect)
});
_effect.deps.length = 0
}
最后,清除的时机选择在每次副作用函数执行时,即
function effect(func) {
function _effect() {
reset(_effect);
...
}
...
}
4-允许effect嵌套
effect的嵌套和vue组件的嵌套本质上是一样的,不过目前我们还不支持,看如下示例(运行demo\vue\响应式设计与实现\03.js查看效果),对age的修改会打印1、2、2
effect(() => {
console.log(1);
effect(() => {
console.log(2);
proxyObj.name;
});
proxyObj.age;
proxyObj.age++;
});
为什么会这样的?我们捋一下现有代码的执行逻辑就明白了:
-
我们使用了全局变量actEffect来标记当前正在激活的副作用函数,它会在其执行时被设置,所以actEffect会被设置为console.log(1)所在的函数
-
由于effect内的代码是同步的,所以最先执行的是proxyObj.name,此时key为name的属性注册的副作用函数actEffect是console.log(2)所在的函数
-
接着执行proxyObj.age,此时的actEffect仍然是console.log(2)所在的函数,故此时收集到的不再是console.log(1)所在的函数
所以,我们只需要在副作用函数执行前,将当前的effect存起来,等执行后再恢复就可以了。由于内层函数一定先执行,即先存的后恢复,所以我们使用栈来进行存储
function effect(func) {
function _effect() {
...
// 存起来
effectStack.push(_effect)
func();
effectStack.pop()
// 恢复
actEffect = effectStack[effectStack.length-1]
}
...
}
5-避免死循环
问题4我们解决了嵌套的问题,但是也引出了新的问题,即proxyObj.age++操作会导致代码死循环(运行demo\vue\响应式设计与实现\04.js查看效果)
这是因为属性设置重新触发了effect的执行,这会触发新一轮的proxyObj.age++,但是此时,上一次的还没执行完毕,所以,如果两次执行的是同一个,我们直接return就好了
const proxyObj = new Proxy(obj, {
get(target, key) {
...
},
set(target, key, value) {
...
if (reactiveObj) {
...
t.forEach((v) => {
actEffect !== v && v()
});
}
...
},
});
6-合并更新
当我们连续调用多次设置值的操作时,应该只触发一次,而对于中间状态应该忽略掉(运行demo\vue\响应式设计与实现\04.js查看效果)
effect(() => {
// bug:更新无法合并
console.log(proxyObj.age);
});
proxyObj.age++;
proxyObj.age++;
proxyObj.age++;
proxyObj.age++;
说白了,就是js的防抖,唯一的差别是,传统实现的防抖只针对一个函数,而我们要针对的是多个函数,即,对age和score对应的effect进行防抖
proxyObj.age++;
proxyObj.age++;
proxyObj.score++;
proxyObj.score++;
为此,我们创建一个异步任务来与同步任务进行隔离,同时创建一个队列记录每一个key对应的effect
const taskQueue = new Set()
const resolve = Promise.resolve()
然后,新建一个flushTask函数来在下一次事件循环中遍历执行
let isFlushing = false;
function flushTask() {
if (isFlushing) return;
isFlushing = true;
resolve
.then(() => taskQueue.forEach((task) => task()))
.finally(() => (isFlushing = false));
}
最后,在set时,向taskQueue中添加当前要执行的effect,由于是Set类型,所以同一个任务只会执行一次,而flushTask一旦开始,则在本次的同步任务结束前会一直处于等待执行的状态(运行demo\vue\响应式设计与实现\06.js查看效果)
const proxyObj = new Proxy(obj, {
get(target, key) {
...
},
set(target, key, value) {
...
if (reactiveObj) {
...
t.forEach((v) => {
if(actEffect !== v){
taskQueue.add(v)
flushTask()
}
});
}
...
},
});
网友评论