您可以捐助,支持我们的公益事业。

1元 10元 50元





认证码:  验证码,看不清楚?请点击刷新验证码 必填



  求知 文章 文库 Lib 视频 iPerson 课程 认证 咨询 工具 讲座 Model Center   Code  
会员   
   
 
     
   
 
 订阅
Vue.js设计与实现--设计一个完善的响应系统
 
作者:一川
  1293  次浏览      18 次
2022-4-20
 
编辑推荐:
本文将对如下几个问题进行展开介绍:分支切换、 嵌套的effect、 无限递归、
可调度性,希望对您的学习有所帮助。
本文来自于51CTO,由火龙果软件Linda编辑、推荐。

1、写在前面

上篇文章主要介绍了如何简易的实现一个响应系统,只是个简易的仍然存在很多未知的不可控的问题,比如副作用函数嵌套、如何避免无限递归以及多个副作用函数之间会产生什么影响?

本文将会解决以下几个问题:

分支切换

嵌套的effect

无限递归

可调度性

2、分支切换与cleanup

分支切换

在进行页面渲染时,我们需要避免副作用函数产生的遗留。为什么这么说呢?先看下面的代码片段,在副作用函数effect内部的箭头函数中有个三元表达式,根据state.flag的值去切换页面渲染的值,这是我们期待的分支切换。

const data = {
name:"pingping",
age:18,
flag:true
};
const state = new Proxy(data,{
/* 其他代码省略 */
});
//副作用函数,effect执行渲染了页面
effect(()=>{
console.log("render");
document.body.innerHTML = state.flag ? state.name : state.age;
})

flag的值为初始值true时,页面渲染的结果如图所示:

但是事实上,分支切换可能会产生遗留的副作用函数。上面代码片段,flag的初始值是true,此时会去响应式对象state中获取字段flag的值,此时effect函数会执行触发flag和name的读取操作,副作用函数会与响应数据之间建立联系。

flag初始值为true的时候,事实上的Map的key值只有flag和name与副作用函数建立了联系,也只会收集这两个响应式数据的依赖--副作用函数。

flag字段值修改为false时,会触发副作用函数effect重新执行,按道理name的值不会被读取,只会触发flag和age的读取操作,理想情况应该是依赖集合收集的是这两个字段所对应的副作用函数。

副作用函数与响应数据之间的关系

但是事实上,在上面代码中实现不了这种变化,在修改字段flag的值会触发副作用函数重新执行后,整个依赖关系会保持flag为true时的关系图,name字段所产生的副作用函数会遗留。

// 设置一个不存在的属性时
setTimeout(()=>{
state.flag = false;
},1000)

 

如上面代码,遗留的副作用函数会导致数据不必要的更新,之所以这样说,是因为flag的值改为false后,会触发更新导致副作用函数重新执行。此时应该不存在name的依赖关系,即不会读取name的值了,无论flag的值怎么变化都应该只是读取age的值而非name。

上面代码实际执行效果如下图所示,页面的渲染值没有改变,控制台打印显示:

// 设置一个不存在的属性时
setTimeout(()=>{
state.flag = false;
setTimeout(()=>{
console.log("更改了name的值,理论上是不会更新页面数据的...");
state.name = "onechuan"
})
},1000)

 

 

即使我们在setTimeout中继续修改name的值,页面依然渲染的是name的初始值"pingping",控制台显示我们是修改了name的值的。

cleanup

那么,我们应该如何解决上面的副作用函数遗留问题呢?其实,我们只需要设置在每次副作用函数触发执行时,先把它从所有与之相关联的依赖集合中删除。当副作用函数执行完毕后,会重新建立联系,重新在依赖集合中收集副作用函数,但是之前遗留的副作用函数已经被清理。『打扫干净屋子,重新请客』。

清除副作用函数与响应式数据之间的联系

我们应该如何实现上面的理论呢,得先确定哪些依赖集合中包含了遗留的副作用函数,我们需要重新设计副作用函数effect。

在effect函数内部定义一个effectFn函数,为其添加effectFn.deps数组,用于存储所有包含当前副作用函数的依赖集合。在每次执行副作用函数前,都需要根据effectFn.deps获取依赖集合,调用cleanupEffect函数完成清理遗留的副作用函数。

