一張圖理清Vue 3.0的響應(yīng)式系統(tǒng)
本文首發(fā)于我的博客:《一張圖理清 Vue 3.0 的響應(yīng)式系統(tǒng)》
隨著 Vue 3.0 Pre Alpha 版本的公布,我們得以一窺其源碼的實現(xiàn)。Vue 最巧妙的特性之一是其響應(yīng)式系統(tǒng),而我們也能夠在倉庫的 packages/reactivity 模塊下找到對應(yīng)的實現(xiàn)。雖然源碼的代碼量不多,網(wǎng)上的分析文章也有一堆,但是要想清晰地理解響應(yīng)式原理的具體實現(xiàn)過程,還是挺費(fèi)腦筋的事情。經(jīng)過一天的研究和整理,我把其響應(yīng)式系統(tǒng)的原理總結(jié)成了一張圖,而本文也將圍繞這張圖去講述具體的實現(xiàn)過程。
文章涉及到的代碼我也已經(jīng)上傳到倉庫,結(jié)合代碼閱讀本文會更為流暢哦!
一個基本的例子
Vue 3.0 的響應(yīng)式系統(tǒng)是獨立的模塊,可以完全脫離 Vue 而使用,所以我們在 clone 了源碼下來以后,可以直接在 packages/reactivity 模塊下調(diào)試。
- 在項目根目錄運(yùn)行 yarn dev reactivity,然后進(jìn)入 packages/reactivity 目錄找到產(chǎn)出的 dist/reactivity.global.js 文件。
- 新建一個 index.html,寫入如下代碼:
- <script src="./dist/reactivity.global.js"></script>
- <script>
- const { reactive, effect } = VueObserver
- const origin = {
- count: 0
- }
- const state = reactive(origin)
- const fn = () => {
- const count = state.count
- console.log(`set count to ${count}`)
- }
- effect(fn)
- </script>
3. 在瀏覽器打開該文件,于控制臺執(zhí)行 state.count++,便可看到輸出 set count to 1。
在上述的例子中,我們使用 reactive() 函數(shù)把 origin 對象轉(zhuǎn)化成了 Proxy 對象 state;使用 effect() 函數(shù)把 fn() 作為響應(yīng)式回調(diào)。當(dāng) state.count 發(fā)生變化時,便觸發(fā)了 fn()。接下來我們將以這個例子結(jié)合上文的流程圖,來講解這套響應(yīng)式系統(tǒng)是怎么運(yùn)行的。
初始化階段
在初始化階段,主要做了兩件事。
- 把 origin 對象轉(zhuǎn)化成響應(yīng)式的 Proxy 對象 state。
- 把函數(shù) fn() 作為一個響應(yīng)式的 effect 函數(shù)。
首先我們來分析第一件事。
大家都知道,Vue 3.0 使用了 Proxy 來代替之前的 Object.defineProperty(),改寫了對象的 getter/setter,完成依賴收集和響應(yīng)觸發(fā)。但是在這一階段中,我們暫時先不管它是如何改寫對象的 getter/setter 的,這個在后續(xù)的”依賴收集階段“會詳細(xì)說明。為了簡單起見,我們可以把這部分的內(nèi)容濃縮成一個只有兩行代碼的 reactive() 函數(shù):
- export function reactive(target) {
- const observed = new Proxy(target, handler)
- return observed
- }
完整代碼在 reactive.js。這里的 handler 就是改造 getter/setter 的關(guān)鍵,我們放到后文講解。
接下來我們分析第二件事。
當(dāng)一個普通的函數(shù) fn() 被 effect() 包裹之后,就會變成一個響應(yīng)式的 effect 函數(shù),而 fn() 也會被立即執(zhí)行一次。
由于在 fn() 里面有引用到 Proxy 對象的屬性,所以這一步會觸發(fā)對象的 getter,從而啟動依賴收集。
除此之外,這個 effect 函數(shù)也會被壓入一個名為”activeReactiveEffectStack“(此處為 effectStack)的棧中,供后續(xù)依賴收集的時候使用。
來看看代碼(完成代碼請看 effect.js):
- export function effect (fn) {
- // 構(gòu)造一個 effect
- const effect = function effect(...args) {
- return run(effect, fn, args)
- }
- // 立即執(zhí)行一次
- effect()
- return effect
- }
- export function run(effect, fn, args) {
- if (effectStack.indexOf(effect) === -1) {
- try {
- // 往池子里放入當(dāng)前 effect
- effectStack.push(effect)
- // 立即執(zhí)行一遍 fn()
- // fn() 執(zhí)行過程會完成依賴收集,會用到 effect
- return fn(...args)
- } finally {
- // 完成依賴收集后從池子中扔掉這個 effect
- effectStack.pop()
- }
- }
- }
至此,初始化階段已經(jīng)完成。接下來就是整個系統(tǒng)最關(guān)鍵的一步——依賴收集階段。
依賴收集階段
這個階段的觸發(fā)時機(jī),就是在 effect 被立即執(zhí)行,其內(nèi)部的 fn() 觸發(fā)了 Proxy 對象的 getter 的時候。簡單來說,只要執(zhí)行到類似 state.count 的語句,就會觸發(fā) state 的 getter。
依賴收集階段最重要的目的,就是建立一份”依賴收集表“,也就是圖示的”targetMap"。targetMap 是一個 WeakMap,其 key 值是當(dāng)前的 Proxy 對象 state代理前的對象origin,而 value 則是該對象所對應(yīng)的 depsMap。
depsMap 是一個 Map,key 值為觸發(fā) getter 時的屬性值(此處為 count),而 value 則是觸發(fā)過該屬性值所對應(yīng)的各個 effect。
還是有點繞?那么我們再舉個例子。假設(shè)有個 Proxy 對象和 effect 如下:
- const state = reactive({
- count: 0,
- age: 18
- })
- const effecteffect1 = effect(() => {
- console.log('effect1: ' + state.count)
- })
- const effecteffect2 = effect(() => {
- console.log('effect2: ' + state.age)
- })
- const effecteffect3 = effect(() => {
- console.log('effect3: ' + state.count, state.age)
- })
那么這里的 targetMap 應(yīng)該為這個樣子:
這樣,{ target -> key -> dep } 的對應(yīng)關(guān)系就建立起來了,依賴收集也就完成了。代碼如下:
- export function track (target, operationType, key) {
- const effect = effectStack[effectStack.length - 1]
- if (effect) {
- let depsMap = targetMap.get(target)
- if (depsMap === void 0) {
- targetMap.set(target, (depsMap = new Map()))
- }
- let dep = depsMap.get(key)
- if (dep === void 0) {
- depsMap.set(key, (dep = new Set()))
- }
- if (!dep.has(effect)) {
- dep.add(effect)
- }
- }
- }
弄明白依賴收集表 targetMap 是非常重要的,因為這是整個響應(yīng)式系統(tǒng)核心中的核心。
響應(yīng)階段
回顧上一章節(jié)的例子,我們得到了一個 { count: 0, age: 18 } 的 Proxy,并構(gòu)造了三個 effect。在控制臺上看看效果:
效果符合預(yù)期,那么它是怎么實現(xiàn)的呢?首先來看看這個階段的原理圖:
當(dāng)修改對象的某個屬性值的時候,會觸發(fā)對應(yīng)的 setter。
setter 里面的 trigger() 函數(shù)會從依賴收集表里找到當(dāng)前屬性對應(yīng)的各個 dep,然后把它們推入到 effects 和 computedEffects(計算屬性) 隊列中,最后通過 scheduleRun() 挨個執(zhí)行里面的 effect。
由于已經(jīng)建立了依賴收集表,所以要找到屬性所對應(yīng)的 dep 也就輕而易舉了,可以看看具體的代碼實現(xiàn):
- export function trigger (target, operationType, key) {
- // 取得對應(yīng)的 depsMap
- const depsMap = targetMap.get(target)
- if (depsMap === void 0) {
- return
- }
- // 取得對應(yīng)的各個 dep
- const effects = new Set()
- if (key !== void 0) {
- const dep = depsMap.get(key)
- dep && dep.forEach(effect => {
- effects.add(effect)
- })
- }
- // 簡化版 scheduleRun,挨個執(zhí)行 effect
- effects.forEach(effect => {
- effect()
- })
- }
這里的代碼沒有處理諸如數(shù)組的 length 被修改的一些特殊情況,感興趣的讀者可以查看 vue-next 對應(yīng)的源碼,或者這篇文章,看看這些情況都是怎么處理的。
至此,響應(yīng)式階段完成。
總結(jié)
閱讀源碼的過程充滿了挑戰(zhàn)性,但同時也常常被 Vue 的一些實現(xiàn)思路給驚艷到,收獲良多。本文按照響應(yīng)式系統(tǒng)的運(yùn)行過程,劃分了”初始化“,”依賴收集“和”響應(yīng)式“三個階段,分別闡述了各個階段所做的事情,應(yīng)該能夠較好地幫助讀者理解其核心思路。最后附上文章實例代碼的倉庫地址,有興趣的讀者可以自行把玩: