前端模塊依賴關係分析與應用

Meta

摘要

這篇分享主要的內容是是如何使用 webpack 的 stats 對象進行依賴和編譯速度的解析,該對象包含大量的編譯時信息,我們可以用它生成包含有關於模塊的統計數據的 JSON 文件。這些統計數據不僅可以幫助開發者來分析應用的依賴圖表,還可以優化編譯的速度。

大綱

1、描述開發過程中遇到的問題,以及本文致力提供的解決的問題思路

2、說明 webpack 以及文章中常用到的 module、chunk、bundle 等概念

3、描述 webpack 運行中 module 依賴關係的構建以及 webpack 關鍵對象的職能

4、引出 webpack 的 stats 對象,介紹一下該對象的來源以及包含的信息和如何導出 stats.json 文件

5、如何使用該對象來分析模塊之間的依賴關係以及部分相關工具的使用

觀衆收益

希望大家在聽完此次分享後,對 webpack 的以來構建以及 chunk 和 module 的關係有一定的瞭解,能夠通過 webpack 導出的的 stats.json 文件,檢索出模塊的依賴關係,判斷出組件的修改會影響哪些業務,進行合理的迴歸測試

引言

在日常的開發過程中,經常會遇到的場景是對某一個公共組件進行修改的場景,公共組件可能是在多個地方進行引用,那麼組件的修改可能是會影響多個業務場景的,但是往往會由於剛剛接手項目或者隨着時間的跨度加大,依靠人力找出所有引入該組件的場景是極其困難的,只能不停的搜索組件名,甚至引入該組件的組件,也可能被多個場景引入,可能會導致迴歸測試無法全面覆蓋,進而導致線上問題

解決方案

概念聲明

首先我們要聲明幾個概念,以便接下來的大家的理解:

1、Module: Module 是離散功能塊,相比於完整程序提供了更小的接觸面。精心編寫的模塊提供了可靠的抽象和封裝界限,使得應用程序中每個模塊都具有條理清楚的設計和明確的目的。簡單來說就是沒有被編譯之前的代碼,我們書寫的一個個文件就是一個個的 module。

2、Chunk: 此 webpack 特定術語在內部用於管理捆綁過程。輸出束(bundle)由塊組成,其中有幾種類型(例如 entry 和 child )。通常,塊 直接與 輸出束 (bundle)相對應,但是,有些配置不會產生一對一的關係。簡單來說是通過 webpack 的根據文件引用關係生成 chunk 文件,基本是一個入口文件對應一個 chunk。

3、Bundle: bundle 由許多不同的模塊生成,包含已經經過加載和編譯過程的源文件的最終版本。webpack 處理好 chunk 文件後,生成運行在瀏覽器中的代碼就是bundle,需要注意的是理論上 chunk 和 bundle 一一對應的,但是當你配置了代碼分離、代碼提取等時,一個 chunk 會根據配置生成多個 bundle 文件

依賴關係的建立過程

構建階段從 entry 開始遞歸解析資源與資源的依賴,在 compilation 對象內逐步構建出 module 集合以及 module 之間的依賴關係,核心流程:

說明一下,構建階段從入口文件開始:

  1. 調用 handleModuleCreate ,根據文件類型構建 module 子類

  2. 調用 loader-runner[1] 倉庫的 runLoaders 轉譯 module 內容,通常是從各類資源類型轉譯爲 JavaScript 文本

  3. 調用 acorn[2] 將 JS 文本解析爲 AST

  4. 遍歷 AST,觸發各種鉤子

  5. 在 HarmonyExportDependencyParserPlugin 插件監聽 exportImportSpecifier 鉤子,解讀 JS 文本對應的資源依賴

  6. 調用 module 對象的 addDependency 將依賴對象加入到 module 依賴列表中

  7. AST 遍歷完畢後,調用 module.handleParseResult 處理模塊依賴

  8. 對於 module 新增的依賴,調用 handleModuleCreate ,控制流回到第一步

  9. 所有依賴都解析完畢後,構建階段結束

