背景
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.0
在2010
年推出, 确定了统一的json
格式和其余规范, 同时也使用了base64
编码, 与现代版本的最大区别是使用的算法不同, 生成的.map
体积会大很多
sourcemap 3.0
在2011
年完善, 成为一项独立的工具, 并使用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; };
{ "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 = {};
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); }, );
|