SourceMap解析


前端發展至今已不再是刀耕火種的年代了,出現了typescript、babel、uglify.js等功能強大的工具。我們手動撰寫的代碼一般具有可讀性,並且可以享受高級語法、類型檢查帶來的便利,但經過工具鏈處理並上線的代碼一般不具有可讀性,且為了兼容低版本瀏覽器往往降級到低級語法,這些代碼在轉換過程中發生了變化,使我們並不能馬上識別原始代碼的組合方式,這提供了一定的源碼安全性。雖然帶來了這些好處,但最終代碼的排錯是一個難點,SourceMap作為一種代碼索引的工具,已經被廣泛應用於這類場景了,它通過保存轉換前和轉換后代碼在行、列上的對應關系,形成類似“映射”的結構,一旦轉換的代碼出了問題,可以查找到對應原始代碼的位置。本文針對webpack SourceMap的生成方法進行了探討,涉及Base64 VLQ編碼的基本知識,配合案例進行討論,希望能對想了解它的開發者有所幫助。

工具鏈

生成SourceMap

我們先創建一個文件index.js,書寫一些ES6的語法,然后配置webpack利用babel轉換到低級語法。

// index.js
const foo = 'hello';
const bar = (a, b) => a+b;

然后配置webpack生成SourceMap文件

const path = require('path')

module.exports = {
  mode: 'development',
  entry: './index.js',
  output: {
    filename: '[name].js',
    path: path.resolve(__dirname, 'dist')
  },
  module: {
    rules: [
      {
        test: /\.js$/,
        use: [
          {
            loader: 'babel-loader',
            options: {
              exclude: /node_modules/
            }
          }
        ]
      }
    ]
  },
  devtool: 'source-map',
  optimization: {
    runtimeChunk: {
      name: 'manifest'
    }
  }
}

devtool指定了生成的sourceMap類型,這里我們選擇最原始的source-map即可。注意使用optimization.runtimeChunk選項抽離webpack注入的骨架代碼,這些代碼會干擾我們分析。

運行打包后,在dist目錄得到四個文件,分別是main.js, main.js.map, manifest.js, manifest.js.map, 其中main.js是輸出的代碼文件,而main.js.map是SourceMap文件。

先看main.js, 文件的10-14行就是轉化后的代碼,可見原始代碼中的const和箭頭函數語法均被低級語法代替。

(window["webpackJsonp"] = window["webpackJsonp"] || []).push([["main"],{

/***/ "./index.js":
/*!******************!*\
  !*** ./index.js ***!
  \******************/
/*! no static exports found */
/***/ (function(module, exports) {

var foo = 'hello';

var bar = function bar(a, b) {
  return a + b;
};

/***/ })

},[["./index.js","manifest"]]]);
//# sourceMappingURL=main.js.map

再看main.js.map文件, 這是一個JSON格式的文件,其中names字段包含了所有原始代碼里的形參和實參,sourcesContent字段是原始代碼,mappings字段則是生成的sourceMap。

{
  "version": 3,
  "sources": ["webpack:///./index.js"],
  "names": ["foo", "bar", "a", "b"],
  "mappings": ";;;;;;;;;AAAA,IAAMA,GAAG,GAAG,OAAZ;;AACA,IAAMC,GAAG,GAAG,SAANA,GAAM,CAACC,CAAD,EAAIC,CAAJ;AAAA,SAAUD,CAAC,GAACC,CAAZ;AAAA,CAAZ,C",
  "file": "main.js",
  "sourcesContent": ["const foo = 'hello';\r\nconst bar = (a, b) => a+b;\r\n"],
  "sourceRoot": ""
}

SourceMap的格式

SourceMap的格式以;作為行分隔符,以,作為行中條目的分隔符,每個條目包含4-5個編碼字符,這5個字符分別表示:

  1. 該位置在轉化后的代碼中的列數(相對於上一個條目)
  2. 文件序號
  3. 該位置在原始代碼中的行數(相對於上一個條目)
  4. 該位置在原始代碼中的列數(相對於上一個條目)
  5. 可能沒有,該位置包含names屬性中的哪個變量的聲明,對應該屬性的index (相對於上一個變量出現的index)

編碼分析

這些信息都是數字格式,使用Base64 VLQ進行編碼,在二進制位運算基礎上操作,具體步驟為:

  1. 如果數字大於或等於0,左移一位;如果數字小於0,先取絕對值,然后左移一位,接着將末位置為1;
  2. 取數字最低的5位,並將數字右移5位;
  3. 如果此時數字為0,使用Base64編碼字符序列輸出第2步中取到的5位;如果數字不為0,則將第2步中取到的5位前面補1,使用Base64編碼字符序列輸出字符並循環第2步;

Base64編碼序列表:

編碼表

下面舉兩個例子具體來看下。