這個過程中數據流 module => ast => dependences => module ,先轉 AST 再從 AST 找依賴。這就要求 loaders 處理完的最後結果必須是可以被 acorn 處理的標準 JavaScript 語法,比如說對於圖片,需要從圖像二進制轉換成類似於 export default "data:image/png;base64,xxx" 這類 base64 格式或者 export default "http://xxx" 這類 url 格式。

compilation 按這個流程遞歸處理,逐步解析出每個模塊的內容以及 module 依賴關係,後續就可以根據這些內容打包輸出。

示例:層級遞進

假如有如下圖所示的文件依賴樹:其中 index.js 爲 entry 文件,依賴於 a/b 文件;a 依賴於 c/d 文件。初始化編譯環境之後,EntryPlugin 根據 entry 配置找到 index.js 文件,調用 compilation.addEntry 函數觸發構建流程,構建完畢後內部會生成這樣的數據結構:

此時得到 module[index.js] 的內容以及對應的依賴對象 dependence[a.js] 、dependence[b.js] 。OK,這就得到下一步的線索:a.js、b.js,根據上面流程圖的邏輯繼續調用 module[index.js] 的 handleParseResult 函數,繼續處理 a.js、b.js 文件,遞歸上述流程,進一步得到 a、b 模塊:

從 a.js 模塊中又解析到 c.js/d.js 依賴,於是再再繼續調用 module[a.js] 的 handleParseResult ,再再遞歸上述流程:

到這裏解析完所有模塊後,發現沒有更多新的依賴,就可以繼續推進。

從構建流程中我們可以清楚地知道,webpack 如何檢索出所有的依賴,而且它也會把這些處理關係清晰的記錄下來,那我們就要說到 stata 對象了,給大家一張 webpack 的知識體系圖,可以看到核心類 Stats:

Stats 配置

stats:是控制 webpack 如何打印出開發環境或者生產環境的打包結果信息,這些統計信息不僅可以幫助開發者來分析應用的依賴圖表,還可以優化編譯的速度。這個 JSON 文件可以通過以下的命令來生成:

webpack --profile --json > stats.json

stats.json 文件中包含的信息是可以在配置文件的進行配置的,下面是它的的配置項,每一個配置項都有它的默認值:

