Source map
, 学习完毕,到如今写一篇博客记录收获。随着时代的发展,JavaScript 脚本正变得越来越复杂。大部分源码(尤其是各种函数库和框架)都要经过转换,才能投入生产环境。
常见的源码转换,主要是以下三种情况:
(1)压缩,减小体积。比如 jQuery 1.9 的源码,压缩前是 252KB,压缩后是 32KB。
(2)多个文件合并,减少 HTTP 请求数。
(3)其他语言编译成 JavaScript。
这三种情况,都使得线上实际运行的代码不同于开发时的代码,调试代码排查问题就变得困难重重。
通常,JavaScript 的解释器会告诉你,第几行第几列代码出错了。但是,这对于转换后的代码毫无用处。举例来说,jQuery@1.9 压缩后只有 3 行,每行 3 万个字符,所有内部变量都改了名字。你看着报错信息,感到毫无头绪,根本不知道它所对应的原始位置。
这就是 Source map 想要解决的问题。
编译后的 Vue.js 的源码
简单来说 Source map 就是一个存储信息的文件,里面储存着位置信息。
- Source map 英文释义:源程序映射。
- 位置信息:
转换后的代码
对应的转换前的代码
位置映射关系。
有了 Source map,就算线上运行的是转换后的代码,调试工具中也可以直接显示转换前的代码。这极大的方便了我们开发者调试和排错。
只要在转换后的代码尾部,加上一行如下代码即可。
//# sourceMappingURL=main.js.map
注意
=
后的名称,依据对应 map 文件名定义;- map 文件可以放在网络上,也可以放在本地;
借助打包工具,在打包编译生成目标代码的同时,生成 Source map(也就是目标代码和源代码的映射关系文件)。
我用比较常见的打包工具:webpack,来演示一下如何生成 Source map。
NodeJs 请自行安装,版本需大于 8。
1. 创建一个 main.js 文件
alert('tomato')
2. 初始化项目 + 安装依赖
npm init -y
npm i webpack@5 webpack-cli -D
3. 创建一个 webpack.config.js 配置文件
const path = require('path')
module.exports = {
// 1.入口文件 从那个文件打包入口文件(相对路径)
entry: './main.js',
// 2.输出内容
output: {
filename: 'main.js', // 一个是文件名
path: path.resolve(__dirname, 'dist'), // 一个是输出路径(绝对路径)
},
// 3. 加载器
// module: {
// rules: [],
// },
// 4. 插件
// plugins: [],
// 5. 配置
devtool: 'source-map',
// 6. 模式 // development
mode: 'production',
}
4. 目前的文件结构
5. 开始打包
npx webpack
注意事项:
webapck@5
。devtool
可以配置的属性值有很多,本次演示就以 source-map
为例。(其他属性值后续会做讲解)6. 输出:
执行完上述命令后,会生成一个 dist 文件夹,其中有两个文件:
main.js
main.js.map
就以我们上述案例生成的 main.js.map
为例,我们来了解一下,它的内容结构是怎样的。
{
// version:Source map 的版本,目前为 3。
"version": 3,
// file:转换后的文件名。
"file": "main.js",
// sourceRoot:转换前的文件所在的目录。如果与转换前的文件在同一目录,该项为空。
"sourceRoot": "",
// sources:转换前的文件。该项是一个数组,表示可能存在多个文件合并。
"sources": ["webpack://app/./main.js"],
// sourcesContent:原始代码
"sourcesContent": ["alert('tomato')\r\n"],
// names:转换前的所有变量名和属性名。
"names": ["alert"],
// mappings:记录位置信息的字符串,下文详细介绍。
"mappings": "AAAAA,MAAM"
}
Source map 文件类似 JSON 格式,它存在 7 个属性。
以下是对每个属性的解释:
version:Source map 的版本,目前为 3。
- Source map 也是从无到有逐渐发展过来的。对比新旧的版本,它们存储信息的编码方式存在差异。
- 目前主流的版本为
3
, 它采用的编码方式是Base64 VLQ
,对标历史版本,文件体积精简很多。
file:转换后的文件名。
sourceRoot:转换前的文件所在的目录。
如果与转换前的文件在同一目录,该项为空。
sources:转换前的文件。
该项是一个数组,表示可能存在多个文件合并成一个目标文件。
sourcesContent:原始代码
sourcesContent
属性会存储对应的源代码。
names:转换前的所有变量名和属性名。
mappings:记录位置信息的字符串,。
存储位置信息的属性,下文详细介绍。
mappings 本身是一个字符串,其中有三个特点:
举例来说,假定 mappings 属性的内容如下:
"mappings":"AAAAA,BBBBB;CCCCC"
就表示:
例如 AAAAA
, 它对应那些信息呢?
第一位,表示这个位置在(转换后的代码的)的第几列。
第二位,表示这个位置属于 sources 属性中的哪一个文件。
第三位,表示这个位置属于转换前代码的第几行。
第四位,表示这个位置属于转换前代码的第几列。
第五位,表示这个位置属于 names 属性中的哪一个变量。
单纯存储 A
是没什么意义,我们重点关注 A
代表什么?
Source map 使用的是 Base64 VLQ 编码规则,具体的规则后续会写,目前直接演示转换结果:
转换演示:
示例一:
AAAAA
[0,0,0,0,0]
示例二:
ubAAAA
[439,0,0,0,0]
示例三:
MAOA,MALAA,QAAQC,IAAI,GAKN,K
[6,0,7,0], [6,0,-5,0,0], [8,0,0,8,1], [4,0,0,4], [3,0,5,-6], [5]
转换方法:
用工具解析 点击这里;
注意事项:
注意,并不是一个字母对应一个位置。是一组字母表示一个位置。例如 ubAAAA
解析为: [439,0,0,0,0]
;
我自己学习到这里的时候,想到这么一个问题:所有的字母加特殊符号,就算考虑大小写,它们能记录的情况肯定是有限的,而任意一个源码,一行就有上万行。
很显然一个字母对应一个位置,并不能记录上万行的内容,所以这里是一组字母对应一个位置。
上方讲述了一些概念知识,但是概念不是很好理解,至少我是有些绕的,现在用实际案例去演示一下如何解析的。
注意,为了节省空间,Source map 中使用的 Base64 VLQ 编码除了第一的字符外,剩余的计算都是相对位置的计算。
也就是相对于上次记录的位置的偏移量。
源码:
alert('tomato')
打包输出的代码:
alert('tomato')
//# sourceMappingURL=main.js.map
打包输出的 Source map 文件:
{
"version": 3,
"file": "main.js",
"mappings": "AAAAA,MAAM",
"sources": [
"webpack://app/./main.js"
],
"sourcesContent": [
"alert('tomato')\r\n"
],
"names": [
"alert"
],
"sourceRoot": ""
}
解析:
1. 输出的 'mappings':
"AAAAA,MAAM"
2. 对应的数字为:
[0,0,0,0,0], [6,0,0,6]
3. 对应的含义分别为:
转换后代码的第0列,第0个引入的文件,转换前的第0行,转换前第0列,第0个变量; 对应的就是 alert
转换后代码的第5列,第0个引入的文件,转换前的第0行第0列。没有变量; 对应的就是 ('tomato')
这个案例在案例一的基础上,添加一行注释,对比一下差异。 重点看一下源码
源码:
// lazy-tomato
alert('tomato')
打包输出的代码:
alert('tomato')
//# sourceMappingURL=main.js.map
打包输出的 sourcemap 文件:
{
"version": 3,
"file": "main.js",
"mappings": "AACAA,MAAM",
"sources": [
"webpack://app/./main.js"
],
"sourcesContent": [
"// lazy-tomato\r\nalert('tomato')\r\n"
],
"names": [
"alert"
],
"sourceRoot": ""
}
解析:
1. 输出的 'mappings': (历史的是 "AAAAA,MAAM")
"AACAA,MAAM"
2. 对应的数字为: (历史的是 [0,0,0,0,0], [6,0,0,6])
[0,0,1,0,0], [6,0,0,6]
3. 对应的含义分别为:
转换后代码的第0列,第0个引入的文件,*转换前的第1行*,转换前第0列,第0个变量; 对应的就是 alert
转换后代码的第5列,第0个引入的文件,转换前的第0行第0列。没有变量; 对应的就是 ('tomato')
可以看到对比案例一,案例二的差异:转换前的代码行数加一了。
源码:
function fn(a, b) {
return a + b
}
console.log(fn(1, 4))
打包输出的代码:
console.log(5)
//# sourceMappingURL=main.js.map
打包输出的 Source map 文件:
{
"version": 3,
"file": "main.js",
"mappings": "AAGAA,QAAQC,IAFCC",
"sources": [
"webpack://app/./main.js"
],
"sourcesContent": [
"function fn(a, b) {\r\n return a + b\r\n}\r\nconsole.log(fn(1, 4))\r\n"
],
"names": [
"console",
"log",
"a"
],
"sourceRoot": ""
}
解析
1. 输出的 'mappings':
"AAGAA,QAAQC,IAFCC"
2. 对应的数字为:
[0,0,3,0,0], [8,0,0,8,1], [4,0,-2,1,1]
3. 对应的含义分别为:
转换后代码的第0列,第0个引入的文件,转换前的第3行,转换前第0列,第0个变量; 对应的就是 console
转换后代码的第8列,第0个引入的文件,转换前的第0行,转换前第8列。第1个变量; 对应的就是 log
转换后代码的第4列,第0个引入的文件,转换前的第-2行,转换前第1列。第1个变量;
会发现第三个数据无法对应,我理解它对应的是函数 fn 的执行结果,也就是 5
;
- 数据无法对应,网上猜想的解释:为了加快编译速度,Source map 对于一些语法是不会计算偏移的,而是直接返回之前的偏移位置。准确的原因,待确定
具体的 Base64 VLQ
编码规则 点击这里
查看官方文档,可以了解到,devtools 的配置项可以达到 10-20 种左右的情况。其实并不需要记住那么多情况,本质上是一些配置项的排列组合。
配置项如下:
- 具体的组合效果,可自行尝试。
- webpack的 devtool 配置项排列顺序,规则:
^(inline-|hidden-|eval-)?(nosources-)?(cheap-(module-)?)?source-map$
。
我以一个 Vue2 项目为例:
在一个普通 Vue2 项目中,添加一行报错;
npm run build
打包一下我们的工程。
将打包输出的 dist
文件夹中的 .map
文件,剪切出来存放到本地。然后上传 dist 其他文件到服务器上,用以模拟调试线上代码的情况。
- 这里生成 Source map 的配置,可以自己灵活配置。
- 报错:
- 对应源码:
- 右键 - Add source map;
- 通过 file 协议选择本地的 map 文件,先在浏览器地址栏中输入确保可以访问到。
- 文件路径示例例:
file:///C:/Users/17607/Desktop/study/chunk-vendors.7723b084.js.map
- 可以直接将 Source map 文件拖拽到谷歌浏览器中,即可得到这个文件路径
- 已经映射到源码了
Vue 项目,如果使用的是 webpack作为打包工具,想要自定义 devtool 配置,可用如下方式:
// vue.config.js
module.exports = {
chainWebpack: (config) => {
config
.when(process.env.NODE_ENV === 'development, (config) => config.devtool('hidden-source-map'))
},
}
// 2. 当然还有一个属性 productionSourceMap,可以设置是否生成 Source map。
// productionSourceMap
上述演示生成 Source map,使用的是 webpack 来演示的,了解一下其他打包工具,如何生成 Source map;
1.新建一个打包配置文件: rollup.config.js
module.exports = {
input: './main.js',
output: {
file: './bundle.js',
format: 'cjs',
sourcemap: true,
},
}
2.安装依赖,开始打包
npm i -g rollup
rollup -c
# 注意一下,注意 NodeJs 版本不要太低。
提到这么一个 npm 依赖,是因为很久之前,番茄我有一次电脑进水了,导致本地 git 仓库丢失了部分 commit 的记录。(大白话来说,代码丢失了)
- 丢失的代码行数还是比较多的,一整天的工作成果;
- 在无法找回源代码的情况下,我发现最新 Source map 文件还存在。最后我通过这个工具,反编译 Source map 文件,找回了大部分我丢失的代码。
使用案例:
# 1. 全局安装此依赖
npm install --global reverse-sourcemap
# 2. 指定编译文件后输出的文件目录,指定编译什么文件;
reverse-sourcemap --output-dir outDir main.js.map
阅读源码:
看一下 reverse-sourcemap
的源码,源码就一个 js 文件,如下:
reverse-sourcemap/index.js
'use strict';
const path = require('path');
const sourceMap = require('source-map');
/**
* @param {string} input Contents of the sourcemap file
* @param {object} options Object {verbose: boolean}
*
* @returns {object} Source contents mapped to file names
*/
module.exports = (input, options) => {
const consumer = new sourceMap.SourceMapConsumer(input);
return consumer.then((response) => {
let map = {};
if (response.hasContentsOfAllSources()) {
if (options.verbose) {
console.log('All sources were included in the sourcemap');
}
debugger
console.log(response)
response.sources.forEach((source) => {
const contents = response.sourceContentFor(source);
map[path.normalize(source).replace(/^(\.\.[/\\])+/, '').replace(/[|\,+()?$~%'":*?<>{}]/g, '').replace(' ', '.')] = contents;
});
} else if (options.verbose) {
console.log('Not all sources were included in the sourcemap');
}
return map;
}).catch((e) => {
console.log(e);
return {};
});
};
小结:
浏览了一下 reverse-sourcemap
的源码,使用了 source-map
提供的一个对象,来实现的文件转换。
待补充…
感谢ღ( ´・ᴗ・` )