[譯] 如何使用 JavaScript 構建響應式引擎 —— Part 1:可觀察的物件

IridescentMia發表於2017-03-30

響應式的方式

隨著對強健、可互動的網站介面的需求不斷增多,很多開發者開始擁抱響應式程式設計規範。

在開始實現我們自己的響應式引擎之前,快速地解釋一下到底什麼是響應式程式設計。維基百科給出一個經典的響應式介面實現的例子 —— 叫做 spreadsheet。定義一個準則,對於 =A1+B1,只要 A1B1 發生變化,=A1+B1 也會隨之變化。這樣的準則也可以被理解為是一種 computed value。

我們將會在這系列教程的 Part 2 部分學習如何實現 computed value。在那之前,我們首先需要對響應式引擎有個基礎的瞭解。

引擎

目前有很多不同解決方案可以觀察到應用狀態的改變,並對其做出反應。

  • Angular 1.x 有髒檢查。
  • React 由於它工作方式,並不追蹤資料模型中的改變。它用虛擬 DOM 比較並修補 DOM。
  • Cycle.js 和 Angular 2 更傾向於響應流方式實現,像 XStream 和 Rx.js。
  • 像 Vue.js, MobX 或 Ractive.js 這些庫都使用 getters/setters 變數建立可觀察的資料模型。

在這篇教程中,我們將使用 getters/setters 的方式觀察並響應變化。

注意:為了讓這篇教程儘量保持簡單,程式碼缺少對非初級資料型別或巢狀屬性的支援,並且很多內容需要完整性檢查,因此絕不能認為這些程式碼已經可以用於生產環境。下面的程式碼是受 Vue.js 啟發的響應式引擎的實現,使用 ES2015 標準編寫。

可觀察的物件

讓我們從一個 data 物件開始,我們想要觀察它的屬性。

let data = { 
firstName: 'Jon', lastName: 'Snow', age: 25
}複製程式碼

首先從建立兩個函式開始,使用 getter/setter 的功能,將物件的普通屬性轉換成可觀察的屬性。

function makeReactive (obj, key) { 
let val = obj[key] Object.defineProperty(obj, key, {
get () {
return val // 簡單地返回快取的 value
}, set (newVal) {
val = newVal // 儲存 newVal notify(key) // 暫時忽略這裡
}
})
}// 迴圈迭代物件的 keysfunction observeData (obj) {
for (let key in obj) {
if (obj.hasOwnProperty(key)) {
makeReactive(obj, key)
}
}
}observeData(data)複製程式碼

通過執行 observeData(data),將原始的物件轉換成可被觀察的物件;現在當物件的 value 發生變化時,我們有建立通知的辦法。

響應變化

在我們開始接收 notifying 前,我們需要一些通知的內容。這裡是使用觀察者模式的一個極好例子。在這個案例中我們將使用 signals 實現。

我們從 observe 函式開始。

let signals = {
} // Signals 從一個空物件開始function observe (property, signalHandler) {
if(!signals[property]) signals[property] = [] // 如果給定屬性沒在 signal 中,則建立這個屬性的 signal,並將其設定為空陣列來儲存 signalHandlers signals[property].push(signalHandler) // 將 signalHandler 存入 signal 陣列,高效地獲得一組儲存在陣列中的回撥函式
}複製程式碼

我們現在可以這樣用 observe 函式:observe('propertyName', callback),每次屬性值發生改變的時候 callback 函式應該被呼叫。當多次在一個屬性上呼叫 observe 時,每個回撥函式將被存在對應屬性的 signal 陣列中。這樣就可以儲存所有的回撥函式並且可以很容易地獲得到它們。

現在來看一下上文中提到的 notify 函式。

function notify (signal, newVal) { 
if(!signals[signal] || signals[signal].length <
1) return // 如果沒有 signal 的處理器則提前 return signals[signal].forEach((signalHandler) =>
signalHandler()) // 呼叫給定屬性的每個 signalHandler
}複製程式碼