module.exports={
  ...
  stats: {
  
    // 未定義選項時,stats 選項的備用值(fallback value)(優先級高於 webpack 本地默認值)
    all: undefined,
  
    // 添加資源信息
    assets: true,
  
    // 對資源按指定的字段進行排序
    // 你可以使用 `!field` 來反轉排序。
    assetsSort: "field",
  
    // 添加構建日期和構建時間信息
    builtAt: true,
  
    // 添加緩存(但未構建)模塊的信息
    cached: true,
  
    // 顯示緩存的資源(將其設置爲 `false` 則僅顯示輸出的文件)
    cachedAssets: true,
  
    // 添加 children 信息
    children: true,
  
    // 添加 chunk 信息(設置爲 `false` 能允許較少的冗長輸出)
    chunks: true,
  
    // 將構建模塊信息添加到 chunk 信息
    chunkModules: true,
  
    // 添加 chunk 和 chunk merge 來源的信息
    chunkOrigins: true,
  
    // 按指定的字段,對 chunk 進行排序
    // 你可以使用 `!field` 來反轉排序。默認是按照 `id` 排序。
    chunksSort: "field",
  
    // 用於縮短 request 的上下文目錄
    context: "../src/",
  
    // `webpack --colors` 等同於
    colors: false,
  
    // 顯示每個模塊到入口起點的距離(distance)
    depth: false,
  
    // 通過對應的 bundle 顯示入口起點
    entrypoints: false,
  
    // 添加 --env information
    env: false,
  
    // 添加錯誤信息
    errors: true,
  
    // 添加錯誤的詳細信息(就像解析日誌一樣)
    errorDetails: true,
  
    // 將資源顯示在 stats 中的情況排除
    // 這可以通過 String, RegExp, 獲取 assetName 的函數來實現
    // 並返回一個布爾值或如下所述的數組。
    excludeAssets: "filter" | /filter/ | (assetName) => ... return true|false |
      ["filter"] | [/filter/] | [(assetName) => ... return true|false],
  
    // 將模塊顯示在 stats 中的情況排除
    // 這可以通過 String, RegExp, 獲取 moduleSource 的函數來實現
    // 並返回一個布爾值或如下所述的數組。
    excludeModules: "filter" | /filter/ | (moduleSource) => ... return true|false |
      ["filter"] | [/filter/] | [(moduleSource) => ... return true|false],
  
    // 和 excludeModules 相同
    exclude: "filter" | /filter/ | (moduleSource) => ... return true|false |
      ["filter"] | [/filter/] | [(moduleSource) => ... return true|false],
  
    // 添加 compilation 的哈希值
    hash: true,
  
    // 設置要顯示的模塊的最大數量
    maxModules: 15,
  
    // 添加構建模塊信息
    modules: true,
  
    // 按指定的字段,對模塊進行排序
    // 你可以使用 `!field` 來反轉排序。默認是按照 `id` 排序。
    modulesSort: "field",
  
    // 顯示警告/錯誤的依賴和來源(從 webpack 2.5.0 開始)
    moduleTrace: true,
  
    // 當文件大小超過 `performance.maxAssetSize` 時顯示性能提示
    performance: true,
  
    // 顯示模塊的導出
    providedExports: false,
  
    // 添加 public path 的信息
    publicPath: true,
  
    // 添加模塊被引入的原因
    reasons: true,
  
    // 添加模塊的源碼
    source: true,
  
    // 添加時間信息
    timings: true,
  
    // 顯示哪個模塊導出被用到
    usedExports: false,
  
    // 添加 webpack 版本信息
    version: true,
  
    // 添加警告
    warnings: true,
  
    // 過濾警告顯示(從 webpack 2.4.0 開始),
    // 可以是 String, Regexp, 一個獲取 warning 的函數
    // 並返回一個布爾值或上述組合的數組。第一個匹配到的爲勝(First match wins.)。
    warningsFilter: "filter" | /filter/ | ["filter", /filter/] | (warning) => ... return true|false
  }
};

stats.json 文件

結構 (Structure)

最外層的輸出 JSON 文件比較容易理解,但是其中還是有一小部分嵌套的數據不是那麼容易理解。不過放心,這其中的每一部分都在後面有更詳細的解釋。

{
  "version": "1.4.13", // 用來編譯的 webpack 的版本
  "hash": "11593e3b3ac85436984a", // 編譯使用的 hash
  "time": 2469, // 編譯耗時 (ms)
  "filteredModules": 0, // 當 `exclude`傳入`toJson` 函數時,統計被無視的模塊的數量
  "outputPath": "/", // path to webpack 輸出目錄的 path 路徑
  "assetsByChunkName": {
    // 用作映射的 chunk 的名稱
    "main": "web.js?h=11593e3b3ac85436984a",
    "named-chunk": "named-chunk.web.js",
    "other-chunk": [
      "other-chunk.js",
      "other-chunk.css"
    ]
  },
  "assets": [
    // asset 對象 (asset objects) 的數組
  ],
  "chunks": [
    // chunk 對象 (chunk objects) 的數組
  ],
  "modules": [
    // 模塊對象 (module objects) 的數組
  ],
  "errors": [
    // 錯誤字符串 (error string) 的數組
  ],
  "warnings": [
    // 警告字符串 (warning string) 的數組
  ]
}

Asset 對象 (Asset Objects)

