詳解 Webpack devtools

語言: CN / TW / HK

最近在開發一個低代碼平台,主要用於運營搭建 H5 活動。這中間涉及到第三方組件的開發,而第三方組件想要接入平台,需要經過我們特定的打包工具來 build。構建之後的組件,會合併成單個的 js 文件,而且代碼會被壓縮會混淆,這個時候如果需要調試,那就會極其痛苦。想要有一個好的調試環境,就要涉及 SourceMap 的輸出,而 Webpack 的 devtools 字段就是用於控制 SourceMap。

SourceMap 原理

在詳細解釋 devtools 配置之前,先看看 SourceMap 的原理。SourceMap 的主要作用就是用來還原代碼,將已經編譯壓縮的代碼,還原成之前的代碼。

下圖左邊代碼為 Webpack 打包之前,右邊為打包之後。

打開 chrome 引入 dist.js ,會發現瀏覽器會自動將壓縮的代碼進行了還原。

那這個 SourceMap 到底是怎麼將右邊的代碼還原成左邊的樣子的呢。我們先看一下 dist.js.map 的結構。

js { // 版本號 "version": 3, // 輸出的文件名 "file": "dist.js", // 輸出代碼與源代碼的映射關係 "mappings": "MAAA,IAAMA,EAAM,CACVC,KAAM,KACNC,OAAQ,KAGV,SAASC,IACPH,EAAIE,QAAU,EAGhB,SAASE,IACPJ,EAAIE,QAAU,EACdG,QAAQC,IAAIN,EAAIC,KAAM,OAGxBE,IACAC,IACAA,IACAD,K", // 原代碼中的一些變量名 "names": [ "dog", "name", "weight", "eat", "call", "console", "log" ], // 源文件列表 // 我們打包的時候經常是多個js文件合併成一個,所以源文件有多個 "sources": [ "webpack:///./src/index.ts" ], // 源文件內容的列表,與sources字段對應 "sourcesContent": [ "const dog = {\n name: '旺財',\n weight: 100\n}\n\nfunction eat() {\n dog.weight += 1\n}\n\nfunction call() {\n dog.weight -= 1\n console.log(dog.name, '汪汪汪')\n}\n\neat()\ncall()\ncall()\neat()" ], }

其他字段應該都好理解,比較難懂的就是 mappings 字段,看着就像是一堆亂碼。這是一串使用 VLQ 進行編碼的字符串,規則比較複雜。我們可以直接在 github 找一個VLQ(http://github.com/Rich-Harris/vlq/blob/master/src/index.js)編碼的庫,對這串字符進行解碼。

```js /* @type {Record} / let char_to_integer = {};

/* @type {Record} / let integer_to_char = {};

'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=' .split('') .forEach(function (char, i) { char_to_integer[char] = i; integer_to_char[i] = char; });

/ @param {string} string */ function decode(string) { / @type {number[]} */ let result = [];

let shift = 0;
let value = 0;

for (let i = 0; i < string.length; i += 1) {
    let integer = char_to_integer[string[i]];

    if (integer === undefined) {
        throw new Error('Invalid character (' + string[i] + ')');
    }

    const has_continuation_bit = integer & 32;

    integer &= 31;
    value += integer << shift;

    if (has_continuation_bit) {
        shift += 5;
    } else {
        const should_negate = value & 1;
        value >>>= 1;

        if (should_negate) {
            result.push(value === 0 ? -0x80000000 : -value);
        } else {
            result.push(value);
        }

        // reset
        value = shift = 0;
    }
}

return result;

} ```

mappings 字符串一般通過分號(;)和逗號(,)進行分隔。每個分號分隔的部分對應壓縮後代碼的每一行。因為上面打包的代碼經過了壓縮,只有一行代碼,所以這個 mappings 中就沒有分號。而通過逗號進行分割的部分表示壓縮後代碼當前行的某一列與源代碼的對應關係。

我們試着通過上面的代碼,對 mappings 的前面一部分進行解碼。

js 'MAAA,IAAMA,EAAM,CACVC,KAAM'.split(',').forEach((str) => { console.log(decode(str)) })

解碼結果如下:

js [ 6, 0, 0, 0 ] // MAAA [ 4, 0, 0, 6, 0 ] // IAAMA [ 2, 0, 0, 6 ] // EAAM [ 1, 0, 1, -10, 1 ] // CACVC [ 5, 0, 0, 6 ] // KAAM

每一串字符都對應五個數字,這個五個數字分別對應下面的含義:

  1. 第一位,表示這個位置壓縮代碼的第幾列(與前面的數字累加獲取)。

  2. 第二位,表示這個位置屬於sources屬性中的哪一個文件。

  3. 第三位,表示這個位置屬於源碼的第幾行(與前面的數字累加獲取)。

  4. 第四位,表示這個位置屬於源碼的第幾列(與前面的數字累加獲取)。
  5. 第五位,表示這個位置屬於names屬性中的哪一個變量。

那麼 MAAA: [ 6, 0, 0, 0 ]: 對應的意思就是,壓縮後代碼的第1行的第7列(PS. 計數都是從0開始,所以數字6對應的應該是第7列,後面的數字同理),對應sources中的第1個文件的第1行的第1列。看代碼能看出,就是表示壓縮後的這個 var 聲明,對應源碼的 const。

在看看 IAAMA: [ 4, 0, 0, 6, 0 ],表示壓縮代碼的第11列(這裏的4,表示從前面已計算的列向後再數4列,也就是第11列),對應源碼第1行的第7列(這裏同理,也是向後數6列),且對應 names 屬性的第1個變量名,也就是 "dog"。這裏對代碼進行了混淆,所以有個 names 字段專門用來記錄壓縮之前的變量名。

簡單翻譯一下前面的解碼結果:

js [ 6, 0, 0, 0 ] // 壓縮代碼的第7列,對應源碼第1行的第1列 [ 4, 0, 0, 6, 0 ] // 壓縮代碼的第11列,對應源碼第1行的第7列,對應names第1個變量("dog") [ 2, 0, 0, 6 ] // 壓縮代碼的第13列,對應源碼第1行的第13列 [ 1, 0, 1, -10, 1 ] // 壓縮代碼的第14列,對應源碼第2行的第3列,對應names第2個變量("name") [ 5, 0, 0, 6 ] // 壓縮代碼的第19列,對應源碼第2行的第9列

可以看到這裏面出現了一個負數,這裏是因為對應關係從源碼的第1行,跳到了第2行,新的一行列數應該從前面開始計算,而列數是按照前面的結果累加的,所以這裏要進行列數的回退,所以出現了一個負數,將列數進行回退。

上面是代碼經過壓縮處理的情況,如果我們只通過webpack進行打包處理,不進行壓縮,生成的 mappings 如下:

可以看到,dist.js 前面5行代碼都是 webpack 生成的 runtime,與源代碼無關,所以 mappings 前面有五個分號(;),表示前 5 行與源碼沒有對應關係,後面的 AAAA,IAAMA,GAAG,GAAG; 才是 dist.js 第六行與源碼的對應關係。

devtools 配置項

在瞭解了 SourceMap 的原理後,在看看 devtools 的配置項。如果看 Webpack 的官方文檔,會發現 devtools 的配置項是一個有十幾行的表格,有點唬人,仔細觀察會發現,devtools 配置以 "source-map" 為基礎,然後加上各種前綴。

格式如下:

js [inline-|hidden-|eval-][nosources-][cheap-[module-]]source-map

不同的配置會生成不同的產物,在 webpack 的 github 倉庫中,有一個專門的demo用於展示不同參數打包後的產物:http://github.com/webpack/webpack/tree/main/examples/source-map

source-map

先看最基礎的配置(devtools: "source-map"),就是單獨生成一個 .map 文件,然後在打包代碼的最後一行加上一個註釋,寫明生成 SourceMap 的路徑,方便瀏覽器讀取。

js //# sourceMappingURL=SourceMap文件路徑

inline-source-map

看名字很容易理解,在前面加上 inline- 屬於內聯的 SourceMap,就是將 SourceMap 的內容進行 base64 轉義,直接放到打包代碼的最後一行。

js //# sourceMappingURL=data:application/json;charset=utf-8;.......

eval/eval-source-map

eval-source-map 會將對應模塊的代碼都放到 eval() 中執行,如果加上了 //# sourceURL=xxx ,瀏覽器會自動將 eval 中的代碼自動放到 sources 中。

eval中的代碼在sources中也能看到

通過 eval 生成代碼的好處,改動了某個模塊,只需要對某個模塊的代碼重新 eval 就可以,可以提升二次編譯的效率。官方文檔也有説明,evalrebuild 的效率基本是最高的。

cheap-source-map/cheap-module-source-map

```js // source-map "mappings": ";;;;;AAAA,IAAMA,GAGL,GAAG;EACFC,IAAI,EAAE,IADJ;EAEFC,MAAM,EAAE;AAFN,CAHJ;;AAQA,SAASC,GAAT,CAAaD,MAAb,EAA6B;EAC3BF,GAAG,CAACE,MAAJ,IAAcA,MAAd;AACD;;AAED,SAASE,IAAT,GAAgB;EACdJ,GAAG,CAACE,MAAJ,IAAc,CAAd;EACAG,OAAO,CAACC,GAAR,CAAYN,GAAG,CAACC,IAAhB,EAAsB,KAAtB;AACD;;AAEDE,GAAG,CAAC,EAAD,CAAH;AACAC,IAAI;AACJA,IAAI;AACJD,GAAG,CAAC,CAAD,CAAH,C"

// cheap-source-map "mappings": ";;;;;AAAA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA" ```

上面是通過 source-mapcheap-source-map 生成的 mappings 的區別,可以看到 cheap-source-map 生成的 mappings 精簡了很多。因為 cheap-source-map 去掉了列信息,可以大幅提高 souremap 生成的效率。

在 webpack 打包的過程中,代碼會經過許多 loader 處理,而 loader 處理的過程中,對應的代碼映射關係可能會發生變化,而 cheap-module-source-map 的作用就是打包後的代碼是與最開始的代碼進行對應的,而不是經過 loader 處理的代碼。

我們先寫一段 typescript 代碼,如下:

``ts const dog: { name: string, weight: number } = { name: '旺財', weight: 100 } function eat(weight: number) { dog.weight += weight } function call() { dog.weight -= 1 console.log(${dog.name}: 汪汪汪`) }

eat(10) call() call() eat(5) ```

先看看直接使用 cheap-source-map 還原出的代碼:

在看看 cheap-module-source-map 進行還原出的代碼:

hidden-source-map

source-map 配置一樣,會單獨生成一個 .map 文件,只是打包代碼的最後沒有與之關聯的註釋,一般生產發佈的時候,將 .map 文件上傳到報錯平台(例如:sentry)。另外,如果配置了多個 loader,可以考慮在上線時,將 devtools 配置成 hidden-cheap-module-source-map

小結

上面介紹了各種配置輸出代碼的特性,每一種都是能排列組合的。比如,在開發環境,為了儘可能的看到未經過 loader 轉化的原代碼,可以配置成 cheap-module-source-map。如果需要進一步提升編譯速度,就可以配置成 eval-cheap-module-source-map。而在發佈上線的時候,就可以將配置調整成 hidden-cheap-module-source-map