Sourcemap入门

本文介绍 webpack 构建 web 应用的时候生成 sourcemap 的相关基础知识。
sourcemap 不仅适用于 chrome 浏览器,也适用于其它很多现代浏览器,本文主要针对 chrome

一、sourcemap 基本信息

当前前端行业,js/css 代码混淆压缩已经是基本操作了,尤其是es6、typescript、react、vue 等框架的模板语法的出现,配合 webpack 打包几乎已经成了前端实质性的标准规范了。
这种模式好处不必多,用过的都说好。

当然,构建之后代码打包以及混淆也有其弊端,其中最主要的问题就是不便于错误定位,很难进行精准的代码 debugger 调试。
sourcemap 就是解决这个问题的一个办法,它的作用是记录打包和压缩混淆过程。
根据这些记录就能很方便的将混淆打包过的代码进行还原。

下面就是一个配置并引入了 sourcemap 文件的的页面,其控制台脚本如下所示:

控制台中的sourcemap文件
在控制台的 Sources 栏目下,可以看到构建后的代码、sourcemap 路径地址以及根据 sourcemap 解析之后的原始代码。

是怎么做到显示原始代码的呢?

共分为3步,打包 —— 关联 —— 解析

第1步:打包,webpack 打包构建的时候将打包过程、压缩方式以特定的形式记录存储起来,并保存为 xxx.js.map 文件。
第2步:关联,sourcemap 和打包文件的关联形式就是将下面这行注释追加到 xxx.js 文件的最后一行。

1
2
// xxx.js
//# sourceMappingURL=http://127.0.0.1/dist-sourcemap/xxx.js.map

sourceMappingURL 可以是相对路径,也可以是完整的网络请求链接,相对路径的时候默认为页面域名。

