一、背景 高德 App 進(jìn)行 Bundle 化后,由于業(yè)務(wù)的復(fù)雜性,Bundle 的數(shù)量非常多。而這帶來了一個新的問題——Bundle 之間的依賴關(guān)系錯綜復(fù)雜,需要進(jìn)行管控,使 Bundle 之間的依賴保持在架構(gòu)設(shè)計之下。 并且,為了保證 Bundle 能實現(xiàn)獨立運轉(zhuǎn),在業(yè)務(wù)持續(xù)迭代的過程中,需要逆向的依賴關(guān)系來迅速確定迭代的影響范圍。同時,對于切面 API(即對容器提供的系統(tǒng) API,類似瀏覽器中的 BOM API),也需要確定每個切面 API 的影響范圍以及使用趨勢,來作為修改或下線某個 API 的依據(jù)。 以組件庫為例,由于組件會被若干業(yè)務(wù)項目所使用,我們對組件的修改會影響這些業(yè)務(wù)項目。在計劃修改前,需要根據(jù)正向的依賴關(guān)系(業(yè)務(wù)依賴組件)來算出逆向的依賴關(guān)系——該組件被哪些地方所依賴,從而確定這個組件修改的影響范圍。 比文件更高的維度,是 Bundle 間的依賴。我們有業(yè)務(wù) Bundle,也有公共 Bundle。公共 Bundle 也分為不同層級的 Bundle。 對于公用 Bundle,業(yè)務(wù) Bundle 可以依賴它,但公用 Bundle 不能反過來依賴業(yè)務(wù) Bundle;同樣的,底層的 Bundle 也禁止依賴上層封裝的 Bundle。我們需要通過依賴分析,來確保這些依賴按照上述規(guī)則進(jìn)行設(shè)計。 二、實現(xiàn)關(guān)鍵步驟 實現(xiàn) JS 依賴分析,整個實現(xiàn)過程大致如下圖所示: 下面挑一些關(guān)鍵步驟來展開介紹。 使用 AST 提取依賴路徑 要做文件級別的依賴分析,就需要提取每個文件中的依賴路徑,提取依賴路徑有 2 個方法:
一般為了保證準(zhǔn)確性,能用第 2 個方案的都會用第 2 個方案。 以類 JS(.js、.jsx、.ts、.tsx)文件為例,我們可以通過 TypeScript 提供的 API ts.createSourceFile 來對類 JS 文件進(jìn)行詞法分析和語法分析,得到 AST: const ast = ts.createSourceFile( abPath, content, ts.ScriptTarget.Latest, false, SCRIPT_KIND[path.extname(abPath)] ); 得到 AST 后,就可以開始遍歷 AST 找到所有我們需要的依賴路徑了。遍歷時,可以通過使用 typeScript 模塊提供的 ts.forEachChild 來遍歷一個語法樹節(jié)點的所有子節(jié)點,從而實現(xiàn)一個遍歷函數(shù) walk: function walk (node: ts.Node) { ts.forEachChild(node, walk); // 深度優(yōu)先遍歷 // 根據(jù)不同類型的語法樹節(jié)點,進(jìn)行不同的處理 // 目的是找到 import、require 和 require.resolve 中的路徑 // 上面 3 種寫法分為兩類——import 聲明和函數(shù)調(diào)用表達(dá)式 // 其中函數(shù)調(diào)用表達(dá)式又分為直接調(diào)用(require)和屬性調(diào)用(require.resolve) switch (node.kind) { // import 聲明處理 case ts.SyntaxKind.ImportDeclaration: // 省略細(xì)節(jié)…… break; // 函數(shù)調(diào)用表達(dá)式處理 case ts.SyntaxKind.CallExpression: // 省略細(xì)節(jié) break; } } 通過這種方式,我們就可以精確地找到類 JS 文件中所有直接引用的依賴文件了。 當(dāng)然了,在 case 具體實現(xiàn)中,除了用戶顯式地寫依賴路徑的情況,用戶還有可能通過變量的方式動態(tài)地進(jìn)行依賴加載,這種情況就需要進(jìn)行基于上下文的語義分析,使得一些常量可以替換成字符串。 但并不是所有的動態(tài)依賴都有辦法提取到,比如如果這個動態(tài)依賴路徑是 Ajax 返回的,那就沒有辦法了。不過無需過度考慮這些情況,直接寫字符串字面量的方式已經(jīng)能滿足絕大多數(shù)場景了,之后計劃通過流程管控+編譯器檢驗對這類寫法進(jìn)行限制,同時在運行時進(jìn)行收集報警,要求必需顯式引用,以 100% 確保對切面 API 的引用是可以被靜態(tài)分析的。 建立文件地圖進(jìn)行尋路 我們對于依賴路徑的寫法,有一套自己的規(guī)則: 引用類 JS 文件支持不寫擴(kuò)展名; 引用本 Bundle 文件,可直接只寫文件名; 使用相對路徑; 引用公用 Bundle 文件,通過 @${bundleName}/${fileName} 的方式引用,fileName 同樣是直接只寫該 Bundle 內(nèi)的文件名。 這些方式要比 CommonJS 或 ECMAScript Module 的規(guī)劃要稍復(fù)雜一些,尤其是「直接只寫文件名」這個規(guī)則。對于我們來說,需要找到這個文件對應(yīng)的真實路徑,才能繼續(xù)進(jìn)行依賴分析。 要實現(xiàn)這個,做法是先構(gòu)建一個文件地圖,其數(shù)據(jù)結(jié)構(gòu)為 { [fileName]: 'relative/path/to/file’ } 。我使用了 glob 來得到整個 Bundle 目錄下的所有文件樹節(jié)點,篩選出所有文件節(jié)點,將文件名作為 key,相對于 Bundle 根目錄的路徑作為 value,生成文件地圖。在使用時,「直接只寫文件名」的情況就可以直接根據(jù)文件名以 O(1) 的時間復(fù)雜度找到對應(yīng)的相對路徑。 此外,對于「引用類 JS 文件支持不寫擴(kuò)展名」這個規(guī)則,需要遍歷每個可能的擴(kuò)展名,對路徑進(jìn)行補(bǔ)充后查找對應(yīng)路徑,復(fù)雜度會高一些。 依賴是圖的關(guān)系,需先建節(jié)點后建關(guān)系 在最開始實現(xiàn)依賴關(guān)系時,由于作為前端的慣性思維,會認(rèn)為「一個文件依賴另一些文件」是一個樹的關(guān)系,在數(shù)據(jù)結(jié)構(gòu)上就會自然地使用類似文件樹中 children: Node[] 的方式——鏈?zhǔn)綐浣Y(jié)構(gòu)。而實際上,依賴是會出現(xiàn)這種情況的: 如果使用樹的方式來維護(hù),那么 utils.js 節(jié)點就會分別出現(xiàn)在 page.jsx 和 comp.jsx 的 children 中,出現(xiàn)冗余數(shù)據(jù),在實際項目中這種情況會非常多。 但如果僅僅是體積的問題,可能還沒那么嚴(yán)重,頂多費點空間成本。但我們又會發(fā)現(xiàn),文件依賴還會出現(xiàn)這種循環(huán)依賴情況: 寫 TypeScript 時在進(jìn)行類型聲明的時候,就經(jīng)常會有這樣循環(huán)依賴的情況。甚至兩個文件之間也會循環(huán)依賴。這是合理的寫法。 但是,這種寫法對于直接使用鏈?zhǔn)綐浣Y(jié)構(gòu)來說,如果創(chuàng)建鏈?zhǔn)綐涞乃惴ㄊ恰冈趧?chuàng)建節(jié)點時,先創(chuàng)建子節(jié)點,待子節(jié)點創(chuàng)建返回后再完成自身的創(chuàng)建」的話,就不可能實現(xiàn)了,因為我們會發(fā)現(xiàn),假如這樣寫就會出現(xiàn)無限依賴: 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', // 和最頂?shù)?foo.ts 是同一個 children: [...] // 無限循環(huán)…… }) ] }) ] }) ] }) 此問題的根本原因是,這個關(guān)系是圖的關(guān)系,而不是樹的關(guān)系,所以在創(chuàng)建這個數(shù)據(jù)結(jié)構(gòu)時,不能使用「在創(chuàng)建節(jié)點時,先創(chuàng)建子節(jié)點,待子節(jié)點創(chuàng)建返回后再完成自身的創(chuàng)建」算法,必須把思路切換回圖的思路——先創(chuàng)建節(jié)點,再創(chuàng)建關(guān)系。 采用這種做法后,就相當(dāng)于使用的是圖的鄰接鏈表結(jié)構(gòu)了。我們來看看換成「先創(chuàng)建節(jié)點,再創(chuàng)建關(guān)系」后的寫法: // 先創(chuàng)建各節(jié)點,并且將 children 置為空數(shù)組 const fooTs = new Node({ name: 'foo.ts', children: [] }); const barTs = new Node({ name: 'bar.ts', children: [] }); const bazTs = new Node({ name: 'baz.ts', children: [] }); // 然后再創(chuàng)建關(guān)系 fooTs.children.push(barTs); barTs.children.push(bazTs); bazTs.children.push(fooTs); 使用這種寫法,就可以完成圖的創(chuàng)建了。 但是,這種數(shù)據(jù)結(jié)構(gòu)只能存在于內(nèi)存當(dāng)中,無法進(jìn)行序列化,因為它是循環(huán)引用的。而無法進(jìn)行序列化就意味著無法進(jìn)行儲存或傳輸,只能在自己進(jìn)程里玩這樣子,這顯然是不行的。 所以還需要對數(shù)據(jù)結(jié)構(gòu)進(jìn)行改造,將鄰接鏈表中的引用換成子指針表,也就是為每個節(jié)點添加一個索引,在 children 里使用索引來進(jìn)行對應(yīng): const graph = { nodes: [ { id: 0, name: 'foo.ts', children: [1] }, { id: 1, name: 'bar.ts', children: [2] }, { id: 2, name: 'baz.ts', children: [0] } ] } 這里會有同學(xué)問:為什么我們不直接用 nodes 的下標(biāo),而要再添加一個跟下標(biāo)數(shù)字一樣的 id 字段?原因很簡單,因為下標(biāo)是依賴數(shù)組本身的順序的,如果一旦打亂了這個順序——比如使用 filter 過濾出一部分節(jié)點出來,那這些下標(biāo)就會發(fā)生變化。而添加一個 id 字段看起來有點冗余,但卻為后面的算法降低了很多復(fù)雜度,更加具備可擴(kuò)展性。 用棧來解決循環(huán)引用(有環(huán)有向圖)的問題 當(dāng)我們需要使用上面生成的這個依賴關(guān)系數(shù)據(jù)時,如果需要進(jìn)行 DFS(深度遍歷)或 BFS(廣度遍歷)算法進(jìn)行遍歷,就會發(fā)現(xiàn)由于這個依賴關(guān)系是循環(huán)依賴的,所以這些遞歸遍歷算法是會死循環(huán)的。要解決這個問題很簡單,有三個辦法:
每次進(jìn)入遍歷一個新節(jié)點時,先檢查之前是否遍歷過。但這種做法會污染這個圖。
這種做法雖然能實現(xiàn),但比較麻煩,也浪費空間。
我們創(chuàng)建一個數(shù)組作為棧,用以下規(guī)則執(zhí)行: 每遍歷一個節(jié)點,就往棧里壓入新節(jié)點的索引(push); 每從一個節(jié)點中返回,則移除棧中的頂部索引(pop); 每次進(jìn)入新節(jié)點前,先檢測這個索引值是否已經(jīng)在棧中存在(使用 includes),若存在則回退。 這種方式適用于 DFS 算法。 三、總結(jié) 依賴關(guān)系是源代碼的另一種表達(dá)方式,也是把控巨型項目質(zhì)量極為有利的工具。我們可以利用依賴關(guān)系挖掘出無數(shù)的想象空間,比如無用文件查找、版本間變動測試范圍精確化等場景。若結(jié)合 Android、iOS、C++ 等底層依賴關(guān)系,就可以計算出更多的分析結(jié)果。 目前,依賴關(guān)系掃描工程是迭代式進(jìn)行的,我們采用敏捷開發(fā)模式,從一些簡單、粗略的 Bundle 級依賴關(guān)系,逐漸精確化到文件級甚至標(biāo)識符級,在落地的過程中根據(jù)不同的精確度來逐漸滿足對精度要求不同的需求,使得整個過程都可獲得不同程度的收益和反饋,驅(qū)使我們不斷持續(xù)迭代和優(yōu)化。
|
|