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

響應式的方式

隨着對強健、可交互的網站界面的需求不斷增多,不少開發者開始擁抱響應式編程規範。javascript

在開始實現咱們本身的響應式引擎以前,快速地解釋一下到底什麼是響應式編程。維基百科給出一個經典的響應式界面實現的例子 —— 叫作 spreadsheet。定義一個準則,對於 =A1+B1,只要 A1B1 發生變化,=A1+B1 也會隨之變化。這樣的準則也能夠被理解爲是一種 computed value。css

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

引擎

目前有不少不一樣解決方案能夠觀察到應用狀態的改變,並對其作出反應。前端

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

在這篇教程中,咱們將使用 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)
      }
    }
  }
}複製代碼

如今咱們須要作的就是建立一個新的可響應對象。多虧了暴露出來的 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前端後端產品設計 等領域,想要查看更多優質譯文請持續關注 掘金翻譯計劃

相關文章
相關標籤/搜索