除了直接将 sourceMappingURL 打包在 js 代码里面,还可临时手动添加
可以在源码区域 右键 —— Add source map 手动添加 sourcemap 文件(仅支持 http:// 或 https 的路径,亲测 file:// 路径的不行,若需要添加本地的文件,可以在本地使用本地服务(如:http-server)启动一个临时静态服务器)。

手动添加 sourcemap 路径

注意:不论是网络链接还是本地路径,xxx.js.map 的加载记录都无法在 network 栏下面查看到。

若想查看 .map 文件的请求,可以点击控制台右上角的“...” —— More Tools —— Developer Resource查看。

查看map文件的请求记录

使用此方式查看,无论是网络链接还是本地链接,都可以看到 .map 文件的加载记录。

map文件的请求记录

第3步:解析,当打开 chrome 控制台的时候,如果发现 js 文件的最后一行有上面这个 sourceMappingURL,chrome 浏览器会 自动加载 此文件并自动解析。
解析后的文件为原始代码文件,会出现在source —— Authored下面(第一张图所示),可以直接针对此目录下的文件进行断点调试。

整个 sourcemap 解析还原过程 chrome 都帮忙做了,我们需要做的就一件事,就是在 js 文件最后一行 或者 以手动添加的方式加上 sourcemap 路径即可。

二、如何构建sourcemap

因为我们是用的是 webpack 进行打包的,而 sourcemap 的生成和打包是同步的。
因此,要想构建 sourcemap 就只需要在 webpack 打包的过程中做相应的配置。

devtool

1
2
3
4
5
// webpack.config.js
module.exports = {
// ...
devtool: 'source-map', // false | eval-source-map | ...
};

最简单的方式就是 在 webpack 加上 devtool 配置,此配置项能控制 sourcemap 是否生成,如何生成。
devtool 设置的值有很25+种,不同的值会明显影响到构建(build)和重新构建(rebuild)的速度。
当然,我们不用刻意去记忆,它的命名是有规律的,其具体规则顺序如下。

1
[inline-|hidden-|eval-][nosources-][cheap-[module-]]source-map.

总体结构由上面几个模块组成,括号里面的部分可以省略

各个模块对应的意义在文末的 webpackDevtool 官方文档链接中有做详细的说明,这里不详细赘述。

建议
若需要在 发布环境(线上、qa测试环境、预发环境等) 调试应用,建议直接启用最完整版 source-map,此版本构建 速度最慢,但是包含的信息最完整,包含了源码、行、列等信息。
若需要在开发过程中调试应用,如:npm run dev,建议使用eval-**
这个构建模式的 sourcemap 只有源行将被映射,列信息将被丢弃,但是构建速度快,开发过程中基本够用。

当然,也可以选择其它需要的配置,具体配置的优缺点可以在文末的官网文档链接中查看对比。

三、sourcemap 官方插件

前面提到的直接对 devtool 配置已经可以实现 sorcemap 的生成了。
然而,其生成的外联 soucemap 在js代码中是一个相对地址。

1
2
3
// xxx.js
// ...文件内容...
//# sourceMappingURL=dist-sourcemap/xxx.js.map

这种路径地址如果上线,打开控制台发起 map 资源请求的时候会默认使用页面的域名,这显然不符合我们的预期。

我需要的是一个完整的指向本地的一个 http 链接地址
比如:

1
2
3
// xxx.js
// ...文件内容...
//# sourceMappingURL=http://127.0.0.1/dist-sourcemap/xxx.js.map

这个也不难实现。
对于 sourcemap,webpack 官方推荐的有两个插件SourceMapDevToolPlugin/EvalSourceMapDevToolPlugin(已内置到 webpack 里面的,无须单独安装)。

其中 SourceMapDevToolPlugin 插件就可以做到。

1
2
直接使用 SourceMapDevToolPlugin/EvalSourceMapDevToolPlugin 可以替代使用 devtool 选项
这两个插件是对 devtool 配置的补充,进行更细粒度的配置,它有更多的选项,可以配置 soucemap 的绝对地址等。

使用方式如下:

1
2
3
4
5
6
// webpack.config.js
module.exports = {
// ...
devtool: false, // source-map | eval-source-map | ...
plugins: [new webpack.SourceMapDevToolPlugin({})],
};

注意:不要同时使用 devtool 选项和 SourceMapDevToolPlugin/EvalSourceMapDevToolPlugin 插件。
使用这两个插件的时候,需要将 devtool 设置为 false。
因为 devtool 选项已在内部添加过这些插件了,如果二者同时使用,将会应用两次插件。

四、两个问题

通过插件,理论上已经能够实现我们的需求了,然而,理想是美好的,现实却有点残酷。
在实现的过程中,我遇到了两个问题。

1
2
3
1.完全模式构建慢,每次开发、测试、线上都需要各自构建一次,浪费大量的时间
2.本人在使用 umijs + webpack4 + SourceMapDevToolPlugin 插件的时候无论如何都生成不了外联的 sourcemap 文件(感觉是 bug)。
umijs + webpack4 + EvalSourceMapDevToolPlugin 却能正常生成,但是是内联的 soucemap,不符合预期。

1.完全模式构建非常慢?
如果我们使用了 source-map 模式,不可避免的会被极慢的构建速度所困扰,但是想要得到更多的信息就只能让它做更多的活,做更多的活当然需要耗费更多的时间。

构建速度我是很难优化了,但是能否减少构建次数呢?
能否 build once run every where(构建一次,跑在所有的地方^_^)。

这理论上是可行的。
因为我们 webpack 构建一般都是用的 文件内容 hash 的形式重命名了文件的,只要文件内容不变,无论构建多少次,文件名都不会变。
同样,map 文件也是不变的,这就意味着,map 文件是跟着代码内容变化而变化的。

既然如此,那么我们 QA环境、预发、线上环境是不是可以用同一套?
我们只需要在闲时(比如QA环境)构建发布一次 sourcemap,其它时候直接跳过 soucemap 构建,仅构建工作代码就行了。

1
2
// 比如以环境变量的形式来控制
devtool: process.env.USE_SOURCE_MAP ? 'source-map' : false,

经过实践之后发现一个问题:
那就是当 devtool 设置 false 的时候不仅不构建 map,就连js 文件末尾的那一行注释同样也不会加上

2.umijs +webpack4 + SourceMapDevToolPlugin 不生成外联sourcemap
webpack 的官方插件理论上是可以实现配置 soucemap 的绝对地址的。
然而,本人在使用 umijs +webpack4 + SourceMapDevToolPlugin的时候却怎么都生成不了外联的 sourcemap 文件。

刚开始以为是自己配置不对,后经多次测试发现:
同样的配置使用 EvalSourceMapDevToolPlugin 打包为内联的 sourcemap 文件就没有问题。
不用插件直接devtool: 'source-map'也没有问题。

所以有理由相信,这是 umijs + SourceMapDevToolPlugin 组合之后产生的的一个 bug。

五、devtool + 原创插件

前面第一个问题都还好,大不了每次都构建 sourcemap,最多耗费更多的时间。
第二个问题就蛋疼了,如果不解决那就无法继续下去了。

于是在多次尝试无果之后,本人决定放弃使用此插件,改为直接使用 devtool。

当然,直接使用 devtool 就没办法将 sourceMappingURL 的路径改为网络路径而使用外部 sourcemap 了。
为了解决这个问题,我决定自己写一个插件

初步分析,这个插件不会很麻烦,毕竟最复杂的部分 devtool 都帮我做了。
我要做的仅仅是将 devtool 生成打包好的 js 代码最后一行中的 sourceMappingURL 的相对路径替换成绝对路径

而且,既然决定自己做插件,那么就顺带把前面的第一个问题也解决了吧,反正也不麻烦。
只需要在前面这个自定义插件实现的基础上加一个判断。

1
2
3
4
5
6
7
8
9
// ...
if (process.env.USE_SOURCE_MAP) {
// 构建 soucemap 的时候替换最后一行
content = content.replace(/\/\/# sourceMappingURL=.*\.map$/, newSourceMapingURLStr)
} else {
// 非构建 soucemap 的时候追加至文件的最后一行
content = content + `\r${newSourceMapingURLStr}`;
}
// ...

这样就可以实现 build once run every where了。


六、原创插件及其用法

插件的 webpack.config.js 配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// webpack.config.js
import webpack from 'webpack';
import MySourceMapDevToolPlugin from './my-source-map-dev-tool-plugin.js'

module.exports = {
// 构建 sourcemap,当且仅当需要 sourcemap 的时候配置 source-map
devtool: process.env.USE_SOURCE_MAP ? 'source-map' : false,
// 无论是否build sourcemap,都加载此插件,在插件内部判断。
plugins: [new MySourceMapDevToolPlugin({
webpack,
publicHost: 'http://127.0.0.1',
publicDir: 'dist-sourcemap',
})]
}

插件代码

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
// 适用 webpack4
// my-source-map-dev-tool-plugin.js
import fs from 'fs';
import path from 'path';

function MySourceMapDevToolPlugin({ publicHost, publicDir, webpack }: { publicHost: string, publicDir: string, webpack: any, }) {
const { RawSource } = webpack.sources;
MySourceMapDevToolPlugin.prototype.apply = function (compiler) {
if (!publicDir || !publicHost || !webpack) return;
// 清理上次的生成文件
if (fs.existsSync(path.resolve(__dirname, publicDir))) {
fs.rmdirSync(path.resolve(__dirname, publicDir), { recursive: true });
}

compiler.hooks.emit.tapAsync('MySourceMapDevToolPlugin', (compilation, callback) => {
const { assets } = compilation;
// 遍历所有编译过的资源文件,修改最后的 sourceMappingURL 路径都添加一行内容。
Object.entries(assets).map(([fName, fContent]: any) => {
if (!fName) return;
if (/\.js$/.test(fName)) {
let content = fContent?.source();
if (typeof content !== 'string') return; // 这里可能有其它文件是二进制格式的,比如一些js资源
const newSourceMapingURLStr = `//# sourceMappingURL=${publicHost}/${publicDir}/${fName}.map`

if (process.env.USE_SOURCE_MAP) {
// 构建 soucemap 的时候替换最后一行
content = content.replace(/\/\/# sourceMappingURL=.*\.map$/, newSourceMapingURLStr)
} else {
// 非构建 soucemap 的时候追加至文件的最后一行
content = content + `\r${newSourceMapingURLStr}`;
}
compilation.assets[fName] = new RawSource(content);
} else if (/\.js\.map$/.test(fName)) { // 迁移 map 文件的位置
delete compilation.assets[fName]
compilation.assets[`../${publicDir}/${fName}`] = fContent;
}
})
callback();
})
};
}

export default MySourceMapDevToolPlugin;

七、其它

sourcemap 文件请求发起时机
附录在 js 代码最后一行的 sourcemap 文件链接地址正常情况下不会去请求,只有当 ”浏览器控制台“ 打开的时候才会去请求。

不建议为 sourcemap 文件使用相对路径,sourcemap 文件也不建议开放外网&线上访问
因为相对路径的请求会默认请求到业务域名上去,一个原因是会造成不必要的垃圾请求,另一个原因就是 sourcemap 本身不建议部署到应用服务器上,因为这会让一些坏人更容易获取、解析和调试到应用的源码,造成未知风险

sourcemap 文件本地访问方法
可以配置比如 127.0.0.1:80 这一类的本地请求地址,然后在本地启动一个静态服务
当本地服务启动之后,sourcemap 中的 sourcemap 资源中配置的 127.0.0.1:80/sourcemap/a.js.map 地址就会请求到本地服务器了。
而本地服务器要做的就是作为代理,将 map 资源请求转发或者直接返回对应的 sourcemap 文件内容。

相关链接

webpackDevtool
devtool各个配置构建demo
http-server