本系列一共七章,Github 地址請查閱這裡,原文地址請查閱這裡。
使用 ES6 代理建立資料繫結
這是編寫 JavaScript 框架系列的第五章。本章將會闡述如何使用 ES6 代理建立一個簡單且強大的資料繫結庫
前言
ES6 讓 JavaScript 更加優雅,但是其中大多數新功能只是一個語法糖。代理是少數幾個不需要墊片的功能之一。如果你不熟悉它們,那麼在繼續之前請快速閱讀 MDN Proxy docs。
有 ES6 的 Reflection API ,Set,Map 和 WeakMap 的基礎知識將會有所幫助。
nx-observe 庫
nx-observe 是一個 140 行程式碼的資料繫結方案。它公開了 observable(obj)
和 observe(fn)
函式,用來建立可監聽物件和監聽函式。監聽函式會在被監聽物件的屬性值發生改變的時候自動執行。如下例子演示了這個過程。
// 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 readme 或者 NX home page 以找到更多的生動的例子。
實現一個簡單的被監聽物件
本小節,我將會闡述 nx-observe 的底層實現。首先,我將向您展示如何檢測到可觀察到的屬性的變化並與觀察者配對。然後我將會闡述怎麼執行這些由改變所觸發的監聽函式方法。
註冊變化
變化是通過把被監聽物件封裝到 ES6 代理來註冊的。這些代理使用 Reflection API 無縫地攔截 get 和 set 操作。
以下程式碼使用 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
- 設定
currentObserver
為print
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
被賦值為 22person
中的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)來實現類似的資料繫結技術。許多流行框架使用這類技術,比如 MobX 和 Vue。使用代理而不是存取器主要有兩個優點和一個主要的缺點。
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。如果你不熟悉微任務的概念,可以查閱之前關於瀏覽器中的定時的文章。