天天看點

高德JS依賴分析工程及關鍵原理

一、背景

高德 App 進行 Bundle 化後,由于業務的複雜性,Bundle 的數量非常多。而這帶來了一個新的問題——Bundle 之間的依賴關系錯綜複雜,需要進行管控,使 Bundle 之間的依賴保持在架構設計之下。

并且,為了保證 Bundle 能實作獨立運轉,在業務持續疊代的過程中,需要逆向的依賴關系來迅速确定疊代的影響範圍。同時,對于切面 API(即對容器提供的系統 API,類似浏覽器中的 BOM API),也需要确定每個切面 API 的影響範圍以及使用趨勢,來作為修改或下線某個 API 的依據。

以元件庫為例,由于元件會被若幹業務項目所使用,我們對元件的修改會影響這些業務項目。在計劃修改前,需要根據正向的依賴關系(業務依賴元件)來算出逆向的依賴關系——該元件被哪些地方所依賴,進而确定這個元件修改的影響範圍。

比檔案更高的次元,是 Bundle 間的依賴。我們有業務 Bundle,也有公共 Bundle。公共 Bundle 也分為不同層級的 Bundle。

對于公用 Bundle,業務 Bundle 可以依賴它,但公用 Bundle 不能反過來依賴業務 Bundle;同樣的,底層的 Bundle 也禁止依賴上層封裝的 Bundle。我們需要通過依賴分析,來確定這些依賴按照上述規則進行設計。

二、實作關鍵步驟

實作 JS 依賴分析,整個實作過程大緻如下圖所示:

高德JS依賴分析工程及關鍵原理

下面挑一些關鍵步驟來展開介紹。

使用 AST 提取依賴路徑

要做檔案級别的依賴分析,就需要提取每個檔案中的依賴路徑,提取依賴路徑有 2 個方法:

  • 使用正規表達式,優點是友善實作,缺點是難以剔除注釋,靈活度也受限;
  • 先進行詞法分析和文法分析,得到 AST(抽象文法樹)後,周遊每個文法樹節點,此方案的優點是分析精确,缺點是實作起來要比純正則麻煩,如果對應語言沒有提供 parser API(如 Less),那就不好實作。

一般為了保證準确性,能用第 2 個方案的都會用第 2 個方案。

以類 JS(.js、.jsx、.ts、.tsx)檔案為例,我們可以通過 TypeScript 提供的 API ts.createSourceFile 來對類 JS 檔案進行詞法分析和文法分析,得到 AST:

const ast = ts.createSourceFile(
  abPath,
  content,
  ts.ScriptTarget.Latest,
  false,
  SCRIPT_KIND[path.extname(abPath)]
);           

得到 AST 後,就可以開始周遊 AST 找到所有我們需要的依賴路徑了。周遊時,可以通過使用 typeScript 子產品提供的 ts.forEachChild 來周遊一個文法樹節點的所有子節點,進而實作一個周遊函數 walk:

function walk (node: ts.Node) {
  ts.forEachChild(node, walk); // 深度優先周遊

  // 根據不同類型的文法樹節點,進行不同的處理
  // 目的是找到 import、require 和 require.resolve 中的路徑
  // 上面 3 種寫法分為兩類——import 聲明和函數調用表達式
  // 其中函數調用表達式又分為直接調用(require)和屬性調用(require.resolve)
  switch (node.kind) {
    // import 聲明處理
    case ts.SyntaxKind.ImportDeclaration:
      // 省略細節……
      break;

    // 函數調用表達式處理
    case ts.SyntaxKind.CallExpression:
      // 省略細節
      break;
  }
}           

通過這種方式,我們就可以精确地找到類 JS 檔案中所有直接引用的依賴檔案了。

當然了,在 case 具體實作中,除了使用者顯式地寫依賴路徑的情況,使用者還有可能通過變量的方式動态地進行依賴加載,這種情況就需要進行基于上下文的語義分析,使得一些常量可以替換成字元串。

但并不是所有的動态依賴都有辦法提取到,比如如果這個動态依賴路徑是 Ajax 傳回的,那就沒有辦法了。不過無需過度考慮這些情況,直接寫字元串字面量的方式已經能滿足絕大多數場景了,之後計劃通過流程管控+編譯器檢驗對這類寫法進行限制,同時在運作時進行收集報警,要求必需顯式引用,以 100% 確定對切面 API 的引用是可以被靜态分析的。

建立檔案地圖進行尋路

我們對于依賴路徑的寫法,有一套自己的規則:

  • 引用類 JS 檔案支援不寫擴充名;
  • 引用本 Bundle 檔案,可直接隻寫檔案名;
  • 使用相對路徑;
  • 引用公用 Bundle 檔案,通過 @${bundleName}/${fileName} 的方式引用,fileName 同樣是直接隻寫該 Bundle 内的檔案名。

這些方式要比 CommonJS 或 ECMAScript Module 的規劃要稍複雜一些,尤其是「直接隻寫檔案名」這個規則。對于我們來說,需要找到這個檔案對應的真實路徑,才能繼續進行依賴分析。

要實作這個,做法是先建構一個檔案地圖,其資料結構為 { [fileName]: ‘relative/path/to/file’ } 。我使用了 glob 來得到整個 Bundle 目錄下的所有檔案樹節點,篩選出所有檔案節點,将檔案名作為 key,相對于 Bundle 根目錄的路徑作為 value,生成檔案地圖。在使用時,「直接隻寫檔案名」的情況就可以直接根據檔案名以 O(1) 的時間複雜度找到對應的相對路徑。