如你所見,現在每次一個屬性發生變化,就會呼叫對其分配的 signalHandlers。

所以我們把它全部封裝起來做成一個工廠函式,傳入想要響應的資料物件。我把它命名為 Seer。我們最終得到如下:

function Seer (dataObj) { 
let signals = {
} observeData(dataObj) // 除了響應式的資料物件,我們也需要返回並且暴露出 observe 和 notify 函式。 return {
data: dataObj, observe, notify
} function observe (property, signalHandler) {
if(!signals[property]) signals[property] = [] signals[property].push(signalHandler)
} function notify (signal) {
if(!signals[signal] || signals[signal].length <
1) return signals[signal].forEach((signalHandler) =>
signalHandler())
} function makeReactive (obj, key) {
let val = obj[key] Object.defineProperty(obj, key, {
get () {
return val
}, set (newVal) {
val = newVal notify(key)
}
})
} function observeData (obj) {
for (let key in obj) {
if (obj.hasOwnProperty(key)) {
makeReactive(obj, key)
}
}
}
}複製程式碼

現在我們需要做的就是建立一個新的可響應物件。多虧了暴露出來的 notifyobserve 函式,我們可以觀察到並響應物件的改變。

const App = new Seer({ 
title: 'Game of Thrones', firstName: 'Jon', lastName: 'Snow', age: 25
})// 為了訂閱並響應可響應 APP 物件的改變:App.observe('firstName', () =>
console.log(App.data.firstName))App.observe('lastName', () =>
console.log(App.data.lastName))// 為了觸發上面的回撥函式,像下面這樣簡單地改變 values:App.data.firstName = 'Sansa'App.data.lastName = 'Stark'複製程式碼

很簡單,是不是?現在我們講完了基本的響應式引擎,讓我們來用用它。
我提到過隨著前端程式設計可響應式方法的增多,我們不能總想著在發生改變後手動地更新 DOM。

有很多方法來完成這項任務。我猜現在最流行的趨勢是用虛擬 DOM 的辦法。如果你對學習如何建立你自己的虛擬 DOM 實現感興趣,已經有很多這方面的教程。然而,這裡我們將用到更簡單的方法。

HTML 看起來像這樣: html<
h1>
Title comes here<
/h1>

響應式更新 DOM 的函式看起來像這樣:

// 首先需要獲得想要保持更新的節點。const h1Node = document.querySelector('h1')function syncNode (node, obj, property) { 
// 用可見物件的屬性值初始化 h1 的 textContent 值 node.textContent = obj[property] // 開始用我們的 Seer 的例項 App.observe 觀察屬性。 App.observe(property, value =>
node.textContent = obj[property] || '')
}syncNode(h1Node, App.data, 'title')複製程式碼

這樣做是可行的,但是使用它把所有資料模型繫結到 DOM 元素需要大量的工作。

這就是我們為什麼要再向前邁一步,然後將所有這些自動化完成。
如果你熟悉 AngularJS 或者 Vue.js,你肯定記得使用自定義屬性 ng-bindv-text。我們在這裡建立類似的東西。
我們的自定義屬性叫做 s-text。我們將尋找在 DOM 和資料模型之間建立繫結的方式。

讓我們更新一下 HTML:

<
!-- 'title' 是我們想要在 <
h1>
內顯示的屬性 -->
<
h1 s-text="title">
Title comes here<
/h1>
function parseDOM (node, observable) {
// 獲得所有具有自定義屬性 s-text 的節點 const nodes = document.querySelectorAll('[s-text]') // 對於每個存在的節點,我們呼叫 syncNode 函式 nodes.forEach((node) =>
{
syncNode(node, observable, node.attributes['s-text'].value)
})
}// 現在我們需要做的就是在根節點 document.body 上呼叫它。所有的 `s-text` 節點將會自動的建立與之對應的響應式屬性的繫結。parseDOM(document.body, App.data)複製程式碼

總結

現在我們可以解析 DOM 並且將資料模型繫結到節點上,把這兩個函式新增到 Seer 工廠函式中,這樣就可以在初始化的時候解析 DOM。

結果應該像下面這樣:

function Seer (dataObj) { 
let signals = {
} observeData(dataObj) return {
data: dataObj, observe, notify
} function observe (property, signalHandler) {
if(!signals[property]) signals[property] = [] signals[property].push(signalHandler)
} function notify (signal) {
if(!signals[signal] || signals[signal].length <
1) return signals[signal].forEach((signalHandler) =>
signalHandler())
} function makeReactive (obj, key) {
let val = obj[key] Object.defineProperty(obj, key, {
get () {
return val
}, set (newVal) {
val = newVal notify(key)
}
})
} function observeData (obj) {
for (let key in obj) {
if (obj.hasOwnProperty(key)) {
makeReactive(obj, key)
}
} //轉換資料物件後,可以安全地解析 DOM 繫結。 parseDOM(document.body, obj)
} function syncNode (node, observable, property) {
node.textContent = observable[property] // 移除了 `Seer.` 是因為 observe 函式在可獲得的作用域範圍之內。 observe(property, () =>
node.textContent = observable[property])
} function parseDOM (node, observable) {
const nodes = document.querySelectorAll('[s-text]') nodes.forEach((node) =>
{
syncNode(node, observable, node.attributes['s-text'].value)
})
}
}複製程式碼

JsFiddle 上的例子:

HTML

<
h1 s-text="title">
<
/h1>
<
div class="form-inline">
<
div class="form-group">
<
label for="title">
Title: <
/label>
<
input type="text" class="form-control" id="title" placeholder="Enter title" oninput="updateText('title', event)">
<
/div>
<
button class="btn btn-default" type="button" onclick="resetTitle()">
Reset title<
/button>
<
/div>
複製程式碼

JS

// 程式碼用了 ES2015,使用相容的瀏覽器才可以哦,比如 Chrome,Opera,Firefoxfunction Seer (dataObj) { 
let signals = {
} observeData(dataObj) return {
data: dataObj, observe, notify
} function observe (property, signalHandler) {
if(!signals[property]) signals[property] = [] signals[property].push(signalHandler)
} function notify (signal) {
if(!signals[signal] || signals[signal].length <
1) return signals[signal].forEach((signalHandler) =>
signalHandler())
} function makeReactive (obj, key) {
let val = obj[key] Object.defineProperty(obj, key, {
get () {
return val
}, set (newVal) {
val = newVal notify(key)
}
})
} function observeData (obj) {
for (let key in obj) {
if (obj.hasOwnProperty(key)) {
makeReactive(obj, key)
}
} //轉換資料物件後,可以安全地解析 DOM 繫結。 parseDOM(document.body, obj)
} function syncNode (node, observable, property) {
node.textContent = observable[property] // 移除了 `Seer.` 是因為 observe 函式在可獲得的作用域範圍之內。 observe(property, () =>
node.textContent = observable[property])
} function parseDOM (node, observable) {
const nodes = document.querySelectorAll('[s-text]') for (const node of nodes) {
syncNode(node, observable, node.attributes['s-text'].value)
}
}
}const App = Seer({
title: 'Game of Thrones', firstName: 'Jon', lastName: 'Snow', age: 25
})function updateText (property, e) {
App.data[property] = e.target.value
}function resetTitle () {
App.data.title = "Game of Thrones"
}複製程式碼

Resources

EXTERNAL RESOURCES LOADED INTO THIS FIDDLE:bootstrap.min.css複製程式碼

Result

Markdown
Markdown

上文的程式碼可以在這裡找到: github.com/shentao/see…

未完待續……

這篇是製作你自己的響應式引擎系列文章中的第一篇。

下一篇 將是關於建立 computed properties,每個屬性都有它自己的可追蹤依賴。

非常歡迎在評論區提出你對於下一篇文章講述內容的反饋和想法!

感謝閱讀。


掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOSReact前端後端產品設計 等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃

來源:https://juejin.im/post/58dc9da661ff4b0061547ca0

相關文章