首先看16這個數:

  1. 16(10000)大於0,按照第1步,左移一位,變成100000;
  2. 按照第2步,取最低的5位,得到00000,數字剩余1,按照第3步,在00000前方補1得到100000,轉化為十進制是32,對應的字符是g,此時有數字剩余,繼續第2步;
  3. 按照第2步,取最低的5位,得到1,數字剩余0,按照第3步,直接輸出1對應的字符'B';

經過轉化,16對應的Base64 VLQ編碼是gB

再看-2333這個數:

  1. -2333小於0,按照第1步,先取絕對值得到2333(100100011101),左移一位,然后末位置為1,變成1001000111011;
  2. 按照第2步,取最低的5位,得到11011,數字剩余10010001,按照第3步,在11011前方補1得到111011,轉化為十進制是59,對應的字符是7,此時有數字剩余,繼續第2步;
  3. 按照第2步,取最低的5位,得到10001,數字剩余100,按照第3步,在10001前方補1得到110001,轉化為十進制是49,對應的字符是x,此時有數字剩余,繼續第2步;
  4. 按照第2步,取最低的5位,得到100,數字剩余0,按照第3步,100轉化為十進制是4,直接輸出對應的字符E;

經過轉化,-2333對應的Base64 VLQ編碼是7xE

解碼分析

經過上面的格式分析,mappings開頭的每個分號都對應着轉換后代碼中的一行,通過觀察轉換后的文件我們發現mappings開頭有9個分號,代表這9行內容都是webpack自己加進去的,跟我們的源代碼沒有關系,所以這里就直接忽略他們。

了解了編碼方式,其實解碼就是編碼的反操作,就不贅述具體步驟了。為了幫助解析mappings這堆字符的含義,我們直接引入vlq這個庫。

先分析第10行,利用下面的代碼解析vlq字符串:

const vlq = require('vlq')
const source = 'AAAA,IAAMA,GAAG,GAAG,OAAZ'

function extract (sourceString) {
  const lines = sourceString.split(';')
  return lines.map(line => line.split(',').map(vlq.decode))
}

console.log(extract(source))

得到一個數組:

[
  [
    [ 0, 0, 0, 0 ], // 第10行第0列對應原始代碼第1行第0列
    [ 4, 0, 0, 6, 0 ], // 第0-3列對應原始代碼第0-5列 (var -> const),同時包含names[0], 即foo變量的聲明
    [ 3, 0, 0, 3 ], // 第4-6列對應原始代碼第6-8列 (foo -> foo)
    [ 3, 0, 0, 3 ], // 第7-9列對應原始代碼第9-11列 ( =  ->  = )
    [ 7, 0, 0, -12 ] // 第10-16列對應源代碼從第12列直到下一行開頭('hello' -> 'hello';)
  ]
]

接下來的第11行是一個空行,直接用一個;結束。

再接下去是箭頭函數的轉換,我們接着看第12行,把AACA,IAAMC,GAAG,GAAG,SAANA,GAAM,CAACC,CAAD,EAAIC,CAAJ進行轉化,得到:

[
  [
    [ 0, 0, 1, 0 ], // 第12行第0列對應原始代碼第2行第0列
    [ 4, 0, 0, 6, 1 ], // 第0-3列對應原始代碼0-5列 (var  -> const ),同時包含names[0+1], 即bar變量的聲明
    [ 3, 0, 0, 3 ], // 第4-6列對應原始代碼第6-8列 (bar -> bar)
    [ 3, 0, 0, 3 ], // 第7-9列對應原始代碼第9-11列 ( =  ->  = )
    [ 9, 0, 0, -6, 0 ], // 第10-18列沒對應到內容,原始代碼回到第5列 (function -> )
    [ 3, 0, 0, 6 ], // 第19-21列對應原始代碼第6-11列 (bar -> bar = )
    [ 1, 0, 0, 1, 1 ], // 第22列對應原始代碼第12列 ( ( -> ( ),同時包含names[1+1], 即形參a的聲明 
    [ 1, 0, 0, -1 ], // 第23列沒對應到內容,原始代碼回到第11列
    [ 2, 0, 0, 4, 1 ], // 第24-25列對應原始代碼第1行第12-15列(, -> (a, ),同時包含names[2+1], 即形參b的聲明 
    [ 1, 0, 0, -4 ] // 第26列沒對應到內容,原始代碼回到第11列
  ]
]

后面的都是以此類推,就不一一分析了。

以上就是SourceMap編解碼的大體流程,github地址在這里,感興趣的可以自己嘗試一下。

cheap-source-map 和 eval-source-map

最后來看看在開發中用得較多的這兩種SourceMap,分別以cheap和eval作為前綴。我們先分析cheap,顧名思義,這種SourceMap比較“便宜”一些,由於大多數情況下我們只需要映射源碼的行號,而列號和變量信息其實不是必需的,因為一行代碼也就那么些字符,出錯后找到對應的行進行檢查即可。這種方式節省了大量的存儲和計算開銷,我們把上面的devtool設置成cheap-source-map再編譯,看下main.js.map文件:

