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

舉例編碼數(shù)值29
數(shù)值29(十進(jìn)制)=11101(二進(jìn)制)
1|1101
先取低四位,數(shù)值的第一個(gè)字符有四個(gè)有效值1101
11010-----------最后加上符號位
111010----------開頭加上連續(xù)位1(后面還有字符表示同一個(gè)數(shù)值)
6---------------轉(zhuǎn)換為base64編碼對應(yīng)是6
數(shù)值的第二個(gè)字符
00001----------補(bǔ)充有效位
000001--------開頭加上連續(xù)位0(表示是數(shù)值的最后一個(gè)字符)
B---------------轉(zhuǎn)換為base64編碼
29=》6B
我們將上述轉(zhuǎn)換的規(guī)則通過代碼方式呈現(xiàn):
代碼實(shí)現(xiàn)vlq編碼
先在最后添加一個(gè)符號位,從低位開始截取5位作為一個(gè)字符,截取完若還有數(shù)值則在截取的5位前添加連續(xù)位1,即生成好一個(gè)字符;最后一個(gè)字符的數(shù)值直接與011111進(jìn)行與運(yùn)算即可。
//https://github.com/mozilla/source-map/blob/HEAD/lib/base64-vlq.js
const base64 = require("./base64");
//移動位數(shù)
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左移一位并在最后加一個(gè)符號位
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進(jìn)行與運(yùn)算,獲取字符中已經(jīng)生成好的后5位
//從低位的5位開始作為第一個(gè)字符
vlq >>>= VLQ_BASE_SHIFT;//vlq=vlq>>>5
//第三步:vlq右移5位用于截取低位的5位,對剩下的數(shù)值繼續(xù)進(jìn)行操作
if (vlq > 0) {
//說明后面還有數(shù)值,則要在現(xiàn)在這個(gè)字符開頭加上連續(xù)位1
digit |= VLQ_CONTINUATION_BIT;//digit=digit|100000,與100000進(jìn)行或運(yùn)算
}
encoded = encoded+base64.encode(digit);//第四步:生成的vlq字符進(jìn)行base64編碼并拼接
} while (vlq > 0);
return encoded;
};
exports.encode = base64VLQ_encode;
舉例解碼字符6B
6B
第一個(gè)字符
6=>111010--------base64解碼并轉(zhuǎn)換為二進(jìn)制
111010------------符號位
110110------------連續(xù)位(表示后面有字符表示同一個(gè)數(shù)值)
第一個(gè)字符有效值value=1101
第二個(gè)字符
B=>000001------base64解碼并轉(zhuǎn)換為二進(jìn)制
000001----------有效值
000001----------連續(xù)位(表示后面沒有字符表示同一個(gè)數(shù)值)
第二個(gè)字符的有效值value=00001
合并value=000011101轉(zhuǎn)為十進(jìn)制29
代碼實(shí)現(xiàn)vlq解碼
從左到右開始遍歷字符,對每個(gè)字符都先去除連續(xù)位剩下后5位數(shù)值,將每個(gè)字符的5位數(shù)值從低到高拼接,最后去除處在最低一位的符號位。
//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.獲取連續(xù)位標(biāo)識
integer =integer & 11111;//3.移除符號位獲取后5位
value = value + (integer << shift);
//4.從低到高拼接有效值
if (has_continuation_bit) {
//5.有連續(xù)位
shift += 5;//移動位數(shù)
} else {
//6.沒有連續(xù)位,處理獲取到的有效值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;
}
整個(gè)轉(zhuǎn)換流程舉例
源碼:
//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]
轉(zhuǎn)換后的代碼的第1列
sources屬性中的第0個(gè)文件
轉(zhuǎn)換前代碼的第0行。
轉(zhuǎn)換前代碼的第0列。
對應(yīng)function
WACE
[11,0,1,2]
轉(zhuǎn)換后的代碼的第12(11+1)列
sources屬性中的第0個(gè)文件
轉(zhuǎn)換前代碼的第1行。
轉(zhuǎn)換前代碼的第2列。
對應(yīng)for
IAAK
[4,0,0,5]
轉(zhuǎn)換后的代碼的第16(12+4)列
sources屬性中的第0個(gè)文件
轉(zhuǎn)換前代碼的第1行。
轉(zhuǎn)換前代碼的第7(2+5)列。
對應(yīng)let
SourceMap使用的規(guī)則是如何優(yōu)化存儲位置信息空間的?
SourceMap規(guī)范進(jìn)行了版本迭代,最初,規(guī)范對所有映射都有非常詳細(xì)的輸出,導(dǎo)致SourceMap大約是生成代碼的10倍。第二個(gè)版本減少了50% 左右,第三個(gè)版本又減少了50% 。
因?yàn)槿绻傻奈恢眯畔?nèi)容比源碼還多未免有些得不償失,所以這樣的規(guī)則是在盡可能的減小存儲空間。
我們可以來總結(jié)一下這個(gè)規(guī)則里使用到的優(yōu)化點(diǎn):
- 使用相對位置,使位置數(shù)值盡可能小,在后續(xù)計(jì)算中獲取真實(shí)的位置數(shù)值,從而減少存儲空間;
- 使用vlq-base64編碼減少存儲空間,如32000=》ggxT,通過計(jì)算減少存儲空間;
- 行數(shù)信息直接用;分割來表示。
解析babel生成SourceMap的實(shí)現(xiàn)方式
我們?nèi)粘5母鞣N轉(zhuǎn)譯/打包工具是如何生成SourceMap的,這里來解析一下babel生成SourceMap的實(shí)現(xiàn)方式。
我們大概需要以下三個(gè)步驟來生成SourceMap:
- 獲取源碼的行列信息
- 獲取生成代碼的行列信息
- 將前后一一對應(yīng)起來,然后進(jìn)行vlq-base64編碼并按照規(guī)則生成sourcemap文件。
babel流程
babel主要執(zhí)行了三個(gè)流程:解析(parse),轉(zhuǎn)換(transform),生成(generate)。
parse解析階段(獲得源碼對應(yīng)的ast)=》transform(plugin插件執(zhí)行轉(zhuǎn)換ast)=》generate通過ast生成代碼
parse和transform階段
在解析和轉(zhuǎn)換的階段,源碼對應(yīng)的ast經(jīng)過一些plugin的執(zhí)行后節(jié)點(diǎn)的類型或者值會發(fā)生改變,但節(jié)點(diǎn)中有一個(gè)loc屬性(類型為SourceLocation)會一直記錄著源碼最開始的行列位置,所以獲取到源碼的ast就能夠得到源碼中的行列信息。
generate階段生成SourceMap
generator階段通過ast生成轉(zhuǎn)譯后的代碼,在這個(gè)階段會對ast樹進(jìn)行遍歷。
針對不同類型的ast節(jié)點(diǎn)根據(jù)節(jié)點(diǎn)的含義執(zhí)行word/space/token/newline等方法生成代碼,這些方法里都會執(zhí)行append方法添加要生成的字符串代碼。
在此之中有一個(gè)記錄生成代碼的行列信息屬性會按照添加的字符串長度進(jìn)行不斷的累加,從而得到轉(zhuǎn)譯前后行列信息的對應(yīng)。
//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新建一個(gè)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];
//獲取不同節(jié)點(diǎn)類型對應(yīng)的生成方法
//....
//調(diào)用
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;
}
}
例如,遍歷到一個(gè)SwitchCase類型的ast節(jié)點(diǎn),會在里面調(diào)用Printer對象的word/space/print/token等方法,而這些方法內(nèi)部都會調(diào)用append方法用于逐個(gè)添加要生成的字符串,并計(jì)算得到對應(yīng)的行列信息。
//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);//用于遍歷,執(zhí)行節(jié)點(diǎn)下的節(jié)點(diǎn)的方法
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方法會去計(jì)算生成代碼的行列信息,并將生成代碼的行列信息和原始代碼的行列信息傳遞給SourceMap對象,SourceMap對象將前后位置信息對應(yīng)起來并進(jìn)行編碼從而生成最終的SourceMap。
//packages/babel-generator/src/buffer.ts
class Buffer {
constructor(map?: SourceMap | null) {
this._map = map;
}
//用于記錄生成代碼的位置
_position = {
line: 1,
column: 0,
};
//第1步:執(zhí)行內(nèi)部_append方法
append(str: string): void {
this._flush();
const { line, column, filename, identifierName } = this._sourcePosition;
this._append(str, line, column, identifierName, filename);
}
//第2步:計(jì)算傳遞進(jìn)來的字符串參數(shù)對應(yīng)的位置
_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) {
//排除開頭是換行符的情況,其他情況執(zhí)行標(biāo)記
this._mark(line, column, identifierName, filename);
}
// Now, find each reamining newline char in the string.
while (i !== -1) {
//2-1.當(dāng)存在換行符時(shí),改變行數(shù)
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);//改變行數(shù),行數(shù)+1
}
i = str.indexOf("\n", last);//尋找下一個(gè)換行符
}
//2-2.改變列數(shù),列數(shù)加上字符的長度
this._position.column += str.length - last;
}
//第3步:調(diào)用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步:將前后行列信息對應(yīng)起來后對位置信息進(jìn)行編碼
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,
},
});
}
}
相關(guān)鏈接
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