[譯] 如何使用 JavaScript 構建響應式引擎 —— Part 2:計算屬性和依賴追蹤

Hey!若是你用過 Vue.js、Ember 或 MobX,我敢確定你被 計算 屬性難倒過。計算屬性容許你建立像正常的值同樣使用的函數,可是一旦完成計算,他們就被緩存下來直到它的一個依賴發生改變。總的來講,這一律念與 getters 很是類似,實際上下面的實現也將會用到 getters。只不過實現的方式更加聰明一點。 ;)javascript

這是如何使用 JavaScript 構建響應式引擎系列文章的第二部分。在深刻閱讀前強烈建議讀一下 Part 1: 可觀察的對象,由於接下來的實現是構建於前一篇文章的代碼基礎之上的。前端

計算屬性

假設有一個計算屬性叫 fullName,是 firstName 和 lastName 之間加上空格的組合。java

在 Vue.js 中這樣的計算值能夠像下面這樣建立:react

data: {
  firstName: 'Cloud',
  lastName: 'Strife'
},
computed: {
  fullName () {
    return this.firstName + ' ' + this.lastName // 'Cloud Strife'
  }
}
複製代碼

如今若是在模板中使用 fullName,咱們但願它能隨着 firstNamelastName 的改變而更新。若是你有使用 AngularJS 的背景,你可能還記得在模板或者函數調用內使用表達式。固然了,使用渲染函數(無論用不用 JSX)的時候和這裏是同樣的;其實這可有可無。android

來看一下下面的例子:ios

<!-- 表達式 -->
<h1>{{ firstName + ' ' + lastName }}</h1>
<!-- 函數調用 -->
<h2>{{ getFullName() }}</h2>
<!-- 計算屬性 -->
<h3>{{ fullName }}</h3>
複製代碼

上面代碼的執行結果幾乎是同樣的。每次 firstNamelastName 發生變化,視圖將會更新這些 <h> 而且顯示出全名。git

然而,若是屢次使用表達式、函數調用和計算屬性呢?使用表達式和函數調用每次都會計算一遍,而計算屬性在第一次計算後將會緩存下來,直到它的依賴發生改變。它也會在從新渲染的週期中一直保持!若是考慮在基於事件模型的現代用戶界面中,很難預測用戶會首先執行哪項操做,那麼這確實是一個最優化方案。github

基礎的計算屬性

在前面文章中,咱們學習瞭如何經過使用事件發射器追蹤和響應可觀察對象屬性內的改變。咱們知道當改變 firstName 時,會調用全部的訂閱了 ’firstName’ 事件的處理器。所以經過手動訂閱它的依賴來構建計算屬性是至關容易的。 這也是 Ember 實現計算屬性的方式:後端

fullName: Ember.computed('firstName', 'lastName', function() {
  return this.get('firstName') + ' ' + this.get('lastName')
})
複製代碼

這樣作的缺點就是你不得不本身聲明依賴。當你的計算屬性是一串高開銷的、複雜的函數的運行結果時候,你就知道這的確是個問題了。例如:數組

selectedTransformedList: Ember.computed('story', 'listA', 'listB', 'listC', function() {
  switch (this.story) {
    case 'A':
      return expensiveTransformation(this.listA)
    case 'B':
      return expensiveTransformation(this.listB)
    default:
      return expensiveTransformation(this.listC)
  }
})
複製代碼

在上面的案例中,即使 this.story 老是等於 ’A’,一旦 lists 發生改變,計算屬性也將不得不每次都反覆計算。

依賴追蹤

Vue.js 和 MobX 在解決這個問題上使用了與上文不一樣的方法。不一樣在於,你根本沒必要聲明依賴,由於在計算的時候他們會自動地檢測。假定 this.story = ‘A’,檢測到的依賴會是:

  • this.story
  • this.listA

this.story 變成 ’B’,它將會收集一組新的依賴,並移除那些以前用而如今再也不使用的多餘的依賴(this.listA)。這樣,儘管其餘 lists 發生變化,也不會觸發 selectedTransformedList 的重計算。真聰明!

