如何編寫一個前端框架之五-基於 ES6 代理的數據綁定(譯)

本系列一共七章,Github 地址請查閱這裏,原文地址請查閱這裏javascript

使用 ES6 代理建立數據綁定

這是編寫 JavaScript 框架系列的第五章。本章將會闡述如何使用 ES6 代理建立一個簡單且強大的數據綁定庫vue

前言

ES6 讓 JavaScript 更加優雅,可是其中大多數新功能只是一個語法糖。代理是少數幾個不須要墊片的功能之一。若是你不熟悉它們,那麼在繼續以前請快速閱讀 MDN Proxy docsjava

有 ES6 的 Reflection API SetMapWeakMap 的基礎知識將會有所幫助。git

nx-observe 庫

nx-observe 是一個 140 行代碼的數據綁定方案。它公開了 observable(obj)observe(fn) 函數,用來建立可監聽對象和監聽函數。監聽函數會在被監聽對象的屬性值發生改變的時候自動執行。以下例子演示了這個過程。es6

// this is an observable object
const person = observable({name: 'John', age: 20})

function print() {
  console.log(`${person.name}, ${person.age}`)
}

// this creates an observer function
// outputs 'John, 20' to the console
observe(print)

// outputs 'Dave, 20' to the console
setTimeout(() => person.name = 'Dave', 100)

// outputs 'Dave, 22' to the console
setTimeout(() => person.age = 22, 200)
複製代碼

每當 person.name 或者 person.age 值改變的時候,傳入 observe()print 函數就會從新運行。print 被稱爲監聽函數。github

若是你想要更多的示例,能夠查看 GitHub readme 或者 NX home page 以找到更多的生動的例子。數組

實現一個簡單的被監聽對象

本小節,我將會闡述 nx-observe 的底層實現。首先,我將向您展現如何檢測到可觀察到的屬性的變化並與觀察者配對。而後我將會闡述怎麼運行這些由改變所觸發的監聽函數方法。瀏覽器

註冊變化

變化是經過把被監聽對象封裝到 ES6 代理來註冊的。這些代理使用 Reflection API 無縫地攔截 get 和 set 操做。bash

如下代碼使用 currentObserver 變量和 queueObserver(),可是隻會在下一小節中進行解釋。如今只須要知道的是 currentObserver 老是指向目前運行的監聽函數,而 queueObserver() 把將要執行的監聽函數插入隊列。數據結構

/* 映射被監聽對象屬性到監聽函數集,監聽函數集會使用監聽對象屬性 */
const observers = new WeakMap()

/* 指向當前運行的監聽函數能夠爲 undefined */
let currentObserver

/* 利用把對象封裝爲一個代理來把對象轉換爲一個可監聽對象,
它也能夠添加一個空白映射,用做之後保存被監聽對象-監聽函數對。
*/
function observable (obj) {
  observers.set(obj, new Map())
  return new Proxy(obj, {get, set})
}

/* 這個陷阱攔截 get 操做,若是當前沒有執行監聽函數它不作任何事 */
function get (target, key, receiver) {
  const result = Reflect.get(target, key, receiver)
   if (currentObserver) {
     registerObserver(target, key, currentObserver)
   }
  return result
}

/* 若是一個監聽函數正在運行,這個函數會配對監聽函數和當前取得的被
監聽對象屬性,並保存到一個監聽函數映射之中 */
function registerObserver (target, key, observer) {
  let observersForKey = observers.get(target).get(key)
  if (!observersForKey) {
    observersForKey = new Set()
    observers.get(target).set(key, observersForKey)
  }
  observersForKey.add(observer)
}

/* 這個陷阱攔截 set 操做,它把每一個關聯當前 set 屬性的監聽函數加入隊列以備以後執行 */
function set (target, key, value, receiver) {
  const observersForKey = observers.get(target).get(key)
  if (observersForKey) {
    observersForKey.forEach(queueObserver)
  }
  return Reflect.set(target, key, value, receiver)
}
複製代碼

若是沒有設置 currentObserver , get 陷阱不作任何事。不然,它配對獲取的可監聽屬性和目前運行的監聽函數,而後把它們保存入監聽者 WeakMap。監聽者會被存入每一個被監聽對象屬性的 Set 之中。這樣能夠保證沒有重複的監聽函數。

set 陷阱函數得到全部改變了值的被監聽者屬性配對的監聽函數,而且把他們插入隊列以備以後執行。

在下面,你能夠找到一個圖像和逐步的描述來解釋 nx-observe 的示例代碼。

  • 建立被監聽對象 person
  • 設置 currentObserverprint
  • print 開始執行
  • print 中得到 person.name
  • person 中的 代理 get 陷阱函數被調用
  • observers.get(person).get('name') 得到屬於 (person, name) 對的監聽函數集合
  • currentObserver(print) 被加入監聽集合中
  • person.age 再次執行步驟 4-7
  • 控制檯輸出 ${person.name}, ${person.age}
  • print 結束運行
  • currentObserver 被設置爲 undefined
  • 其它代碼開始執行
  • person.age 被賦值爲 22
  • person 中的 set 代理 陷阱被調用
  • observers.get(person).get('age') 得到 (person, age) 對中的監聽集合
  • 監聽集合中的監聽函數(包括 print )被插入隊列以運行
  • print 再次運行

運行監聽函數

在一個批處理中,異步執行隊列中的監聽函數,會帶來很好的性能。在註冊階段,監聽函數被同步加入 queuedObservers Set。一個 Set 不會有有重複的監聽函數,因此屢次加入同一個 observer 也不會致使重複執行。若是以前 Set 是空的,那麼會加入一個新任務在一段時間後迭代並執行全部排隊的 observer。

/* contains the triggered observer functions,
which should run soon */
const queuedObservers = new Set()

/* points to the currently running observer,
it can be undefined */
let currentObserver

/* the exposed observe function */
function observe (fn) {
  queueObserver(fn)
}

/* adds the observer to the queue and 
ensures that the queue will be executed soon */
function queueObserver (observer) {
  if (queuedObservers.size === 0) {
    Promise.resolve().then(runObservers)
  }
  queuedObservers.add(observer)
}

/* runs the queued observers,
currentObserver is set to undefined in the end */
function runObservers () {
  try {
    queuedObservers.forEach(runObserver)
  } finally {
    currentObserver = undefined
    queuedObservers.clear()
  }
}

/* sets the global currentObserver to observer, 
then executes it */
function runObserver (observer) {
  currentObserver = observer
  observer()
}
複製代碼

以上代碼確保不管什麼時候運行一個監聽函數,全局的 currentObserver 就指向它。設置 currentObserver 切換 get 陷阱函數,以便監聽和配對 currentObserver 和其運行時使用的全部的可監聽的屬性。

構建一個動態的可監聽樹

迄今爲止,咱們的模型在單層數據結構運行得很好,可是要求咱們手工把每一個新對象-值屬性封裝爲可監聽。例如,以下代碼將不能按預期運行。

const person = observable({data: {name: 'John'}})

function print () {
  console.log(person.data.name)
}

// outputs 'John' to the console
observe(print)

// does nothing
setTimeout(() => person.data.name = 'Dave', 100)
複製代碼

爲了讓代碼運行,咱們不得不把 observable({data: {name: 'John'}}) 替換爲 observable({data: observable({name: 'John'})})。幸運的是,咱們能夠經過稍微修改 get 陷阱函數來去除這種不便。

function get (target, key, receiver) {
  const result = Reflect.get(target, key, receiver)
  if (currentObserver) {
    registerObserver(target, key, currentObserver)
    if (typeof result === 'object') {
      const observableResult = observable(result)
      Reflect.set(target, key, observableResult, receiver)
      return observableResult
    }
  }
  return result
}
複製代碼

若是返回值是一個對象的時候,上面的 get 陷阱函數會在返回以前把返回值設置爲一個可監聽的代理對象。從性能的角度來看,這也是至關完美的方案,由於可監聽對象僅當監聽函數須要的時候才建立。

和 ES5 技術對比

除了 ES6 代理,還能夠用 ES5 的屬性存取器(getter/setter)來實現相似的數據綁定技術。許多流行框架使用這類技術,好比 MobXVue。使用代理而不是存取器主要有兩個優勢和一個主要的缺點。

Expando 屬性

Expando 屬性指的是在 JavaScript 中動態添加的屬性。ES5 中不支持 expando 屬性,每一個屬性的訪問器都必須預先定義才能實現攔截操做。這是爲何如今預約義的鍵值集合成爲趨勢的技術緣由。

另外一方面,代理技術支持 expando 屬性,由於每一個對象定義代理,而且他們爲每一個對象的屬性攔截操做。

expando 屬性是很是重要,一個經典的例子就是使用數組。若是不能從數組中添加或者刪除數組元素 JavaScript 中的數組就會很雞肋。ES5 數據綁定技術常常經過提供自定義或者重寫 Array 方法來解決這個問題。

Getters and setters

使用 ES5 方法的庫使用一些特殊的語法來提供 computed 綁定屬性。這些屬性擁有相應的原生實現,即 getters and setters。然而 ES5 方法內部使用 getters/setters 來建立數據綁定邏輯,因此不可以和屬性存取器一塊兒工做。

代理攔截各類屬性訪問和改變包括 getters 和 setters,因此它不會給 ES6 方法帶來問題。

缺點

使用代理最大的缺點即爲瀏覽器支持。只有最新的瀏覽器纔會支持,而且Proxy API 的最好的部分卻沒法經過 polyfill 實現。

一些注意事項

這裏介紹的數據綁定方法只是一個可運行的版本,可是爲了讓其易懂我作了一個簡化。你能夠在下面找到一些由於簡化而忽略的主題的說明。

內存清理

內存泄漏是惱人的。這裏的代碼在某種意義上避免了這一問題,由於它使用 WeakMap 來保存監聽函數。這意味着可監聽對象相關聯的監聽函數會和被監聽對象一塊兒被垃圾回收。

然而,一個可能的用例是一箇中心化,持久化的存儲,伴隨着頻繁的 DOM 變更。在這個狀況下, DOM 節點在內存垃圾回收前必須釋放全部的註冊的監聽函數。示例中沒有寫上這個功能,但你能夠在 nx-observe code 中檢查 unobserve() 是如何實現的。

使用代理進行雙重封裝

代理是透明的,這意味着沒有原生的方法來肯定對象是代理仍是簡單對象。還有,它們能夠無限嵌套,若不進行必要的預防,最終可能致使不停地對 observable 對象進行包裝。

有不少種聰明的方法來把代理對象和普通對象區分開來,可是我沒有在例子中寫出。一個方法便是把代理添加入 WeakSet 並命名爲 proxies ,而後在以後檢查是否包含。若是對 nx-observe 是如何實現 isObservable ,有興趣的能夠查看這裏

繼承

nx-observe 也支持原型鏈繼承。以下例子演示了繼承是如何運做的。

const parent = observable({greeting: 'Hello'})
const child = observable({subject: 'World!'})
Object.setPrototypeOf(child, parent)

function print () {
  console.log(`${child.greeting} ${child.subject}`)
}

// outputs 'Hello World!' to the console
observe(print)

// outputs 'Hello There!' to the console
setTimeout(() => child.subject = 'There!')

// outputs 'Hey There!' to the console
setTimeout(() => parent.greeting = 'Hey', 100)

// outputs 'Look There!' to the console
setTimeout(() => child.greeting = 'Look', 200)
複製代碼

對原型鏈的每一個成員調用get 操做,直到找到屬性爲止,所以在須要的地方都會註冊監聽器。

有些極端狀況是由一些極少見的狀況引發的,既 set 操做也會遍歷原型鏈(偷偷摸摸地),但這裏將不會闡述。

內部屬性

代理也能夠攔截內部屬性訪問。你的代碼可能使用了許多一般不考慮的內部屬性。好比 well-known Symbols 便是這樣的屬性。這樣的屬性一般會被代理正確地攔截,可是也有一些錯誤的狀況。

異步特性

當攔截set 操做時,能夠同步運行監聽器。這將會帶來幾個優勢,如減小複雜性,精肯定時和更好的堆棧追蹤,可是它在一些狀況下會引發巨大的麻煩。

想象一下,在一個循環中往一個被監聽的數組插入 1000 個對象。數組長度會改變 1000 次並,且與之相關的監聽器會也會緊接着連續執行 1000 次。這意味着運行徹底相同的函數集 1000 次,這是毫無用處的。

const observable1 = observable({prop: 'value1'})
const observable2 = observable({prop: 'value2'})

observe(() => observable1.prop = observable2.prop)
observe(() => observable2.prop = observable1.prop)
複製代碼

另外一個有問題的場景,即一個雙向監聽。若是監聽函數同步執行,如下代碼將會是一個無限循環。

const observable1 = observable({prop: 'value1'})
const observable2 = observable({prop: 'value2'})

observe(() => observable1.prop = observable2.prop)
observe(() => observable2.prop = observable1.prop)
複製代碼

基於這些緣由, nx-observe 不重複地把監聽函數插入隊列,而後把它們做爲微任務批量運行以防止 FOUC。若是你不熟悉微任務的概念,能夠查閱以前關於瀏覽器中的定時的文章。

本系列一共七章,Github 地址請查閱這裏,原文地址請查閱這裏

相關文章
相關標籤/搜索