SourceMap的用途
前端工程打包后代碼會與源碼產生不一致,當代碼運行出錯時控制臺會定位出錯代碼的位置。SourceMap的用途是可以將轉換后的代碼映射回源碼,如果你部署了js文件對應的map文件資源,那么在控制臺里調試時可以直接定位到源碼的位置。
SourceMap的格式
我們可以生成一個SouceMap文件看看里面的字段分別都對應什么意思,這里使用webpack打包舉例。
源碼:
//src/index.js
function a() {
for (let i = 0; i < 3; i++) {
console.log('s');
}
}
a();
打包后的代碼:
//dist/main-145900df.js
!function(){for(let o=0;o<3;o++)console.log("s")}();
//# sourceMappingURL=main-145900df.js.map
.map文件:
//dist/main-145900df.js.map
{
"version": 3,
"file": "main-145900df.js",
"mappings": "CAAA,WACE,IAAK,IAAIA,
EAAI,EAAGA,EAAI,EAAGA,IACrBC,QAAQC,IAAI,KAGhBC",
"sources": ["webpack://source-map-webpack-demo/./src/index.js"],
"sourcesContent": ["function a() {\n for (let i = 0; i < 3; i++) {\n console.log('s');\n }\n}\na();"],
"names": ["i", "console", "log", "a"],
"sourceRoot": ""
}
- version:目前source map標準的版本為3;
- file:生成的文件名;
- mappings:記錄位置信息的字符串;
- sources:源文件地址列表;
- sourcesContent:源文件的內容,一個可選的源文件內容列表;
- names:轉換前的所有變量名和屬性名;
- sourceRoot:源文件目錄地址,可以用于重新定位服務器上的源文件。
這些字段里大部分都很好理解,接下來主要解讀mappings這個字段是通過什么規則來記錄位置信息的。
?mappings字段的定義規則?
"mappings": "CAAA,WACE,IAAK,IAAIA,
EAAI,EAAGA,EAAI,EAAGA,IACrBC,QAAQC,IAAI,KAGhBC",
為了盡可能減少存儲空間但同時要達到記錄原始位置和目標位置映射關系的目的,mappings字段按照了一些特殊的規則來生成。
- 生成文件中的一行作為一組,用“;”隔開。
- 連續的字母共同表示一個位置信息,用逗號分隔每個位置信息。
- 一個位置信息由1、4或5個可變長度的字段組成。
- // generatedColumn, [sourceIndex, originalLine, orignalColumn, [nameIndex]]
- 第一位,表示這個位置在轉換后的代碼第幾列,使用的是相對于上一個的相對位置,除非這是這個字段的第一次出現。
- 第二位(可選),表示所在的文件是屬于sources屬性中的第幾個文件,這個字段使用的是相對位置。
- 第三位(可選),表示對應轉換前代碼的第幾行,這個字段使用的是相對位置。
- 第四位(可選),表示對應轉換前代碼的第幾列,這個字段使用的是相對位置。
- 第五位(可選),表示屬于names屬性中的第幾個變量,這個字段使用的是相對位置。
- 字段的生成原理是將數值通過vlq-base64編碼轉換成字母。
?vlq原理?
vlq是Variable-length quantity的縮寫,是一種通用的,使用任意位數的二進制來表示一個任意大的數字的一種編碼方式。
SourceMap中的編碼流程是將位置從十進制數值—>二進制數值—>vlq編碼—>base64編碼最終生成字母。
// Continuation
// | Sign
// | |
// V V
// 101011
vlq編碼的規則:
- 一個數值可能由多個字符組成
- 對于每個字符使用6個2進制位表示
如果是表示數值的第一個字符中的最后一個位置,則為符號位。
否則用于實際有效值的一位。
0為正,1為負(SourceMap的符號固定為0),
第一個位置是連續位,如果是1,代表下一個字符也屬于同一個數值;如果是0,表示這個字符是表示這個數值的最后一個字符。
最后一個位置
- 至少含有4個有效值,所以數值范圍為(1111到-1111)即-15到15的可以由一個字符表示。
數值的第一個字符有4個有效值
之后的字符有5個有效值
最后將6個2進制位轉換成base64編碼的字母,如圖。

