Vue.js設(shè)計(jì)與實(shí)現(xiàn)之設(shè)計(jì)一個(gè)完善的響應(yīng)系統(tǒng)
1.寫在前面
響應(yīng)系統(tǒng)是Vue.js的重要組成部分,我們要實(shí)現(xiàn)一個(gè)簡易的響應(yīng)式系統(tǒng),必須先要了解什么是響應(yīng)式數(shù)據(jù)和副作用函數(shù)。在實(shí)現(xiàn)過程中,我們需要考慮如何避免無限遞歸,為什么需要嵌套副作用函數(shù),以及多個(gè)副作用函數(shù)之間會(huì)產(chǎn)生什么影響?
2.副作用函數(shù)
所謂副作用函數(shù),指的是會(huì)產(chǎn)生副作用的函數(shù),而副作用指的是函數(shù)effect的執(zhí)行會(huì)直接或間接影響到其它函數(shù)的執(zhí)行,那么就說effect函數(shù)產(chǎn)生了副作用,effect就是副作用函數(shù)。
<div id="app"></div>
<script>
//全局變量
let state = {
name:"onechuan",
age:18,
address:"北京"
}
function effect(){
app.innerHTML = "hello pingping," + state.name + "," + state.age + "," + state.address;
}
effect();
setTimeout(()=>{
//修改全局變量,產(chǎn)生副作用
state.address = "廣州";
},1000)
</script>
在上面的代碼片段中,副作用函數(shù)effect會(huì)設(shè)置id為app的標(biāo)簽innerHTML屬性 app.innerHTML = "hello pingping," + state.name + "," + state.age + "," + state.address;,其中state.address的值為"北京"。而當(dāng)state.address發(fā)生變化時(shí),希望副作用函數(shù)effect能夠重新執(zhí)行,state.address的值變?yōu)?廣州"。
當(dāng)前在setTimeout函數(shù)中代碼修改了state.address的值,除了對象的值本身發(fā)生變化外,沒有其他任何變化,達(dá)不到要求的效果。如果希望值變化后副作用函數(shù)立即更新,那么state對象數(shù)據(jù)就必須是響應(yīng)式的,那么什么是響應(yīng)式的,應(yīng)該如何讓state實(shí)現(xiàn)響應(yīng)式呢?
3.響應(yīng)式數(shù)據(jù)
對上面的要求進(jìn)行分析,要想讓state變成響應(yīng)式數(shù)據(jù),需要滿足兩個(gè)條件:
- 在副作用函數(shù)effect執(zhí)行時(shí),從對象state中讀取address的值,觸發(fā)讀取操作。
- 當(dāng)修改state.address的值時(shí),把對象state中的address的值進(jìn)行修改,觸發(fā)設(shè)置操作。
再次思考,響應(yīng)式數(shù)據(jù)的實(shí)現(xiàn)就變成了攔截對象進(jìn)行取值和設(shè)值操作。當(dāng)從state對象中讀取address時(shí),就將副作用函數(shù)effect存儲(chǔ)到容器中,當(dāng)設(shè)置state對象中的address值的時(shí)候,從容器中取出effect函數(shù)并執(zhí)行。
取值操作
設(shè)置操作
那么,到底應(yīng)該如何實(shí)現(xiàn)對一個(gè)對象屬性的讀取和設(shè)置操作呢?在Vue.js2中采用的是Object.defineProperty函數(shù)實(shí)現(xiàn)的,而在Vue.js3中則是采用Proxy代理對象的方法實(shí)現(xiàn)的。我們根據(jù)上面的思路和流程圖,先簡易實(shí)現(xiàn)個(gè)最low的攔截取值設(shè)置操作。
<div id="app"></div>
<script>
//全局變量
let state = {
name:"onechuan",
age:18,
address:"北京"
}
// 存儲(chǔ)副作用函數(shù)的桶
const bucket = new Set();
// 對原始數(shù)據(jù)的代理
const obj = new Proxy(state,{
// 攔截讀取操作
get(target, key){
// 將副作用函數(shù)effect添加到存儲(chǔ)副作用函數(shù)的桶中
bucket.add(effect)
// 返回屬性值
return target[key]
},
// 攔截設(shè)置操作
set(target, key, newVal){
// 設(shè)置屬性值
target[key] = newVal
// 把副作用函數(shù)從桶里取出并執(zhí)行
bucket.forEach(fn=>fn())
// 返回true代表設(shè)置操作成功
return true
}
})
function effect(){
const app = document.querySelector("#app");
app.innerHTML = obj.name + "," + obj.age + "," + obj.address;
}
effect();
setTimeout(()=>{
//修改全局變量,產(chǎn)生副作用
obj.address = "廣州";
},1000)
</script>
在瀏覽器中渲染得到:
1s后頁面更新渲染為:
看到上面的代碼片段,不禁想問為什么要將存儲(chǔ)副作用函數(shù)的容器類型設(shè)置為Set類型,這是因?yàn)閷τ谕粋€(gè)對象屬性進(jìn)行多次代理就會(huì)出現(xiàn)死循環(huán)的情況,對此使用Set可以用于去重。
state是被代理的原始數(shù)據(jù),而obj是采用Proxy進(jìn)行代理后的對象數(shù)據(jù),在其中實(shí)現(xiàn)了攔截和取值設(shè)值操作,在取值和設(shè)置過程中實(shí)現(xiàn)了副作用函數(shù)effect的存儲(chǔ)和取出執(zhí)行的操作。
4.尚且完善的響應(yīng)式系統(tǒng)
為什么說是尚且完善的響應(yīng)式系統(tǒng),這是因?yàn)樵诒径沃袑⒀驖u進(jìn)介紹,如何實(shí)現(xiàn)一個(gè)功能尚且完善的響應(yīng)式系統(tǒng)??梢詫?shí)現(xiàn)通用式的副作用函數(shù),匿名函數(shù)也能夠被收集到副作用函數(shù)容器中,而非命名的effect函數(shù)。
注冊副作用函數(shù)
要實(shí)現(xiàn)這一點(diǎn),只需要編寫一個(gè)通用函數(shù),提供注冊副作用函數(shù)機(jī)制即可。
// 全局變量用于存儲(chǔ)當(dāng)前被注冊的副作用函數(shù)
let activeEffect;
// effect用于注冊副作用函數(shù)
function effect(fn){
// 當(dāng)調(diào)用effect注冊副作用函數(shù)時(shí),將副作用函數(shù)fn賦值給activeEffect
activeEffect = fn;
// 執(zhí)行副作用函數(shù)
fn();
}
effect(()=>{
app.innerHTML = state.name + "," + state.age + "," + state.address;
})
在上面代碼片段中,傳遞一個(gè)閉包即可實(shí)現(xiàn)注冊副作用函數(shù)的功能,當(dāng)effect函數(shù)執(zhí)行時(shí),先將effect傳遞的閉包函數(shù)暫存到變量activeEffect,作為當(dāng)前注冊的副作用函數(shù)。
//原始數(shù)據(jù)
let state = {
name:"onechuan",
age:18,
address:"北京"
}
// 存儲(chǔ)副作用函數(shù)的桶
const bucket = new Set();
// 對原始數(shù)據(jù)的代理
const obj = new Proxy(state,{
// 攔截讀取操作
get(target, key){
// 將activeEffect存儲(chǔ)的副作用函數(shù)收集到桶里
if(activeEffect){
bucket.add(activeEffect)
}
// 返回屬性值
return target[key]
},
// 攔截設(shè)置操作
set(target, key, newVal){
// 設(shè)置屬性值
target[key] = newVal
// 把副作用函數(shù)從桶里取出并執(zhí)行
bucket.forEach(fn=>fn())
// 返回true代表設(shè)置操作成功
return true
}
})
effect(()=>{
app.innerHTML = state.name + "," + state.age + "," + state.address;
})
setTimeout(()=>{
//修改全局變量,產(chǎn)生副作用
obj.address = "廣州";
},1000)
當(dāng)我們在響應(yīng)式數(shù)據(jù)obj上設(shè)置一個(gè)不存在的屬性時(shí),副作用函數(shù)并不會(huì)去對象上讀取這個(gè)屬性的值,也就是這個(gè)不存在的屬性并沒有與副作用函數(shù)建立響應(yīng)聯(lián)系。原本不應(yīng)該觸發(fā)副作用函數(shù)中的匿名函數(shù),但是實(shí)際上卻觸發(fā)了effect函數(shù)的執(zhí)行,這也印證了我們當(dāng)前設(shè)計(jì)的系統(tǒng)還存在缺陷。
之所以出現(xiàn)上面的問題,這是因?yàn)樵跊]有副作用函數(shù)與被操作的目標(biāo)字段之間建立明確的關(guān)系,這就是為什么在Vue.js3實(shí)際設(shè)計(jì)中沒有簡單使用Set類型的原因。為了解決這種問題,我們只需要在副作用函數(shù)與被操作字段間建立聯(lián)系即可,重新設(shè)計(jì)收集副作用函數(shù)的容器數(shù)據(jù)結(jié)構(gòu)。
依賴收集的數(shù)據(jù)結(jié)構(gòu)
要重新設(shè)計(jì)副作用函數(shù)的容器數(shù)據(jù)結(jié)構(gòu),需要我們分析effect函數(shù)的執(zhí)行機(jī)制,這段代碼中存在三個(gè)重要部分:
- 被操作(讀取)的代理對象obj (target對象)。
- 被操作(讀取)的屬性名稱address (target對象的鍵名)。
- 使用effect函數(shù)注冊的副作用函數(shù)effectFn。
三者建立的關(guān)系是:
|-target
|- key
|- effectFn
對于上面的分析,我們得先重新設(shè)計(jì)存儲(chǔ)副作用函數(shù)的依賴收集容器的數(shù)據(jù)結(jié)構(gòu),創(chuàng)建WeakMap用于存儲(chǔ)對象,Set用于存儲(chǔ)副作用函數(shù)。
// 創(chuàng)建存儲(chǔ)副作用函數(shù)的桶
const bucket = new WeakMap();
// 全局變量用于存儲(chǔ)被注冊的副作用函數(shù)
let activeEffect;
// 響應(yīng)式函數(shù)
const obj = new Proxy(state,{
// 攔截讀取操作
get(target, key){
// 沒有activeEffect
if(!activeEffect) return
// 根據(jù)目標(biāo)對象從桶中獲得副作用函數(shù)
let depsMap = bucket.get(target);
// 判斷是否存在,不存在則創(chuàng)建一個(gè)Map
if(!depsMap) bucket.set(target, depsMap = new Map())
// 根據(jù)key從depsMap取的deps,存儲(chǔ)著與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)
// 返回屬性值
return target[key]
},
// 攔截設(shè)值操作
set(target, key, newVal){
// 設(shè)置屬性值
target[key] = newVal;
// 根據(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())
}
})
在上面的代碼片段中,所寫WeakMap、Map和Set的數(shù)據(jù)結(jié)構(gòu)關(guān)系如下圖所示。三者的具體作用:
- WeakMap用于存儲(chǔ)代理對象target,用于存儲(chǔ)和判斷當(dāng)前對象是否已經(jīng)被Proxy進(jìn)行代理過。如果被代理過則直接返回WeakMap中的代理對象,如果沒有被代理過則使用Proxy進(jìn)行代理后存儲(chǔ),從而避免同一個(gè)對象被代理多次。
- Map用于存儲(chǔ)經(jīng)過Proxy代理的對象的屬性名。
- Set用于存儲(chǔ)Map中對應(yīng)的每個(gè)屬性的副作用函數(shù),可以用于去重,避免多次調(diào)用。
為什么使用WeakMap作為存儲(chǔ)對象的容器呢?
這是因?yàn)閃eakMap是弱引用的Map,不會(huì)影響到垃圾回收機(jī)制的正常工作,WeakMap多引用的對象執(zhí)行完畢后,會(huì)將對象從內(nèi)存中移除,從而避免內(nèi)存泄漏。所以WeakMap經(jīng)常用于存儲(chǔ)那些只有當(dāng)key所引用對象存在時(shí)(沒有被回收)才有價(jià)值的信息。
在前面代碼片段中,如果target對象沒有任何引用了,說明用戶沒有使用它,此時(shí)垃圾回收機(jī)制就可以將其進(jìn)行清除,從而避免內(nèi)存溢出。
整理抽取代碼
將前面的代碼片段進(jìn)行抽取函數(shù),封裝得到track和trigger函數(shù),使得我們的代碼邏輯更加清晰明了,也能帶給我們更大的靈活性。
// 全局變量用于存儲(chǔ)被注冊的副作用函數(shù)
let activeEffect;
// 創(chuàng)建存儲(chǔ)副作用函數(shù)的桶
const bucket = new WeakMap();
// 原始數(shù)據(jù)
const state = {
name:"pingping",
age:18,
address:"北京"
}
// 響應(yīng)式函數(shù)
const obj = new Proxy(state,{
// 攔截讀取操作
get(target, key){
// 將副作用函數(shù)activeEffect添加到存儲(chǔ)副作用函數(shù)的WeakMap中
track(target, key)
// 返回屬性值
return target[key]
},
// 攔截設(shè)值操作
set(target, key, newVal){
// 設(shè)置屬性值
target[key] = newVal;
// 將副作用函數(shù)從WeakMap中取出并執(zhí)行
trigger(target, key)
}
})
// 在get攔截函數(shù)中調(diào)用追蹤取值函數(shù)的變化
function track(target, key){
// 沒有activeEffect
if(!activeEffect) return
// 根據(jù)目標(biāo)對象從桶中獲得副作用函數(shù)
let depsMap = bucket.get(target);
// 判斷是否存在,不存在則創(chuàng)建一個(gè)Map
if(!depsMap) bucket.set(target, depsMap = new Map())
// 根據(jù)key從depsMap取的deps,存儲(chǔ)著與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)
}
// 在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())
}
// effect用于注冊副作用函數(shù)
function effect(fn){
// 當(dāng)調(diào)用effect注冊副作用函數(shù)時(shí),將副作用函數(shù)fn賦值給activeEffect
activeEffect = fn;
// 執(zhí)行副作用函數(shù)
fn();
}
effect(()=>{
console.log("打印");
document.body.innerText = obj.name + "," + obj.age + "," + obj.address;
})
// 設(shè)置一個(gè)不存在的屬性時(shí)
setTimeout(()=>{
obj.address = "廣州"
},1000)
5.寫在后面
在本文中簡單實(shí)現(xiàn)了可以進(jìn)行依賴收集的響應(yīng)式系統(tǒng),使用WeakMap配合Map構(gòu)建了新的存儲(chǔ)結(jié)構(gòu),能夠在響應(yīng)式數(shù)據(jù)和副作用函數(shù)之間建立更加精確的聯(lián)系。之所以采用WeakMap存儲(chǔ)引用對象,是因?yàn)槠涫侨跻玫?,?dāng)某個(gè)對象不再被使用時(shí)會(huì)被垃圾回收機(jī)制清除。此外,還對響應(yīng)式系統(tǒng)的代碼進(jìn)行了功能抽取,對應(yīng)封裝成調(diào)用函數(shù)track和trigger。