如今是時候返回來看一看 上一篇文章中的代碼 - JSFiddle,下面的改動將基於這些代碼。

這篇文章中的代碼儘可能寫的簡單,忽略不少完整性檢查和優化。毫不是已經能夠用於生產環境的,僅僅用於教育目的。

咱們來建立一個新的數據模型:

const App = Seer({
  data: {
    // 可觀察的值
    goodCharacter: 'Cloud Strife',
    evilCharacter: 'Sephiroth',
    placeholder: 'Choose your side!',
    side: null,
    // 計算屬性
    selectedCharacter () {
      switch (this.side) {
        case 'Good':
          return `Your character is ${this.goodCharacter}!`
        case 'Evil':
          return `Your character is ${this.evilCharacter}!`
        default:
          return this.placeholder
      }
    },
    // 依賴其餘計算屬性的計算屬性
    selectedCharacterSentenceLength () {
      return this.selectedCharacter.length
    }
  }
})
複製代碼

檢測依賴

爲了找到當前求值計算屬性的依賴,須要一種收集依賴的辦法。如你所知,每一個可觀察屬性是已經轉換成 getter 和 setter 的形式。當對計算屬性(函數)求值的時候,須要用到其餘的屬性,也就是觸發他們的 getters。

例如這個函數:

{
  fullName () {
    return this.firstName + ' ' + this.lastName
  }
}
複製代碼

將會調用 firstName 和 lastName 的 getters。

讓咱們利用一下這一點!

當對計算屬性求值的時候,咱們須要收集 getter 被調用的信息。爲了完成這項工做,首先須要空間存儲當前求值的計算屬性。能夠用這樣的簡單對象:

let Dep = {
  // 當前求值的計算屬性的名字
  target: null
}
複製代碼

咱們過去曾用 makeReactive 函數將原始屬性轉換成可觀察屬性。如今讓咱們爲計算屬性建立一個轉換函數並將它命名爲 makeComputed

function makeComputed (obj, key, computeFunc) {
  Object.defineProperty(obj, key, {
    get () {
      // 若是沒有 target 集合
      if (!Dep.target) {
        // 設置 target 爲當前求值的屬性
        Dep.target = key
      }
      const value = computeFunc.call(obj)
      // 清空 target 上下文
      Dep.target = null
      return value
    },
    set () {
      // Do nothing!
    }
  })
}

// 後面將會用這種方式調用
makeComputed(data, 'fullName', data['fullName'])
複製代碼

Okay!既然上下文能夠獲取了,修改上一篇文章中建立的 makeReactive 函數以便使用獲取到的上下文。

新的 makeReactive 函數像下面這樣:

function makeReactive (obj, key) {
  let val = obj[key]
  // 建立空數組用來存依賴
  let deps = []

  Object.defineProperty(obj, key, {
    get () {
      // 只有在計算屬性上下文中調用的時候纔會執行
      if (Dep.target) {
        // 若是還沒添加,則做爲依賴這個值的計算屬性添加
        if (!deps.includes(Dep.target)) {
          deps.push(Dep.target)
        }
      }
      return val
    },
    set (newVal) {
      val = newVal
      // 若是有依賴於這個值的計算屬性
      if (deps.length) {
        // 通知每一個計算屬性的觀察者
        deps.forEach(notify)
      }
      notify(key)
    }
  })
}
複製代碼

咱們要作的最後一件事就是稍稍改進 observeData 函數,以便對於函數形式的屬性,它運行 makeComputed 而不是 makeReactive

function observeData (obj) {
  for (let key in obj) {
    if (obj.hasOwnProperty(key)) {
      if (typeof obj[key] === 'function') {
        makeComputed(obj, key, obj[key])
      } else {
        makeReactive(obj, key)
      }
    }
  }
  parseDOM(document.body, obj)
}
複製代碼

基本上就是這樣!咱們剛剛經過依賴追蹤建立了咱們本身的計算屬性實現。

