高德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 依賴分析,整個實現過程大致如下圖所示:
下面挑一些關鍵步驟來展開介紹。
使用 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[] 的方式——鏈式樹結構。而實際上,依賴是會出現這種情況的:
如果使用樹的方式來維護,那麼 utils.js 節點就會分別出現在 page.jsx 和 comp.jsx 的 children 中,出現冗餘資料,在實際專案中這種情況會非常多。
但如果僅僅是體積的問題,可能還沒那麼嚴重,頂多費點空間成本。但我們又會發現,檔案依賴還會出現這種迴圈依賴情況:
寫 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 級依賴關係,逐漸精確化到檔案級甚至識別符號級,在落地的過程中根據不同的精確度來逐漸滿足對精度要求不同的需求,使得整個過程都可獲得不同程度的收益和反饋,驅使我們不斷持續迭代和優化。