資源依賴問題在 bowl 中的一種解決方式

問題

bowl 是一個利用 local storage 進行靜態資源緩存和加載的工具庫,在開發過程當中遇到過一些問題,其中比較典型的是加載多個資源的時候資源之間可能出現相互依賴的狀況,假設有一個基於 Angular 的應用,開發者在構建工具,如 webpack,中構建出了兩個 JS 文件,一個文件包含了項目全部的依賴模塊,好比 Angular, jQuery, lodash 等等,名爲 vendor.js,另外一個 JS 文件則所有是業務相關的代碼,名爲 app.js。顯然,app.js 的加載依賴 vendor.js 的先行加載。若是先加載並執行 app.js 的話,會因爲全局環境中還不存在 Angualr 和 jQuery 這些庫或框架而報錯。javascript

思考

問題描述完了,這種問題實際上也是很常見的問題,但在 bowl 的場景下,須要結合 bowl 的實現原理來進行分析。html

在 bowl 的內部,須要加載的資源分爲幾種類型,一種是存在於和頁面同域下的資源且用戶須要緩存的,它會使用 XMLHttpRequest 發起請求的方式獲取資源內容,另外一種純第三方資源,好比在頁面中直接引用第三方 CDN 域名上的資源,如 jQuery 等都提供了 CDN 的資源鏡像,它屬於跨域資源,沒法用 XMLHttpRequest 的方式獲取,那麼只能退一步使用常規的 HTML 標籤的方式請求數據。另外這裏用 promise 包裝了標籤加載的代碼,在 onload 事件中進行 resolve 操做,將同步的加載過程用異步的方式呈現,目的是和異步請求資源內容的方式保持一致,保證流程可控。第三種和第一種類似,不一樣點在於用戶聲明不須要緩存,這種類型也使用了和第二種相同的加載方式。前端

對於資源間依賴關係的聲明,首先進行的是 API 的設計,這裏採用了比較簡單的方式:java

bowl.add[{
  key: 'vendor',
  url: 'vendor.js'
}, {
  key: 'app',
  url: 'app.js',
  dependencies: ['vendor']
}]

若是 a 資源依賴 b 資源,那麼在 a 資源的 dependencies 屬性中寫入一個數組,裏面包含依賴資源的 key 名便可。webpack

bowl 中執行資源請求並注入的方法是 inject(),那麼在調用這個方法的時候首先要作的就是分析資源的依賴關係,一開始我沒有什麼特別好的想法,爲了鍛鍊一下本身的思惟能力也沒有谷歌什麼現成的解決方案,就在紙上隨手寫寫畫畫:git

不行,太抽象了,換一種方式:github

看起來有點眼熟,原來是典型的有向圖數據結構,想到這個就有思路了(旁邊是歸類的依賴類型,這個稍後說)。web

有向圖

有向圖是圖數據結構的一種,在圖中,分爲兩種數據單元,一種是頂點,另外一種是邊。其中邊又分爲兩種類型,無向的和有向的,只包含無向邊的叫無向圖,包含有向邊的就叫有向圖了。在當前的場景中,資源之間的依賴是單向的,a 依賴 b 但不表明 b 依賴 a,所以有向圖是合適的數據結構。算法

在這裏的有向圖中,每一個資源對應有向圖中的頂點,資源間的依賴關係則是兩個頂點之間的邊了。若是 a 依賴 b,那麼在 a 和 b 之間就有一條由 b 指向 a 的邊。編程

在 JS 中實現一個簡單的有向圖數據結構仍是挺簡單的:

function Graph() {
  this.vertices = {}
}

用一個對象包含全部的頂點,資源的 key 做爲這個頂點的鍵值,每一個頂點還須要有各自的屬性:

  • name: 頂點的名字,對應資源名稱

  • prev: 頂點的入度,這裏表示該資源依賴其餘資源的數量

  • next: 頂點的出度,這裏表示該資源被多少其餘資源依賴

  • adjList: 以該頂點爲起點的邊指向的頂點列表,這裏就是依賴本資源的其餘資源名稱列表

有了這個就能夠實現 addVertexaddEdge 方法了:

