完美解釋 Javascript 響應式程式設計原理

陳澤輝發表於2019-03-03

深入響應式原理 — Vue.js
https://medium.com/vue-mastery/the-best-explanation-of-javascript-reactivity-fea6112dd80d
https://www.vuemastery.com/courses/advanced-components/evan-you-on-proxies/

很多前端 JavaScript 框架,包含但不限於(Angular,React,Vue)都擁有自己的響應式引擎。通過了解響應式變成原理以及具體的實現方式,可以提成對既有響應式框架的高效應用。

響應式系統

我們看一下 Vue 的響應式系統:

<div id="app">
	<div>Price: ${{ price }}</div>
	<div>Total: ${{ price * quantity }}</div>
	<div>Taxes: ${{ totalPriceWithTax }}</div>
<div>
複製程式碼
<script src="https://cdn.jsdelivr.net/npm/vue"></script>
<script>
	var vm = new Vue({
	  el: '#app',
	  data: {
	    price: 5.00,
	    quantity: 2
	  },
	  computed: {
	    totalPriceWithTax() {
	      return this.price * this.quantity * 1.03
	    }
	  }
	})
</script>
複製程式碼

Vue 知道每當 price 發生變換時,它做了如下三件事情:

  • 在頁面中更新 price 的值。
  • 在頁面中重新計算 price * quantity 表示式,並更新。
  • 呼叫 totalPriceWithTax 方法,並更新。

但是等一下,這裡有些疑惑,Vue 是怎麼知道 price 更新了呢,如何去追蹤更新的具體過程呢?

完美解釋 Javascript 響應式程式設計原理

響應式在原生 JavaScript 中如何實現的呢,我們從宣告變數開始吧~

let price = 5
let quantity = 2
let total = price *  quantity // 10 ?
price = 20
console.log(`total is ${total}`) // 10

// 我們想要的 total 值希望是更新後的 40
複製程式碼

⚠️ 問題

我們需要將 total 的計算過程存起來,這樣我們就能夠在 price 或者 quantity 變化時執行計算過程。

✅ 方案

首先,我們需要告訴應用程式,“這裡有一個關於計算的方法,存起來,我會在資料更新的時候去執行它。“

完美解釋 Javascript 響應式程式設計原理

我們建立一個記錄函式並執行它:

let price = 5
let quantity = 2
let total = 0
let target = null

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

record() // 記錄我們想要執行的例項 
target() // 執行 total 計算過程
複製程式碼

簡單定義一個 record

let storage = [] // 將 target 函式存在這裡
function record () { // target = () => { total = price * quantity }
	storage.push(target)
}
複製程式碼

我們儲存了 target 函式,我們需要執行它,需要頂一個 replay 函式來執行我們記錄的函式:

function replay () {
	storage.forEach( run => run() )
}
複製程式碼

在程式碼中我們執行:

price = 20
console.log(total) // => 10
replay()
console.log(total) // => 40
複製程式碼

是不是足夠的簡單,程式碼可讀性好,並且可以執行多次。FYI,這裡用了最基礎的方式進行編碼,先掃盲。

let price = 5
let quantity = 2
let total = 0
let target = null
let storage = []

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

function record () {
	storage.push(target)
}

function replay () {
	storage.forEach( run => run() )
}

record()
target()

price = 20
console.log(total) // => 10
replay()
console.log(total) // => 40
複製程式碼

物件化

⚠️ 問題

我們可以不斷記錄我們需要的 target, 並進行 record,但是我們需要更健壯的模式去擴充套件我們的應用。也許物件導向的方式可以維護一個 targe 列表,我們用通知的方式進行回撥。

✅ 方案 Dependency Class

我們通過一個屬於自己的類進行行為的封裝,一個標準的依賴類 Dependency Class,實現觀察者模式。

如果我們想要建立一個管理依賴的類,標準模式如下:

class Dep { // Stands for dependency 
	constructor () {
	  this.subscribers = [] // 依賴陣列,當 notify() 呼叫時執行
	}