舉例編碼數值29
數值29(十進制)=11101(二進制)
1|1101
先取低四位,數值的第一個字符有四個有效值1101
11010-----------最后加上符號位
111010----------開頭加上連續位1(后面還有字符表示同一個數值)
6---------------轉換為base64編碼對應是6
數值的第二個字符
00001----------補充有效位
000001--------開頭加上連續位0(表示是數值的最后一個字符)
B---------------轉換為base64編碼
29=》6B
我們將上述轉換的規則通過代碼方式呈現:
代碼實現vlq編碼
先在最后添加一個符號位,從低位開始截取5位作為一個字符,截取完若還有數值則在截取的5位前添加連續位1,即生成好一個字符;最后一個字符的數值直接與011111進行與運算即可。
//https://github.com/mozilla/source-map/blob/HEAD/lib/base64-vlq.js
const base64 = require("./base64");
//移動位數
const VLQ_BASE_SHIFT = 5;
// binary: 100000
const VLQ_BASE = 1 << VLQ_BASE_SHIFT;
//1左移5位:100000=32
// binary: 011111
const VLQ_BASE_MASK = VLQ_BASE - 1;
// binary: 100000
const VLQ_CONTINUATION_BIT = VLQ_BASE;
//符號位在最低位
//1.1左移一位并在最后加一個符號位
function toVLQSigned(aValue) {
return aValue < 0 ? (-aValue << 1) + 1 : (aValue << 1) + 0;
}
/**
* Returns the base 64 VLQ encoded value.
*/
function base64VLQ_encode(aValue) {
let encoded = "";
let digit;
let vlq = toVLQSigned(aValue);//第一步:左移一位,最后添加符號位
do {
digit = vlq & VLQ_BASE_MASK;
//第二步:vlq和011111進行與運算,獲取字符中已經生成好的后5位
//從低位的5位開始作為第一個字符
vlq >>>= VLQ_BASE_SHIFT;//vlq=vlq>>>5
//第三步:vlq右移5位用于截取低位的5位,對剩下的數值繼續進行操作
if (vlq > 0) {
//說明后面還有數值,則要在現在這個字符開頭加上連續位1
digit |= VLQ_CONTINUATION_BIT;//digit=digit|100000,與100000進行或運算
}
encoded = encoded+base64.encode(digit);//第四步:生成的vlq字符進行base64編碼并拼接
} while (vlq > 0);
return encoded;
};
exports.encode = base64VLQ_encode;
舉例解碼字符6B
6B
第一個字符
6=>111010--------base64解碼并轉換為二進制
111010------------符號位
110110------------連續位(表示后面有字符表示同一個數值)
第一個字符有效值value=1101
第二個字符
B=>000001------base64解碼并轉換為二進制
000001----------有效值
000001----------連續位(表示后面沒有字符表示同一個數值)
第二個字符的有效值value=00001
合并value=000011101轉為十進制29
代碼實現vlq解碼
從左到右開始遍歷字符,對每個字符都先去除連續位剩下后5位數值,將每個字符的5位數值從低到高拼接,最后去除處在最低一位的符號位。
//https://github.com/Rich-Harris/vlq/blob/HEAD/src/index.js
/** @param {string} string */
export 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]];//1.base64解碼
if (integer === undefined) {
throw new Error('Invalid character (' + string[i] + ')');
}
const has_continuation_bit = integer & 100000;//2.獲取連續位標識
integer =integer & 11111;//3.移除符號位獲取后5位
value = value + (integer << shift);
//4.從低到高拼接有效值
if (has_continuation_bit) {
//5.有連續位
shift += 5;//移動位數
} else {
//6.沒有連續位,處理獲取到的有效值value
const should_negate = value & 1;//獲取符號位
value =value >>>1;//7.右移一位去除符號位,獲取最終有效值
if (should_negate) {
result.push(value === 0 ? -0x80000000 : -value);
} else {
result.push(value);
}
// reset
value = shift = 0;
}
}
return result;
}
整個轉換流程舉例
源碼:
//src/index.js
function a() {
for (let i = 0; i < 3; i++) {
console.log('s');
}
}
a();
打包后的代碼:
//dist/main-145900df.js
!function(){for(let o=0;o<3;o++)console.log("s")}();
//# sourceMappingURL=main-145900df.js.map
.map文件:
//dist/main-145900df.js.map
{
"version": 3,
"file": "main-145900df.js",
"mappings": "CAAA,WACE,IAAK,IAAIA,
EAAI,EAAGA,EAAI,EAAGA,IACrBC,QAAQC,IAAI,KAGhBC",
"sources": ["webpack://source-map-webpack-demo/./src/index.js"],
"sourcesContent": ["function a() {\n for (let i = 0; i < 3; i++) {\n console.log('s');\n }\n}\na();"],
"names": ["i", "console", "log", "a"],
"sourceRoot": ""
}
CAAA
[1,0,0,0]
轉換后的代碼的第1列
sources屬性中的第0個文件
轉換前代碼的第0行。
轉換前代碼的第0列。
對應function
WACE
[11,0,1,2]
轉換后的代碼的第12(11+1)列
sources屬性中的第0個文件
轉換前代碼的第1行。
轉換前代碼的第2列。
對應for
IAAK
[4,0,0,5]
轉換后的代碼的第16(12+4)列
sources屬性中的第0個文件
轉換前代碼的第1行。
轉換前代碼的第7(2+5)列。
對應let
SourceMap使用的規則是如何優化存儲位置信息空間的?
SourceMap規范進行了版本迭代,最初,規范對所有映射都有非常詳細的輸出,導致SourceMap大約是生成代碼的10倍。第二個版本減少了50% 左右,第三個版本又減少了50% 。
因為如果生成的位置信息內容比源碼還多未免有些得不償失,所以這樣的規則是在盡可能的減小存儲空間。
我們可以來總結一下這個規則里使用到的優化點:
- 使用相對位置,使位置數值盡可能小,在后續計算中獲取真實的位置數值,從而減少存儲空間;
- 使用vlq-base64編碼減少存儲空間,如32000=》ggxT,通過計算減少存儲空間;
- 行數信息直接用;分割來表示。
解析babel生成SourceMap的實現方式
我們日常的各種轉譯/打包工具是如何生成SourceMap的,這里來解析一下babel生成SourceMap的實現方式。
我們大概需要以下三個步驟來生成SourceMap:
- 獲取源碼的行列信息
- 獲取生成代碼的行列信息
- 將前后一一對應起來,然后進行vlq-base64編碼并按照規則生成sourcemap文件。
babel流程
babel主要執行了三個流程:解析(parse),轉換(transform),生成(generate)。
parse解析階段(獲得源碼對應的ast)=》transform(plugin插件執行轉換ast)=》generate通過ast生成代碼
parse和transform階段
在解析和轉換的階段,源碼對應的ast經過一些plugin的執行后節點的類型或者值會發生改變,但節點中有一個loc屬性(類型為SourceLocation)會一直記錄著源碼最開始的行列位置,所以獲取到源碼的ast就能夠得到源碼中的行列信息。
generate階段生成SourceMap
generator階段通過ast生成轉譯后的代碼,在這個階段會對ast樹進行遍歷。
針對不同類型的ast節點根據節點的含義執行word/space/token/newline等方法生成代碼,這些方法里都會執行append方法添加要生成的字符串代碼。
在此之中有一個記錄生成代碼的行列信息屬性會按照添加的字符串長度進行不斷的累加,從而得到轉譯前后行列信息的對應。
//packages/babel-generator/src/index.ts
export default function generate(
ast: t.Node,
opts?: GeneratorOptions,
code?: string | { [filename: string]: string },
) {
const gen = new Generator(ast, opts, code);
//1.傳遞ast新建一個Generator對象
return gen.generate();
}
class Generator extends Printer {
generate() {
return super.generate(this.ast);
}
}
//packages/babel-generator/src/printer.ts
class Printer {
generate(ast) {
this.print(ast);
//2.通過ast生成代碼
this._maybeAddAuxComment();
return this._buf.get();
}
print(node, parent?) {
if (!node) return;
const oldConcise = this.format.concise;
if (node._compact) {
this.format.concise = true;
}
const printMethod = this[node.type];
//獲取不同節點類型對應的生成方法
//....
//調用
this.withSource("start", loc, () => {
printMethod.call(this, node, parent);
});
// this._printTrailingComments(node);
// if (shouldPrintParens) this.token(")");
// end
this._printStack.pop();
this.format.concise = oldConcise;
this._insideAux = oldInAux;
}
}
例如,遍歷到一個SwitchCase類型的ast節點,會在里面調用Printer對象的word/space/print/token等方法,而這些方法內部都會調用append方法用于逐個添加要生成的字符串,并計算得到對應的行列信息。
//packages/babel-generator/src/generators/statements.ts
export function SwitchCase(this: Printer, node: t.SwitchCase) {
if (node.test) {
this.word("case");
this.space();
this.print(node.test, node);//用于遍歷,執行節點下的節點的方法
this.token(":");
} else {
this.word("default");
this.token(":");
}
if (node.consequent.length) {
this.newline();
this.printSequence(node.consequent, node, { indent: true });
}
}
Printer對象中聲明了word/space/print/token等方法,這些方法都會將字符串添加到Buffer對象中。
//packages/babel-generator/src/printer.ts
class Printer {
constructor(format: Format, map: SourceMap) {
this._buf = new Buffer(map);
}
//...
_append(str: string, queue: boolean = false) {
if (queue) this._buf.queue(str);
else this._buf.append(str);
}
word(str: string): void {
// prevent concatenating words and creating // comment out of division and regex
if (
this._endsWithWord ||
(this.endsWith(charCodes.slash) && str.charCodeAt(0) === charCodes.slash)
) {
this._space();
}
this._maybeAddAuxComment();
this._append(str);
this._endsWithWord = true;
}
token(str: string): void {
// space is mandatory to avoid outputting <!--
// http://javascript.spec.whatwg.org/#comment-syntax
const lastChar = this.getLastChar();
const strFirst = str.charCodeAt(0);
if (
(str === "--" && lastChar === charCodes.exclamationMark) ||
// Need spaces for operators of the same kind to avoid: `a+++b`
(strFirst === charCodes.plusSign && lastChar === charCodes.plusSign) ||
(strFirst === charCodes.dash && lastChar === charCodes.dash) ||
// Needs spaces to avoid changing '34' to '34.', which would still be a valid number.
(strFirst === charCodes.dot && this._endsWithInteger)
) {
this._space();
}
this._maybeAddAuxComment();
this._append(str);
}
}
Buffer對象的append方法會去計算生成代碼的行列信息,并將生成代碼的行列信息和原始代碼的行列信息傳遞給SourceMap對象,SourceMap對象將前后位置信息對應起來并進行編碼從而生成最終的SourceMap。
//packages/babel-generator/src/buffer.ts
class Buffer {
constructor(map?: SourceMap | null) {
this._map = map;
}
//用于記錄生成代碼的位置
_position = {
line: 1,
column: 0,
};
//第1步:執行內部_append方法
append(str: string): void {
this._flush();
const { line, column, filename, identifierName } = this._sourcePosition;
this._append(str, line, column, identifierName, filename);
}
//第2步:計算傳遞進來的字符串參數對應的位置
_append(
str: string,
line: number | undefined,
column: number | undefined,
identifierName: string | undefined,
filename: string | undefined,
): void {
this._buf += str;
this._last = str.charCodeAt(str.length - 1);
let i = str.indexOf("\n");//查找換行符位置
let last = 0;
if (i !== 0) {
//排除開頭是換行符的情況,其他情況執行標記
this._mark(line, column, identifierName, filename);
}
// Now, find each reamining newline char in the string.
while (i !== -1) {
//2-1.當存在換行符時,改變行數
this._position.line++;
this._position.column = 0;
last = i + 1;//換行符后一位
// We mark the start of each line, which happens directly after this newline char
// unless this is the last char.
if (last < str.length) {
this._mark(++line, 0, identifierName, filename);//改變行數,行數+1
}
i = str.indexOf("\n", last);//尋找下一個換行符
}
//2-2.改變列數,列數加上字符的長度
this._position.column += str.length - last;
}
//第3步:調用sourcemap對象的mark方法
_mark(
line: number | undefined,
column: number | undefined,
identifierName: string | undefined,
filename: string | undefined,
): void {
this._map?.mark(this._position, line, column, identifierName, filename);
}
}
export default class SourceMap {
//第4步:將前后行列信息對應起來后對位置信息進行編碼
mark(
generated: { line: number; column: number },
line: number,
column: number,
identifierName?: string | null,
filename?: string | null,
) {
this._rawMappings = undefined;
maybeAddMapping(this._map, {
name: identifierName,
generated,
source:
line == null
? undefined
: filename?.replace(/\/g, "/") || this._sourceFileName,
original:
line == null
? undefined
: {
line: line,
column: column,
},
});
}
}
相關鏈接
Introduction to JavaScript Source Maps
Source Map Revision 3 Proposal
https://github.com/babel/babel
https://github.com/Rich-Harris/vlq
https://github.com/mozilla/source-map