{
  "version": 3,
  "file": "main.js",
  "sources": ["webpack:///./index.js"],
  "sourcesContent": ["var foo = 'hello';\n\nvar bar = function bar(a, b) {\n  return a + b;\n};"],
  "mappings": ";;;;;;;;;AAAA;AACA;AACA;AACA;AACA;;;;A",
  "sourceRoot": ""
}

與source-map不同,這里的sourcesContent字段保存的是經過babel轉換后的代碼,這意味着它是webpack生成的代碼與經babel轉化后的代碼的映射,而非與原始代碼的映射。再看mappings信息,前9行仍然是沒法對應,都以一個分號表示,第10行是AAAA,解碼后是[0, 0, 0, 0]代表sourcesContent的第1行; 第11-14行都是AACA,解碼后是[0, 0, 1, 0]分別代表sourcesContent的第2-5行,這幾行都是由原始代碼中的箭頭函數解析得到的。

再來看看eval-source-map,使用它SourceMap信息始終內聯在代碼文件中,比如這樣:

eval("var foo = 'hello';\n\nvar bar = function bar(a, b) {\n  return a + b;\n};//# sourceURL=[module]\n//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJzb3VyY2VzIjpbIndlYnBhY2s6Ly8vLi9pbmRleC5qcz80MWY1Il0sIm5hbWVzIjpbImZvbyIsImJhciIsImEiLCJiIl0sIm1hcHBpbmdzIjoiQUFBQSxJQUFNQSxHQUFHLEdBQUcsT0FBWjs7QUFDQSxJQUFNQyxHQUFHLEdBQUcsU0FBTkEsR0FBTSxDQUFDQyxDQUFELEVBQUlDLENBQUo7QUFBQSxTQUFVRCxDQUFDLEdBQUNDLENBQVo7QUFBQSxDQUFaIiwiZmlsZSI6Ii4vaW5kZXguanMuanMiLCJzb3VyY2VzQ29udGVudCI6WyJjb25zdCBmb28gPSAnaGVsbG8nO1xyXG5jb25zdCBiYXIgPSAoYSwgYikgPT4gYStiO1xyXG4iXSwic291cmNlUm9vdCI6IiJ9\n//# sourceURL=webpack-internal:///./index.js\n");

看來看去,我們的代碼就是前面一小段,后面帶着一個很長的尾巴sourceMappingURL,這個是什么東西呢?不妨用base64解碼一下:

JSON.parse(atob('eyJ2ZXJzaW9uIjozLCJzb3VyY2VzIjpbIndlYnBhY2s6Ly8vLi9pbmRleC5qcz80MWY1Il0sIm5hbWVzIjpbImZvbyIsImJhciIsImEiLCJiIl0sIm1hcHBpbmdzIjoiQUFBQSxJQUFNQSxHQUFHLEdBQUcsT0FBWjs7QUFDQSxJQUFNQyxHQUFHLEdBQUcsU0FBTkEsR0FBTSxDQUFDQyxDQUFELEVBQUlDLENBQUo7QUFBQSxTQUFVRCxDQUFDLEdBQUNDLENBQVo7QUFBQSxDQUFaIiwiZmlsZSI6Ii4vaW5kZXguanMuanMiLCJzb3VyY2VzQ29udGVudCI6WyJjb25zdCBmb28gPSAnaGVsbG8nO1xyXG5jb25zdCBiYXIgPSAoYSwgYikgPT4gYStiO1xyXG4iXSwic291cmNlUm9vdCI6IiJ9'))

{
  "version": 3,
  "sources": ["webpack:///./index.js?41f5"],
  "names": ["foo", "bar", "a", "b"],
  "mappings": "AAAA,IAAMA,GAAG,GAAG,OAAZ;;AACA,IAAMC,GAAG,GAAG,SAANA,GAAM,CAACC,CAAD,EAAIC,CAAJ;AAAA,SAAUD,CAAC,GAACC,CAAZ;AAAA,CAAZ",
  "file": "./index.js.js",
  "sourcesContent": ["const foo = 'hello';\r\nconst bar = (a, b) => a+b;\r\n"],
  "sourceRoot": ""
}

可見其只不過是把map信息以base64格式存儲在代碼中,換湯不換葯,其實還是那些東西,穿了個馬甲而已。

總結

  1. 本文探索了SourceMap的編解碼原理,這種常用的源碼映射工具使用了Base64 VLQ編碼,引入vlq庫可以輕松地進行編解碼;
  2. 對webpack中常用的cheap-source-map和eval-source-map進行了分析,其實跟上者大同小異;

References

[1]. JavaScript Source Map 詳解
[2]. Decoding and Encoding Base64 Vlqs in Source Maps
[3]. wiki


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM