- 原文地址:How to build a reactive engine in JavaScript. Part 1: Observable objects
- 原文做者:本文已獲原做者 Damian Dulisz 受權
- 譯文出自:掘金翻譯計劃
- 譯者:IridescentMia
- 校對者:reid3290,malcolmyu
隨着對強健、可交互的網站界面的需求不斷增多,不少開發者開始擁抱響應式編程規範。javascript
在開始實現咱們本身的響應式引擎以前,快速地解釋一下到底什麼是響應式編程。維基百科給出一個經典的響應式界面實現的例子 —— 叫作 spreadsheet。定義一個準則,對於 =A1+B1
,只要 A1
或 B1
發生變化,=A1+B1
也會隨之變化。這樣的準則也能夠被理解爲是一種 computed value。css
咱們將會在這系列教程的 Part 2 部分學習如何實現 computed value。在那以前,咱們首先須要對響應式引擎有個基礎的瞭解。html
目前有不少不一樣解決方案能夠觀察到應用狀態的改變,並對其作出反應。前端
在這篇教程中,咱們將使用 getters/setters 的方式觀察並響應變化。java
注意:爲了讓這篇教程儘可能保持簡單,代碼缺乏對非初級數據類型或嵌套屬性的支持,而且不少內容須要完整性檢查,所以毫不能認爲這些代碼已經能夠用於生產環境。下面的代碼是受 Vue.js 啓發的響應式引擎的實現,使用 ES2015 標準編寫。node
讓咱們從一個 data
對象開始,咱們想要觀察它的屬性。react
let data = {
firstName: 'Jon',
lastName: 'Snow',
age: 25
}複製代碼
首先從建立兩個函數開始,使用 getter/setter 的功能,將對象的普通屬性轉換成可觀察的屬性。android
function makeReactive (obj, key) {
let val = obj[key]
Object.defineProperty(obj, key, {
get () {
return val // 簡單地返回緩存的 value
},
set (newVal) {
val = newVal // 保存 newVal
notify(key) // 暫時忽略這裏
}
})
}
// 循環迭代對象的 keys
function observeData (obj) {
for (let key in obj) {
if (obj.hasOwnProperty(key)) {
makeReactive(obj, key)
}
}
}
observeData(data)複製代碼
經過運行 observeData(data)
,將原始的對象轉換成可被觀察的對象;如今當對象的 value 發生變化時,咱們有建立通知的辦法。ios
在咱們開始接收 notifying 前,咱們須要一些通知的內容。這裏是使用觀察者模式的一個極好例子。在這個案例中咱們將使用 signals 實現。git
咱們從 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)
}
}
}
}複製代碼
如今咱們須要作的就是建立一個新的可響應對象。多虧了暴露出來的 notify
和 observe
函數,咱們能夠觀察到並響應對象的改變。
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-bind
或 v-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,Firefox
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]')
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
上文的代碼能夠在這裏找到: github.com/shentao/see…
這篇是製做你本身的響應式引擎系列文章中的第一篇。
下一篇 將是關於建立 computed properties,每一個屬性都有它本身的可追蹤依賴。
很是歡迎在評論區提出你對於下一篇文章講述內容的反饋和想法!
感謝閱讀。
掘金翻譯計劃 是一個翻譯優質互聯網技術文章的社區,文章來源爲 掘金 上的英文分享文章。內容覆蓋 Android、iOS、React、前端、後端、產品、設計 等領域,想要查看更多優質譯文請持續關注 掘金翻譯計劃。