【第1418期】JavaScript 響應式原理的最佳解釋

尋夢新聞LINE@每日推播熱門推薦文章,趣聞不漏接❤️

加入LINE好友

前言

第二屆Vue開發者大會,你有關注嗎?今日早讀文章由汽車之家@花生翻譯投稿分享。

譯者註:本文旨在解釋響應式的原理(也可以說數據雙向綁定的做到),雖然話題較老,但部分觀點很奇特。

@花生,就職於汽車之家用戶產品中心團隊,雲雲搬磚碼農的中的一員。

正文從這開始~~

? 開始

許多前端的Java框架(例如Angular,React和Vue)都有自己的響應式引擎。了解其響應原理及工作原理,有助於提升自己並更加有效地使用框架。在下面的文章中,模擬了Vue源碼中的響應原理,我們一起來看一下。

? 響應式

當你第一次看到Vue工作時,你會覺得它很神奇。以這個簡單的Vue應用程序為例:

<divid=“app”>

<div>Price: ${{ price}}</div>

<div>Total: ${{ price * quantity }}</div>

<div>Taxes: ${{ totalPriceWithTax}}</div>

</div>

<>

varvm =newVue({

el:‘#app’,

data:{

price:5.00,

quantity:2

},

computed:{

totalPriceWithTax(){

returnthis.price *this.quantity *1.03

}

}

})

</>

當price發生變化後,Vue會做三件事:

  • 在頁面上更新price的值。
  • 重新計算price * quantity的值,並更新頁面。
  • 再次調用totalPriceWithTax,並更新頁面。

不過這並不重要,重要的是當price變化時,Vue怎麼知道該更新什麼,以及它是如何跟蹤所有內容的?

通常的Java代碼是做到不了這樣的功能的。那麼我們看下邊的代碼:

let price =5

let quantity =2

let total =price *quantity // => 10

price =20

console.log(`total is ${total}`)

它會列印10:

>>total is10

在Vue中,我們希望price或quantity變化後total跟著更新,我們想要的是如下的結果:

>>total is40

不巧的是,Java不是響應式的,所以我們沒有得到想要的結果。這時候我們就得想點辦法,來達到我們的目的。

⚠️ 問題一

我們需要保存計算total的方法,以便在price或quantity發生變化時再一次調用。

✅ 解決方案

首先,我們需要一些方法來告訴我們的應用程序,「存儲我將要調用的這段代碼,我可能會在其他時間再次調用。」緊接著我們來執行這段代碼,當price或quantity變量更新後,再次運行之前存儲的代碼。

我們可以創建一個記錄函數來保存我們要的東西,這樣我們就可以再次調用它:

let price =5

let quantity =2

let total =0

let target =null

target =()=>{total =price *quantity }

record()

target()

注意,我們在target變量中存儲一個匿名函數,然後調用record函數。record的定義很簡單:

let storge =[]// 用來存儲target

// 記錄函數

functionrecord (){

storge.push(target)

}

我們已經保存了target({total = price * quantity}),因此我們可以之後再運行它,這時我們可以使用一個replay函數,來運行我們所記錄的所有內容。

functionreplay (){

storge.forEach(run =>run())

}

這將遍歷存儲在storage這個數組中的所有匿名函數,並執行每個函數。

然後就會變成這樣:

price =20

console.log(total)// => 10

replay()

console.log(total)// => 40

下面是完整的代碼,你可以通讀以方便理解:

let price =5

let quantity =2

let total =0

let target =null

let storge =[]// 用來存儲target

// 記錄函數

functionrecord (){

storge.push(target)

}

functionreplay (){

storge.forEach(run =>run())

}

target =()=>{total =price *quantity }

record()

target()

price =20

console.log(total)// => 10

replay()

console.log(total)// => 40

⚠️ 問題二

我們可以根據需要,繼續記錄target這類的代碼,但最好有一個一勞永逸的辦法。

✅ 解決方案:依賴類

我們來解決這個問題的方法是將這種行為(target這種匿名函數)封裝到它自己的類中,這是一個標準編程中做到觀察者模式的依賴類

