一、什么是 Sourcemap
Sourcemap 協議最初由 Google 設計并率先在 Closure Inspector 實現,它能夠將經過壓縮、混淆、合并的代碼還原回未打包狀態,幫助開發者在生產環境中精確定位問題發生的行列位置。
發展至今,Sourcemap 已廣泛受 Webpack、Rollup、Babel、Less、Typescript、Chrome、Safari、VS Code 等工具支持。
參考:https://docs.google.com/document/d/1U1RGAehQwRypUTovF1KRlpiOFze0b-_2gc6fAH0KY0k
實現上,Sourcemap 由三部分組成:
- 開發者編寫的原始代碼
- 經過 Webpack、Rollup 等工程化工具壓縮、轉化、合并后的產物,且產物中必須包含指向 Sourcemap 文件地址的 //# sourceMappingURL=https://xxxx/bundle.js.map 指令
- 記錄原始代碼與經過工程化處理代碼之間位置映射關系 Map 文件
頁面初始運行時只會加載編譯構建產物,直到特定事件發生 —— 例如在 Chrome 打開 Devtool 面板時,才會根據 //# sourceMappingURL 內容自動加載 Map 文件,并按 Sourcemap 協議約定的映射規則將代碼重構還原回原始形態,這既能保證終端用戶的性能體驗,又能幫助開發者快速還原現場,提升線上問題的定位與調試效率。
1.1 示例
以 Webpack 為例,設置 devtool = 'source-map' 即可同時打包出代碼產物 xxx.js 文件與同名 xxx.js.map 文件,Map 文件通常為 JSON 格式,內容如:
- {
- "version": 3,
- "sources": [
- "webpack:///./src/index.js"
- ],
- "names": ["name", "console", "log"],
- "mappings": ";;;;;AAAA,IAAMA,IAAI,GAAG,QAAb;AAEAC,OAAO,CAACC,GAAR,CAAYF,IAAZ,E",
- "file": "main.js",
- "sourcesContent": [
- "const name = 'tecvan';\n\nconsole.log(name)"
- ],
- "sourceRoot": ""
- }
各字段含義分別為:
- version:指代 sourcemap 版本,目前最新版本為 3names:字符串數組,記錄原始代碼中出現的變量名
- file:字符串,該 Sourcemap 文件對應的編譯產物文件名
- sourcesContent:字符串數組,原始代碼的內容
- sourceRoot:字符串,源文件根目錄
- sources:字符串數組,原始文件路徑名,與 sourcesContent 內容一一對應
- mappings:字符串數組,記錄打包產物與原始代碼的位置映射關系
使用時,瀏覽器會按照 mappings 記錄的數值關系,將產物代碼映射回 sourcesContent 數組所記錄的原始代碼文件、行、列位置,這里面最復雜難懂的點就在于 mappings 字段的規則。
1.2 源碼映射與 VLQ
Sourcemap 最初版本生成的 .map 文件非常大,體積大概為編譯產物的 10 倍;V2 引入 base64 編碼等算法將之減少 20% ~ 30%;而最新版本 V3 又在 V2 基礎上引入 VLQ 等算法,體積進一步壓縮了 50%。這一系列進化造就了一個效率極高的 Sourcemap 體系,但伴隨而來的則是較為復雜的 mappings 編碼規則。
1.2.1 mappings 編碼規則
舉個例子,對于下面的代碼:
當 devtool = 'source-map' 時,Webpack 生成的 mappings 字段為:
- ;;;;;AAAA,IAAMA,IAAI,GAAG,QAAb;AAEAC,OAAO,CAACC,GAAR,CAAYF,IAAZ,E
字段內容包含三層結構:
- 以 ; 分割的「行映射」,每一個 ; 對應編譯產物每一行到源碼的映射,上例經過分割后:
- [
- // 產物第 1-5 行內容為 Webpack 生成的 runtime,不需要記錄映射關系
- '', '', '', '', '',
- // 產物第 6 行的映射信息
- 'AAAA,IAAMA,IAAI,GAAG,QAAb',
- // 產物第 7 行的映射信息
- 'AAEAC,OAAO,CAACC,GAAR,CAAYF,IAAZ,E'
- ]
- 以 , 分割的「片段映射」,每一個 , 對應該行中每一個代碼片段到源碼的映射,上例經過分割后:
- [
- // 產物第 1-5 行內容為 Webpack 生成的 runtime,不需要記錄映射關系
- '', '', '', '', '',
- // 產物第 6 行的映射信息
- [
- // 片段 `var` 到 `const` 的映射
- 'AAAA',
- // 片段 `name` 到 `name` 的映射
- 'IAAMA',
- // 等等
- 'IAAI', 'GAAG', 'QAAb'],
- // 產物第 7 行的映射信息
- ['AAEAC', 'OAAO', 'CAACC', 'GAAR', 'CAAYF', 'IAAZ', 'E']
- ]
第三層邏輯為片段映射到源碼的具體位置,以上例 IAAMA 為例:
- 第一位 I 該代碼片段在產物中列數
- 第二位 A 代表源碼文件的索引,即該片段對標到 sources 數組的元素下標
- 第三位 A 代表片段在源碼文件的行數
- 第四位 M 代表片段在源碼文件的列數
- 第五位 A 代表該片段對應的名稱索引,即該片段對標到 names 數組的元素下標
上述第1、2層邏輯比較簡單,唯一需要注意的是片段之間是一種相對偏移關系,例如對于上例第六行映射值:AAAA,IAAMA,IAAI,GAAG,QAAb,每一個片段的第一位 —— 即片段列數為 A,I,I,G,Q,分別代表:
- A :第 A 列
- I :第 A + I 列
- I :第 A + I + I 列
- G :第 A + I + I + G 列
- Q :第 A + I + I + G + Q 列
這種相對偏移能減少 Sourcemap 產物的體積,提升整體性能。
而第三層的片段位置映射則用到了一種比較高效數值編碼算法 —— VLQ(Variable-length Quantity)。
1.2.2 VLQ編碼
參考:https://en.wikipedia.org/wiki/Variable-lengsth_quantity
VLQ 本質上是一種將整數數值轉換為 Base64 的編碼算法,它先將任意大的整數轉換為一系列六位字節碼,再按 Base64 規則轉換為一串可見字符。VLQ 使用六位比特存儲一個編碼分組,例如:
數字 7 經過 VLQ 編碼后,結果為 001110,其中:
- 第一位為連續標志位,標識后續分組是否為同一數字;
- 第六位表示該數字的正負符號,0為正整數,1為負整數;
- 中間第 2-5 為實際數值。
這樣一個六位編碼分組,就可以按照 Base64 的映射規則轉換為 ABC 等可見字符,例如上述數字 7 編碼結果 001110,等于十進制的 14,按 Base64 字碼表可映射為字母 O。
但是,分組中只有中間的 4 個字節用于表示數值,因此單個分組只能表達 「-15 ~ 15」 之間的數值范圍,對于超過這個范圍的整數需要組合多個分組共同表達同一數字,組合規則:
- 第一個分組的最后一位為符號位,其它分組從 2-6 均為數值位
- 取二進制值最后四位為第一個分組值,之后從后到前,每 5 位為一個劃分為一個分組
- 除最后一個分組外,其余分組的連續標志位都設置為 1
例如對于十進制 -17,其二進制為 10001 (取 17 的二進制) 共5位,首先從后到前拆分為兩組,后四位 0001 為第一組,連續標志位為 1,符號位為 1,結果為 1,0001,1;剩下的 1 分配到第二個 —— 也是最后一個分組,連續標志位為 0,結果為 0,00001。按 Base64 規則 [100011, 000001] 最終映射為 jA。
- 十進制 二進制 VLQ Base64
- -17 => 1,0001 => 100011, 000001 => jA
同樣的,對于更大的數字,例如 1200,其二進制為 10010110000,分組為 [10, 01011, 0000],從后到前編碼,第一個分組為 1,0000,0;第二個分組為 1,01011;最后一個分組為 0,00010。按 Base64 映射為 grC。
- 十進制 二進制 VLQ Base64
- 1200 => 10;01011;0000 => 100000,101011,000010 => grC
1.2.3 解碼 mappings
結合 VLQ 編碼知識,我們再回過來頭來解讀本章開頭的例子,對于代碼:
編譯生成 mappings:
- ;;;;;AAAA,IAAMA,IAAI,GAAG,QAAb;AAEAC,OAAO,CAACC,GAAR,CAAYF,IAAZ,E
按行、片段規則分割后,得出如下片段:
- [
- // 產物第 1-5 行內容為 Webpack 生成的 runtime,不需要記錄映射關系
- '', '', '', '', '',
- // 產物第 6 行的映射信息
- ['AAAA', 'IAAMA', 'IAAI', 'GAAG', 'QAAb'],
- // 產物第 7 行的映射信息
- ['AAEAC', 'OAAO', 'CAACC', 'GAAR', 'CAAYF', 'IAAZ', 'E']
- ]
以第 6 行 ['AAAA', 'IAAMA', 'IAAI', 'GAAG', 'QAAb'] 為例:
- AAAA 解碼結果為 [000000, 000000, 000000, 000000],即產物第 6 行「第0列」映射到 sources[0] 文件的「第0行」,「第0列」,實際對應 var 到 const 的位置映射
- IAAMA 解碼結果為 [001000, 000000, 000000, 001100, 000000],即產物第 6 行第4列映射到 sources[0] 文件的「第0行」,「第6列」,實際對應產物 name 到源碼 name 的位置映射
其它片段以此類推。
二、使用 Sourcemap
Webpack 提供了兩種設置 Sourcemap 的方式,一是通過 devtool 配置項設置 Sourcemap 規則短語;二是直接使用 SourceMapDevToolPlugin 或 EvalSourceMapDevToolPlugin 插件深度定制 Sourcemap 的生成邏輯。
下面我們先展開介紹比較晦澀的 devtool 配置項,理解 Webpack 所提供的各種 Sourcemap 功能規則。
2.1 使用devtooldevtool
支持 25 種字符串枚舉值,包括 eval、source-map、eval-source-map 等,分開來看都特別晦澀,但仔細觀察可發現這些值都是由 inline、eval、source-map、nosources、hidden、cheap、module 七種關鍵詞組合而成,這些關鍵詞各自代表一項 Sourcemap 規則。
2.1.1 eval
當 devtool 值包含 eval 時,生成的模塊代碼會被包裹進一段 eval 函數中,且模塊的 Sourcemap 信息通過 //# sourceURL 直接掛載在模塊代碼內。例如:
- eval("var foo = 'bar'\n\n\n//# sourceURL=webpack:///./src/index.ts?")
eval 模式編譯速度通常比較快,但產物中直接包含了 Sourcemap 信息,因此只推薦在開發環境中使用。
2.1.2 source-map
當 devtool 包含 source-map 時,Webpack 才會生成 Sourcemap 內容。例如,對于 devtool = 'source-map',產物會額外生成 .map 文件,形如:
- {
- "version": 3,
- "sources": [
- "webpack:///./src/index.ts"
- ],
- "names": [
- "console",
- "log"
- ],
- "mappings": "AACAA,QAAQC,IADI",
- "file": "bundle.js",
- "sourcesContent": [
- "const foo = 'bar';\nconsole.log(foo);"
- ],
- "sourceRoot": ""
- }
實際上,除 eval 之外的其它枚舉值都包含該字段。
2.1.3 cheap
當 devtool 包含 cheap 時,生成的 Sourcemap 內容會拋棄「列」維度的信息,這就意味著瀏覽器只能映射到代碼行維度。例如 devtool = 'cheap-source-map' 時,產物:
- {
- "version": 3,
- "file": "bundle.js",
- "sources": [
- "webpack:///bundle.js"
- ],
- "sourcesContent": [
- "console.log(\"bar\");"
- ],
- // 帶 cheap 效果:
- "mappings": "AAAA",
- // 不帶 cheap 效果:
- // "mappings": "AACAA,QAAQC,IADI",
- "sourceRoot": ""
- }
瀏覽器映射效果:
雖然 Sourcemap 提供的映射功能可精確定位到文件、行、列粒度,但有時在「行」級別已經足夠幫助我們達到調試定位的目的,此時可選擇使用 cheap 關鍵字,簡化 Sourcemap 內容,減少 Sourcemap 文件體積。
2.1.4 modulemodule
關鍵字只在 cheap 場景下生效,例如 cheap-module-source-map、eval-cheap-module-source-map。當 devtool 包含 cheap 時,Webpack 根據 module 關鍵字判斷按 loader 聯調處理結果作為 source,還是按處理之前的代碼作為 source。例如:
注意觀察上例 sourcesContent 字段,左邊 devtool 帶 module 關鍵字,因此此處映射的是包含 class Person 的最原始代碼;而右邊生成的 sourcesContent 則是經過 babel-loader 編譯處理的內容。
2.1.5 nosources
當 devtool 包含 nosources 時,生成的 Sourcemap 內容中不包含源碼內容 —— 即 sourcesContent 字段。例如 devtool = 'nosources-source-map' 時,產物:
- {
- "version": 3,
- "sources": [
- "webpack:///./src/index.ts"
- ],
- "names": [
- "console",
- "log"
- ],
- "mappings": "AACAA,QAAQC,IADI",
- "file": "bundle.js",
- "sourceRoot": ""
- }
雖然沒有帶上源碼,但 .map 產物中還帶有文件名、 mappings 字段、變量名等信息,依然能夠幫助開發者定位到代碼對應的原始位置,配合 sentry 等工具提供的源碼映射功能,可在異地還原諸如錯誤堆棧之類的信息。
2.1.6 inline
當 devtool 包含 inline 時,Webpack 會將 Sourcemap 內容編碼為 Base64 DataURL,直接追加到產物文件中。例如對于 devtool = 'inline-source-map',產物:
- console.log("bar");
- //# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJzb3VyY2VzIjpbIndlYnBhY2s6Ly8vLi9zcmMvaW5kZXgudHMiXSwibmFtZXMiOlsiY29uc29sZSIsImxvZyJdLCJtYXBwaW5ncyI6IkFBQ0FBLFFBQVFDLElBREkiLCJmaWxlIjoiYnVuZGxlLmpzIiwic291cmNlc0NvbnRlbnQiOlsiY29uc3QgZm9vID0gJ2Jhcic7XG5jb25zb2xlLmxvZyhmb28pOyJdLCJzb3VyY2VSb290IjoiIn0=
inline 模式編譯速度較慢,且產物體積非常大,只適合開發環境使用。
2.1.7 hidden
通常情況下,產物中必須攜帶 //# sourceMappingURL= 指令,瀏覽器才能正確找到 Sourcemap 文件,
當 devtool 包含 hidden 時,編譯產物中不包含 //# sourceMappingURL= 指令。例如:
兩者區別僅在于編譯產物最后一行的 //# sourceMappingURL= 指令,當你需要 Sourcemap 功能,又不希望瀏覽器 Devtool 工具自動加載時,可使用此選項。你也可以通過以下操作手動打開 Sourcemap:
2.1.8 小結
總結一下,Webpack 的 devtool 值都是由以上七種關鍵字的一個或多個組成,雖然提供了 27 種候選項,但邏輯上都是由上述規則疊加而成,例如:
- cheap-source-map:代表 「不帶列映射」 的 Sourcemap
- eval-nosources-cheap-source-map:代表 「以」 **eval** 「包裹模塊代碼」 ,且 **.map** 「映射文件中不帶源碼」 ,且 「不帶列映射」 的 Sourcemap
其它選項以此類推。最后再總結一下:
對于開發環境,適合使用:
- eval:速度極快,但只能看到原始文件結構,看不到打包前的代碼內容
- cheap-eval-source-map:速度比較快,可以看到打包前的代碼內容,但看不到 loader 處理之前的源碼
- cheap-module-eval-source-map:速度比較快,可以看到 loader 處理之前的源碼,不過定位不到列級別
- eval-source-map:初次編譯較慢,但定位精度最高
對于生產環境,則適合使用:
- source-map:信息最完整,但安全性最低,外部用戶可輕易獲取到壓縮、混淆之前的源碼,慎重使用
- hidden-source-map:信息較完整,安全性較低,外部用戶獲取到 .map 文件地址時依然可以拿到源碼
- nosources-source-map:源碼信息確實,但安全性較高,需要配合 Sentry 等工具實現完整的 Sourcemap 映射
2.2 使用插件
上面介紹的 devtool 配置項本質上只是一種方便記憶、使用的規則縮寫短語,Sourcemap 的底層處理邏輯實際由 SourceMapDevToolPlugin 與 EvalSourceMapDevToolPlugin 插件實現。
參考:https://webpack.js.org/plugins/source-map-dev-tool-plugin/
在 devtool 基礎上,插件還提供了更多更細粒度的配置項,用于滿足更復雜的需求場景,包括:
- 使用 test、include、exclude 配置項設定對那些 bundle 生成 Sourcemap
- 使用 append、filename、moduleFilenameTemplate、publicPath 配置項設定 Sourcemap 文件的文件名、URL
使用方法與其它插件無異,如:
- const webpack = require('webpack');
- module.exports = {
- // ...
- devtool: false,
- plugins: [new webpack.SourceMapDevToolPlugin({
- exclude: ['vendor.js']
- })],
- };
插件配置規則較簡單,此處不贅述。
三、總結
至此,有關 Sourcemap 的大部分內容就講解完畢了,讀者們需要了解 Sourcemap 是一種高效位置映射算法,它將產物到源碼之間的位置關系表達為 mappings 分層設計與 VLQ 編碼規則,再通過 Chrome、Safari、VS Code、Sentry 等工具異地還原為接近開發狀態的源碼形式。
在 Webpack 場景下,通常只需要選擇適當的 devtool 短語即可滿足大多數場景需求,特殊情況下也可以直接使用 SourceMapDevToolPlugin 做更深度的定制化。
原文鏈接:https://mp.weixin.qq.com/s/-y35QBSIx2jMvG5dNklcPQ