[譯] 以 Vue 為例,解釋 JavaScript 的反應性

程式碼灣發表於2018-08-19

作者|Gregg Pollack

譯者|張衛濱

很多前端 JavaScript 框架(如 Angular、React 和 Vue)都有自己的反應性(Reactivity)引擎。理解反應式是什麼以及如何執行能夠提升你的開發水平,同時能夠更高效地使用 JavaScript。在本文中,我們構建了與 Vue 原始碼相同的反應性功能。

反應性系統

當你第一次見到 Vue 的反應性系統時,你可能會感覺有些神奇。以下面這個簡單的 Vue 應用為例:

不知道基於什麼原因,Vue 能夠知道 price 的值是否發生了變化,並且在變化的時候能夠完成如下三件事情:

  • 更新 Web 頁面 price 的值;
  • 重新計算乘法表示式 price * quantity ,並更新頁面;
  • 再次呼叫 totalPriceWithTax 函式並更新頁面。

但是,稍等,我似乎聽到你想問,Vue 如何知道在 price 發生變化的時候都要更新哪些值,它又是如何跟蹤所有的內容的呢?

這並不是 JavaScript 程式設計通常的執行方式。

如果這對你來說不那麼直觀,那麼我們需要明白程式通常並不是按照這種方式來執行的。例如,如果我執行下面的樣例程式碼:

你猜將會列印出什麼內容呢?因為我們沒有使用 Vue,它將會列印出 10 :

在 Vue 中,我們想要在 pricequantity 更新的時候, total 也進行更新。我們希望的輸出是:

但令人遺憾的是,JavaScript 是過程性的,並不是反應式的,所以在現實程式碼並這並不可行。為了讓 total 具有反應性,我們必須讓 JavaScript 語言按照不同的方式來執行。

問題

我們需要記住如何計算 total ,這樣才能在 pricequantity 發生變化的時候重新執行。

解決方案

首先,我們需要有某種方法告訴我們的應用,“我將要執行的程式碼是什麼, 將它儲存起來 ,在稍後某個時間點我可能需要你執行它”。然後,我們執行程式碼,在 pricequantity 變數發生變化的時候,再次執行儲存的程式碼:

我們想到的辦法可能就是將函式的內容記錄下來,這樣就能再次執行了:

需要注意,我們在 target 變數中儲存了一個匿名函式,然後呼叫了 record 函式。如果採用 ES6 的箭頭語法的話,我還可以寫成如下的形式:

record 的定義非常簡單:

我們將 target 儲存了起來(我們的示例中也就是 { total = price * quantity } ),這樣的話,我們就能在隨後執行它,可能會藉助一個 replay 函式執行我們記錄下來的所有內容。

這樣會遍歷我們在 storage 陣列中儲存的所有匿名函式,並執行它們。

那麼在我們的程式碼中,只需:

非常簡單,對吧?如果你想要通讀程式碼並再次嘗試的話,下面給出了完整的程式碼。

問題

我們可以按需繼續記錄 target,但是更好的方式是有一種健壯的方案,能夠擴充套件我們的應用。我們可以使用一個類,讓這個類維護一個 target 的列表,當需要它們重新執行的時候,這個類會得到通知。

解決方案:依賴類

要解決這個問題,我們將這些行為封裝到單獨的類中,使用一個依賴類( Dependency Class )來實現標準的觀察者模式程式設計。

如果我們建立 JavaScript 類來管理依賴的話(類似於 Vue 的處理方式),它看起來可能會如下所示:

需要注意,我們這裡不再使用 storage ,而是使用 subscribers 來儲存匿名函式,也不再使用 record 函式了,而是呼叫 depend ,同時使用 notify 代替了 replay 。要讓它執行起來,只需:

它依然可以執行,而且我們的程式碼看上去具備了一定的可重用性。唯一感覺尤其詭異的地方就是設定和執行 target

問題

未來,每個類都會有一個 Dep 類,如果能將建立匿名函式觀察更新的行為封裝起來就更好了。接下來, watcher 函式將會出場來負責這種行為。

所以,我們將不會再呼叫:

(這就是上面示例的程式碼)

相反,我們只需這樣呼叫:

解決方案:Watcher 函式

在 Watcher 函式中,我們可以做幾件很簡單的事情:

可以看到, watcher 函式接受一個 myFunc 變數,將其作為我們的全域性 target 屬性,呼叫 dep.depend() ,將會以訂閱者的形式新增我們的 target,呼叫 target 函式並重置 target

