[譯] 如何使用 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) // 暫時忽略這裡
    }
  })
}

// 迴圈迭代物件的 keys
function 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,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

Markdown
Markdown

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

未完待續……

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

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

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

感謝閱讀。


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

相關文章