Graph.prototype.addVertex = function(v) {
  // 檢測頂點是否已存在
  if (isObject(this.vertices[v])) {
    return
  }
  var newVertex = {
    name: v,
    prev: 0,
    next: 0,
    adjList: []
  }
  this.vertices[v] = newVertex
}

Graph.prototype.addEdge = function(begin, end) {
  // 檢查兩個頂點是否存在
  if (!this.vertices[begin] ||
      !this.vertices[end] ||
       this.vertices[begin].adjList.indexOf(end) > -1) {
    return
  }
  ++this.vertices[begin].next
  this.vertices[begin].adjList.push(end)
  ++this.vertices[end].prev
}

有了這兩個方法,在調用 bowl.inject 的時候能夠根據已添加的資源生成一個描述資源依賴關係的圖數據結構了,舉例以下:

Graph {
  vertices: {
    a: {
      name: 'a',
      next: 0,
      prev: 1,
      adjList: []
    },
    b: {
      name: 'b',
      next: 1,
      prev: 0,
      adjList: ['c']
    },
    c: {
      name: 'c',
      next: 1,
      prev: 1,
      adjList: ['a']
    }
  }
}

分析圖中的環

環在有向圖中表示有向邊構成的環路,兩個頂點之間存在互相指向對方的邊的狀況也稱爲環。在 bowl 中若是出現了環,就表示資源以前出現了循環依賴或相互依賴的狀況。而這種狀況是不該該出現的,若是出現了須要報錯。所以,咱們首先要作的是分析圖中是否存在環。

對於環的檢測,經常使用的算法是深度優先遍歷,例如在 Angular 中注入器檢測循環依賴用的就是這個算法。

實際上,在 bowl 中我使用了另外一種名爲 Kahn 算法的環檢測的算法,它是拓撲排序算法的一種,相比於深度優先遍歷算法來講它比較直觀。它的原理概括起來有三點:

  • 遍歷圖中全部的頂點,將全部入度爲 0 的頂點依次入棧

  • 若是棧非空,則從棧頂取出頂點,刪除該頂點以及以該頂點爲起點的邊,若是刪除的邊的另外一個頂點入度爲 0 了,則把它入棧

  • 最後,若是圖中還存在頂點,則表示圖中有環

這個算法結合業務場景會很好理解,入度爲 0 的頂點表示其對應的資源沒有任何依賴,將頂點和邊刪除後剩下的入度爲 0 的頂點表示只依賴前一個資源的資源,前一個資源加載後,當前資源就能夠加載了,以此類推。最後若是還有頂點被剩下的話,說明可順序加載的資源都加載完了還有沒法加載的資源,這些資源之間必定存在循環依賴的關係。

Kahn 算法寫成代碼以下:

Graph.prototype.hasCycle = function() {
  const cycleTestStack = []
  const vertices = merge({}, this.vertices) // 複製一份數據進行操做
  let popVertex = null

  for (let k in vertices) {
    if (vertices[k].prev === 0) { // 入度爲 0 的資源入棧
      cycleTestStack.push(vertices[k])
    }
  }
  while (cycleTestStack.length > 0) {
    popVertex = cycleTestStack.pop()
    delete vertices[popVertex.name]
    popVertex.adjList.forEach(nextVertex => {
      --vertices[nextVertex].prev
      if (vertices[nextVertex].prev === 0) {
        cycleTestStack.push(vertices[nextVertex])
      }
    })
  }
  return Object.keys(vertices).length > 0
}

計算加載順序

若是圖可以經過環檢測,說明其中的資源不存在循環依賴關係,下一步就是要計算資源的加載順序了。很明顯,這裏要作的是圖的遍歷,上面提到的深度遍歷也是能夠用的,可是這是不是最好的方式呢?

我認爲不是的,假設有依賴關係的資源以下:

a<---b<---c<---d
     ^
     |
     -----e<---f

若是用深度遍從來進行資源加載的話,加載順序將會是 a->b->c->d->e->f,每一個資源順序加載。而這裏 bowl 加載資源的行爲都是被包裝在 promise 中的,請求也能夠併發出去,併發的多個請求只要經過 Promise.all 取到 resolve 的時間點就能夠保證所有加載完成了,因此,較爲理想的加載順序應該是 a->b->[c, e]->[d, f]