此外,對于「引用類 JS 檔案支援不寫擴充名」這個規則,需要周遊每個可能的擴充名,對路徑進行補充後查找對應路徑,複雜度會高一些。

依賴是圖的關系,需先建節點後建關系

在最開始實作依賴關系時,由于作為前端的慣性思維,會認為「一個檔案依賴另一些檔案」是一個樹的關系,在資料結構上就會自然地使用類似檔案樹中 children: Node[] 的方式——鍊式樹結構。而實際上,依賴是會出現這種情況的:

高德JS依賴分析工程及關鍵原理

如果使用樹的方式來維護,那麼 utils.js 節點就會分别出現在 page.jsx 和 comp.jsx 的 children 中,出現備援資料,在實際項目中這種情況會非常多。

但如果僅僅是體積的問題,可能還沒那麼嚴重,頂多費點空間成本。但我們又會發現,檔案依賴還會出現這種循環依賴情況:

高德JS依賴分析工程及關鍵原理

寫 TypeScript 時在進行類型聲明的時候,就經常會有這樣循環依賴的情況。甚至兩個檔案之間也會循環依賴。這是合理的寫法。

但是,這種寫法對于直接使用鍊式樹結構來說,如果建立鍊式樹的算法是「在建立節點時,先建立子節點,待子節點建立傳回後再完成自身的建立」的話,就不可能實作了,因為我們會發現,假如這樣寫就會出現無限依賴:

const fooTs = new Node({
  name: 'foo.ts',
  children: [
    new Node({ 
      name: 'bar.ts', 
      children: [
        new Node({
          name: 'baz.ts',
          children: [
            new Node({
              name: 'foo.ts', // 和最頂的 foo.ts 是同一個
              children: [...] // 無限循環……
            })
          ]
        })
      ]
    })
  ]
})           

此問題的根本原因是,這個關系是圖的關系,而不是樹的關系,是以在建立這個資料結構時,不能使用「在建立節點時,先建立子節點,待子節點建立傳回後再完成自身的建立」算法,必須把思路切換回圖的思路——先建立節點,再建立關系。

采用這種做法後,就相當于使用的是圖的鄰接連結清單結構了。我們來看看換成「先建立節點,再建立關系」後的寫法:

// 先建立各節點,并且将 children 置為空數組
const fooTs = new Node({
  name: 'foo.ts',
  children: []
});

const barTs = new Node({
  name: 'bar.ts',
  children: []
});

const bazTs = new Node({
  name: 'baz.ts',
  children: []
});


// 然後再建立關系
fooTs.children.push(barTs);
barTs.children.push(bazTs);
bazTs.children.push(fooTs);           

使用這種寫法,就可以完成圖的建立了。

但是,這種資料結構隻能存在于記憶體當中,無法進行序列化,因為它是循環引用的。而無法進行序列化就意味着無法進行儲存或傳輸,隻能在自己程序裡玩這樣子,這顯然是不行的。

是以還需要對資料結構進行改造,将鄰接連結清單中的引用換成子指針表,也就是為每個節點添加一個索引,在 children 裡使用索引來進行對應:

const graph = {
  nodes: [
    { id: 0, name: 'foo.ts', children: [1] },
    { id: 1, name: 'bar.ts', children: [2] },
    { id: 2, name: 'baz.ts', children: [0] }
  ]
}           

這裡會有同學問:為什麼我們不直接用 nodes 的下标,而要再添加一個跟下标數字一樣的 id 字段?原因很簡單,因為下标是依賴數組本身的順序的,如果一旦打亂了這個順序——比如使用 filter 過濾出一部分節點出來,那這些下标就會發生變化。而添加一個 id 字段看起來有點備援,但卻為後面的算法降低了很多複雜度,更加具備可擴充性。

用棧來解決循環引用(有環有向圖)的問題

當我們需要使用上面生成的這個依賴關系資料時,如果需要進行 DFS(深度周遊)或 BFS(廣度周遊)算法進行周遊,就會發現由于這個依賴關系是循環依賴的,是以這些遞歸周遊算法是會死循環的。要解決這個問題很簡單,有三個辦法:

  • 在已有圖上添加一個字段來進行标記

    每次進入周遊一個新節點時,先檢查之前是否周遊過。但這種做法會污染這個圖。

  • 建立一個新的同樣依賴關系的圖,在這個新圖中進行标記

    這種做法雖然能實作,但比較麻煩,也浪費空間。

  • 使用棧來記錄周遊路徑

    我們建立一個數組作為棧,用以下規則執行:

每周遊一個節點,就往棧裡壓入新節點的索引(push);

每從一個節點中傳回,則移除棧中的頂部索引(pop);

每次進入新節點前,先檢測這個索引值是否已經在棧中存在(使用 includes),若存在則回退。

這種方式适用于 DFS 算法。

三、總結

依賴關系是源代碼的另一種表達方式,也是把控巨型項目品質極為有利的工具。我們可以利用依賴關系挖掘出無數的想象空間,比如無用檔案查找、版本間變動測試範圍精确化等場景。若結合 Android、iOS、C++ 等底層依賴關系,就可以計算出更多的分析結果。

目前,依賴關系掃描工程是疊代式進行的,我們采用靈活開發模式,從一些簡單、粗略的 Bundle 級依賴關系,逐漸精确化到檔案級甚至辨別符級,在落地的過程中根據不同的精确度來逐漸滿足對精度要求不同的需求,使得整個過程都可獲得不同程度的收益和回報,驅使我們不斷持續疊代和優化。

繼續閱讀