Vue.js 3 學習筆記-透過實現的方式理解 Vue.js 的響應式原理(四)

Eason Lin
11 min readJul 7, 2022

--

Photo by Zdeněk Macháček on Unsplash

作為前端最熱門的三大框架/庫之一,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 由於被覆蓋,在 dataProxyget 方法觸發時,就會存入錯誤的 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 的值,接著發生的事情:

  • dataProxyget 方法觸發
  • track 函式被呼叫,副作用函式被存進 effectsWeakMap
  • dataProxy.text 被賦值
  • dataProxyset 方法觸發
  • trigger 函式被呼叫,副作用函式從 effectsWeakMap 被取出呼叫
  • 執行的程式碼 dataProxy.text = dataProxy.text + “a” 中,讀了dataProxy.text 的值
  • dataProxyget 方法觸發

在最後 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:

--

--

Eason Lin
Eason Lin

Written by Eason Lin

Frontend Web Developer | Books

No responses yet