	depend () {
	  if (target && !this.subscribers.includes(target)) {
	    // target 存在並且不存在於依賴陣列中,進行依賴注入
	    this.subscribers.push(target)
	  }
	}

	notify () { // 替代之前的 replay 函式
	  this.subscribers.forEach(sub => sub()) // 執行我們的 targets,或者觀察者函式
  }
}
複製程式碼

注意之前替換的方法,storage 替換成了建構函式中的 subscribersrecod 函式替換為 dependreplay 函式替換為 notify

現在在執行:

const dep = new Dep()

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

dep.depend() // 依賴注入
target() // 計算 total

console.log(total) // => 10
price = 20
console.log(total) // => 10
dep.notify()
console.log(total) // => 40
複製程式碼

工作正常,到這一步感覺奇怪的地方,配置 和 執行 target 的地方。

觀察者

⚠️ 問題

之後我們希望能夠將 Dep 類應用在每一個變數中,然後優雅地通過匿名函式去觀察更新。可能需要一個觀察者 watcher 函式去滿足這樣的行為。

我們需要替換的程式碼:

target = () => { total = price * quantity }
dep.depend()
target()
複製程式碼

替換為:

watcher( () => {
  total = price * quantit
})
複製程式碼

✅ 方案 A Watcher Function

我們先定義一個簡單的 watcher 函式:

function watcher (myFunc) {
	target = myFunc // 動態配置 target
	dep.depend() // 依賴注入
	target() // 回撥 target 方法
  target = null // 重置 target
}
複製程式碼

正如你所見, watcher 函式傳入一個 myFunc 的形參,配置全域性變數 target,呼叫 dep.depend() 進行依賴注入,回撥 target 方法,最後,重置 target

執行一下:

price = 20
console.log(total) // => 10
dep.notify()
console.log(total) // => 40
複製程式碼

你可能會質疑,為什麼我們要對一個全域性變數的 target 進行操作,這顯得很傻,為什麼不用引數傳遞進行操作呢?文章的最後將揭曉答案,答案也是顯而易見的。

資料抽象

⚠️ 問題

我們現在有一個單一的 Dep class,但是我們真正想要的是我們每一個變數都擁有自己的 Dep。讓我們先將資料抽象到 properties

let data = { price: 5, quantity: 2 }
複製程式碼

將設每個屬性都有自己的內建 Dep 類

完美解釋 Javascript 響應式程式設計原理

然我我們執行:

watcher( () => {
  total = data.price * data.quantit
})
複製程式碼

data.price 的 value 開始存取時,我想讓關於 price 屬性的 Dep 類 push 我們的匿名函式(儲存在 target 中)進入 subscriber 陣列(通過呼叫 dep.depend())。而當 quantity 的 value 開始存取時,我們也做同樣的事情。

完美解釋 Javascript 響應式程式設計原理

如果我們有其他的匿名函式,假設存取了 data.price,同樣的在 price 的 Dep 類中 push 此匿名函式。

完美解釋 Javascript 響應式程式設計原理

當我們想通過 dep.notify() 進行 price 的依賴回撥時候。我們想 在 price set 時候讓回撥執行。在最後我們要達到的效果是:

$ total
10
$ price = 20 // 回撥 notify() 函式
$ total
40
複製程式碼

✅ 方案 Object.defineProperty()

我們需要學習一下關於 Object.defineProperty() - JavaScript | MDN。它允許我們在 property 上定義 getter 和 setter 函式。我們展示一下最基本的用法:

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

Object.defineProperty(data, 'price', { // 僅定義 price 屬性
  get () { // 建立一個 get 方法
    console.log(`I was accessed`)
	},
	set (newVal) { // 建立一個 set 方法
    console.log(`I was changed`)
	}
})

data.price // 回撥 get()
// => I was accessed
data.price = 20 // 回撥 set()
// => I was changed
複製程式碼

正如你所見,列印兩行 log。然而,這並不能推翻既有功能, get 或者 set 任意的 valueget() 期望返回一個 value,set() 需要持續更新一個值,所以我們加入 internalValue 變數用於儲存 price 的值。

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

let internalValue = data.price // 初始值