因此,如果我們創建一個Java類來管理我們的依賴項(使它更接近Vue的處理方式),就像這樣:

classDep{// 例子

constructor (){

this.subscribers =[]// 替代之前的storage

}

depend (){// 替代之前的record

if(target &&!this.subscribers.includes(target)){

this.subscribers.push(target)

}

}

notify (){// 替代之前的replay

this.subscribers.forEach(sub =>sub())// 運行我們的target或觀察者

}

}

注意,我們現在將匿名函數存儲在subscribers中,而不是storage,record也變成了depend,使用notify來代替replay,然後就會變成這樣:

constdep =newDep()

let price =5

let quantity =2

let total =0

let target =()=>{total =price *quantity }

dep.depend()// target添加到subscribers中

target()// 運行並得到total

console.log(total)// => 10

price =20

console.log(total)// => 10

dep.notify()// 調用subscribers里存儲的target

console.log(total)// => 40

改了命名,依舊可以運行,但更適合復用。唯一有點別扭的就是target的存儲和調用。

⚠️ 問題三

我們會為每個變量創建一個依賴類,並且對創建匿名函數的行為進行封裝,從而做到響應式。而不是像這樣調用(這是上面的部分代碼):

target =()=>{total =price *quantity }

dep.depend()

target()

我們可以改為:

watcher(()=>{

total =price *quantity

})

✅ 解決方案: 監聽函數(觀察者模式)

在我們的監聽函數中,我們可以做一些簡單的事情:

functionwatcher(myFun){

target =myFun

dep.depend()

target()

target =null

}

正如你所看到的,watcher函數接受myFunc參數,將其賦給全局的target上,調用dep.depend()將其添加到subscribers里,之後調用並重置target。

運行下面的代碼:

price =20

console.log(total)

dep.notify()

console.log(total)

輸出:

>>10

>>40

還有個問題沒有說,為什麼我們將target設置為全局變量,而不是在需要的時候將其傳遞到函數中。這個答案,請在後邊的內容里尋找。

⚠️ 問題四

我們有一個Dep class,但我們真正想要的是每個變量都有它自己的依賴類,我們把每個屬性都放到一個對象里。

let data ={price:5,quantity:2}

假設一下,我們的每個屬性(price和quantity)都有自己的依賴類。

【第1418期】JavaScript 響應式原理的最佳解釋

運行下面的代碼:

watcher(()=>{

total =data.price *data.quantity

})

因為data.price值被訪問,我希望price屬性的依賴類將我們存儲在target中的匿名函數,通過調用dep.depend()將其推到它的訂閱者(用來存儲target)數組中。

同理,因為data.quantity被訪問,我同樣希望quantity屬性的依賴類將這個存儲在target中的匿名函數推入其訂閱者(用來存儲target)數組中。

【第1418期】JavaScript 響應式原理的最佳解釋

如果我有另一個匿名函數,里邊只是data.price被訪問,我希望只是將其推送到price屬性的依賴類中。

【第1418期】JavaScript 響應式原理的最佳解釋

我們需要在price更新的時候,來調用dep.notify(),我們想要的結果就是這樣的:

console.log(total)// >> 10

price =20// 此時,需要調用price上的notify()

console.log(total)// >> 40

我們需要一些方法來連接data里的屬性(如price或quantity),所以當它被訪問時,我們可以將target保存到我們的訂閱者數組中,當它被改變時,運行我們存儲在訂閱者數組中的函數。

✅ 解決方案: Object.defineProperty()

我們需要了解下ES5中的Object.defineProperty()函數。它可以為屬性定義getter和setter函數。讓我們看一下它的基本用法:

let data ={price:5,quantity:2}

Object.defineProperty(data,‘price’,{

get(){

console.log(`I was accessed`)

},

set(newVal){

console.log(`I was changed`);

}

})

data.price // 調用get() >> I was accessed

data.price =20// 調用set() >> I was changed