// 全局变量用于存储被注册的副作用函数
let activeEffect;
// effect用于注册副作用函数
function effect(fn){
const effectFn = ()=>{
// 调用函数完成清理遗留副作用函数
cleanupEffect(effectFn)
// 当调用effect注册副作用函数时,将副作用函数fn赋值给activeEffect
activeEffect = effectFn;
// 执行副作用函数
fn();
}
//deps是用于存储所有与该副作用函数相关联的依赖集合
effectFn.deps = [];
// 执行副作用函数effectFn
effectFn()
}

 

cleanupEffect函数的设计实现如下代码段,其接收一个effectFn副作用函数作为参数,遍历收集依赖集合的effectFn.deps数组,将effectFn该函数从依赖集合中清除,最后重置effectFn.deps数组。

// 遗留的副作用函数的清除函数
function cleanupEffect(effectFn){
const { deps } = effectFn
// 遍历依赖集合数组
for(let i = 0; i < deps.length; i++){
//从依赖集合中删除
deps[i].delete(effectFn)
}
// 重置数组
deps.length = 0
}

 

那么,effectFn.deps数组又是如何收集依赖集合的呢?首先将当前执行的副作用函数activeEffect添加到依赖集合deps中,此时deps存储的是与当前副作用函数存在联系的依赖集合,而后将其添加到activeEffect.deps数组中完成收集。

// 在get拦截函数中调用追踪取值函数的变化
function track(target, key){
// 没有activeEffect
if(!activeEffect) return
// 根据目标对象从桶中获得副作用函数
let depsMap = bucket.get(target);
// 判断是否存在,不存在则创建一个Map
if(!depsMap) bucket.set(target, depsMap = new Map())
// 根据key从depsMap取的deps,存储着与key相关的副作用函数
let deps = depsMap.get(key);
// 判断key对应的副作用函数是否存在
if(!deps) depsMap.set(key, deps = new Set())
// 最后将激活的副作用函数添加到桶里
deps.add(activeEffect)
// deps是与当前副作用函数存在联系的依赖集合
activeEffect.deps.push(deps)
}

 

注意:前面的代码片段在副作用函数触发时会执行清理操作,在执行后会进行收集effect,但是在执行过程中会导致无限循环执行(死循环)。

为什么会出现死循环呢?

这是因为在trigger函数中,会遍历存储着副作用函数Set集合effects。在副作用函数执行时,会调用cleanup执行清除操作,实际上就是从effects集合中找出当前执行的副作用函数进行清除。但是副作用函数的执行,会导致其重新被收集到effects集合中,这样就不断的清除和收集了。

在ECMA规范中:调用forEach对Set集合进行遍历时,如果一个值已经被访问过,那么该值被删除并重新添加到集合中,如果此时forEach遍历没有结束,该值就会重新被访问。

let effect = () => {};
let s = new Set([effect])
s.forEach(item=>{
s.delete(effect);
s.add(effect)}
); // 这样就导致死循环了

 

那么我们应该如何打破循环呢?

很简单,只需要新构造一个Set集合进行遍历即可。即在trigger函数中修改语句即可:

// 在set拦截函数中调用trigger函数触发变化
function trigger(target, key){
// 根据target从桶中取的depMaps
const depMaps = bucket.get(target);
// 判断是否存在
if(!depMaps) return
// 根据key值取得对应的副作用函数
const effects = depMaps.get(key);
// 执行副作用函数
// effects && effects.forEach(fn=>fn())
const effectsToRun = new Set(effects);
effectsToRun.forEach(effectFn=>effectFn());
}

此时就有:

修改age值前的页面

控制台打印结果:

3、嵌套的effect和effect栈

嵌套的effect

在实际开发中,我们不可避免会写出effect函数嵌套,即一个effect函数内部嵌套着另外一个effect函数。

effect(()=>{
effct(()=>{
/*...*/
})
})

 

如果我们的响应式系统不支持effect嵌套,那么会发生什么事情呢?

// 原始数据
const data = {
name:"pingping",
age:18,
flag:true
}
//代理对象
const state = new Proxy(data,{
/* 其他代码省略 */
});
//全局变量
let temp1, temp2;
//effectFn1嵌套effectFn2
effect(()=>{
console.log("执行effectFn1");
effect(()=>{
console.log("执行effectFn2");
//在effectFn2中读取state.name属性
temp2 = state.name;
})
//在effectFn1中读取state.age属性
temp1 = state.age;
})
setTimeout(()=>{
state.age = 19
},1000)

 