每一個 assets 對象都表示一個編譯出的 output 文件。assets 都會有一個共同的結構:

{
  "chunkNames": [], // 這個 asset 包含的 chunk
  "chunks": [ 10, 6 ], // 這個 asset 包含的 chunk 的 id
  "emitted": true, // 表示這個 asset 是否會讓它輸出到 output 目錄
  "name": "10.web.js", // 輸出的文件名
  "size": 1058 // 文件的大小
}

Chunk 對象 (Chunk Objects)

每一個 chunks 表示一組稱爲 chunk[3] 的模塊。每一個對象都滿足以下的結構。

{
  "entry": true, // 表示這個 chunk 是否包含 webpack 的運行時
  "files": [
    // 一個包含這個 chunk 的文件名的數組
  ],
  "filteredModules": 0, // 見上文的 結構
  "id": 0, // 這個 chunk 的id
  "initial": true, // 表示這個 chunk 是開始就要加載還是 懶加載(lazy-loading)
  "modules": [
    // 模塊對象 (module objects)的數組
    "web.js?h=11593e3b3ac85436984a"
  ],
  "names": [
    // 包含在這個 chunk 內的 chunk 的名字的數組
  ],
  "origins": [
    // 下文詳述
  ],
  "parents": [], // 父 chunk 的 ids
  "rendered": true, // 表示這個 chunk 是否會參與進編譯
  "size": 188057 // chunk 的大小(byte)
}

chunks 對象還會包含一個 來源 (origins) ,來表示每一個 chunk 是從哪裏來的。來源 (origins) 是以下的形式

{

  "loc": "", // 具體是哪行生成了這個chunk

  "module": "(webpack)\test\browsertest\lib\index.web.js", // 模塊的位置

  "moduleId": 0, // 模塊的ID

  "moduleIdentifier": "(webpack)\test\browsertest\lib\index.web.js", // 模塊的地址

  "moduleName": "./lib/index.web.js", // 模塊的相對地址

  "name": "main", // chunk的名稱

  "reasons": [

    // 模塊對象中`reason`的數組

  ]

}

模塊對象 (Module Objects)

每一個在依賴圖表中的模塊都可以表示成以下的形式,這一部分正是我們需要重點關注的,模塊之間的依賴信息都在這一部分,

{

  "assets": [

    // asset對象 (asset objects)的數組

  ],

  "built": true, // 表示這個模塊會參與 Loaders , 解析, 並被編譯

  "cacheable": true, // 表示這個模塊是否會被緩存

  "chunks": [

    // 包含這個模塊的 chunks 的 id

  ],

  "errors": 0, // 處理這個模塊發現的錯誤的數量

  "failed": false, // 編譯是否失敗

  "id": 0, // 這個模塊的ID (類似於 `module.id`)

  "identifier": "(webpack)\test\browsertest\lib\index.web.js", // webpack內部使用的唯一的標識

  "name": "./lib/index.web.js", // 實際文件的地址

  "optional": false, // 每一個對這個模塊的請求都會包裹在 `try... catch` 內 (與ESM無關)

  "prefetched": false, // 表示這個模塊是否會被 prefetched

  "profile": {

    // 有關 `--profile` flag 的這個模塊特有的編譯數據 (ms)

    "building": 73, // 載入和解析

    "dependencies": 242, // 編譯依賴

    "factory": 11 // 解決依賴

  },

  "reasons": [

    // 見下文描述

  ],

  "size": 3593, // 預估模塊的大小 (byte)

  "source": "// Should not break it...\r\nif(typeof...", // 字符串化的輸入

  "warnings": 0 // 處理模塊時警告的數量

}

每一個模塊都包含一個 理由 (reasons) 對象,這個對象描述了這個模塊被加入依賴圖表的理由。每一個 理由 (reasons) 都類似於上文 chunk objects[4] 中的 來源 (origins):

