JS sourcemap 原理与解析

背景

JavaScript作为一种运行在客户端的脚本语言, 安全性和代码文件体积是需要重点关注的事情, 而这两项都已经有了比较成熟的解决方案, 就是混淆与压缩, 其中比较出名的就是UglifyJS工具, 常见的webpack打包就是使用由uglify-es迭代而来的terser作为JavaScript代码的压缩工具.

使用压缩工具后, 代码的安全性和体积都得到了优化, 但是随之而来的也会出现一个问题: 线上报错时候无法定位具体原因, 所有的报错都会变成形如1.0a1b2c3d.js:3:4567这种无法直接看懂的调用栈, 原因是因为压缩后的代码会都集中在几行, 而且变量名和语法也会压缩. 有什么解决办法吗? 有的, 在压缩的时候我们可以将原始行/列/变量名/文件和压缩后的代码位置记录下来, 提供一份映射表, 这样在查看报错的时候就能根据这份记录定位到具体报错的源码了, 这也就是sourcemap的原理.

sourcemap历史

  • sourcemap 1.0 出现在2009年, 由google推出的Closure Inspector配合Cloure Compiler压缩工具, 支持调试编译后的代码
  • sourcemap 2.02010年推出, 确定了统一的json格式和其余规范, 同时也使用了base64编码, 与现代版本的最大区别是使用的算法不同, 生成的.map体积会大很多
  • sourcemap 3.02011年完善, 成为一项独立的工具, 并使用VLQ编码生成base64前的mapping, 减少了.map文件的体积, 后续逐渐被各浏览器实现, 成为我们现在使用的开发工具.

.map文件

来看一段简单的代码和编译内容

源码如下

1
const square = (x) => x * x;

babel编译后的结果和.map文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
'use strict';

var square = function square(x) {
return x * x;
};

//# sourceMappingURL=test.js.map

{
"version": 3,
"sources": ["test.es6.js"],
"names": [],
"mappings": ";;AAAA,IAAM,MAAM,GAAG,SAAT,MAAM,CAAI,CAAC;SAAK,CAAC,GAAG,CAAC;CAAA,CAAC",
"file": "test.js",
"sourcesContent": ["const square = (x) => x * x;"]
}

.map文件里字段的简介

  • version: 版本号, 目前固定都是v3
  • sources: 编译前代码的源文件
  • sourcesContent: 源文件内容, 可以没有可以有, 一般生产环境不会记录, 可能会泄露源码
  • names: 原文件中的变量名与属性名, 浏览器断点能看见变量内容, 控制台却不能调用的原因就是这里
  • mapping: 映射的json, map文件的核心内容
  • file: 编译后的文件名

Mappings格式

1
';;AAAA,IAAM,MAAM,GAAG,SAAT,MAAM,CAAI,CAAC;SAAK,CAAC,GAAG,CAAC;CAAA,CAAC';

mapping由固定格式组成

  • 分号: 代表分隔行
  • 逗号: 代表分隔列
  • 其他字母: VLQ编码, 有可能是1/4/5
    • 第一位,表示这个位置在(转换后的代码的)的第几列。
    • 第二位,表示这个位置属于sources属性中的哪一个文件。
    • 第三位,表示这个位置属于转换前代码的第几行。
    • 第四位,表示这个位置属于转换前代码的第几列。
    • 第五位,表示这个位置属于names属性中的哪一个变量。

VLQ(Variable Length Quantities)编码

这种编码最早用于MIDI文件,后来被多种格式采用。它的特点就是可以非常精简地表示很大的数值。
理解VLQ两个要解决的问题, 就很好理解VLQ的设计原理了

  • 如何解决数字连续: 12如何表示为十二, 又如何表示为一和一
  • 如何解决正负

VLQ编码是变长的。如果(整)数值在-15 到+15 之间(含两个端点),用一个字符表示;超出这个范围,就需要用多个字符表示。它规定,每个字符使用6个二进制位,正好可以借用Base64编码的字符表。

VLQ每一小节由6位组成, 将每节的第一位用于标识是否连续, 最后一位代表正负, 同时, 连续的情况下, 不需要最后一位来代表真正负, 有五位可以用于计数

VLQ编码实例

下面看一个例子,如何对数值16进行VLQ编码。

1
2
3
4
5
6
7
第一步,将16改写成二进制形式10000。
第二步,在最右边补充符号位。因为16大于0,所以符号位为0,整个数变成100000。
第三步,从右边的最低位开始,将整个数每隔5位,进行分段,即变成1和00000两段。如果最高位所在的段不足5位,则前面补0,因此两段变成00001和00000。
第四步,将两段的顺序倒过来,即00000和00001。
第五步,在每一段的最前面添加一个"连续位",除了最后一段为0,其他都为1,即变成100000和000001。
第六步,将每一段转成Base 64编码。
第七步,查表可知,100000为g,000001为B。因此,数值16的VLQ编码为gB。

解析sourcemap

看完原理之后, 就需要实现sourcemap的解析了. 好在有source-map这个库, 已经将上面的这些内容完全封装好, 直接使用即可, 不过还是有些实际项目中会碰到的问题需要解决

从堆栈中获取错误源信息

如需要从http://localhost/static/js/1.0a1b2c3d.js:3:4567这个信息中获取文件/行号/列号

