作為前端最熱門的三大框架/庫之一,Vue.js 讓我們能使用更易讀、更好維護的方式建構出頁面中的功能。不知道大家是否想過框架中所提供的 API 底層是如何運作的?這篇文就來記錄關於響應式的部分。
文章內容主要都來自 Vue.js 官方團隊成員霍春陽撰寫的 Vue.js 設計與實現並加上我自己的一點超譯,算是一份閱讀筆記。
上一章主要對響應式做了更進一步的完善,使其可以避免在執行時也沒有任何意義的情況下觸發。這章主要會繼續完善響應式,讓其可以支援使用巢狀的方式撰寫、並解決可能觸發無限遞迴問題。
支援巢狀 effect
在 Vue.js 中,render 函式就是在一個 effect 中執行的。當我們在組件中撰寫組件時,就等同運用了巢狀的 effect。回到我們到目前為止所撰寫的響應式:
const effectsWeakMap = new WeakMap();
const originData = { showText: true, text: "Hello Vue!" };
const dataProxy = new Proxy(originData, {
get(target, key) {
track(target, key);
return target[key];
},
set(target, key, newVal) {
target[key] = newVal;
trigger(target, key);
},
});
let activeEffect = null;/**
* 將副作用函式儲存至 effectsWeakMap 中
* @param {object} target 被代理的原始物件
* @param {string} key 被讀取的鍵
* @returns {void}
*/
function track(target, key) {
if (!activeEffect) return;
let targetDependenciesMap = effectsWeakMap.get(target);
if (!targetDependenciesMap) {
targetDependenciesMap = new Map();
effectsWeakMap.set(target, targetDependenciesMap);
}
let keyRelatedEffectsSet = targetDependenciesMap.get(key);
if (!keyRelatedEffectsSet) {
keyRelatedEffectsSet = new Set();
targetDependenciesMap.set(key, keyRelatedEffectsSet);
}
keyRelatedEffectsSet.add(activeEffect);
activeEffect.dependencies.push(keyRelatedEffectsSet);
}
/**
* 在被代理物件的鍵對應的值被調整時,執行對應的副作用函式
* @param {object} target 被代理的原始物件
* @param {string} key 被讀取的鍵
* @returns {void}
*/
function trigger(target, key) {
const targetDependenciesMap = effectsWeakMap.get(target);
if (!targetDependenciesMap) return;
const keyRelatedEffectsSet = targetDependenciesMap.get(key);
// 避免 forEach 進入無窮迴圈
const effectsToRun = new Set(keyRelatedEffectsSet);
effectsToRun.forEach((effectFn) => effectFn());
}
/**
* 用於註冊及觸發副作用函式
* @param {function} fn 要觸發的副作用函式
* @returns {void}
*/
function effect(fn) {
const runningEffectFn = () => {
cleanup(runningEffectFn);
activeEffect = runningEffectFn;
fn();
};
runningEffectFn.dependencies = [];
runningEffectFn();
}
/**
* 將傳入的副作用函式從副作用函式集合中刪除,並清空傳入副作用函式存入的集合
* @param {function} runningEffectFn 要觸發的副作用函式
*/
function cleanup(runningEffectFn) {
for (let i = 0; i < runningEffectFn.dependencies.length; i++) {
const dependencies = runningEffectFn.dependencies[i];
dependencies.delete(runningEffectFn);
}
runningEffectFn.dependencies.length = 0;
}
effect(function effectFn() {
console.log("effect run!!");
document.getElementById("app").textContent = dataProxy.showText
? dataProxy.text
: "hiding text!";
});
假設我們依目前的設計來執行巢狀 effect:
<!-- 為方便測試HTML -->
<div id="app">
<p></p>
<span></span>
</div>
// 刪除所有原有的 effect 呼叫,並加入以下 JS
effect(function outerEffect() {
console.log("outerEffect run");
effect(function innerEffect() {
console.log("innerEffect run");
document.querySelector("#app span").textContent = dataProxy.anotherText;
});
document.querySelector("#app p").textContent = dataProxy.text;
});setTimeout(() => {
dataProxy.text = "text changed!";
}, 1000);
此時如果到瀏覽器確認,結果會是:
在後面我們掛了個 setTimeout
並修改 dataProxy.text
的值,結果觸發的卻是 innerEffect
函式的呼叫,也因此畫面上在 setTimeout
執行後沒有任何變化。比照以往,我們注意 effect
函式的執行:
effect
函式被呼叫,runningEffectFn
函式被宣告,dependencies
屬性被設為空陣列並呼叫activeEffect
中的fn
此時為外層effect
呼叫時帶入的參數:
function outerEffect() {
console.log("outerEffect run");
effect(function innerEffect() {
console.log("innerEffect run");
document.querySelector("#app span").textContent = dataProxy.anotherText;
});
document.querySelector("#app p").textContent = dataProxy.text;
}
- 內層
effect
函式被呼叫,這時activeEffect
便被覆蓋,也因此
document.querySelector("#app p").textContent = dataProxy.text;
- 這裡的
activeEffect
由於被覆蓋,在dataProxy
的get
方法觸發時,就會存入錯誤的activeEffect
。
要解決這個問題,我們需要建立一個堆疊 effectStack
來儲存副作用函式。堆疊是一種抽象的資料型別,具有後進先出的特性,我們會以陣列為底實作堆疊:
// 於 activeEffect 的宣告下方宣告
const effectStack = [];
改寫 effect
函式:
function effect(fn) {
const runningEffectFn = () => {
cleanup(runningEffectFn);
activeEffect = runningEffectFn;
effectStack.push(runningEffectFn);
fn();
effectStack.pop();
activeEffect = effectStack[effectStack.length - 1];
};
runningEffectFn.dependencies = [];
runningEffectFn();
}
我們在這裡透過 effectStack.push(runningEffectFn)
將 runningEffectFn
先存入堆疊,接著執行 fn
,此時的 fn
會是 outerEffect
,因為 outerEffect
的執行導致了 innerEffect
的執行,activeEffect
此時會被覆蓋。然而,在 innerEffect
執行完畢後,因為先前已在 effectStack
存入原先被覆蓋的 activeEffect
,此時藉由將其自堆疊取出並重新覆蓋:
藉著堆疊先存入後執行的副作用函式,並把執行後的副作用函式從堆疊移除,便能解決 activeEffect
被覆蓋的問題。
避免無限遞迴
目前的 trigger
函式潛藏著一個可能導致無限遞迴的問題。我們可以刪掉先前的所有 effect
呼叫,並保留下方的 effect
呼叫:
effect(() => (dataProxy.text = dataProxy.text + "a"));
這時如果開啟瀏覽器的 console,應該會看到:
Uncaught RangeError: Maximum call stack size exceeded
在這裡,首先讀了 dataProxy.text
的值,接著發生的事情:
dataProxy
的get
方法觸發track
函式被呼叫,副作用函式被存進effectsWeakMap
dataProxy.text
被賦值dataProxy
的set
方法觸發trigger
函式被呼叫,副作用函式從effectsWeakMap
被取出呼叫- 執行的程式碼
dataProxy.text = dataProxy.text + “a”
中,讀了dataProxy.text
的值 dataProxy
的get
方法觸發- …
在最後 trigger
函式被呼叫時,因為重複的讀寫 dataProxy.text
,導致了無限遞迴的狀況發生。
若要解決這個問題,我們可以在 trigger
被觸發時多做一層檢查,如果 trigger
函式內要觸發的副作用函式與目前正在執行的副作用函式相同,則不觸發:
function trigger(target, key) {
const targetDependenciesMap = effectsWeakMap.get(target);
if (!targetDependenciesMap) return;
const keyRelatedEffectsSet = targetDependenciesMap.get(key);
const effectsToRun = new Set();
if (keyRelatedEffectsSet) {
keyRelatedEffectsSet.forEach((effectFn) => {
if (effectFn !== activeEffect) {
effectsToRun.add(effectFn);
}
});
}
effectsToRun.forEach((effectFn) => effectFn());
}
這樣就能避免副作用函式不斷執行的問題。
以上就是關於支援巢狀 effect 及避免無限遞迴的筆記,關於響應式已經慢慢接近尾聲,下一篇會做個收尾。
References: