使用 Proxy 構建響應式系統

小蘿蔔丁發表於2019-09-22

?本篇博文可能學到的知識點

  • 更好的理解 Vue 響應式工作原理
  • 學習 Vue 的設計模式
  • 學習 Proxy API
  • 使用 Proxy 實現觀察者模式

現代前端開發必不可少會用到的 Vue、React 等框架,這些框架的共同之處在於都提供了響應式(Reactive)和元件化(Composable)的檢視元件,元件化開發重新定義了前端開發技術棧。結合前端構建工具以及基於框架出現的各種經過精心設計的UI元件庫,讓前端也進入到了一個工程化的時代。構建頁面變得從未有過的簡潔高效。

如果你是一名經驗豐富(nian ling da)的程式設計師,或多或少都會接觸到沒有這些框架之前的狀態,那時候我們還手持 jQuery 利器,操縱著一手好 dom,當你初次接觸到響應式框架的時候或許會被它的好用所驚豔到。我們只需要改變資料,dom就更新了。本篇博文主要是來討論被驚豔到的響應式框架是如何實現的。我們首先來看看 Vue 是如何實現響應式系統的?

Vue 是如何實現響應式系統的?

看一個簡單的小例子

假設我們購物車中有一個商品,單價 price,數量 quantity,總價為 total

有這樣一個簡單的功能,點選按鈕讓 price 發生變化,那麼 total 為計算屬性,也隨之發生變化。

<!-- App.vue -->
<div id="app">
  <div>Price: ¥{{ price }}</div>
  <div>Quantity: {{ quantity }}</div>
  <div>Total: ¥{{ total }}</div>
  <div>
    <button @click="price = 10">
      change price
    </button>
  </div>
</div>
複製程式碼
// main.js
new Vue({
  el: '#app',
  data: {
    price: 6.00,
    quantity: 2
  },
  computed: {
    total () {
      return this.price * this.quantity
    }
  }
})
複製程式碼

線上檢視DEMO

考慮一下 Vue是如何實現這樣的功能的,其實我們是更改了資料,而依賴於它的計算屬性也發生了變化。

Vue 是如何實現追蹤資料的變化的

在官方文件,深入響應式原理裡有講到

當你把一個普通的 JavaScript 物件傳入 Vue 例項做為 data 選項,Vue 將遍歷此物件所有的屬性,並使用 Object.defineProperty 把這些屬性全部轉為 getter/setter。在內部它們讓 Vue 能夠追蹤依賴,在屬性被訪問和修改時通知變更。

每個元件例項都對應一個 watcher 例項,它會在元件渲染的過程中把“接觸”過的資料屬性記錄為依賴。之後當依賴項的 setter 觸發時,會通知 watcher,從而使它關聯的元件重新渲染。

reactivity

一些弊端

受現代 JavaScript 的限制

對於已建立的例項,Vue **無法檢測到物件屬性的新增和刪除。**Vue 允許使用 Vue.set(object, propertyName, value) 的方法進行動態新增響應式屬性,或者使用別名寫成 this.$set(object, propertyName, value)

對於陣列,Vue 無法檢測以下陣列的變動

  1. 當你利用索引直接設定一個陣列項時,例如:vm.items[indexOfItem] = newValue
  2. 當你修改陣列的長度時,例如:vm.items.length = newLength

第一條的解決方式同樣是使用 this.$set(vm.items, indexOfItem, newValue)

第二條的解決方式是用 vm.items.splice(indexOfItem, 1, newValue)

對於以上的弊端,其實官方已經給出瞭解決方法,就是使用 Proxy 代替 Object.defineProperty API,這個改動會伴隨著萬眾期待的 Vue 3.0 的釋出而應用。大家都知道 Vue 2.x 也就是現在的版本,響應式是通過 Object.defineProperty API來實現的,那麼既然知道了解決方法,我們不妨提前學習一下 Proxy 是如何做到響應式的。

使用 Proxy 實現響應式系統

Proxy 簡介

Proxy 物件用於定義基本操作的自定義行為(如屬性查詢,賦值,列舉,函式呼叫等)。

基本用法

let p = new Proxy(target, hanlder)

引數:

target 用 Proxy 包裝的目標物件(可以是任何型別的物件,包括原生陣列,函式,甚至另一個代理)。

handler 一個物件,其屬性是當執行一個操作時定義代理的行為的函式。

MDN 上的基礎示例

let handler = {
    get: function(target, name){
        return name in target ? target[name] : 37;
    }
};
    
let p = new Proxy({}, handler);
    
p.a = 1;
p.b = undefined;
    
console.log(p.a, p.b);    // 1, undefined
    
console.log('c' in p, p.c);    // false, 37
複製程式碼

上述例子使用了 get ,當物件中不存在屬性名時,預設返回數為37。我們知道了對於操作物件,可以在使用 handler 處理一些事務。關於 handler 則有十三種。

實現一個響應式系統

重新回到我們的小例子,我們有一些變數,單價 price,數量 quantity,總價為 total。我們來看下 JavaScript 是如何工作的

let price = 6
let quantity = 2
let total = 0
    
total = price * quantity
    
console.log(total)  // 12
price = 10
console.log(total)  // 12
複製程式碼

這好像不是我們的期望,我們期望的是,改變了 price,total的值也會更新。如何做到這一點,並不難。

let price = 6
let quantity = 2
let total = 0
    
total = price * quantity
    
console.log(total)  // 12
price = 10
console.log(total)  // 12
    
const updateTotal = () => {
  total = price * quantity
}
    
updateTotal()
    
console.log(total)  // 20
複製程式碼

我們定義了一個方法 updateTotal,讓這個方法執行了我們需要更新 total 的業務邏輯,再重新執行這個方法那麼 total 的值就改變了。

我們可以考慮下我們想到達到什麼樣的目的,其實很簡單,就是當改變 price 或者 quantity 的時候 total 的值會跟著改變。學過設計模式的話,我們很容易想到這個場景符合觀察者模式。

使用觀察者模式

觀察者模式,它定義物件間的一種一對多的依賴關係,當一個物件的狀態發生改變時,所有依賴於它的物件都將得到通知。

observer_pattern

現在我們使用觀察者模式定義了觀察變數 price 與 quantity,如果它們的值發生變化,那麼依賴於它的計算屬性 total 將會得到一個 notify,這個 notify 即是我們的目標,去執行 updateTotal。

建立依賴類

把觀察者模式抽象為一個依賴類

// 代表依賴類
class Dep {
  constructor () {
    this.subscribers = [] // 把所有目標收集到訂閱裡 
  }
  addSub (sub) {  // 當有可觀察目標時,新增到訂閱裡
    if (sub && !this.subscribers.includes(sub)) {
      // 只新增未新增過的訂閱
      this.subscribers.push(sub)
    } 
  }
  notify () {  // 當被觀察的屬性發生變動時通知所有依賴於它的物件
    this.subscribers.forEach(fn => fn()) // 重新執行所有訂閱過的目標方法
  }
}
複製程式碼

那麼如何使變數 price 和 quantify 變得可觀察,在 Vue 2.x 中使用的是 Object.defineProperty ,本文會使用 Proxy 來實現。

// 使變數變為一個可觀察的物件的屬性
const dataObj = {
  price: 6,
  quantity: 2
}
let total = 0
let target = null
    
class Dep { // 代表依賴類
	...
}
    
const dep = new Dep()
    
data = new Proxy(dataObj, {
  get (obj, key) {
    dep.addSub(target)  // 將目標新增到訂閱中
    return obj[key]
  },
  set (obj, key, newVal) {
    obj[key] = newVal // 將新的值賦值給舊的值,引起值的變化
    dep.notify()  // 被觀察的屬性值發生變化,即通知所有依賴於它的物件
  }
})
    
total = data.price * data.quantity
    
console.log(total)  // 12
data.price = 10
console.log(total)  // 12
    
target = () => {
  total = data.price * data.quantity
}
target()
target = null
    
console.log(total)  // 20
複製程式碼

上面程式碼稍微有些凌亂,我們重構一下,觀察者模式結合 Proxy 最終目的就是輸出被觀察的物件。我們可以抽象為一個 observer

使用 Proxy 實現觀察者模式

// 將依賴類與 Proxy 封裝為 observer,輸入一個普通物件,輸出為被觀察的物件
const observer = dataObj => {
  const dep = new Dep()
    
  return new Proxy(dataObj, {
    get (obj, key) {
      dep.addSub(target)  // 將目標新增到訂閱中
      return obj[key]
    },
    set (obj, key, newVal) {
      obj[key] = newVal // 將新的值賦值給舊的值,引起值的變化
      dep.notify()  // 被觀察的屬性值發生變化,即通知所有依賴於它的物件
    }
  })
}
    
const data = observer(dataObj)
複製程式碼

我們注意到,其實每次我們還要重新執行我們的目標 target ,讓 total 值發生變化。這塊兒邏輯我們可以抽象為一個 watcher ,讓它幫我們做一些重複做的業務邏輯。

建立 watcher

const watcher = fn => {
  target = fn
  target()
  target = null
}
    
watcher(() => {
  total = data.price * data.quantity
})
複製程式碼

我們最終程式碼優化為:

// 使變數變為一個可觀察的物件的屬性
const dataObj = {
  price: 6,
  quantity: 2
}
let total = 0
let target = null
    
// 代表依賴類
class Dep {
  ...
}
    
// 使用 Proxy 實現了觀察者模式
const observer = dataObj => {
  ...
}
    
const data = observer(dataObj)
    
// 高階函式,重複執行訂閱方法
const watcher = fn => {
  ...
}
    
watcher(() => {
  total = data.price * data.quantity
})
    
console.log(total)  // 12
data.price = 30
console.log(total) // 60
複製程式碼

我們最終實現了開始的想法,total 會根據 price 值的改變而改變。實現了簡單的響應式系統。

為了縮小篇幅,上面的方法同時也有講過,即摺疊(簡化)起來,我們再回到 Vue 是如何追蹤資料的依賴的那張圖。再看看我們是如何實現的。

vue-reactive-proxy

總結一下

通過小例子我們學習到的內容

  • 我們學習到了通過建立一個 Dep 依賴類,來收集依賴關係,在訂閱者屬性被改變時,所有依賴於它的物件得以得到一個通知。
  • 結合 Dep 依賴類,我們使用 Proxy 實現了觀察者模式的 observer 方法
  • 我們建立了一個 watcher 觀察者方便管理我們要重新執行的業務邏輯,即我們新增到訂閱裡需要執行的方法

結尾

本文講到了 Vue2.x 中響應式系統的一些弊端,在即將到來的 Vue 3.0 Updates 中都將得到解決,在去年這時候尤大在 Plans for the Next Iteration of Vue.js 這篇博文中也有提到過。讓我們期待 Vue 3.0 的到來吧。

原文:xlbd.me/build-a-rea…

相關文章