好在现在浏览器的错误堆栈格式都没有太大差异, 使用一个正则就能获取到这些信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
let splitChar = '\r\n';
splitChar = trace.split(splitChar).length > 1 ? splitChar : '\n';
const lines = trace.split(splitChar);
const reg = new RegExp(`(^|[@( ])https?://(?<host>[a-z0-9-_.:]+/)(?<file>.+?):(?<line>\\d+):(?<column>\\d+)\\)?$`, 'i');
const linesMatch = lines.map((x) => {
const match = x.match(reg);
if (!match) {
return {
resolve: true,
msg: 'not match file regex',
result: x,
};
}
const { file, line, column } = match.groups;
return {
resolve: false,
file: file,
fileLower: _.toLower(file),
line: parseInt(line),
column: parseInt(column),
source: x,
error: null,
};
});

根据错误文件获取.map文件

.map文件是在系统上线前上传到后端统一存储到s3中, 并且带上额外的路径信息, 在上一步解析到文件名后可以根据版本号加文件名加路径信息从本地缓存和s3下载到原始的.map文件

额外的路径信息用于解决前置代理或者部署路径与打包路径不一致的问题
比如打包文件位于/static/js/1.0a1b2c3d.js
但是线上访问路径是/web/static/js/1.0a1b2c3d.js

文件的缓存和s3读取

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
class FileService extends Service {
async getFile(project, client_v, file) {
const symbolInfo = {}; // 根据文件获取到sourcemap文件信息

const filepath = symbolInfo.s3path;
const filemd5 = crypto.createHash('md5').update(filepath).digest('hex');
const cacheFileName = `${this.config.tmpCache.path}${filemd5}.gz`;

let fileData = null;
try {
const fileReadStream = fs.createReadStream(cacheFileName);
fileData = await this.streamToBuffer(fileReadStream);
} catch (e) {
try {
if (symbolInfo.s3path.startsWith('weed://')) {
const filepathMatch = symbolInfo.s3path.match(/weed:\/\/([^/]+)\//);
const response = await this.ctx.curl(`${this.config.dumpx.weed_url}/${filepathMatch[1]}`, { followRedirect: true });
fileData = response.data;
} else {
const fileReadStream = await this.service.s3.getS3FileReadStream(this.config.s3.symbolsBucket, filepath);
fileData = await this.streamToBuffer(fileReadStream);
}

try {
// 即使缓存写入失败不影响解析
await writeFileAtomic(cacheFileName, fileData);
} catch (err) {
this.ctx.logger.error(err);
}
} catch (e) {
throw new Error(`Get S3 file error: ${filepath}, ${e}`);
}
}
return (await ungzip(fileData)).toString('utf-8');
}
streamToBuffer(stream) {
return new Promise((resolve, reject) => {
const buffers = [];
stream.on('error', reject);
stream.on('data', (data) => buffers.push(data));
stream.on('end', () => resolve(Buffer.concat(buffers)));
});
}
}

使用.map文件解析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
const fileGroup = _.groupBy(
linesMatch.filter((x) => !x.resolve),
'fileLower',
);
const linesResolve = _.map(fileGroup, async (lines, filepath) => {
let map = null;
try {
const mapData = await this.service.file.getFile(project, client_v, filepath);
map = await new sourceMap.SourceMapConsumer(mapData);
lines.map((x) => {
const result = map.originalPositionFor({
line: x.line,
column: x.column,
});
if (_.isNull(result.source)) {
throw new Error(`resolve code with sourcemap error`);
}
x.resolve = true;
x.result = x.source.replace(
`${x.file}:${x.line}:${x.column}`,
`${result.source.indexOf('.') !== -1 ? result.source : x.file}:${result.line}:${result.column}`,
);
});
} catch (e) {
lines
.filter((x) => !x.resolve)
.map((x) => {
x.resolve = true;
x.result = x.source;
x.error = _.toString(e);
});
} finally {
// 一定要销毁, 否则会持久占用内存
map && map.destroy && map.destroy();
}
return lines;
});
await Promise.all(linesResolve);
const source = linesMatch.map((x) => x.result).join(splitChar);
const errors = _.compact(linesMatch.slice(0, 5).map((x, i) => (x.error ? { line: i, error: x.error } : null)));

return {
source: source,
errors: errors,
};

附录: 上传.map文件

webpack打包完成后, 自动检测目录下的.map文件, 上传到服务端保存

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
const AdmZip = require('adm-zip');
const fs = require('fs');
const path = require('path');
const _ = require('lodash');
const request = require('request');
const pkg = require('../package.json');

const buildPath = path.join(__dirname, '../build');

const zipFile = path.join(__dirname, '../source-map.zip');

var sourcemapFileList = [];
const getAllSourcemap = (dir) => {
var arr = fs.readdirSync(dir);
arr.forEach(function (item) {
var fullpath = path.join(dir, item);
var stats = fs.statSync(fullpath);
if (stats.isDirectory()) {
getAllSourcemap(fullpath);
} else if (fullpath.endsWith('.map')) {
sourcemapFileList.push(fullpath.replace(/\\/gi, '/'));
}
});
return sourcemapFileList;
};

getAllSourcemap(buildPath);

const zip = new AdmZip();

_.map(sourcemapFileList, (file) => {
const relativePath = path.relative(buildPath, file);
console.log(`Get sourcemap: ${relativePath}`);
zip.addLocalFile(file, path.dirname(relativePath));
});

zip.writeZip(zipFile);

request.post(
{
url: 'https://......',
headers: {
'Content-Type': 'multipart/form-data',
'x-token': process.env.X_TOKEN,
},
formData: {
project_code: '',
os_type: '',
client_v: `${pkg.version}(${appSmallVersion})`,
js_sourcemap: fs.createReadStream(zipFile),
},
},
function optionalCallback(err, httpResponse, body) {
if (err) {
throw new Error('upload failed:', err);
}
console.log('Upload successful! Server responded with:', body);
},
);
作者

Mosby

发布于

2019-08-18

许可协议

评论