新聞中心
1、寫在前面
上篇文章主要介紹了如何簡易的實現(xiàn)一個響應(yīng)系統(tǒng),只是個簡易的仍然存在很多未知的不可控的問題,比如副作用函數(shù)嵌套、如何避免無限遞歸以及多個副作用函數(shù)之間會產(chǎn)生什么影響?

本文將會解決以下幾個問題:
- 分支切換
- 嵌套的effect
- 無限遞歸
- 可調(diào)度性
2、分支切換與cleanup
分支切換
在進行頁面渲染時,我們需要避免副作用函數(shù)產(chǎn)生的遺留。為什么這么說呢?先看下面的代碼片段,在副作用函數(shù)effect內(nèi)部的箭頭函數(shù)中有個三元表達式,根據(jù)state.flag的值去切換頁面渲染的值,這是我們期待的分支切換。
const data = {
name:"pingping",
age:18,
flag:true
};
const state = new Proxy(data,{
/* 其他代碼省略 */
});
//副作用函數(shù),effect執(zhí)行渲染了頁面
effect(()=>{
console.log("render");
document.body.innerHTML = state.flag ? state.name : state.age;
})
flag的值為初始值true時,頁面渲染的結(jié)果如圖所示:
但是事實上,分支切換可能會產(chǎn)生遺留的副作用函數(shù)。上面代碼片段,flag的初始值是true,此時會去響應(yīng)式對象state中獲取字段flag的值,此時effect函數(shù)會執(zhí)行觸發(fā)flag和name的讀取操作,副作用函數(shù)會與響應(yīng)數(shù)據(jù)之間建立聯(lián)系。
flag初始值為true的時候,事實上的Map的key值只有flag和name與副作用函數(shù)建立了聯(lián)系,也只會收集這兩個響應(yīng)式數(shù)據(jù)的依賴--副作用函數(shù)。
flag字段值修改為false時,會觸發(fā)副作用函數(shù)effect重新執(zhí)行,按道理name的值不會被讀取,只會觸發(fā)flag和age的讀取操作,理想情況應(yīng)該是依賴集合收集的是這兩個字段所對應(yīng)的副作用函數(shù)。
副作用函數(shù)與響應(yīng)數(shù)據(jù)之間的關(guān)系
但是事實上,在上面代碼中實現(xiàn)不了這種變化,在修改字段flag的值會觸發(fā)副作用函數(shù)重新執(zhí)行后,整個依賴關(guān)系會保持flag為true時的關(guān)系圖,name字段所產(chǎn)生的副作用函數(shù)會遺留。
// 設(shè)置一個不存在的屬性時
setTimeout(()=>{
state.flag = false;
},1000)
如上面代碼,遺留的副作用函數(shù)會導致數(shù)據(jù)不必要的更新,之所以這樣說,是因為flag的值改為false后,會觸發(fā)更新導致副作用函數(shù)重新執(zhí)行。此時應(yīng)該不存在name的依賴關(guān)系,即不會讀取name的值了,無論flag的值怎么變化都應(yīng)該只是讀取age的值而非name。
上面代碼實際執(zhí)行效果如下圖所示,頁面的渲染值沒有改變,控制臺打印顯示:
// 設(shè)置一個不存在的屬性時
setTimeout(()=>{
state.flag = false;
setTimeout(()=>{
console.log("更改了name的值,理論上是不會更新頁面數(shù)據(jù)的...");
state.name = "onechuan"
})
},1000)
即使我們在setTimeout中繼續(xù)修改name的值,頁面依然渲染的是name的初始值"pingping",控制臺顯示我們是修改了name的值的。
cleanup
那么,我們應(yīng)該如何解決上面的副作用函數(shù)遺留問題呢?其實,我們只需要設(shè)置在每次副作用函數(shù)觸發(fā)執(zhí)行時,先把它從所有與之相關(guān)聯(lián)的依賴集合中刪除。當副作用函數(shù)執(zhí)行完畢后,會重新建立聯(lián)系,重新在依賴集合中收集副作用函數(shù),但是之前遺留的副作用函數(shù)已經(jīng)被清理?!捍驋吒蓛粑葑?,重新請客』。
清除副作用函數(shù)與響應(yīng)式數(shù)據(jù)之間的聯(lián)系
我們應(yīng)該如何實現(xiàn)上面的理論呢,得先確定哪些依賴集合中包含了遺留的副作用函數(shù),我們需要重新設(shè)計副作用函數(shù)effect。
在effect函數(shù)內(nèi)部定義一個effectFn函數(shù),為其添加effectFn.deps數(shù)組,用于存儲所有包含當前副作用函數(shù)的依賴集合。在每次執(zhí)行副作用函數(shù)前,都需要根據(jù)effectFn.deps獲取依賴集合,調(diào)用cleanupEffect函數(shù)完成清理遺留的副作用函數(shù)。
// 全局變量用于存儲被注冊的副作用函數(shù)
let activeEffect;
// effect用于注冊副作用函數(shù)
function effect(fn){
const effectFn = ()=>{
// 調(diào)用函數(shù)完成清理遺留副作用函數(shù)
cleanupEffect(effectFn)
// 當調(diào)用effect注冊副作用函數(shù)時,將副作用函數(shù)fn賦值給activeEffect
activeEffect = effectFn;
// 執(zhí)行副作用函數(shù)
fn();
}
//deps是用于存儲所有與該副作用函數(shù)相關(guān)聯(lián)的依賴集合
effectFn.deps = [];
// 執(zhí)行副作用函數(shù)effectFn
effectFn()
}
cleanupEffect函數(shù)的設(shè)計實現(xiàn)如下代碼段,其接收一個effectFn副作用函數(shù)作為參數(shù),遍歷收集依賴集合的effectFn.deps數(shù)組,將effectFn該函數(shù)從依賴集合中清除,最后重置effectFn.deps數(shù)組。
// 遺留的副作用函數(shù)的清除函數(shù)
function cleanupEffect(effectFn){
const { deps } = effectFn
// 遍歷依賴集合數(shù)組
for(let i = 0; i < deps.length; i++){
//從依賴集合中刪除
deps[i].delete(effectFn)
}
// 重置數(shù)組
deps.length = 0
}
那么,effectFn.deps數(shù)組又是如何收集依賴集合的呢?首先將當前執(zhí)行的副作用函數(shù)activeEffect添加到依賴集合deps中,此時deps存儲的是與當前副作用函數(shù)存在聯(lián)系的依賴集合,而后將其添加到activeEffect.deps數(shù)組中完成收集。
// 在get攔截函數(shù)中調(diào)用追蹤取值函數(shù)的變化
function track(target, key){
// 沒有activeEffect
if(!activeEffect) return
// 根據(jù)目標對象從桶中獲得副作用函數(shù)
let depsMap = bucket.get(target);
// 判斷是否存在,不存在則創(chuàng)建一個Map
if(!depsMap) bucket.set(target, depsMap = new Map())
// 根據(jù)key從depsMap取的deps,存儲著與key相關(guān)的副作用函數(shù)
let deps = depsMap.get(key);
// 判斷key對應(yīng)的副作用函數(shù)是否存在
if(!deps) depsMap.set(key, deps = new Set())
// 最后將激活的副作用函數(shù)添加到桶里
deps.add(activeEffect)
// deps是與當前副作用函數(shù)存在聯(lián)系的依賴集合
activeEffect.deps.push(deps)
}
注意:前面的代碼片段在副作用函數(shù)觸發(fā)時會執(zhí)行清理操作,在執(zhí)行后會進行收集effect,但是在執(zhí)行過程中會導致無限循環(huán)執(zhí)行(死循環(huán))。
為什么會出現(xiàn)死循環(huán)呢?
這是因為在trigger函數(shù)中,會遍歷存儲著副作用函數(shù)Set集合effects。在副作用函數(shù)執(zhí)行時,會調(diào)用cleanup執(zhí)行清除操作,實際上就是從effects集合中找出當前執(zhí)行的副作用函數(shù)進行清除。但是副作用函數(shù)的執(zhí)行,會導致其重新被收集到effects集合中,這樣就不斷的清除和收集了。
在ECMA規(guī)范中:調(diào)用forEach對Set集合進行遍歷時,如果一個值已經(jīng)被訪問過,那么該值被刪除并重新添加到集合中,如果此時forEach遍歷沒有結(jié)束,該值就會重新被訪問。
let effect = () => {};
let s = new Set([effect])
s.forEach(item=>{
s.delete(effect);
s.add(effect)}
); // 這樣就導致死循環(huán)了那么我們應(yīng)該如何打破循環(huán)呢?
很簡單,只需要新構(gòu)造一個Set集合進行遍歷即可。即在trigger函數(shù)中修改語句即可:
// 在set攔截函數(shù)中調(diào)用trigger函數(shù)觸發(fā)變化
function trigger(target, key){
// 根據(jù)target從桶中取的depMaps
const depMaps = bucket.get(target);
// 判斷是否存在
if(!depMaps) return
// 根據(jù)key值取得對應(yīng)的副作用函數(shù)
const effects = depMaps.get(key);
// 執(zhí)行副作用函數(shù)
// effects && effects.forEach(fn=>fn())
const effectsToRun = new Set(effects);
effectsToRun.forEach(effectFn=>effectFn());
}
此時就有:
修改age值前的頁面
控制臺打印結(jié)果:
3、嵌套的effect和effect棧
嵌套的effect
在實際開發(fā)中,我們不可避免會寫出effect函數(shù)嵌套,即一個effect函數(shù)內(nèi)部嵌套著另外一個effect函數(shù)。
effect(()=>{
effct(()=>{
/*...*/
})
})如果我們的響應(yīng)式系統(tǒng)不支持effect嵌套,那么會發(fā)生什么事情呢?
// 原始數(shù)據(jù)
const data = {
name:"pingping",
age:18,
flag:true
}
//代理對象
const state = new Proxy(data,{
/* 其他代碼省略 */
});
//全局變量
let temp1, temp2;
//effectFn1嵌套effectFn2
effect(()=>{
console.log("執(zhí)行effectFn1");
effect(()=>{
console.log("執(zhí)行effectFn2");
//在effectFn2中讀取state.name屬性
temp2 = state.name;
})
//在effectFn1中讀取state.age屬性
temp1 = state.age;
})
setTimeout(()=>{
state.age = 19
},1000)
在上面代碼中,簡單的寫了一個effect嵌套的demo,effectFn1內(nèi)部嵌套了effectFn2,那么effectFn1執(zhí)行會導致effectFn2的執(zhí)行。effectFn2中讀取了state.name的值,而effectFn1中讀取了state.age的值,且effectFn2的讀取操作優(yōu)先于effectFn1的讀取操作。即:
state
|__ name
|__ effectFn1
|__ age
|__ effectFn2
在這種情況下,理論上修改state.name的值只會觸發(fā)effectFn2的執(zhí)行,而當修改state.age的值時,會觸發(fā)effectFn1的執(zhí)行且間接觸發(fā)effectFn2函數(shù)的執(zhí)行。
但是,事實上修改state.age的值輸出的結(jié)果如下圖所示,打印了三次,effectFn1只執(zhí)行了一次,而effectFn2卻執(zhí)行了兩次,修改時的并沒有重新執(zhí)行effectFn1函數(shù)。
為什么會出現(xiàn)這種情況呢?
這是因為我們嵌套了多個effect函數(shù),而activeEffect全局變量同一時刻只能存儲一個通過effect函數(shù)注冊的副作用函數(shù)。當effect發(fā)生嵌套時,內(nèi)層effect產(chǎn)生的副作用函數(shù)會覆蓋掉activeEffect的值,并且永遠不能回到過去了?!赫媸莻€渣男』。
effect執(zhí)行棧
那么應(yīng)該如何解決這個問題呢?
想下js事件循環(huán)機制就知道,通過一個棧數(shù)據(jù)結(jié)構(gòu)去存儲當前執(zhí)行的事件。同樣的,我們也可以添加一個副作用函數(shù)執(zhí)行棧effectStack,當前副作用函數(shù)執(zhí)行時,將其壓入棧中,在執(zhí)行完畢后將其出棧,并讓activeEffect指向棧頂?shù)母弊饔煤瘮?shù),即最近執(zhí)行的副作用函數(shù)。
let effectStack = [];
// effect用于注冊副作用函數(shù)
function effect(fn){
const effectFn = ()=>{
// 調(diào)用函數(shù)完成清理遺留副作用函數(shù)
cleanupEffect(effectFn)
// 當調(diào)用effect注冊副作用函數(shù)時,將副作用函數(shù)fn賦值給activeEffect
activeEffect = effectFn;
// 在副作用函數(shù)執(zhí)行前壓棧
effectStack.push(effectFn)
// 執(zhí)行副作用函數(shù)
fn();
// 執(zhí)行完畢后出棧
effectStack.pop()
activeEffect = effectStack[effectStack.length - 1]
}
//deps是用于存儲所有與該副作用函數(shù)相關(guān)聯(lián)的依賴集合
effectFn.deps = [];
// 執(zhí)行副作用函數(shù)effectFn
effectFn()
}
在上面代碼片段中,定義了一個effectStack數(shù)組去存儲待執(zhí)行的副作用函數(shù),activeEffect始終指向當前執(zhí)行的副作用函數(shù)。根據(jù)棧結(jié)構(gòu)的先進后出原則,剛好外層effect先進存儲在棧地,內(nèi)層effect后進存儲在棧頂,在內(nèi)層執(zhí)行完畢后出棧執(zhí)行外層effect。這樣,響應(yīng)式數(shù)據(jù)只會收集直接讀取當前值的副作用函數(shù)作為依賴,從而避免錯亂。
這樣控制打?。?/p>
打印結(jié)果
4、避免無限遞歸循環(huán)
前面在存儲當前執(zhí)行的副作用函數(shù)的依賴集合時,可能會出現(xiàn)循環(huán)執(zhí)行的情況,我們也添加了新Set集合進行解決。當我們在副作用函數(shù)中,對同一個字段的值進行無限遞歸循環(huán),那么會出現(xiàn)什么情況?
// 原始數(shù)據(jù)
const data = {
name:"pingping",
age:18,
flag:true
}
//代理對象
const state = new Proxy(data,{
/* 其他代碼省略 */
});
effect(()=>{
state.age++;
})
我們看到執(zhí)行結(jié)果出現(xiàn)爆棧的情況,內(nèi)存溢出:
內(nèi)存溢出
我們可以看到state.age++;語句中,既有state.age的讀取操作,又有設(shè)值操作,這樣前一個副作用函數(shù)還沒執(zhí)行完畢,又重新開啟了新的執(zhí)行,這樣就無限遞歸調(diào)用自己了?!何艺{(diào)用我自己,超越本我』
那么,我們應(yīng)該如何避免棧溢出呢?
在前面的文章中知道,在對state.age的取值track和設(shè)值trigger操作都是在同一個副作用函數(shù)activeEffect中執(zhí)行的。那么只需要在trigger中增加守衛(wèi)條件:判斷下觸發(fā)trigger的副作用函數(shù)和當前正在執(zhí)行的副作用函數(shù)是不是同一個,如果是同一個則不觸發(fā)執(zhí)行,否則執(zhí)行。
// 在set攔截函數(shù)中調(diào)用trigger函數(shù)觸發(fā)變化
function trigger(target, key){
// 根據(jù)target從桶中取的depMaps
const depMaps = bucket.get(target);
// 判斷是否存在
if(!depMaps) return
// 根據(jù)key值取得對應(yīng)的副作用函數(shù)
const effects = depMaps.get(key);
const effectsToRun = new Set();
// 執(zhí)行副作用函數(shù)
effects && effects.forEach(effectFn=>{
if(effectFn !== activeEffect){
effectsToRun.add(effectFn)
}
})
effectsToRun.forEach(effectFn=>effectFn());
}
在執(zhí)行觸發(fā)trigger時,對觸發(fā)trigger的副作用函數(shù)和當前執(zhí)行的副作用函數(shù)進行比較篩選,即可避免棧內(nèi)存的溢出。
5、調(diào)度執(zhí)行
先了解下可調(diào)度性對于意義,就是trigger觸發(fā)副作用函數(shù)重新執(zhí)行時,可以自定義決定副作用函數(shù)執(zhí)行的時機、次數(shù)、及執(zhí)行方式。
// 原始數(shù)據(jù)
const data = {
name:"pingping",
age:18,
flag:true
}
//代理對象
const state = new Proxy(data,{
/* 其他代碼省略 */
});
effect(()=>{
console.log(state.age);
});
state.age++;
console.log("run end");
執(zhí)行結(jié)果
如果我們需要改變代碼的執(zhí)行順序,得到不同的結(jié)果,那么需要提供給用戶調(diào)度能力,即允許使用者自定義調(diào)度器。
// effect用于注冊副作用函數(shù)
function effect(fn,options={}){
const effectFn = ()=>{
// 調(diào)用函數(shù)完成清理遺留副作用函數(shù)
cleanupEffect(effectFn)
// 當調(diào)用effect注冊副作用函數(shù)時,將副作用函數(shù)fn賦值給activeEffect
activeEffect = effectFn;
// 在副作用函數(shù)執(zhí)行前壓棧
effectStack.push(effectFn)
// 執(zhí)行副作用函數(shù)
fn();
// 執(zhí)行完畢后出棧
effectStack.pop()
activeEffect = effectStack[effectStack.length - 1]
}
// 將options掛載到effectFn函數(shù)上
effectFn.options = options
//deps是用于存儲所有與該副作用函數(shù)相關(guān)聯(lián)的依賴集合
effectFn.deps = [];
// 執(zhí)行副作用函數(shù)effectFn
effectFn()
}
// 在set攔截函數(shù)中調(diào)用trigger函數(shù)觸發(fā)變化
function trigger(target, key){
// 根據(jù)target從桶中取的depMaps
const depMaps = bucket.get(target);
// 判斷是否存在
if(!depMaps) return
// 根據(jù)key值取得對應(yīng)的副作用函數(shù)
const effects = depMaps.get(key);
const effectsToRun = new Set();
// 執(zhí)行副作用函數(shù)
effects && effects.forEach(effectFn=>{
if(effectFn !== activeEffect){
effectsToRun.add(effectFn)
}
})
effectsToRun.forEach(effectFn=>{
// 如果副作用函數(shù)中存在調(diào)度器
if(effectFn.options.scheduler){
effectFn.options.scheduler(effectFn)
}else{
effectFn()
}
});
}
在上面代碼片段中,在trigger觸發(fā)副作用函數(shù)執(zhí)行時,會先判斷該副作用函數(shù)中是否存在調(diào)度器:
- 存在調(diào)度器,直接執(zhí)行調(diào)度器函數(shù),并將當前副作用函數(shù)作為參數(shù)傳遞effectFn.options.scheduler(effectFn)。
- 不存在調(diào)度器,則直接執(zhí)行副作用函數(shù)effectFn()。
effect(()=>{
console.log(state.age);
},{//options
scheduler(fn){//調(diào)度器
setTimeout(fn);
}
});
state.age++;
console.log("run end");執(zhí)行結(jié)果
這樣,系統(tǒng)設(shè)計實現(xiàn)了控制副作用函數(shù)的執(zhí)行順序。除此之外,我們還可以添加實現(xiàn)控制副作用函數(shù)的執(zhí)行次數(shù),同樣只需要修改調(diào)度器代碼就行,這里就不贅述了。
6、寫在最后
在本文中,主要解決的問題有:
- 分支切換導致遺留的副作用函數(shù),可以添加一個集合收集依賴集合,在每次執(zhí)行副作用函數(shù)前將其對應(yīng)的聯(lián)系清除,在執(zhí)行后重新建立聯(lián)系。
- 對于effect嵌套問題可以通過添加一個effectStack執(zhí)行棧解決,外層副作用函數(shù)先入棧,內(nèi)層后入棧,activeEffect永遠指向當前要執(zhí)行的副作用函數(shù)。
- 對于避免無限遞歸循環(huán),可以在trigger觸發(fā)副作用函數(shù)執(zhí)行前進行判斷,觸發(fā)的副作用函數(shù)與當前執(zhí)行的副作用函數(shù)是否相同。
- 對于響應(yīng)系統(tǒng)的調(diào)度性,可以通過設(shè)置調(diào)度器去控制副作用函數(shù)執(zhí)行的順序、時機、次數(shù)等。
本文名稱:Vue.js設(shè)計與實現(xiàn)之五-設(shè)計一個完善的響應(yīng)系統(tǒng)
網(wǎng)頁URL:http://fisionsoft.com.cn/article/dhoeoce.html


咨詢
建站咨詢