現在,我們可以執行下面的程式碼:

你可能會想,我們為什麼要將 target 實現為全域性變數,而不是將其傳遞給所需的函式。這裡有一定的原因,在本文結束的時候,相信你就明白了。

問題

我們現在有了一個 Dep 類,但是我們真正想要實現的是每個變數都有自己的 Dep。在進行下一步講解之前,我們先將它們放到屬性中。

我們先假設每個屬性( pricequantity )都有其自己的內部 Dep 類。

現在,當我們執行:

因為訪問到了 data.price 的值,所以我希望 price 屬性的 Dep 類要將我們的匿名函式(儲存在 target 中)放到它的訂閱陣列中(通過呼叫 dep.depend() )。因為 data.quantity 也被訪問到了,所以我希望 quantity 屬性的 Dep 類要將該匿名函式(儲存在 target 中)放到它的訂閱陣列中:

如果我還有其他的匿名函式只訪問 data.price 的話,我希望要將這個函式放到 price 屬性的 Dep 類中。

那麼,我該在何時為 price 的訂閱者呼叫 dep.notify() 呢?答案是為 price 賦值的時候。在本文結束的時候,我希望能夠在命令列中實現如下的效果:

我們希望能有某種方式嵌入到資料屬性中( pricequantity ),這樣的話,當屬性被訪問的時候,能夠將 target 儲存到訂閱者陣列中,當屬性變更時,能夠執行儲存在訂閱者陣列中的函式。

解決方案:Object.defineProperty()

我們需要學習 ES 5 JavaScript 所提供 Object.defineProperty() 函式。它允許我們為屬性定義 getter 和 setter 函式。在展示如何與 Dep 類協作之前,我們看一下它的基礎用法。

可以看到,這裡只是列印了兩條日誌。但是,它並沒有實際 getset 值,這是因為我們將功能覆蓋掉了。現在,我們將功能新增回來。 get() 預期要返回一個值,而 set() 依然要更新值,所以我們新增一個 internalValue 變數來儲存當前的 price 值。

我們的 getset 都能正常執行了,你覺得控制檯的列印資訊會是什麼呢?

所以,當取值和設定值的時候,我們有了一種得到提醒的方法。藉助一些遞迴,我們就可以將其用到資料陣列的所有條目中了。

值得一提的是, Object.keys(data) 能夠返回物件中 key 所組成的陣列。

現在,所有的屬性都有 getter 和 setter 了,我們來看一下控制檯:

將這兩個理念組合在一起

當這樣的程式碼執行並嘗試 get price 屬性的值時,我們希望 price 能夠記住這個匿名函式( target )。通過這種方式,如果 price 發生了變化,或者被 set 了一個新的值,這個函式就能重新執行,因為它能夠知道這行程式碼依賴該屬性。所以,你可以按照如下的方式來思考。

Get=>記住該匿名函式,當值發生變化的時候我們會重新執行。

Set=>執行儲存的匿名函式,我們的值就會發生變化。

或者,在 Dep Class 的場景下:

Price 訪問 (get)=>呼叫 dep.depend() 儲存當前的 target ;

Price set=>呼叫 price 的 dep.notify() ,重新執行所有的 target

接下來,我們將這兩個理念組合起來,並看一下最終的程式碼。

在我們執行的時候,看一下控制檯的輸出:

完全符合我們的預期!現在 pricequantity 都是反應式的了。當 pricequantity 的值更新時,我們的程式碼完全重新執行了。

Vue 文件中的圖示對你來說應該就非常清晰了。

看到漂亮的 Data 圓圈中的 getters 和 setters 了嗎?它看起來似曾相識!每個元件例項都有一個 watcher (藍色圓圈),它會從 getter 中收集依賴(紅線)。當 setter 隨後被呼叫時,它會 通知 watcher,從而會導致元件的重新渲染。如下的圖片新增了一些我自己的註釋。

現在,是不是感覺一目瞭然了呢?

當然,Vue 底層的處理要更復雜,但是你現在已經掌握了它的基礎。

我們學到了什麼呢?

  • 如何建立 Dep 來收集依賴(depend)並重新執行所有的依賴(notify);
  • 如何建立 watcher 來管理我們正在執行的程式碼,這些程式碼可能需要作為依賴新增進來(target);
  • 如何使用 Object.defineProperty() 來建立 getter 和 setter。

相關文章