如你所見,控制台有兩行輸出,但是,它實際上並沒有get或set任何值,因為我們的用法並不合理。我們現在將其恢復,get()方法返回一個值,set()方法更新一個值,我們添加一個變量internalValue來存儲我們當前的price。

let data ={price:5,quantity:2}

let internalValue =data.price // 初始的值

Object.defineProperty(data,‘price’,{

get(){

console.log(`Gettingprice:${internalValue}`)

returninternalValue

},

set(newVal){

console.log(`Settingprice to:${newVal}`);

internalValue =newVal

}

})

total =data.price *data.quantity // 調用get() >> Getting price: 5

data.price =20// 調用set() >> Setting price to: 20

當我們的get和set正常工作時,控制台輸出的結果也不會出現其他可能。

所以,當我們獲取和設置值時,我們就可以得到我們想要的通知。通過一些遞歸,我們可以為data內的所有屬性運行Object.defineProperty。這時候就可以用到Object.keys(data),像這樣:

let data ={price:5,quantity:2}

Object.keys(data).forEach(key =>{

let internalValue =data[key]

Object.defineProperty(data,key,{

get(){

console.log(`Getting${key}:${internalValue}`)

returninternalValue

},

set(newVal){

console.log(`Setting${key}to:${newVal}`);

internalValue =newVal

}

})

})

total =data.price *data.quantity

data.price =20

現在每個屬性都有了get和set,控制台的輸出很好的證實了這一點。

Gettingprice:5

Gettingquantity:2

Settingprice to:20

? 結合這兩個想法

total =data.price *data.quantity

當這段代碼運行並獲取price值時,我們希望price記住這個匿名函數(target)。這樣,如果price發生變化或者被賦新值時,它就會重新觸發這個函數,因為它知道這一行依賴於它。你可以這樣理解。

Get =>記住這個匿名函數,當我們的值發生變化時,我們會再次運行它。

Set =>運行保存的匿名函數,我們的值發生改變。

或者就我們的Dep Class而言

訪問price (get) => 調用dep.depend()以保存當前target

修改price (set) => 用price調用dep.notify(), 重新運行全部的targets

讓我們結合這兩個想法,然後看看我們的最終代碼:

let data ={price:5,quantity:2}

let target =null

classDep{

constructor (){

this.subscribers =[]

}

depend (){

if(target &&!this.subscribers.includes(target)){

this.subscribers.push(target)

}

}

notify (){

this.subscribers.forEach(sub =>sub())

}

}

Object.keys(data).forEach(key =>{

let internalValue =data[key]

constdep =newDep()

Object.defineProperty(data,key,{

get(){

dep.depend()

returninternalValue

},

set(newVal){

internalValue =newVal

dep.notify()

}

})

})

functionwatcher(myFun){

target =myFun

target()

target =null

}

watcher(()=>{

data.total =data.price *data.quantity

})

現在我們在控制台試一試:

【第1418期】JavaScript 響應式原理的最佳解釋

正是我們所希望的!每當price或quantity更新時,我們的total都會賦值。這個來自Vue文檔的插圖的意義就很明顯了。

【第1418期】JavaScript 響應式原理的最佳解釋

你看到那個帶getter和setter的data圈(紫色)了嗎?看起來應該很眼熟!每個組件實例都有一個watcher實例(藍色圈),它從getter中收集(紅線)依賴項。稍後調用setter時,它會通知監視器,從而做到重新渲染的功能。下邊是我修改後的插圖:

【第1418期】JavaScript 響應式原理的最佳解釋

顯然,Vue在幕後做的更為複雜,但你現在已經對其原理有所了解了。

⏪ 總結

  • 如何創建一個Dep class來收集依賴項(depend)並重新運行所有依賴項(notify)。
  • 如何創建一個watcher來監聽我們正在運行的代碼,可能需要保存這些代碼(target)並添加為依賴項。
  • 怎樣使用Object.defineProperty()創建getter和setter。

關於本文

譯者:@花生

作者:@Gregg Pollack

原文:

https://medium.com/vue-mastery/the-best-explanation-of-java-reactivity-fea6112dd80d

【第1394期】Java 2018 中即將迎來的新功能