在上面代码中,简单的写了一个effect嵌套的demo,effectFn1内部嵌套了effectFn2,那么effectFn1执行会导致effectFn2的执行。effectFn2中读取了state.name的值,而effectFn1中读取了state.age的值,且effectFn2的读取操作优先于effectFn1的读取操作。即:

state
|__ name
|__ effectFn1
|__ age
|__ effectFn2

 

 

在这种情况下,理论上修改state.name的值只会触发effectFn2的执行,而当修改state.age的值时,会触发effectFn1的执行且间接触发effectFn2函数的执行。

但是,事实上修改state.age的值输出的结果如下图所示,打印了三次,effectFn1只执行了一次,而effectFn2却执行了两次,修改时的并没有重新执行effectFn1函数。

为什么会出现这种情况呢?

这是因为我们嵌套了多个effect函数,而activeEffect全局变量同一时刻只能存储一个通过effect函数注册的副作用函数。当effect发生嵌套时,内层effect产生的副作用函数会覆盖掉activeEffect的值,并且永远不能回到过去了。『真是个渣男』。

effect执行栈

那么应该如何解决这个问题呢?

想下js事件循环机制就知道,通过一个栈数据结构去存储当前执行的事件。同样的,我们也可以添加一个副作用函数执行栈effectStack,当前副作用函数执行时,将其压入栈中,在执行完毕后将其出栈,并让activeEffect指向栈顶的副作用函数,即最近执行的副作用函数。

let effectStack = [];
// effect用于注册副作用函数
function effect(fn){
const effectFn = ()=>{
// 调用函数完成清理遗留副作用函数
cleanupEffect(effectFn)
// 当调用effect注册副作用函数时,将副作用函数fn赋值给activeEffect
activeEffect = effectFn;
// 在副作用函数执行前压栈
effectStack.push(effectFn)
// 执行副作用函数
fn();
// 执行完毕后出栈
effectStack.pop()
activeEffect = effectStack[effectStack.length - 1]
}
//deps是用于存储所有与该副作用函数相关联的依赖集合
effectFn.deps = [];
// 执行副作用函数effectFn
effectFn()
}

 

在上面代码片段中,定义了一个effectStack数组去存储待执行的副作用函数,activeEffect始终指向当前执行的副作用函数。根据栈结构的先进后出原则,刚好外层effect先进存储在栈地,内层effect后进存储在栈顶,在内层执行完毕后出栈执行外层effect。这样,响应式数据只会收集直接读取当前值的副作用函数作为依赖,从而避免错乱。

这样控制打印:

打印结果

4、避免无限递归循环

前面在存储当前执行的副作用函数的依赖集合时,可能会出现循环执行的情况,我们也添加了新Set集合进行解决。当我们在副作用函数中,对同一个字段的值进行无限递归循环,那么会出现什么情况?

// 原始数据
const data = {
name:"pingping",
age:18,
flag:true
}
//代理对象
const state = new Proxy(data,{
/* 其他代码省略 */
});
effect(()=>{
state.age++;
})

 

我们看到执行结果出现爆栈的情况,内存溢出:

内存溢出

我们可以看到state.age++;语句中,既有state.age的读取操作,又有设值操作,这样前一个副作用函数还没执行完毕,又重新开启了新的执行,这样就无限递归调用自己了。『我调用我自己,超越本我』

那么,我们应该如何避免栈溢出呢?

在前面的文章中知道,在对state.age的取值track和设值trigger操作都是在同一个副作用函数activeEffect中执行的。那么只需要在trigger中增加守卫条件:判断下触发trigger的副作用函数和当前正在执行的副作用函数是不是同一个,如果是同一个则不触发执行,否则执行。

// 在set拦截函数中调用trigger函数触发变化
function trigger(target, key){
// 根据target从桶中取的depMaps
const depMaps = bucket.get(target);
// 判断是否存在
if(!depMaps) return
// 根据key值取得对应的副作用函数
const effects = depMaps.get(key);
const effectsToRun = new Set();
// 执行副作用函数
effects && effects.forEach(effectFn=>{
if(effectFn !== activeEffect){
effectsToRun.add(effectFn)
}
})
effectsToRun.forEach(effectFn=>effectFn());
}

 

在执行触发trigger时,对触发trigger的副作用函数和当前执行的副作用函数进行比较筛选,即可避免栈内存的溢出。

5、调度执行

