您好,登錄后才能下訂單哦!
這篇文章主要講解了“怎么用代碼實現一個迷你響應式系統vue”,文中的講解內容簡單清晰,易于學習與理解,下面請大家跟著小編的思路慢慢深入,一起來研究和學習“怎么用代碼實現一個迷你響應式系統vue”吧!
什么是響應式系統?學術上的定義,我們就不細究了。通過縱觀前端業界對響應系統的實現,其實,這個定義是很簡單的。 無非是 - 一個系統,它能夠對接入這個系統的 js 值的變化自動地做出反應的話,那么這個系統就可以稱之為「響應式系統」。
從上面的基本定義來看,響應式系統就包含兩個基本的,必不可少的要素:
被觀察的值
能夠響應值發生變化的能力
「能被觀察的值」在不同的 UI 庫中叫法不一樣。比如:
mobx 中稱之為「observables」
solidjs 稱之為「signal」
vue 稱之為「ref」
recoil 稱之為 「atom」
還有稱之為「subjects」或者「state」
不管你怎么叫,它終究還是一個能被觀察的 「js 值」。顯然, 原始的 js 值是沒有響應性的,這里的「能被觀察」正是需要我們自己去封裝實現的。這里的實現的基本思路就是「包裹」。展開說,就是你想某個 js 值能被觀察,那么它就必須被「某個東西」包裹住,然后與之配合,用戶消費的是包裹后的產物而不是原始值。
實現「包裹」的方式不一樣,那么最終提供給用戶的 API 的風格就不一樣。不同風格的 API 所帶來的 DX 不同。比如,vue3 里面,它的響應式系統是基于瀏覽器的原生 API Proxy
來實現值的包裹的。在這中技術方案下,用戶使用原生的 js 值訪問語法和賦值語法即可:
const proxyVal = new Proxy(originVal, { get(){}, set(){} }); // 讀值 console.log(proxyVal); // 寫值 proxyVal = newVal;
跟 vue 不同,solidjs 自己實現了一套顯式的讀和寫 API:
const [val, setVal] = createSignal(originVal); // 讀值 console.log(val()); // 寫值 setVal(newVal)
以上是第一基本要素。第二個基本要素是,我們得有響應被觀察值發生變化的能力。這種能力主要體現在當我們所消費的 js 值發生了變化后,我們要根據特定的上下文來做出對應的反應。js 值被消費的最常見的地方就是 js 語句。如果我們能讓這個語句重新再執行一次,那么它就能拿到最新的值。這就是所謂的響應式。那如果能夠讓一個 js 語句再執行一遍呢?答案是:“把它放在函數里面,重新調用這個函數即可”。
上面所提到的「函數」就是函數式編程概念里面的「副作用」(effect)。還是老樣子,同一個東西,不同的類庫有不同的叫法。effect 又可以稱之為:
reaction
consumer(值的消費者)
listener(值的監聽者)
等等。一般而言,副作用是要被響應式系統接管起來的,等到被觀察的 js 值發生變化的時候,我們再去調用它。從而實現了所謂的響應能力。這個用于接管的 API,不同的類庫有不同的叫法:
createEffect
consume
addListener
subscribe
以上是對響應式系統的最基本的兩個要素的闡述。下面,我們就從這個認知基礎出發,循序漸進地用 60 行代碼去實現一個迷你響應系統。為了提高逼格,我們沿用 solidjs 響應式系統所采用的相關術語。
包裹 js 值的根本目的就是為了監聽用戶對這些值的「讀」和「寫」的兩個動作:
function createSignal(value) { const getter = () => { console.log('我監聽到讀值了') return value; }; const setter = (nextValue) => { console.log('我監聽到寫值了') value = nextValue; }; return [getter, setter]; } const [count, setCount] = createSignal(0) //讀 count() // 我監聽到讀值了 //寫 setCount(1) // 我監聽到寫值了
可以說,我們的這種 API 設計改變了用戶對 js 值的讀寫習慣,甚至可以說有點強迫性。很多人都不習慣讀值的這種語法是一個函數調用。沒辦法,拿人手短,吃人嘴軟,習慣就好(不就是多敲連兩個字符嗎?哈哈)。
通過這種帶有一點強制意味的 API 設計,我們能夠監聽到用戶對所觀察值的讀和寫。
其實,上面的短短的幾行代碼是本次要實現的迷你型響應系統的奠基框架。因為,剩下要做的,我們就是不斷往 setter 和 getter 的函數體里面堆砌代碼,以實現響應式系統的基本功能。
用戶對 js 值的消費一般是發生在語句中。為了重新執行這些語句,我們需要提供一個 API 給用戶來將語句封裝起來成為一個函數,然后把這個函數當做值存儲起來,在未來的某個時刻由系統去調用這個函數。當然,順應「語句」的語義,我們應該在將語句封裝在函數里面之后,應該馬上執行一次:
let effect function createSignal(value) { const subscriptions = []; const getter = () => { subscriptions.push(effect) return value; }; const setter = (nextValue) => { value = nextValue; for (const sub of subscriptions) { sub() } }; return [getter, setter]; } function createEffect(fn){ effect = fn; fn() }
至此,我們算是實現了響應系統的基本框架:
一個可以幫助 js 值被觀察的 API
一個輔助用戶創建 effect 的 API
熟悉設計模式的讀者可以看出,這個框架的背后其實就是「訂閱-發布模式」 - 系統在用戶「讀值」的時候去做訂閱,在用戶「寫值」的時候去通知所有的訂閱者(effect)。
上面的代碼看起來好像沒問題。不信?我們測試一下:
代碼片段1
const [count, setCount] = createSignal(0) createEffect(()=> { console.log(`count: ${count()}`); }) // 打印一次:count: 0 setCount(1) // ?
在打問號的地方,我們期待它是打印一次count: 1
。但是實際上它一直在打印,導致頁面卡死了。看來,setCount(1)
導致了無限循環調用了。仔細分析一下,我們會發現,導致無限循環調用的原因在于:setCount(1)
會導致系統遍歷subscriptions
數組,去調用每一個 effect。而調用 effect()
又會產生一次讀值。一旦讀值,我們就會把當前全局變量effect
push 到subscriptions
數組。這就會導致了我們的 subscriptions
數組永遠遍歷不完。我們可以通過組合下面兩個防守來解決這個問題:
防止同一個 effect 被重復 push 到 subscriptions
數組里面了。
先對 subscriptions
數組做淺拷貝,再遍歷這個淺拷貝的數組。
修改后的代碼如下:
function createSignal(value) { const subscriptions = []; const getter = () => { if(!subscriptions.includes(effect)){ subscriptions.push(effect) } return value; }; const setter = (nextValue) => { value = nextValue; for (const sub of [...subscriptions]) { sub() } }; return [getter, setter]; }
我們再用上面「代碼片段1」去測試一下,你會發現,結果是符合預期的,沒有 bug。
細心的讀者可能會注意到,其實上面的代碼還是可以有優化的空間的 - 我們可以讓它更精簡和健壯。
首先我們看看這段防守代碼:
if(!subscriptions.includes(effect)){ subscriptions.push(effect) }
這段代碼的目的不言而喻,我們不希望 subscriptions
存在「重復的」effect。一提到去重相關的需求,我們得馬上想到「自帶去重功能的」,ES6 規范添加的新的數據結構 「Set」。于是,我們用 Set 來代替數組:
function createSignal(value) { const getter = () => { subscriptions.add(effect); return value; }; const setter = (nextValue) => { value = nextValue; for (const sub of [...subscriptions]) { sub(); } }; return [getter, setter]; }
看來用上 Set 之后,我們的代碼精簡了不少,so far so good。
這個優化真的很考驗讀者對 js 這門復雜語言的掌握程度。首先,你得知道 forEach
和 for...of
雖然都是用來遍歷 Iterable 的數據結構,但是兩者之間還是有很多不同的。其中的一個很大的不同體現在「是否支持在遍歷中對源數據進行動態修改」。在這一點上,forEach
是不支持的,而for...of
是支持的。下面舉個簡單的例子進行說明: 首先
const a = [1,2,3]; a.forEach(i=> { if(i === 3){ a.push(4)} console.log(i) }) // 1 // 2 // 3 console.log(a); // [1,2,3,4] for(const i of a){ if(i === 4){ a.push(5)} console.log(i) } // 1 // 2 // 3 // 4 // 5 console.log(a); // [1,2,3,4,5]
通過上面的對比,我們驗證了上面提及的這兩者的不同點:forEach
不會對源數據的動態修改做出反應,而for...of
則是相反。
當你知道 forEach
和 for...of
這一點區別后,結合我們實現響應系統的這個上下文,顯然,我們這里更適合使用forEach
來遍歷 Set 這個數據結構。于是,我們修改代碼,目前最終代碼如下:
let effect function createSignal(value) { const subscriptions = new Set(); const getter = () => { subscriptions.add(effect) return value; }; const setter = (nextValue) => { value = nextValue; [...subscriptions].forEach(sub=> sub()) }; return [getter, setter]; } function createEffect(fn){ effect = fn; fn() }
到目前為止,我們就可以交差了。因為,如果用戶「不亂用」的話,這個迷你響應系統是能夠運行良好的。
何為「亂用」呢?好吧,讓我們現在來思考一下:「萬一用戶嵌套式地創建 effect 呢?」
好,我們基于上面的最新代碼,用下面的代碼測試一下:
代碼片段2
const [count1, setCount1] = createSignal(0); const [count2, setCount2] = createSignal(0); createEffect(function count1Effect() { console.log(`count1: ${count1()}`) createEffect(function count2Effect(){ console.log(`count2: ${count2()}`) }) }) // count1: 0 // count2: 0 setCount1(1) // count1: 1 // count2: 0 // count2: 0 // 多了一次打印,為什么?
setCount1(1)
之后,我們期待應該只打印兩次:
count1: 1
count2: 0
實際上卻是多了一次count2: 0
,這一次打印是哪里來的?問題似乎出現在全局變量 effect
上 - 一旦 createEffect
嵌套調用了,那么,effect 的收集就發生了錯亂。具體表現在,我們第一次調用 createEffect()
去創建 count1Effect 的時候,代碼執行完畢后,此時全局變量 effect
指向 count2Effect。當我們調用setCount1()
之后,我們就會通知 count1Effect,也就是調用count1Effect()
。這次調用過程中,我們就會再次去收集 count1 的訂閱者,此時訂閱者卻指向 count2Effect。好,這就是問題之所在。
針對這個問題,最簡單的解決方法就是:調用完 effect 函數后,就釋放了全局變量的占用,如下:
function createEffect(fn){ effect = fn; fn(); effect = null; // 新增這一行 }
同時,在收集 effect 函數地方加多一個防守:
function createSignal(value) { const subscriptions = new Set(); const getter = () => { !!effect && subscriptions.add(effect) // 新增防守 return value; }; const setter = (nextValue) => { value = nextValue; [...subscriptions].forEach(sub=> sub()) }; return [getter, setter]; }
如此一來,就解決我們的問題。解決這個問題,還有另外一種解決方案 - 用「棧」的思想解決特定 js 值與所對應的 effect 的匹配問題。在這種方案中,我們將全局變量 effect
重命名為數組類型的 activeEffects
更符合語義:
let activeEffects = []; // 修改這一行 function createSignal(value) { const subscriptions = new Set(); const getter = () => { const currentEffect = activeEffects[activeEffects.length - 1]; // 新增這一行 subscriptions.add(currentEffect); return value; }; const setter = (nextValue) => { value = nextValue; [...subscriptions].forEach(sub=> sub()) }; return [getter, setter]; } function createEffect(fn){ activeEffects.push(fn); // 新增這一行 fn(); activeEffects.pop(); // 新增這一行 }
細心的讀者可能會發現,在代碼片段2中,如果我們接著去設置 count2
的值的話,count2Effect 會被執行兩次。實際上,我覺得它僅僅被執行一次是比較合理的。當然,在這個示例代碼中,因為我們重復調用createEffect()
時候傳入是不同的,新的函數實例,因此被視為不同的 effect 也是理所當然的。但是萬一用戶在這種場景下(嵌套創建 effect)傳遞給我們的是同一個 effect 函數實例的引用,我們能做到 『當這個 effect 函數所依賴的響應值發生改變的時候,這個 effect 函數只被調用一次嗎』?
答案是:“能”。而且我們目前已經誤打誤撞地實現了這個功能。請看上面「用 Set 代替 數組」的優化之后的結果:subscriptions.add(effect);
。這句代碼就通過 Set 數據結構自帶的去重特性,防止在嵌套創建 effect 場景下,如果用戶多次傳入的是同一個 effect 函數實例引用,我們能夠保證它在響應值的 subscriptions
中只會存在一個。因此,該 effect 函數只會被調用一次。
回到代碼片段2中,如果我們想 count2Effect 函數只會被執行一次,那么我們該怎么做呢?答案是:“傳遞一個外部的函數實例引用”。比如這樣:
const [count1, setCount1] = createSignal(0); const [count2, setCount2] = createSignal(0); function count2Effect(){ console.log(`count2: ${count2()}`) } createEffect(function count1Effect() { console.log(`count1: ${count1()}`) createEffect(count2Effect) })
好了,到了這里,我們基本上可以交差了,因為我們已經實現了響應式系統的兩個基本要素:
實現值的包裹
訂閱值的變化
如果我們現在拿「代碼片段2」去測試,現在的結果應該是符合我們的預期的。
從更高的標準來看,目前為止,前面實現的迷你型響應系統還是比較粗糙的。其中的一個方面是:響應的準確性不高。下面我們著手來解決這個問題。
如果讀者朋友能細心去把玩和測試我們目前實現的代碼,你會發現,如果你對同一個響應值多次設置同一個值的話,這個響應值所對應的 effect 都會被執行:
代碼片段3
const [count1, setCount1] = createSignal(0); createEffect(function count1Effect(){ console.log(`count1: ${count1()}`) }) setCount1(1) // count1: 1 setCount1(1) // count1: 1
從上面的測試示例,我們可以看出,被觀察值沒有發生變化,我們還是執行了 effect。這顯然是不夠準確的。解決這個問題也很簡單,我們在設置新值之前,加一個相等性判斷的防守 - 只有新值不等于舊值,我們才會設置新值。優化如下:
function createSignal(value) { // ......省略很多代碼 const setter = (nextValue) => { if(nextValue !== value){ value = nextValue; [...subscriptions].forEach(sub=> sub()) } }; return [getter, setter]; }
或者,我們可以更進一步,把判斷兩個值是否相等的決策權交給用戶。為了實現這個想法,我們可以讓用戶在創建響應值的時候傳遞個用于判斷兩個值是否相等的函數進來。如果用戶沒有傳遞,我們才使用 ===
作為相等性判斷的方法:
function createSignal(value, eqFn) { // ......省略很多代碼 const setter = (nextValue) => { let isChange if(typeof eqFn === 'function'){ isChange = !eqFn(value, nextValue); }else { isChange = nextValue !== value } if(isChange){ value = nextValue; [...subscriptions].forEach(sub=> sub()) } }; return [getter, setter]; }
經過上面的優化,我們再拿代碼片段3去測試一下,結果是達到了我們的預期了: 第二次的 setCount1(1)
不會導致 effect 函數的執行。
這里引入了「依賴管理」的概念。現在,我們先不討論這個概念應該如何理解,而是看看下面這個示例代碼:
代碼片段4
const [count1, setCount1] = createSignal(0); const [count2, setCount2] = createSignal(0); const [flag, setFlag] = createSignal(true); createEffect(function totalEffect(){ if(flag()){ console.log(`total : ${count1() + count2()}`); }else { console.log(`total : ${count1()}`); } }); setCount1(1); // total : 1 (第 1 次打印,符合預期) setCount2(1); // total : 2 (第 2 次打印,符合預期) setFlag(false); // total : 1 (第 3 次打印,符合預期) setCount1(2); // total : 2 (第 4 次打印,符合預期) setCount2(2); // total : 2 (第 5 次打印,不符合預期)
首先,我們得討論一下,什么是「依賴」?「依賴」其實是在描述 「effect 函數」跟「響應值」之間的關系。現在如果有這樣的觀點:你「使用」了某個物品,我們就說你「依賴」這個物品。那么,在上面的示例代碼中,totalEffect()
使用了響應值count1
和 count2
,我們就可以說,totalEffect()
依賴(及物動詞)了 count1
和 count2
。反過來我們也可以說,count1
和 count2
是totalEffect()
的依賴(名詞)。這就是「依賴管理」中「依賴」的含義 - 取名詞之義。
通過發散思維,我們不難發現,effect 函數會依賴多個響應值,一個響應值會被多個 effect 函數所依賴。effect 函數 與 響應值之間的關系是「N:N」的關系。而這種關系是會隨著程序的執行發生動態變化的 - 之前依賴的響應值,也許現在就不依賴了。又或者添加之間沒有的依賴項。就目前而言,我們還沒實現依賴管理的動態化。回到本示例中,在setFlag(false);
調用之前,我們的 totalEffect 是依賴兩個響應值 count1
和 count2
。而在此之后,實際上它只依賴 count1
。但是,從第 5 次的打印來看,setCount2(2)
還是通知到了 totalEffect()
。實際上,因為我 totalEffect()
并沒有使用 count2
了,所以,我并不需要對 count2
值的改變做出響應。
那我們該如何實現 effect 函數跟響應值依賴關系的動態化管理呢?基本思路就是:我們需要在 effect 函數執行之前,先清空之前的依賴關系。然后,在本次執行完畢,構建一個新的依賴關系圖。
就目前而言,某個響應值被哪些 effect 函數所依賴,這個關系是在創建響應值時候所閉包住的 subscriptions
數組中體現的。而一個 effect 函數所依賴了哪些響應值,這個依賴關系并沒有數據結構來體現。所以,我們得先實現這個。我們要在創建 effect 的時候,為每一個 effect 函數創建一個與一一對應的依賴管理器,命名為 effectDependencyManager
:
function createEffect(fn, eqFn) { const effectDependencyManager = { dependencies: new Set(), run() { activeEffect = effectDependencyManager; fn(); // 執行的時候再重建新的依賴關系圖 activeEffect = null; } }; effectDependencyManager.run(); }
然后在 effect 函數被收集到 subscriptions
數組的時候,也要把subscriptions
數組放到 effectDependencyManager.dependencies
數組里面,以便于當 effect 函數不依賴某個響應值的時候,也能從該響應值的subscriptions
數組反向找到自己,然后刪除自己。
function createSignal(value, eqFn) { const subscriptions = new Set(); const getter = () => { if (activeEffect) { activeEffect.dependencies.add(subscriptions); subscriptions.add(activeEffect); } return value; }; // ......省略其他代碼 }
上面已經提到了,為了動態更新一個 effect 函數跟其他響應值的依賴關系,我們需要在它的每個次執行前「先清除所有的依賴關系,然后再重新構建新的依賴圖」。現在,就差「清除 effect 函數所有的依賴關系」這一步了。為了實現這一步,我們要實現一個 cleanup()
函數:
function cleanup(effectDependencyManager) { const deps = effectDependencyManager.dependencies; deps.forEach(sub=> sub.delete(effectDependencyManager)) effectDependencyManager.dependencies = new Set(); }
上面的代碼意圖已經很明確了。cleanup()
函數要實現的就是遍歷 effect 函數上一輪所依賴的響應值,然后從響應值的subscriptions
數組中把自己刪除掉。最后,清空effectDependencyManager.dependencies
數組。
最后,我們在 effect 函數調用之前,調用一下這個 cleanup()
:
function createEffect(fn, eqFn) { const effectDependencyManager = { dependencies: [], run() { cleanup(effectDependencyManager); activeEffect = effectDependencyManager; fn(); // 執行的時候再重建新的依賴關系圖 activeEffect = null; } }; effectDependencyManager.run(); }
我們再拿代碼片段4來測試一下,現在的打印結果應該是符合我們得預期了 - 當我們調用setFlag(false);
之后,我們實現了 totalEffect 的依賴關系圖的動態更新。在新的依賴關系圖中,我們已經不依賴響應值count2
了。所以,當count2
的值發生改變后,totalEffect 函數也不會被重新執行。
當前,我們引入了新的數據結構 effectDependencyManager
。這會導致我們之前所已經實現的某個功能被回退掉了。哪個呢?答案是:“同一個 effect 函數實例不被重復入隊”。
為什么?因為,現在我們添加到 subscriptions
集合的元素不再是用戶傳遞進來的 effect 函數,而是經過我們包裝后的依賴管理器 effectDependencyManager
。而這個依賴管理器每次在用戶在調用 createEffect()
的時候都生成一個新的實例。這就導致了之前利用 Set 集合的天生去重能力就喪失掉了。所以,接下來,我們需要把這塊的功能給補回來。首先,我們在 effectDependencyManager
身上新加一個屬性,用它來保存用戶傳進來的函數實例引用:
function createEffect(fn) { const effectDependencyManager = { dependencies: new Set(), run() { // 在執行 effect 之前,清除上一次的依賴關系 cleanup(effectDependencyManager); activeEffect = effectDependencyManager; // activeEffects.push(effectDependencyManager); fn(); // 執行的時候再重建新的依賴關系圖 activeEffect = null; }, origin: fn // 新增一行 }; effectDependencyManager.run(); }
其次,我們在把 effectDependencyManager
添加到響應值的 subscriptions
集合去之前,我們先做個手動的去重防守:
function createSignal(value, eqFn) { const subscriptions = new Set(); const getter = ()=>{ if (activeEffect) { const originEffects = [] for (const effectManager of subscriptions) { originEffects.push(effectManager.origin) } const hadSubscribed = originEffects.includes(activeEffect.origin) if (!hadSubscribed) { activeEffect.dependencies.add(subscriptions); subscriptions.add(activeEffect); } } return value; } // ...省略其他代碼 return [getter, setter]; }
至此,我們把丟失的「同一個 effect 函數實例不被重復入隊」功能補回來了。
換句話說,我們需要支持用戶向響應值的 setter 傳入函數來訪問舊值,然后計算出要設置的值。用代碼來說,即支持下面的 API 語法:
const [count1, setCount1] = createSignal(0); setCount1(c=> c + 1);
實現這個特性很簡單,我們判斷用戶傳進來的 nextValue
值的類型,區別處理即可:
function createSignal(value, eqFn) { // ......省略其他代碼 const setter = (nextValue)=>{ nextValue = typeof nextValue === 'function' ? nextValue(value) : nextValue;// 新增一行 let isChange; if (typeof eqFn === 'function') { isChange = !eqFn(value, nextValue); } else { isChange = nextValue !== value } if (isChange) { value = nextValue; [...subscriptions].forEach(sub=>sub.run()) } }; return [getter, setter]; }
計算屬性(computed)也有很多叫法,它還可以稱之為:
Derivations
Memos
pure computed
在這里我們沿用 solidjs 的叫法: memo
。 這是一個很常見和廣為接受的概念了。在這,我們一并實現它。其實,在我們當前這個框架上實現這個特性是比較簡單的 - 本質上是對 createEffect
函數的二次封裝:
function createMemo(fn){ const [result, setResult] = createSingal(); createEffect(()=> { setResult(fn()) }); return result; }
你可以用下面的代碼去測試一下:
const [count1, setCount1] = createSignal(0); const [count2, setCount2] = createSignal(0); const total = createMemo(() => count1() + count2()); createEffect(()=> { console.log(`total: ${total()}`) }); // total: 0 setCount1(1); // total: 1 setCount2(100); // total: 101
感謝各位的閱讀,以上就是“怎么用代碼實現一個迷你響應式系統vue”的內容了,經過本文的學習后,相信大家對怎么用代碼實現一個迷你響應式系統vue這一問題有了更深刻的體會,具體使用情況還需要大家實踐驗證。這里是億速云,小編將為大家推送更多相關知識點的文章,歡迎關注!
免責聲明:本站發布的內容(圖片、視頻和文字)以原創、轉載和分享為主,文章觀點不代表本網站立場,如果涉及侵權請聯系站長郵箱:is@yisu.com進行舉報,并提供相關證據,一經查實,將立刻刪除涉嫌侵權內容。