Object.defineProperty(data, 'price', { // 僅定義 price 屬性
  get () { // 建立一個 get 方法
    console.log(`Getting price: ${internalValue}`)
    return internalValue
	},
	set (newVal) { // 建立一個 set 方法
    console.log(`Setting price: ${newVal}`)
	  internalValue = newVal
	}
})

total = data.price * data.quantity // 回撥 get()
// => Getting price: 5
data.price = 20 // 回撥 set()
// => Setting price: 20
複製程式碼

至此,我們有了一個當 get 或者 set 值的時候的通知方法。我們也可以用某種遞迴可以將此執行在我們的資料佇列中?

FYI,Object.keys(data) 返回物件的 key 值列表。

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}`)
      return internalValue
	  },
	  set (newVal) {
      console.log(`Setting ${key}: ${newVal}`)
	    internalValue = newVal
	  }
  })
})

total = data.price * data.quantity
// => Getting price: 5
// => Getting quantity: 2
data.price = 20
// => Setting price: 20
複製程式碼

CI 整合

將所有的理念整合起來

total = data.price * data.quantity
複製程式碼

當程式碼碎片比如 get 函式的執行並且 get 到 price 的值,我們需要 price 記錄在匿名函式 function(target) 中,如果 price 變化了,或者 set 了一個新的 value,會觸發這個匿名函式並且 get return,它能夠知道這裡更新了一樣。所以我們可以做如下抽象:

  • Get => 記錄這個匿名函式,如果值更新了,會執行此匿名函式

  • Set => 執行儲存的匿名函式,僅僅改變儲存的值。

在我們的 Dep 類的例項中,抽象如下:

  • Price accessed (get) => 回撥 dep.depend() 去注入當前的 target

  • Price set => 回撥 price 繫結的 dep.notify() ,重新計算所有的 targets

讓我們合併這兩個理念,生成最終程式碼:

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

class Dep { // Stands for dependency 
	constructor () {
	  this.subscribers = [] // 依賴陣列,當 notify() 呼叫時執行
	}

	depend () {
	  if (target && !this.subscribers.includes(target)) {
	    // target 存在並且不存在於依賴陣列中,進行依賴注入
	    this.subscribers.push(target)
	  }
	}

	notify () { // 替代之前的 replay 函式
	  this.subscribers.forEach(sub => sub()) // 執行我們的 targets,或者觀察者函式
  }
}

// 遍歷資料的屬性
Object.keys(data).forEach(key => {
  let internalValue = data[key]

	// 每個屬性都有一個依賴類的例項
  const dep = new Dep()

  Object.defineProperty(data, key, {
	  get () {
      dep.depend()
      return internalValue
	  },
	  set (newVal) {
	    internalValue = newVal
		dep.notify()
	  }
  })
})

// watcher 不再呼叫 dep.depend
// 在資料 get 方法中執行
function watcher (myFunc) {
	target = myFunce
	target()
	target = null
}

watcher(()=> {
	data.total = data.price * data.quantity
})

data.total
// => 10
data.price = 20
// => 20
data.total
// => 40
data.quantity = 3
// => 3
data.total
// => 60
複製程式碼

這已經達到了我們的期望,pricequantity 都成為響應式的資料了。

Vue 的資料響應圖如下:

完美解釋 Javascript 響應式程式設計原理

看到紫色的 Data 資料,裡面的 getter 和 setter?是不是很熟悉了。每個元件的例項會有一個 watcher的例項(藍色圓圈), 從 getter 中收集依賴。然後 setter 會被回撥,這裡 notifies 通知 watcher 讓元件重新渲染。下圖是本例抽象的情況:

完美解釋 Javascript 響應式程式設計原理

顯然,Vue 的內部轉換相對於本例更復雜,但我們已經知道最基本的了。

知識點

  • 如何建立一個 Dep class 並且注入所有的依賴執行(notify)
  • 如何建立一個 watcher 進行程式碼管理,需要新增一個依賴專案
  • 如何使用 Object.defineProperty() 建立一個 getters 和 setters

相關文章