先了解下可调度性对于意义,就是trigger触发副作用函数重新执行时,可以自定义决定副作用函数执行的时机、次数、及执行方式。

// 原始数据
const data = {
name:"pingping",
age:18,
flag:true
}
//代理对象
const state = new Proxy(data,{
/* 其他代码省略 */
});
effect(()=>{
console.log(state.age);
});

state.age++;

console.log("run end");

 

执行结果

如果我们需要改变代码的执行顺序,得到不同的结果,那么需要提供给用户调度能力,即允许使用者自定义调度器。

// effect用于注册副作用函数
function effect(fn,options={}){
const effectFn = ()=>{
// 调用函数完成清理遗留副作用函数
cleanupEffect(effectFn)
// 当调用effect注册副作用函数时,将副作用函数fn赋值给activeEffect
activeEffect = effectFn;
// 在副作用函数执行前压栈
effectStack.push(effectFn)
// 执行副作用函数
fn();
// 执行完毕后出栈
effectStack.pop()
activeEffect = effectStack[effectStack.length - 1]
}
// 将options挂载到effectFn函数上
effectFn.options = options
//deps是用于存储所有与该副作用函数相关联的依赖集合
effectFn.deps = [];
// 执行副作用函数effectFn
effectFn()
}
// 在set拦截函数中调用trigger函数触发变化
function trigger(target, key){
// 根据target从桶中取的depMaps
const depMaps = bucket.get(target);
// 判断是否存在
if(!depMaps) return
// 根据key值取得对应的副作用函数
const effects = depMaps.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()
}
});
}

 

在上面代码片段中,在trigger触发副作用函数执行时,会先判断该副作用函数中是否存在调度器:

存在调度器,直接执行调度器函数,并将当前副作用函数作为参数传递effectFn.options.scheduler(effectFn)。

不存在调度器,则直接执行副作用函数effectFn()。

effect(()=>{
console.log(state.age);
},{//options
scheduler(fn){//调度器
setTimeout(fn);
}
});

state.age++;

console.log("run end");

执行结果

这样,系统设计实现了控制副作用函数的执行顺序。除此之外,我们还可以添加实现控制副作用函数的执行次数,同样只需要修改调度器代码就行,这里就不赘述了。

6、写在最后

在本文中,主要解决的问题有:

分支切换导致遗留的副作用函数,可以添加一个集合收集依赖集合,在每次执行副作用函数前将其对应的联系清除,在执行后重新建立联系。

对于effect嵌套问题可以通过添加一个effectStack执行栈解决,外层副作用函数先入栈,内层后入栈,activeEffect永远指向当前要执行的副作用函数。

对于避免无限递归循环,可以在trigger触发副作用函数执行前进行判断,触发的副作用函数与当前执行的副作用函数是否相同。

对于响应系统的调度性,可以通过设置调度器去控制副作用函数执行的顺序、时机、次数等。

   
1293 次浏览       18
相关文章

基于图卷积网络的图深度学习
自动驾驶中的3D目标检测
工业机器人控制系统架构介绍
项目实战:如何构建知识图谱
 
相关文档

5G人工智能物联网的典型应用
深度学习在自动驾驶中的应用
图神经网络在交叉学科领域的应用研究
无人机系统原理
相关课程

人工智能、机器学习&TensorFlow
机器人软件开发技术
人工智能,机器学习和深度学习
图像处理算法方法与实践

最新活动计划
MBSE(基于模型的系统工程)4-18[北京]
自然语言处理(NLP) 4-25[北京]
基于 UML 和EA进行分析设计 4-29[北京]
以用户为中心的软件界面设计 5-16[北京]
DoDAF规范、模型与实例 5-23[北京]
信息架构建模(基于UML+EA)5-29[北京]
 
 
最新文章
AIGC技术与应用全解析
详解知识图谱的构建全流程
大模型升级与设计之道
自动驾驶和辅助驾驶系统
ROS机器人操作系统底层原理
最新课程
人工智能,机器学习和深度学习
人工智能与机器学习应用实战
人工智能-图像处理和识别
人工智能、机器学习& TensorFlow+Keras框架实践
人工智能+Python+大数据
成功案例
某综合性科研机构 人工智能与机器学习应用
某银行 人工智能+Python+大数据
北京 人工智能、机器学习& TensorFlow框架实践
某领先数字地图提供商 Python数据分析与机器学习
中国移动 人工智能、机器学习和深度学习