SourceMap是什么
像C++、OC等语言的编译器,在编译的时候会生成符号文件,对外无需发布这些符号文件,而当有异常上报或本地Debug二进制文件时,可以帮助开发人员将二进制尽可能还原成源码级别(好像无法完全还原)进行调试或进行错误分析。
而前端工程中的SourceMap也是类似的功能。我们构建前端工程时会做这么多动作:
- 各类型文件处理,如vue、jsx、less、ts等
- es6+ 转 es5
- 代码压缩
- 代码混淆
- …
经过这些处理后,代码往往被处理得面目全非了,变量名也变成了无意义的字母,感受下:
被处理成这样子了,要调试是很困难的,而SourceMap就是在编译阶段由构建工具生成的源码与目标代码的对照表,所以,我们可以通过SourceMap将打包后的代码完美还原为源代码:
既然SourceMap有这么强大的作用,我们必然不能将其暴露到生产环境,不然我们辛辛苦苦开发的功能会被有心人直接下载,稍加修改就改头换面上线了。这也就是为什么会有本文章的原因。至于SourceMap的原理,大家可以参考阮一峰的JavaScript Source Map 详解
webpack下的SourceMap
webpack项提供了devtool选项来配置是否需要在构建的时候生成SourceMap,需要的话生成何种SourceMap。
咦,SourceMap不就是那个什么映射文件么,怎么在webpack这边还分品种了呢?实际上,不同的人对构建速度、SourceMap对编译后代码的还原程度、SourceMap的安全问题都有不同的要求,为了满足不同人的要求,同时又降低配置难度,webpack只给devtool
提供了13个单选值,开发者根据需求选择就好了(说得好轻松呀:只有13个):
该用哪个?我好方!我好南!
不过其实静下心来仔细看看说明,搞清三个概念,再配合你对构建速度的要求、安全问题就能很快抉择了:
以SourceMap对js文件的还原度为例
- 打包后的代码: 就是最开始那张截图,基本无法调试
- 生成后的代码:经webpack处理了模块化依赖、经babel等loader处理了es6+转es5的代码,但保留了模块信息
- 转换过的代码:经babel等loader处理了es6+转es5的代码,还未处理模块化(可以看到import之类的语法)
- 原始源代码:和我们开发中的源代码一致,还原度非常高
- 仅限行:由于SourceMap中没有列信息,所以调试只能在行级别,无法进行单行多表达式调试
记住上面几个概念,我们就能很快通过查表确定自己需要哪种SourceMap了。而我们若想通过这些配置项名称快速决定使用哪种SourceMap,请继续看。
我们仔细对比可以发现:
- 名字中带
eval
的(重新)构建速度一般比其他的快 - 名字中带
cheap
的没有列信息,只能进行行调试,同类型SourceMap带cheap比不带cheap速度要快一些 - 名字中带
module
都能映射原始源代码 - 名字中带
inline
和eval
的都不能用于生产环境 - 还原度最高的是
source-map
、eval-source-map
、hidden-source-map
注:带
inline
与eval
的不能用于生产环境是因为这两者生成的SourceMap是内嵌在构建完成的js代码中的,会在生产环境直接暴露源代码。
怎么选?
开发环境,追求重新构建速度,同时也要高度还原代码,可选:eval-source-map
或cheap-module-eval-source-map
,这二者可以自己抉择,后者速度更快、生成的包体积更小,但无法进行行内调试。
生产环境,又要安全,又要高还原度,不在乎打包速度,那么可选source-map
与hidden-source-map
。
生产环境的SourceMap
为什么在生成环境需要SourceMap呢?试想下,如果生成环境出现什么Bug,而在其他环境不容易复现,最简单的方式就是直接在生产调试,如果没有SourceMap,这个调试过程势必十分痛苦。
上面我们提到了,生产环境可选的两种配置,他们的区别在于:source-map
会在构建后的js文件末尾添加类似:
//# sourceMappingURL=jquery.min.map
的注释,告知浏览器该文件对应的SourceMap在何处下载,而hidden-source-map
则仅仅生成SourceMap,不会告知浏览器该去哪寻找SourceMap还原代码。这里我们面临两个问题:
- 选择
source-map
,我们的SourceMap路径会暴露在外面,如果我们的浏览器能下载,那么别人一样可以下载 - 选择
hidden-source-map
,该怎么告诉浏览器我们的SourceMap在何处呢?
针对问题2,Chrome确实提供了针对单个js文件手动导入SourceMap的方式,方式是在开发者工具的Sources
面板,找到需要调试的js文件,右键代码区域,在弹出的菜单中点击Add source map...
,然后填入这个文件对应的SourceMap文件地址,弊端就是:得针对构建完成的文件一个个手动添加,而且一刷新就没了,需要再次手动添加。
ps: 实际上
hidden-source-map
更多的场景是用于类似于sentry这样的异常监控平台,在监控到线上的异常后上传对应的SourceMap文件,精确分析错误。
针对问题1,我们可以在构建后将SourceMap上传至一个仅内网可访问的地方,这样,同样打开控制台,我们自己的人可以进行源码调试,而其他人则没办法。但是,这样就没问题了嘛?没问题说明你的日子还是太安逸啊,居然没有在休假的时候被打电话告知线上出现了一个bug,需要紧急定位一下,这时候,我们用VPN拨号回公司确实可以解决问题,那么还有没有其他方式呢?
只要努力去想,方法的数量总是高过问题的。
我这里借助gitlab来实现,相信很多公司都部署了私有的gitlab,同时为了方便小伙伴在任何地方都方便提交代码,是允许外网授权访问的。gitblab中的项目权限有一项:“内部”,也就是仅内部员工登录后可查看,这就符合我们的需求了。
借力gitlab
心路历程:其实我一开始是去尝试用gitlab的代码片段(类似于GitHub的gist)来做这个事情,但是后面发现其比gist的功能要弱很多,只支持单文件,只好放弃。
依据我们的需求,webpack提供的几种SourceMap已经无法满足我们的需求了,需要通过SourceMapDevToolPlugin插件来定制需求,该插件实现了对SourceMap生成内容进行更细粒度的控制,说白了,上面预制的一些选项都可以通过该插件配置而来。
先来确定下我们的需求:
- 完整的SourceMap
- SourceMap地址需要以自定义地址的形式注释在js文件的末尾
SourceMapDevToolPlugin很容易达成该要求:
new webpack.SourceMapDevToolPlugin({
append: `\n//# sourceMappingURL=[url]`,
filename: '[file].map',
publicPath: 'https://gitlab.xxx.com/xxxx/sourcemap/raw/dev',
fileContext: 'js',
include: /\.jsx?$|\.vue$/,
})
这样,我们构建完成的js文件最后都会带上诸如如下的SourceMap路径注释了:
//# sourceMappingURL=https://gitlab.xxx.com/xxxx/sourcemap/raw/dev/xxxx.map
另一边,我们还需要将map文件给提交到项目xxxx/sourcemap
的dev
分支才行,要拿到SourceMap文件并上传,需等到资源构建完成或者webpack的整个流程完成,我们这里选择用插件的形式在资源构建完成的时候push至gitlab,先完成插件:
const path = require('path');
const fs = require('fs');
class SourcemapWebpackPlugin {
constructor(handler) {
if (typeof handler !== 'function')
throw new TypeError('SourcemapWebpackPlugin构造函数参数只能是函数');
this.handler = handler;
}
apply(compiler) {
// 监听`afterEmit`事件
compiler.hooks.afterEmit.tapAsync('SourcemapPlugin', (compilation, callback) => {
// 取得所有构建出来的资源
const assets = compilation.assets;
const files = [];
Object.keys(assets).forEach(fileName => {
// 我们只care map文件
if (/\.map$/.test(fileName)) {
// 拧出文件完整路径、文件名、文件内容
files.push({
path: assets[fileName].existsAt,
name: path.basename(fileName),
// 不用到的时候不要取出来
get content() {
return assets[fileName].source();
},
});
}
});
// 丢给插件使用者自行做上传处理
this.handler(files, err => {
// map文件清理,防止发布到线上
files.forEach(file => fs.unlink(file.path, () => {}));
callback(err);
});
});
}
}
gitlab文件的push需要用到这个API,然后事情就简单了:
const got = require('got');
const qs = require('qs');
new SourcemapWebpackPlugin((files, callback) => {
console.log('开始上传SourceMap...');
const actions = files.map(file => ({
action: 'create',
file_path: file.name,
content: file.content,
}));
const data = {
actions,
// 事先创建一个空白的master分支,然后在此基础上分出dev
// 以后每次都是以master为基础,对dev进行commit
// 配合 force: true 选项,每次构建都能清理上次提交的map文件
force: true,
branch: "dev",
start_branch: "master",
commit_message: 'commit at sourcemap',
};
return got
.post(`https://gitlab.xxx.com/api/v4/projects/xxx%2F/sourcemap/repository/commits`, {
body: qs.stringify(data, { arrayFormat: 'brackets' }),
headers: {
'PRIVATE-TOKEN': "<YOUR_ACCESS_TOKEN>",
'Content-Type': 'application/x-www-form-urlencoded',
},
}).then(() => callback()).catch(err => callback(err.body));
// plugin hook的callback正常情况下不能传参,传参代表失败
});
至此,全部工作完成,可以build构建试试,如果没得效果,请确认当前浏览器是否登录了gitlab,是否有权限打开xxx/sourcemap
这个项目。
任何框架、方法都不是银弹,只有适合自己实际情况才是王道,并没有完美的最佳实践。
《完》