教你60行代碼實(shí)現(xiàn)一個(gè)迷你響應(yīng)式系統(tǒng)vue
前言
沒有耐心看完文章,想直接把玩?鯊叔傾情奉獻(xiàn)給你:60 行代碼實(shí)現(xiàn)迷你響應(yīng)式系統(tǒng)。
前些天,去面試字節(jié)的前端崗位。面試官看到我在簡(jiǎn)歷上提及了深入理解響應(yīng)式系統(tǒng)。于是乎,他問我能不能簡(jiǎn)單地寫一個(gè)迷你的響應(yīng)式類庫。這正中我的下懷,于是我信手拈來,巴拉巴拉地寫了出來。為什么我能這么快寫出來呢?那是因?yàn)槲仪岸螘r(shí)間剛好研究過響應(yīng)式系統(tǒng)的核心原理。這里面涉獵的庫有 mobx,solid,vue 等等。
時(shí)下,solidjs 和 quickjs 的作者將 signal 和 fine-grained-reactivity 等概念炒得火熱。但是,無論怎么炒,其實(shí)響應(yīng)式系統(tǒng)的內(nèi)核原理是萬變不離其宗的。變是變?cè)?,各個(gè)響應(yīng)式 UI 庫實(shí)現(xiàn)響應(yīng)式系統(tǒng)所采用的技術(shù)方案,附加特性方面表現(xiàn)得不同。以不變應(yīng)萬變,我們只要掌握好響應(yīng)式系統(tǒng)的內(nèi)核原理即可。下面,讓我娓娓道來,用大概 60
行代碼為你循序漸進(jìn)地實(shí)現(xiàn)一個(gè)精簡(jiǎn)版的響應(yīng)式系統(tǒng)。
基本定義
什么是響應(yīng)式系統(tǒng)?學(xué)術(shù)上的定義,我們就不細(xì)究了。通過縱觀前端業(yè)界對(duì)響應(yīng)系統(tǒng)的實(shí)現(xiàn),其實(shí),這個(gè)定義是很簡(jiǎn)單的。 無非是 - 一個(gè)系統(tǒng),它能夠?qū)尤脒@個(gè)系統(tǒng)的 js 值的變化自動(dòng)地做出反應(yīng)的話,那么這個(gè)系統(tǒng)就可以稱之為「響應(yīng)式系統(tǒng)」。
基本要素
從上面的基本定義來看,響應(yīng)式系統(tǒng)就包含兩個(gè)基本的,必不可少的要素:
- 被觀察的值
- 能夠響應(yīng)值發(fā)生變化的能力
「能被觀察的值」在不同的 UI 庫中叫法不一樣。比如:
- mobx 中稱之為「observables」
- solidjs 稱之為「signal」
- vue 稱之為「ref」
- recoil 稱之為 「atom」
- 還有稱之為「subjects」或者「state」
不管你怎么叫,它終究還是一個(gè)能被觀察的 「js 值」。顯然, 原始的 js 值是沒有響應(yīng)性的,這里的「能被觀察」正是需要我們自己去封裝實(shí)現(xiàn)的。這里的實(shí)現(xiàn)的基本思路就是「包裹」。展開說,就是你想某個(gè) js 值能被觀察,那么它就必須被「某個(gè)東西」包裹住,然后與之配合,用戶消費(fèi)的是包裹后的產(chǎn)物而不是原始值。
實(shí)現(xiàn)「包裹」的方式不一樣,那么最終提供給用戶的 API 的風(fēng)格就不一樣。不同風(fēng)格的 API 所帶來的 DX 不同。比如,vue3 里面,它的響應(yīng)式系統(tǒng)是基于瀏覽器的原生 API Proxy
來實(shí)現(xiàn)值的包裹的。在這中技術(shù)方案下,用戶使用原生的 js 值訪問語法和賦值語法即可:
const proxyVal = new Proxy(originVal, { get(){}, set(){} }); // 讀值 console.log(proxyVal); // 寫值 proxyVal = newVal;
跟 vue 不同,solidjs 自己實(shí)現(xiàn)了一套顯式的讀和寫 API:
const [val, setVal] = createSignal(originVal); // 讀值 console.log(val()); // 寫值 setVal(newVal)
以上是第一基本要素。第二個(gè)基本要素是,我們得有響應(yīng)被觀察值發(fā)生變化的能力。這種能力主要體現(xiàn)在當(dāng)我們所消費(fèi)的 js 值發(fā)生了變化后,我們要根據(jù)特定的上下文來做出對(duì)應(yīng)的反應(yīng)。js 值被消費(fèi)的最常見的地方就是 js 語句。如果我們能讓這個(gè)語句重新再執(zhí)行一次,那么它就能拿到最新的值。這就是所謂的響應(yīng)式。那如果能夠讓一個(gè) js 語句再執(zhí)行一遍呢?答案是:“把它放在函數(shù)里面,重新調(diào)用這個(gè)函數(shù)即可”。
上面所提到的「函數(shù)」就是函數(shù)式編程概念里面的「副作用」(effect)。還是老樣子,同一個(gè)東西,不同的類庫有不同的叫法。effect 又可以稱之為:
- reaction
- consumer(值的消費(fèi)者)
- listener(值的監(jiān)聽者)
等等。一般而言,副作用是要被響應(yīng)式系統(tǒng)接管起來的,等到被觀察的 js 值發(fā)生變化的時(shí)候,我們?cè)偃フ{(diào)用它。從而實(shí)現(xiàn)了所謂的響應(yīng)能力。這個(gè)用于接管的 API,不同的類庫有不同的叫法:
- createEffect
- consume
- addListener
- subscribe
以上是對(duì)響應(yīng)式系統(tǒng)的最基本的兩個(gè)要素的闡述。下面,我們就從這個(gè)認(rèn)知基礎(chǔ)出發(fā),循序漸進(jìn)地用 60 行代碼去實(shí)現(xiàn)一個(gè)迷你響應(yīng)系統(tǒng)。為了提高逼格,我們沿用 solidjs 響應(yīng)式系統(tǒng)所采用的相關(guān)術(shù)語。
代碼實(shí)現(xiàn)
實(shí)現(xiàn)值的包裹
包裹 js 值的根本目的就是為了監(jiān)聽用戶對(duì)這些值的「讀」和「寫」的兩個(gè)動(dòng)作:
function createSignal(value) { const getter = () => { console.log('我監(jiān)聽到讀值了') return value; }; const setter = (nextValue) => { console.log('我監(jiān)聽到寫值了') value = nextValue; }; return [getter, setter]; } const [count, setCount] = createSignal(0) //讀 count() // 我監(jiān)聽到讀值了 //寫 setCount(1) // 我監(jiān)聽到寫值了
可以說,我們的這種 API 設(shè)計(jì)改變了用戶對(duì) js 值的讀寫習(xí)慣,甚至可以說有點(diǎn)強(qiáng)迫性。很多人都不習(xí)慣讀值的這種語法是一個(gè)函數(shù)調(diào)用。沒辦法,拿人手短,吃人嘴軟,習(xí)慣就好(不就是多敲連兩個(gè)字符嗎?哈哈)。
通過這種帶有一點(diǎn)強(qiáng)制意味的 API 設(shè)計(jì),我們能夠監(jiān)聽到用戶對(duì)所觀察值的讀和寫。
其實(shí),上面的短短的幾行代碼是本次要實(shí)現(xiàn)的迷你型響應(yīng)系統(tǒng)的奠基框架。因?yàn)?,剩下要做的,我們就是不斷?setter 和 getter 的函數(shù)體里面堆砌代碼,以實(shí)現(xiàn)響應(yīng)式系統(tǒng)的基本功能。
訂閱值的變化
用戶對(duì) js 值的消費(fèi)一般是發(fā)生在語句中。為了重新執(zhí)行這些語句,我們需要提供一個(gè) API 給用戶來將語句封裝起來成為一個(gè)函數(shù),然后把這個(gè)函數(shù)當(dāng)做值存儲(chǔ)起來,在未來的某個(gè)時(shí)刻由系統(tǒng)去調(diào)用這個(gè)函數(shù)。當(dāng)然,順應(yīng)「語句」的語義,我們應(yīng)該在將語句封裝在函數(shù)里面之后,應(yīng)該馬上執(zhí)行一次:
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() }
至此,我們算是實(shí)現(xiàn)了響應(yīng)系統(tǒng)的基本框架:
- 一個(gè)可以幫助 js 值被觀察的 API
- 一個(gè)輔助用戶創(chuàng)建 effect 的 API
熟悉設(shè)計(jì)模式的讀者可以看出,這個(gè)框架的背后其實(shí)就是「訂閱-發(fā)布模式」 - 系統(tǒng)在用戶「讀值」的時(shí)候去做訂閱,在用戶「寫值」的時(shí)候去通知所有的訂閱者(effect)。
上面的代碼看起來好像沒問題。不信?我們測(cè)試一下:
代碼片段1
const [count, setCount] = createSignal(0) createEffect(()=> { console.log(`count: ${count()}`); }) // 打印一次:count: 0 setCount(1) // ?
在打問號(hào)的地方,我們期待它是打印一次count: 1
。但是實(shí)際上它一直在打印,導(dǎo)致頁面卡死了??磥?,setCount(1)
導(dǎo)致了無限循環(huán)調(diào)用了。仔細(xì)分析一下,我們會(huì)發(fā)現(xiàn),導(dǎo)致無限循環(huán)調(diào)用的原因在于:setCount(1)
會(huì)導(dǎo)致系統(tǒng)遍歷subscriptions
數(shù)組,去調(diào)用每一個(gè) effect。而調(diào)用 effect()
又會(huì)產(chǎn)生一次讀值。一旦讀值,我們就會(huì)把當(dāng)前全局變量effect
push 到subscriptions
數(shù)組。這就會(huì)導(dǎo)致了我們的 subscriptions
數(shù)組永遠(yuǎn)遍歷不完。我們可以通過組合下面兩個(gè)防守來解決這個(gè)問題:
- 防止同一個(gè) effect 被重復(fù) push 到
subscriptions
數(shù)組里面了。 - 先對(duì)
subscriptions
數(shù)組做淺拷貝,再遍歷這個(gè)淺拷貝的數(shù)組。
修改后的代碼如下:
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]; }
我們?cè)儆蒙厦妗复a片段1」去測(cè)試一下,你會(huì)發(fā)現(xiàn),結(jié)果是符合預(yù)期的,沒有 bug。
小優(yōu)化
細(xì)心的讀者可能會(huì)注意到,其實(shí)上面的代碼還是可以有優(yōu)化的空間的 - 我們可以讓它更精簡(jiǎn)和健壯。
用 Set 代替數(shù)組
首先我們看看這段防守代碼:
if(!subscriptions.includes(effect)){ subscriptions.push(effect) }
這段代碼的目的不言而喻,我們不希望 subscriptions
存在「重復(fù)的」effect。一提到去重相關(guān)的需求,我們得馬上想到「自帶去重功能的」,ES6 規(guī)范添加的新的數(shù)據(jù)結(jié)構(gòu) 「Set」。于是,我們用 Set 來代替數(shù)組:
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 之后,我們的代碼精簡(jiǎn)了不少,so far so good。
用 forEach 代替 for...of
這個(gè)優(yōu)化真的很考驗(yàn)讀者對(duì) js 這門復(fù)雜語言的掌握程度。首先,你得知道 forEach
和 for...of
雖然都是用來遍歷 Iterable 的數(shù)據(jù)結(jié)構(gòu),但是兩者之間還是有很多不同的。其中的一個(gè)很大的不同體現(xiàn)在「是否支持在遍歷中對(duì)源數(shù)據(jù)進(jìn)行動(dòng)態(tài)修改」。在這一點(diǎn)上,forEach
是不支持的,而for...of
是支持的。下面舉個(gè)簡(jiǎn)單的例子進(jìn)行說明: 首先
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]
通過上面的對(duì)比,我們驗(yàn)證了上面提及的這兩者的不同點(diǎn):forEach
不會(huì)對(duì)源數(shù)據(jù)的動(dòng)態(tài)修改做出反應(yīng),而for...of
則是相反。
當(dāng)你知道 forEach
和 for...of
這一點(diǎn)區(qū)別后,結(jié)合我們實(shí)現(xiàn)響應(yīng)系統(tǒng)的這個(gè)上下文,顯然,我們這里更適合使用forEach
來遍歷 Set 這個(gè)數(shù)據(jù)結(jié)構(gòu)。于是,我們修改代碼,目前最終代碼如下:
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() }
到目前為止,我們就可以交差了。因?yàn)?,如果用戶「不亂用」的話,這個(gè)迷你響應(yīng)系統(tǒng)是能夠運(yùn)行良好的。
何為「亂用」呢?好吧,讓我們現(xiàn)在來思考一下:「萬一用戶嵌套式地創(chuàng)建 effect 呢?」
支持 effect 嵌套
好,我們基于上面的最新代碼,用下面的代碼測(cè)試一下:
代碼片段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)
之后,我們期待應(yīng)該只打印兩次:
count1: 1
count2: 0
實(shí)際上卻是多了一次count2: 0
,這一次打印是哪里來的?問題似乎出現(xiàn)在全局變量 effect
上 - 一旦 createEffect
嵌套調(diào)用了,那么,effect 的收集就發(fā)生了錯(cuò)亂。具體表現(xiàn)在,我們第一次調(diào)用 createEffect()
去創(chuàng)建 count1Effect 的時(shí)候,代碼執(zhí)行完畢后,此時(shí)全局變量 effect
指向 count2Effect。當(dāng)我們調(diào)用setCount1()
之后,我們就會(huì)通知 count1Effect,也就是調(diào)用count1Effect()
。這次調(diào)用過程中,我們就會(huì)再次去收集 count1 的訂閱者,此時(shí)訂閱者卻指向 count2Effect。好,這就是問題之所在。
針對(duì)這個(gè)問題,最簡(jiǎn)單的解決方法就是:調(diào)用完 effect 函數(shù)后,就釋放了全局變量的占用,如下:
function createEffect(fn){ effect = fn; fn(); effect = null; // 新增這一行 }
同時(shí),在收集 effect 函數(shù)地方加多一個(gè)防守:
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]; }
如此一來,就解決我們的問題。解決這個(gè)問題,還有另外一種解決方案 - 用「棧」的思想解決特定 js 值與所對(duì)應(yīng)的 effect 的匹配問題。在這種方案中,我們將全局變量 effect
重命名為數(shù)組類型的 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(); // 新增這一行 }
同一個(gè) effect 函數(shù)實(shí)例不被重復(fù)入隊(duì)
細(xì)心的讀者可能會(huì)發(fā)現(xiàn),在代碼片段2中,如果我們接著去設(shè)置 count2
的值的話,count2Effect 會(huì)被執(zhí)行兩次。實(shí)際上,我覺得它僅僅被執(zhí)行一次是比較合理的。當(dāng)然,在這個(gè)示例代碼中,因?yàn)槲覀冎貜?fù)調(diào)用createEffect()
時(shí)候傳入是不同的,新的函數(shù)實(shí)例,因此被視為不同的 effect 也是理所當(dāng)然的。但是萬一用戶在這種場(chǎng)景下(嵌套創(chuàng)建 effect)傳遞給我們的是同一個(gè) effect 函數(shù)實(shí)例的引用,我們能做到 『當(dāng)這個(gè) effect 函數(shù)所依賴的響應(yīng)值發(fā)生改變的時(shí)候,這個(gè) effect 函數(shù)只被調(diào)用一次嗎』?
答案是:“能”。而且我們目前已經(jīng)誤打誤撞地實(shí)現(xiàn)了這個(gè)功能。請(qǐng)看上面「用 Set 代替 數(shù)組」的優(yōu)化之后的結(jié)果:subscriptions.add(effect);
。這句代碼就通過 Set 數(shù)據(jù)結(jié)構(gòu)自帶的去重特性,防止在嵌套創(chuàng)建 effect 場(chǎng)景下,如果用戶多次傳入的是同一個(gè) effect 函數(shù)實(shí)例引用,我們能夠保證它在響應(yīng)值的 subscriptions
中只會(huì)存在一個(gè)。因此,該 effect 函數(shù)只會(huì)被調(diào)用一次。
回到代碼片段2中,如果我們想 count2Effect 函數(shù)只會(huì)被執(zhí)行一次,那么我們?cè)撛趺醋瞿??答案是?ldquo;傳遞一個(gè)外部的函數(shù)實(shí)例引用”。比如這樣:
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) })
小結(jié)
好了,到了這里,我們基本上可以交差了,因?yàn)槲覀円呀?jīng)實(shí)現(xiàn)了響應(yīng)式系統(tǒng)的兩個(gè)基本要素:
- 實(shí)現(xiàn)值的包裹
- 訂閱值的變化
如果我們現(xiàn)在拿「代碼片段2」去測(cè)試,現(xiàn)在的結(jié)果應(yīng)該是符合我們的預(yù)期的。
提高響應(yīng)的準(zhǔn)確性
從更高的標(biāo)準(zhǔn)來看,目前為止,前面實(shí)現(xiàn)的迷你型響應(yīng)系統(tǒng)還是比較粗糙的。其中的一個(gè)方面是:響應(yīng)的準(zhǔn)確性不高。下面我們著手來解決這個(gè)問題。
避免不必要的 rerun
如果讀者朋友能細(xì)心去把玩和測(cè)試我們目前實(shí)現(xiàn)的代碼,你會(huì)發(fā)現(xiàn),如果你對(duì)同一個(gè)響應(yīng)值多次設(shè)置同一個(gè)值的話,這個(gè)響應(yīng)值所對(duì)應(yīng)的 effect 都會(huì)被執(zhí)行:
代碼片段3
const [count1, setCount1] = createSignal(0); createEffect(function count1Effect(){ console.log(`count1: ${count1()}`) }) setCount1(1) // count1: 1 setCount1(1) // count1: 1
從上面的測(cè)試示例,我們可以看出,被觀察值沒有發(fā)生變化,我們還是執(zhí)行了 effect。這顯然是不夠準(zhǔn)確的。解決這個(gè)問題也很簡(jiǎn)單,我們?cè)谠O(shè)置新值之前,加一個(gè)相等性判斷的防守 - 只有新值不等于舊值,我們才會(huì)設(shè)置新值。優(yōu)化如下:
function createSignal(value) { // ......省略很多代碼 const setter = (nextValue) => { if(nextValue !== value){ value = nextValue; [...subscriptions].forEach(sub=> sub()) } }; return [getter, setter]; }
或者,我們可以更進(jìn)一步,把判斷兩個(gè)值是否相等的決策權(quán)交給用戶。為了實(shí)現(xiàn)這個(gè)想法,我們可以讓用戶在創(chuàng)建響應(yīng)值的時(shí)候傳遞個(gè)用于判斷兩個(gè)值是否相等的函數(shù)進(jìn)來。如果用戶沒有傳遞,我們才使用 ===
作為相等性判斷的方法:
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]; }
經(jīng)過上面的優(yōu)化,我們?cè)倌?strong>代碼片段3去測(cè)試一下,結(jié)果是達(dá)到了我們的預(yù)期了: 第二次的 setCount1(1)
不會(huì)導(dǎo)致 effect 函數(shù)的執(zhí)行。
動(dòng)態(tài)的依賴管理
這里引入了「依賴管理」的概念?,F(xiàn)在,我們先不討論這個(gè)概念應(yīng)該如何理解,而是看看下面這個(gè)示例代碼:
代碼片段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 次打印,符合預(yù)期) setCount2(1); // total : 2 (第 2 次打印,符合預(yù)期) setFlag(false); // total : 1 (第 3 次打印,符合預(yù)期) setCount1(2); // total : 2 (第 4 次打印,符合預(yù)期) setCount2(2); // total : 2 (第 5 次打印,不符合預(yù)期)
首先,我們得討論一下,什么是「依賴」?「依賴」其實(shí)是在描述 「effect 函數(shù)」跟「響應(yīng)值」之間的關(guān)系。現(xiàn)在如果有這樣的觀點(diǎn):你「使用」了某個(gè)物品,我們就說你「依賴」這個(gè)物品。那么,在上面的示例代碼中,totalEffect()
使用了響應(yīng)值count1
和 count2
,我們就可以說,totalEffect()
依賴(及物動(dòng)詞)了 count1
和 count2
。反過來我們也可以說,count1
和 count2
是totalEffect()
的依賴(名詞)。這就是「依賴管理」中「依賴」的含義 - 取名詞之義。
通過發(fā)散思維,我們不難發(fā)現(xiàn),effect 函數(shù)會(huì)依賴多個(gè)響應(yīng)值,一個(gè)響應(yīng)值會(huì)被多個(gè) effect 函數(shù)所依賴。effect 函數(shù) 與 響應(yīng)值之間的關(guān)系是「N:N」的關(guān)系。而這種關(guān)系是會(huì)隨著程序的執(zhí)行發(fā)生動(dòng)態(tài)變化的 - 之前依賴的響應(yīng)值,也許現(xiàn)在就不依賴了。又或者添加之間沒有的依賴項(xiàng)。就目前而言,我們還沒實(shí)現(xiàn)依賴管理的動(dòng)態(tài)化。回到本示例中,在setFlag(false);
調(diào)用之前,我們的 totalEffect 是依賴兩個(gè)響應(yīng)值 count1
和 count2
。而在此之后,實(shí)際上它只依賴 count1
。但是,從第 5 次的打印來看,setCount2(2)
還是通知到了 totalEffect()
。實(shí)際上,因?yàn)槲?totalEffect()
并沒有使用 count2
了,所以,我并不需要對(duì) count2
值的改變做出響應(yīng)。
那我們?cè)撊绾螌?shí)現(xiàn) effect 函數(shù)跟響應(yīng)值依賴關(guān)系的動(dòng)態(tài)化管理呢?基本思路就是:我們需要在 effect 函數(shù)執(zhí)行之前,先清空之前的依賴關(guān)系。然后,在本次執(zhí)行完畢,構(gòu)建一個(gè)新的依賴關(guān)系圖。
就目前而言,某個(gè)響應(yīng)值被哪些 effect 函數(shù)所依賴,這個(gè)關(guān)系是在創(chuàng)建響應(yīng)值時(shí)候所閉包住的 subscriptions
數(shù)組中體現(xiàn)的。而一個(gè) effect 函數(shù)所依賴了哪些響應(yīng)值,這個(gè)依賴關(guān)系并沒有數(shù)據(jù)結(jié)構(gòu)來體現(xiàn)。所以,我們得先實(shí)現(xiàn)這個(gè)。我們要在創(chuàng)建 effect 的時(shí)候,為每一個(gè) effect 函數(shù)創(chuàng)建一個(gè)與一一對(duì)應(yīng)的依賴管理器,命名為 effectDependencyManager
:
function createEffect(fn, eqFn) { const effectDependencyManager = { dependencies: new Set(), run() { activeEffect = effectDependencyManager; fn(); // 執(zhí)行的時(shí)候再重建新的依賴關(guān)系圖 activeEffect = null; } }; effectDependencyManager.run(); }
然后在 effect 函數(shù)被收集到 subscriptions
數(shù)組的時(shí)候,也要把subscriptions
數(shù)組放到 effectDependencyManager.dependencies
數(shù)組里面,以便于當(dāng) effect 函數(shù)不依賴某個(gè)響應(yīng)值的時(shí)候,也能從該響應(yīng)值的subscriptions
數(shù)組反向找到自己,然后刪除自己。
function createSignal(value, eqFn) { const subscriptions = new Set(); const getter = () => { if (activeEffect) { activeEffect.dependencies.add(subscriptions); subscriptions.add(activeEffect); } return value; }; // ......省略其他代碼 }
上面已經(jīng)提到了,為了動(dòng)態(tài)更新一個(gè) effect 函數(shù)跟其他響應(yīng)值的依賴關(guān)系,我們需要在它的每個(gè)次執(zhí)行前「先清除所有的依賴關(guān)系,然后再重新構(gòu)建新的依賴圖」?,F(xiàn)在,就差「清除 effect 函數(shù)所有的依賴關(guān)系」這一步了。為了實(shí)現(xiàn)這一步,我們要實(shí)現(xiàn)一個(gè) cleanup()
函數(shù):
function cleanup(effectDependencyManager) { const deps = effectDependencyManager.dependencies; deps.forEach(sub=> sub.delete(effectDependencyManager)) effectDependencyManager.dependencies = new Set(); }
上面的代碼意圖已經(jīng)很明確了。cleanup()
函數(shù)要實(shí)現(xiàn)的就是遍歷 effect 函數(shù)上一輪所依賴的響應(yīng)值,然后從響應(yīng)值的subscriptions
數(shù)組中把自己刪除掉。最后,清空effectDependencyManager.dependencies
數(shù)組。
最后,我們?cè)?effect 函數(shù)調(diào)用之前,調(diào)用一下這個(gè) cleanup()
:
function createEffect(fn, eqFn) { const effectDependencyManager = { dependencies: [], run() { cleanup(effectDependencyManager); activeEffect = effectDependencyManager; fn(); // 執(zhí)行的時(shí)候再重建新的依賴關(guān)系圖 activeEffect = null; } }; effectDependencyManager.run(); }
我們?cè)倌?strong>代碼片段4來測(cè)試一下,現(xiàn)在的打印結(jié)果應(yīng)該是符合我們得預(yù)期了 - 當(dāng)我們調(diào)用setFlag(false);
之后,我們實(shí)現(xiàn)了 totalEffect 的依賴關(guān)系圖的動(dòng)態(tài)更新。在新的依賴關(guān)系圖中,我們已經(jīng)不依賴響應(yīng)值count2
了。所以,當(dāng)count2
的值發(fā)生改變后,totalEffect 函數(shù)也不會(huì)被重新執(zhí)行。
修復(fù)功能回退
當(dāng)前,我們引入了新的數(shù)據(jù)結(jié)構(gòu) effectDependencyManager
。這會(huì)導(dǎo)致我們之前所已經(jīng)實(shí)現(xiàn)的某個(gè)功能被回退掉了。哪個(gè)呢?答案是:“同一個(gè) effect 函數(shù)實(shí)例不被重復(fù)入隊(duì)”。
為什么?因?yàn)?,現(xiàn)在我們添加到 subscriptions
集合的元素不再是用戶傳遞進(jìn)來的 effect 函數(shù),而是經(jīng)過我們包裝后的依賴管理器 effectDependencyManager
。而這個(gè)依賴管理器每次在用戶在調(diào)用 createEffect()
的時(shí)候都生成一個(gè)新的實(shí)例。這就導(dǎo)致了之前利用 Set 集合的天生去重能力就喪失掉了。所以,接下來,我們需要把這塊的功能給補(bǔ)回來。首先,我們?cè)?effectDependencyManager
身上新加一個(gè)屬性,用它來保存用戶傳進(jìn)來的函數(shù)實(shí)例引用:
function createEffect(fn) { const effectDependencyManager = { dependencies: new Set(), run() { // 在執(zhí)行 effect 之前,清除上一次的依賴關(guān)系 cleanup(effectDependencyManager); activeEffect = effectDependencyManager; // activeEffects.push(effectDependencyManager); fn(); // 執(zhí)行的時(shí)候再重建新的依賴關(guān)系圖 activeEffect = null; }, origin: fn // 新增一行 }; effectDependencyManager.run(); }
其次,我們?cè)诎?effectDependencyManager
添加到響應(yīng)值的 subscriptions
集合去之前,我們先做個(gè)手動(dòng)的去重防守:
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]; }
至此,我們把丟失的「同一個(gè) effect 函數(shù)實(shí)例不被重復(fù)入隊(duì)」功能補(bǔ)回來了。
附加特性
支持基于舊值來產(chǎn)生新值
換句話說,我們需要支持用戶向響應(yīng)值的 setter 傳入函數(shù)來訪問舊值,然后計(jì)算出要設(shè)置的值。用代碼來說,即支持下面的 API 語法:
const [count1, setCount1] = createSignal(0); setCount1(c=> c + 1);
實(shí)現(xiàn)這個(gè)特性很簡(jiǎn)單,我們判斷用戶傳進(jìn)來的 nextValue
值的類型,區(qū)別處理即可:
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]; }
派生值/計(jì)算屬性
計(jì)算屬性(computed)也有很多叫法,它還可以稱之為:
- Derivations
- Memos
- pure computed
在這里我們沿用 solidjs 的叫法: memo
。 這是一個(gè)很常見和廣為接受的概念了。在這,我們一并實(shí)現(xiàn)它。其實(shí),在我們當(dāng)前這個(gè)框架上實(shí)現(xiàn)這個(gè)特性是比較簡(jiǎn)單的 - 本質(zhì)上是對(duì) createEffect
函數(shù)的二次封裝:
function createMemo(fn){ const [result, setResult] = createSingal(); createEffect(()=> { setResult(fn()) }); return result; }
你可以用下面的代碼去測(cè)試一下:
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
總結(jié)
以上就是用 60 行代碼循序漸進(jìn)去實(shí)現(xiàn)一個(gè)迷你響應(yīng)式系統(tǒng)的全部過程。在這個(gè)這個(gè)迷你響應(yīng)式系統(tǒng)里面,我們實(shí)現(xiàn)的功能有:
- 對(duì)原始 js 值進(jìn)行響應(yīng)式包裹,被包裹的 js 值我們可以稱之為「響應(yīng)值」;
- 響應(yīng)值發(fā)生真正改變的時(shí)候,我們的 effect 能被重新執(zhí)行;
- 保證多個(gè)響應(yīng)值與它們自己的 effect 之間的對(duì)應(yīng)關(guān)系是正確的,互不影響;
- 支持嵌套式地創(chuàng)建 effect。且實(shí)現(xiàn)了該場(chǎng)景下,同一個(gè) effect 函數(shù)實(shí)例不被重復(fù)入隊(duì);
- 實(shí)現(xiàn) effect 函數(shù)的依賴關(guān)系的動(dòng)態(tài)更新;
- 支持計(jì)算屬性特性。
別小看這 60 行代碼,它就是響應(yīng)式系統(tǒng)的極度精簡(jiǎn)的內(nèi)核,幾乎可以講是字字珠璣。在這個(gè)內(nèi)核模型下,我們可以很容易地?cái)U(kuò)展更多附加特性,比如像 vue3 的響應(yīng)式系統(tǒng)里面的 watch, scheduler 等特性。
最后,總結(jié)一下該代碼實(shí)現(xiàn)的原理。其實(shí)還是大名鼎鼎的「閉包」。通過閉包,這個(gè)迷你響應(yīng)式系統(tǒng)在內(nèi)存中保存著一條用戶看不見的結(jié)構(gòu)數(shù)據(jù)關(guān)系鏈,如下:
基于上面的結(jié)構(gòu)數(shù)據(jù)關(guān)系鏈圖,我們完全可以實(shí)現(xiàn)一版 OOP 的迷你響應(yīng)式系統(tǒng),這個(gè)任務(wù)就交給讀者朋友來挑戰(zhàn)了。
以上就是教你60行代碼實(shí)現(xiàn)一個(gè)迷你響應(yīng)式系統(tǒng)vue的詳細(xì)內(nèi)容,更多關(guān)于vue迷你響應(yīng)式系統(tǒng)的資料請(qǐng)關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
VueX?mapGetters獲取Modules中的Getters方式
這篇文章主要介紹了VueX?mapGetters獲取Modules中的Getters方式,具有很好的參考價(jià)值,希望對(duì)大家有所幫助。如有錯(cuò)誤或未考慮完全的地方,望不吝賜教2022-08-08vue+three.js實(shí)現(xiàn)炫酷的3D登陸頁面示例詳解
這篇文章主要為大家介紹了vue+three.js實(shí)現(xiàn)炫酷的3D登陸頁面示例詳解,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進(jìn)步,早日升職加薪2022-07-07vue better scroll 無法滾動(dòng)的解決方法
better scroll可以實(shí)現(xiàn)輪播圖和頁面滾動(dòng),是移動(dòng)端滾動(dòng)插件,這篇文章主要介紹了vue better scroll 無法滾動(dòng)的解決方法,具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2018-06-06el-select 數(shù)據(jù)回顯,只顯示value不顯示lable問題
這篇文章主要介紹了el-select 數(shù)據(jù)回顯,只顯示value不顯示lable問題,具有很好的參考價(jià)值,希望對(duì)大家有所幫助。如有錯(cuò)誤或未考慮完全的地方,望不吝賜教2022-09-09Vue如何動(dòng)態(tài)改變列表搜索出關(guān)鍵詞的字體顏色
這篇文章主要介紹了Vue如何動(dòng)態(tài)改變列表搜索出關(guān)鍵詞的字體顏色問題,具有很好的參考價(jià)值,希望對(duì)大家有所幫助,如有錯(cuò)誤或未考慮完全的地方,望不吝賜教2023-10-10詳解vue-cli 構(gòu)建Vue項(xiàng)目遇到的坑
本篇文章主要介紹了詳解vue-cli 構(gòu)建Vue項(xiàng)目遇到的坑,小編覺得挺不錯(cuò)的,現(xiàn)在分享給大家,也給大家做個(gè)參考。一起跟隨小編過來看看吧2017-08-08vue中使用element組件時(shí)事件想要傳遞其他參數(shù)的問題
這篇文章主要介紹了vue中使用element組件時(shí)事件想要傳遞其他參數(shù)的問題,本文給大家介紹的非常詳細(xì),具有一定的參考借鑒價(jià)值,需要的朋友可以參考下2019-09-09