要獲得這樣的結果,實際上能夠直接利用 Kahn 算法的思想,每次遍歷過濾出一批沒有依賴未加載的資源,最後獲得一個分批次的加載順序。

要獲得上面提到的分批加載順序,能夠經過如下代碼:

Graph.prototype.getGroup = function() {
  if (this.hasCycle()) { // 有環則報錯
    throw new Error('There are cycles in resource\'s dependency relation')
    return
  }
  const result = []
  const graphCopy = new Graph(this.vertices)
  while (Object.keys(graphCopy.vertices).length) {
    const noPrevVertices = []
    for (let k in graphCopy.vertices) {
      if (graphCopy.vertices[k].prev === 0) {
        noPrevVertices.push(k)
      }
    }
    if (noPrevVertices.length) {
      result.push(noPrevVertices)
      noPrevVertices.forEach(vertex => {
        graphCopy.vertices[vertex].adjList.forEach(next => {
          --graphCopy.vertices[next].prev
        })
        delete graphCopy.vertices[vertex]
      })
    }
  }
  return result
}

固然除了深度優先和 Kahn 算法,廣度優先也是可用的算法,在這幾種算法中,DFS 和 BFS 的時間複雜度都是 O(n^2)(這裏的代碼中使用的能夠當作是一個鄰接矩陣),若是用鄰接鏈表的方式表示圖的話,時間複雜度將會是 O(n+e)。對於 Kahn 算法,時間複雜度明顯是 O(n^2)。既然這裏用了鄰接矩陣的方式,時間複雜度都是同樣的,效率上差異不大。並且在前端資源的加載場景下,不會出現那麼多的資源要去分析,這點差異是能夠忽略的。

多個異步任務的順序執行

經過 getGroup 方法,取得了一個描述加載順序的二維數組:[['a'], ['b'], ['c', 'e'], ['d', 'f']]。下面要作的是加載它們,對於這個數組中的每一個子數組中的資源,它們都是能夠同時加載的,把這塊邏輯抽出來,返回一個 promise 便可:

const batchFetch = (group) => {
  const fetches = []
  group.forEach(item => {
    fetches.push(this.injector.inject(this.ingredients.find(ingredient => ingredient.key === item)))
  })
  return Promise.all(fetches)
}

這段代碼的具體細節就省略了,最後經過一個 Promise.all 返回一個包裝後的 promise,group 中的資源所有加載完成後這個 promise 會被 resolve。

這個時候問題就來了,對於這個二維數組,不能簡單的將每一個子數組都一股腦傳入 batchFetch 方法中,由於傳入 Promise 構造函數中的函數是會當即執行的,然後一個子數組中的資源必需在前一個 batchFetch promise 被 resolve 後才能加載。同時,二維數組的長度也是不定的,更不能窮舉。

這裏就是一個典型的多個 promise 異步任務的場景,每一個異步任務的構建依賴前一個任務的完成狀態。一開始因爲我對異步編程不是特別熟悉,有點想不通,在 bluebird 這個 promise 庫中找到了 Promise.reducePromise.each 這兩個靜態方法是能夠解決問題的,可是對於 bowl 這麼一個小型庫來講,引入一個 bluebird 有點殺雞用牛刀的感受,不太合適。

最終經過查 Promise/A+ 規範以及一些嘗試,找到了一個解決方案,其實很簡單。對於 promise 中的 then 回調函數,它返回的是一個新的 Promise,而每一個 then 中的 onFulfill 回調都會在前一個 Promise resolve 後執行。利用這個特性,只須要遍歷原二維數組,將每一個 batchFetch(group) 放在一個 then 中的 onFulfill 函數中執行並返回便可(由於 batchFetch 的返回值就是一個 promise),有一種惰性執行的感受。

let ret = Promise.resolve() // 強行開啓一個 promise 鏈
resolvedIngredients.forEach(group => {
  ret = ret.then(function() {
    return batchFetch(group)
  })
})
return ret

這樣,最終 ret 被 resolve 的時候,說明全部資源都按順序加載完了。

參考資料:

相關文章
相關標籤/搜索