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

IridescentMia發表於2017-03-31

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

Hey!如果你用過 Vue.js、Ember 或 MobX,我敢肯定你被 計算 屬性難倒過。計算屬性允許你建立像正常的值一樣使用的函式,但是一旦完成計算,他們就被快取下來直到它的一個依賴發生改變。總的來說,這一概念與 getters 非常相似,實際上下面的實現也將會用到 getters。只不過實現的方式更加聰明一點。 ;)

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

計算屬性

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

在 Vue.js 中這樣的計算值可以像下面這樣建立:

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

現在如果在模板中使用 fullName,我們希望它能隨著 firstNamelastName 的改變而更新。如果你有使用 AngularJS 的背景,你可能還記得在模板或者函式呼叫內使用表示式。當然了,使用渲染函式(不管用不用 JSX)的時候和這裡是一樣的;其實這無關緊要。

來看一下下面的例子:

<!-- 表示式 -->
<h1>{{ firstName + ' ' + lastName }}</h1>
<!-- 函式呼叫 -->
<h2>{{ getFullName() }}</h2>
<!-- 計算屬性 -->
<h3>{{ fullName }}</h3>
複製程式碼

上面程式碼的執行結果幾乎是一樣的。每次 firstNamelastName 發生變化,檢視將會更新這些 <h> 並且顯示出全名。

然而,如果多次使用表示式、函式呼叫和計算屬性呢?使用表示式和函式呼叫每次都會計算一遍,而計算屬性在第一次計算後將會快取下來,直到它的依賴發生改變。它也會在重新渲染的週期中一直保持!如果考慮在基於事件模型的現代使用者介面中,很難預測使用者會首先執行哪項操作,那麼這確實是一個最優化方案。

基礎的計算屬性

在前面文章中,我們學習瞭如何通過使用事件發射器追蹤和響應可觀察物件屬性內的改變。我們知道當改變 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前端後端產品設計 等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃

相關文章