{

  "loc": "33:24-93", // 導致這個被加入依賴圖標的代碼行數

  "module": "./lib/index.web.js", // 所基於模塊的相對地址 context

  "moduleId": 0, // 模塊的 ID

  "moduleIdentifier": "(webpack)\test\browsertest\lib\index.web.js", // 模塊的地址

  "moduleName": "./lib/index.web.js", // 可讀性更好的模塊名稱 (用於 "更好的打印 (pretty-printing)")

  "type": "require.context", // 使用的請求的種類 (type of request)

  "userRequest": "../../cases" // 用來 `import` 或者 `require` 的源字符串

}

錯誤與警告

錯誤 (errors) 和 警告 (warnings) 會包含一個字符串數組。每個字符串包含了信息和棧的追溯:

../cases/parsing/browserify/index.js

Critical dependencies:

2:114-121 This seem to be a pre-built javascript file. Even while this is possible, it's not recommended. Try to require to orginal source to get better results.

 @ ../cases/parsing/browserify/index.js 2:114-121

最佳實踐

給出總結、方法論、套路,讓觀衆有強烈的成長感和獲得感。

總結一下,實現過程如下:

在 webpack.config.js 文件中添加關於 stats 的配置,如:

module.exports={
  ...
  stats:{
    chunkModules: false,
    chunks: false,
    modules: true,
    children: false,
    exclude: [/.*node_modules\/.*/]
  },
}

運行:webpack --config webpack.config.js --profile --json > stats.json

--profile: 提供每一步編譯的具體時間

--json: 將編譯信息以 JSON 文件的類型輸出 wen

這樣就能在項目的根目錄即 webpack.config.js 的同級目錄生成一個 stats.json 文件。

在線分析,可以使我們對構建結果有全方位的分析

Webpack 官方提供了一個可視化分析工具 Webpack Analyse[5],它是一個在線 Web 應用。

打開 Webpack Analyse 鏈接的網頁後,你就會看到一個彈窗提示你上傳 JSON 文件,也就是需要上傳上面講到的 stats.json 文件,如圖:

Webpack Analyse 不會把你選擇的 stats.json 文件發達到服務器,而是在瀏覽器本地解析,你不用擔心自己的代碼爲此而泄露。選擇文件後,你馬上就能如下的效果圖 (可以先使用網頁提供的 Examples 看一下效果):

我們的關注點主要是放在 Module 功能上,這裏會顯示出模塊的依賴關係:

圖中的每一個節點代表一個 module,與之對應的下面列表羅列的是所有的 module,兩者一一對應。當我們需要查找某個 module 的依賴關係,在列表中點擊該 module,就會在圖中標識出前後的依賴,如:

圖中黑色的節點代表當前節點、綠色代表當前 module 依賴的 module、紅色代表當前節點被那些節點依賴。

當然這樣的依賴查詢只能看到前後的 module 關係,當我們修改一個組件時,自然是希望拿到從該節點開始,一直向上檢索,一直查詢到最頂層組件,這樣纔是我們需要的,但是這樣就需要我們手動處理。

手動解析,可以獲取到組件的依賴鏈

我們已經知道上面生成的 stats.json 包含我們需要的依賴信息,那麼當我們需要如果需要反推出我們的測試範圍,那麼我們接下來要做的就是根據這個文件的結構來提取其中的部分數據,構建出我們需要的數據結構。

重點關注的是 module 屬性,以及 module 中每個對象的 reasons 屬性:該屬性包含該組件在哪裏被依賴,那我們的大致想法就是構建一個樹形結構,從目標 module 開始,作爲根節點,遍歷它的 reasons 屬性,查找哪些組件依賴該組件,然後在 module 數組中找到該 module,再遍歷它的 reasons 屬性,直到最後查找到頂層組件。這樣一個樹結構是自下而上生成的,不利於我們查找受影響的組件,所以我們需要遍歷樹結構,獲取它的每一條路徑,就能自上而下獲取到所有的路徑。