不幸的是 —— 上面的實現是很是基礎的,仍然缺乏 Vue.js 和 MobX 中能夠找到的重要的特性。我猜最重要的就是緩存和移除廢棄的依賴。因此咱們把它們添上。

緩存

首先,咱們須要空間存儲緩存。咱們在 makeComputed 函數中添加緩存管理器。

function makeComputed (obj, key, computeFunc) {
  let cache = null

  // 自觀察,當 deps 改變的時候清除緩存
  observe(key, () => {
    // 清空緩存
    cache = null
  })

  Object.defineProperty(obj, key, {
    get () {
      if (!Dep.target) {
        Dep.target = key
      }
      // 當沒有緩存
      if (!cache) {
        // 計算新的值並存入緩存
        cache = computeFunc.call(obj)
      }

      Dep.target = null
      return cache
    },
    set () {
      // Do nothing!
    }
  })
}
複製代碼

就是這樣!如今在初始化計算後,每次讀取計算屬性,它都會返回緩存的值,直到不得不從新計算。至關簡單,是否是?

多虧了 observe 函數,在數據轉換過程當中咱們在 makeComputed 內部使用,確保在其餘信號處理器執行前清空緩存。這意味着,計算屬性的一個依賴發生變化,緩存將被清空,剛恰好在界面更新前完成。

移除沒必要要的依賴

如今剩下的工做就是清理無效的依賴。當計算屬性依賴於不一樣的值的時候一般是一個案例。咱們想達到的效果是計算屬性僅依賴最後使用到的依賴。上面的實如今這方面是有缺陷的,一旦計算屬性登記了依賴於它,它就一直在那了。

可能有更好的方式處理這種狀況,可是由於咱們想保持簡單,咱們來建立第二個依賴列表,來存儲計算屬性的依賴項。 總結來講,咱們的依賴列表:

  • 依賴於這個值(可觀察的或者其餘的計算後的)的計算屬性名列表存儲在本地。能夠這樣想:這些是依賴於個人值。
  • 第二個依賴列表,用來移除廢棄的依賴並存儲計算屬性的最新的依賴。能夠這樣想:這些值是我依賴的。

用這兩列表,咱們能夠運行一個過濾函數來移除無效的依賴。讓咱們首先建立一個存儲第二個依賴列表的對象和一些實用的函數。

let Dep = {
  target: null,
  // 存儲計算屬性的依賴
  subs: {},
  // 在計算屬性和其餘計算後的或者可觀察的值之間建立雙向的依賴關係
  depend (deps, dep) {
    // 若是還沒添加,則添加當前上下文(Dep.target)到本地的 deps,做爲依賴於當前屬性
    if (!deps.includes(this.target)) {
      deps.push(this.target)
    }
    // 若是尚未添加,將當前屬性做爲計算值的依賴加入
    if (!Dep.subs[this.target].includes(dep)) {
      Dep.subs[this.target].push(dep)
    }
  },
  getValidDeps (deps, key) {
    // 經過移除在上一次計算中沒有使用的廢棄依賴,僅僅過濾出有效的依賴
    return deps.filter(dep => this.subs[dep].includes(key))
  },
  notifyDeps (deps) {
    // 通知全部已存在的 deps
    deps.forEach(notify)
  }
}
複製代碼

Dep.depend 函數如今還看不出用處,但咱們待會就會用到它。那時在這裏它的用處會更清楚。

首先,來調整 makeReactive 轉換函數。

function makeReactive (obj, key, computeFunc) {
  let deps = []
  let val = obj[key]

  Object.defineProperty(obj, key, {
    get () {
      // 只有當在計算值的上下文內時才執行
      if (Dep.target) {
        // 將 Dep.target 做爲依賴於這個值添加,這將使 deps 數組發生變化,由於咱們給它傳了一個引用
        Dep.depend(deps, key)
      }

      return val
    },
    set (newVal) {
      val = newVal
      // 清除廢棄依賴
      deps = Dep.getValidDeps(deps, key)
      // 並通知有效的 deps
      Dep.notifyDeps(deps, key)

      notify(key)
    }
  })
}
複製代碼

