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