const fs = require('fs')
const loadsh=require('loadsh')

fs.readFile('../../nodeServerClient/stats.json', 'utf8' , (err, data) => {
  if (err) {
    console.error(err)
    return
  }
  const fileObj=JSON.parse(data);//將讀取到的文件內容,轉換成JSON對象
  const moduleArr=fileObj.modules;//獲取模塊數據的數組
  const moduleObj={};
  moduleArr.forEach(ele => {    //將數組轉換爲對象的屬性,key值爲數組每一項的nameForCondition屬性,方便之後查找到該module
    moduleObj[ele.nameForCondition]=ele;
  });
 let filename='/Users/bytedance/workSpace/personDir/vscode_workspace/nodeServerClient/src/controller/ser.js'
  const tree=[];
  //從改變的模塊向上檢索出樹形的結構圖
  function createTree(filename,tree){
    //獲取變更的module對象
    /* const targetmodule=moduleObj[Object.keys(moduleObj).filter(ele=>{
        return  ele.includes(filename)||ele===filename;
    })[0]]; */

    const targetmodule=moduleObj[filename];
    if(!targetmodule){
        console.log(`未獲取到:${filename}模塊`);
        return;
    };
    //查看當前的一級中是否已經添加了該module,因爲一個module依賴另一個module時,會在該module的reasons中出現多次
    let isHaveTarget=tree.filter(item=>{
        return item.name===targetmodule.nameForCondition
    });
    if(isHaveTarget&&isHaveTarget.length>0){
        return;
    }
    //將該模塊放到該節點中
    tree.push({
        name:targetmodule.nameForCondition,
        children:[]
    });
    
    if(targetmodule.reasons&&targetmodule.reasons.length>0){
        for(let item of targetmodule.reasons){
            //判斷終止條件
            if(item.type!=='entry' && item.resolvedModuleIdentifier!==tree[tree.length-1].name){
                createTree(item.resolvedModuleIdentifier,tree[tree.length-1].children);
            }    
        }
    }else{
        return;
    }
  }
  //以修改的組件爲根節點創建一個樹形結構數據
  createTree(filename,tree);
  //獲取哪些組件依賴了該組件
  console.log('======',JSON.stringify(tree));
  const pathArr=[];//存放所有路徑的數組
  //打印樹結構的所有路徑組成的數組
  function getTreeAllPath(tree){
      
      function getData(tree,path){
        tree.forEach(ele => {
            if(ele.children&&ele.children.length>0){
                path.push(ele.name);
                getData(ele.children,path);
                path.pop();
            }else{
                path.push(ele.name);
                pathArr.push(loadsh.cloneDeep(path));
                path.pop();
            }
        });
      }
      getData(tree,[]);
  }

  getTreeAllPath(tree);
  //數組的每一項也都是數組,顛倒數組的順序
  pathArr.forEach(item=>{
      item.reverse();
  })
  console.log('++++++',JSON.stringify(pathArr));
})

未來展望

上面所述的都是理論以及實現,未來的實踐方向:1、進行 yarn commit 時,添加是否檢索影響模塊的功能,如果選擇是,則需要根據提交文件,導出影響的相關業務功能,確定合理的測試範圍。2、當在進行 git MR 時,依據合併的文件,導出影響的相關業務功能,進行最後一步的排查,是否有測試遺漏的場景。

參考資料

https://segmentfault.com/a/1190000039956437

https://gitmind.cn/app/doc/fac1c196e29b8f9052239f16cff7d4c7

https://webpack.wuhaolin.cn/4%E4%BC%98%E5%8C%96/4-15%E8%BE%93%E5%87%BA%E5%88%86%E6%9E%90.html

https://www.webpackjs.com/api/stats/#asset-objects

https://webpack.github.io/analyse/

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/wwkrTWazjaPv4LoPiIK9Iw