makeComputed 轉換函數內部也須要作類似的改動。不一樣在於不使用 setter 而是用傳給 observe 函數的信號回調處理器。爲何?由於這個回調不管什麼時候計算的值更新了,也就是依賴改變了,都會被調用。

function makeComputed (obj, key, computeFunc) {
  let cache = null
  // 建立一個本地的 deps 列表,類似於 makeReactive 的 deps
  let deps = []

  observe(key, () => {
    cache = null
    // 清空並通知有效的 deps
    deps = Dep.getValidDeps(deps, key)
    Dep.notifyDeps(deps, key)
  })

  Object.defineProperty(obj, key, {
    get () {
      // 若是若是在其餘計算屬性正在計算的時候計算
      if (Dep.target) {
        // 在這兩個計算屬性之間建立一個依賴關係
        Dep.depend(deps, key)
      }
      // 將 Dep.target 標準化成它本來的樣子,這使得構建一個依賴樹成爲可能,而不是一個扁平化的結構
      Dep.target = key

      if (!cache) {
        // 清空依賴列表以得到一個新的列表
        Dep.subs[key] = []
        cache = computeFunc.call(obj)
      }

      // 清空目標上下文
      Dep.target = null
      return cache
    },
    set () {
      // Do nothing!
    }
  })
}
複製代碼

完成了!你可能已經注意到,它容許計算屬性依賴於其餘計算屬性,不須要知道背後的可觀察的對象。至關不錯,是不?

異步陷阱

既然你知道了依賴追蹤如何工做,在 MobX 和 Vue.js 中不能追蹤計算屬性種的異步數據的緣由就很明顯了。這一切會被打破,由於即便 setTimeout(callback, 0) 將會在當前上下文外被調用,在那裏 Dep.target 再也不存在。這也就意味着在回調函數中不管發生什麼都不會被追蹤到。

紅利:Watchers

然而,上面的問題能夠經過 watchers 部分解決。你可能已經在 Vue.js 中瞭解過它們。在咱們已有的基礎上建立 watchers 真的很容易。畢竟,watcher 是一個給定值發生變化時調用的信號處理器。

咱們只是不得不添加一個 watchers 註冊方法並在 Seer 函數內觸發它。

function subscribeWatchers(watchers, context) {
  for (let key in watchers) {
    if (watchers.hasOwnProperty(key)) {
      // 使用 Function.prototype.bind 來綁定數據模型,做爲咱們信號處理器新的 `this` 上下文
      observe(key, watchers[key].bind(context))
    }
  }
}

subscribeWatchers(config.watch, config.data)
複製代碼

這就是所有了,能夠像這樣用它:

const App = Seer({
  data: {
    goodCharacter: 'Cloud Strife'
  },
  // 這裏能夠忽略 watchers
  watch: {
    // 'goodCharacter' 改變時的 watch
    goodCharacter () {
      // 在控制檯輸出值
      console.log(this.goodCharacter)
    }
  }
}

複製代碼

完整的代碼能夠在下面得到: github.com/shentao/see…

你能夠在線的試玩(僅支持 Opera/Chrome): jsfiddle.net/oyw72Lyy/

總結

我但願大家喜歡這個教程,當使用計算屬性的時候,但願個人解釋很好的闡明瞭 Vue 或 MobX 內部的原理。記住本文提供的實現是至關基礎的,和提到的庫中的實現不是同等水平的。不管如何都不是能夠直接用於生產環境的。

接下來說什麼?

第三部分涵蓋了對嵌套屬性和可觀察數組的支持,我也可能在最後添加從事件中取消訂閱的辦法! :D 至於第四部分,也許是數據流?大家感興趣嗎?

歡迎在評論區隨意反饋意見!

感謝閱讀!


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

相關文章
相關標籤/搜索