出于一些性能和安全等的考虑,通常我们发布到线上的代码,通常并非原始的代码,而是经过混淆压缩后的代码,即使不经过压缩,大部分的前端工程都会经过一个 build 的过程,这个过程里通常会包括代码的转换、打包和压缩等,这使得调试生成的代码变得异常困难,因此,我们需要一个工具帮我们解决这类调试问题。
SourceMap
=========
SourceMap 几乎完美的解决了代码反解问题,其使用方式十分简单,我们在编译的时候除了生成最终产物 xxx.js 文件外还会额外生成一个 xxx.js.map 的文件,这个 map 文件里包含了原始代码及其位置映射信息,这样我们利用 xxx.js 和 xxx.js.map 就可以将 xxx.js 的代码及其位置完美的映射会源代码以及位置,这样我们的调试工具就可以基于这个 map 文件实现源码调试了。其原理虽然很简单,但是当我们在工程中实际应用 SourceMap 的时候,仍然会碰到这样或那样的问题。一个很常见的问题就是,为啥用户上报的错误没法反解为原始代码的错误堆栈了?
SourceMap 支持的全链路流程
SourceMap 的使用并非是简单的一个编译生成即可,其实际上是需要我们整个的工作链路进行配合,才能使得 SourceMap 可以正常工作,因此我们需要先看看我们的整个工作链路上哪些环节会涉及 SourceMap,以及可能会碰到哪些问题。
以一个业务场景为例,我们用 Vue 开发的应用部署到线上 -> 发生了异常 -> 上报到了 Sentry -> Sentry 帮我们将错误进行反解展示给我们。这个业务场景非常简单但是实际涉及到了很多 SourceMap 的处理。
Transformer
首先我们需要我们的 DSL Transformer 支持 SourceMap
// App.vue
{ “greeting”: “hello” }
我们使用 @vue/compiler-sfc
将该 Vue 文件编译为一个 SFCRecord,此时 SFCRecord 里实际上包含了每个 block 的 SourceMap
const { parse } = require(‘@vue/compiler-sfc’);
const fs = require(‘fs’);
const path = require(‘path’);
async function main(){
const content = fs.readFileSync(path.join(__dirname, ‘./App.vue’),‘utf-8’);
const sfcRecord = parse(content);
const map = sfcRecord.descriptor[‘styles’][0].map
console.log(‘sfc:’,map) // 打印style的SourceMap
}
main();
我们可以进一步的根据每个 block 的 tag 和 lang 来继续 transform 每个 block,如使用 Babel | Typescript 处理 Script,PostCSS来处理 Style,Pug 来处理 Template,这里每个 Transformer 也都需要处理 SourceMap。
Bundler
处理完 Vue 文件的编译后,我们希望通过一个 bundler 来处理 Vue 模块的打包,此时我们可以使用esbuild、rollup、或者 Webpack,我们这里使用 rollup-plugin-vue 来配合 rollup 给 Vue 应用进行打包。
async function bundle(){
const bundle = await rollup.rollup({
input: [path.join(__dirname, ‘./App.vue’)],
plugins:[rollupPluginVue({needMap:true})],
external: [‘vue’],
output: {
sourcemap:‘inline’
}
})
const result = await bundle.write({
output: {
file: ‘bundle.js’,
sourcemap:true
},
})
for(const chunk of result.output){
console.log(‘chunk:’,chunk.map) // SourceMap
}
}
bundle()
因此这里也需要 bundle 在进行 bundler 的过程中同时处理生成 sourcemap。
Minifier
但我们 bundler 完代码后,还需要将代码进行压缩混淆才能发布到线上,这时我们需要使用 minify 工具进行混淆压缩。我们使用 terser 进行压缩。压缩时不仅需要处理 minfy 过程生成的 SourceMap 还需要处理其和原始 bundler 生成的 SourceMap 合并的问题,否则 SourceMap 和经过压缩处理的代码对应不上了。
for(const chunk of result.output){
console.log(‘chunk:’, chunk.map)
const minifyResult = await require(‘terser’).minify(chunk.code, {
sourceMap:true,
})
console.log(‘minifyMap:’, minifyResult.map)
}
Runtime
经过一番折腾,我们的编译流程终于处理完 SourceMap 了,我们开发过程中突然发现了代码出问题了,我们希望错误的堆栈能显示源码的位置,另外能支持源码调试应用,这时候就需要用的浏览器的 SourceMap 支持和 node 的 SourceMap 支持了。
日志收集和上报
经过一番眼花缭乱的操作,我们的代码终于和 SourceMap 对应上了,我们平稳的将业务部署上线,上线前我们需要确保我们的错误能够以正确的格式上报到我们的日志平台,然而我们线上运行的平台那么多样,运行的 JS 引擎也是各式各样,我们需要将用户的错误统一成一个格式上报给平台,幸运的是 Sentry 的客户端已经帮我们做了这件事情。我们只需要考虑接入 Sentry 的客户端就行了。因为如果直接将 SourceMap 一起跟随 js 代码下发,这就导致用户可以直接窥探你的源码了,类似发生这样的事情就很尴尬了https://zhuanlan.zhihu.com/p/26033573,因此我们还需要考虑将 SourceMap 发布到内网而非公网上,这时就需要处理 SourceMap 关联的问题了。
错误日志反解
一切都妥当了,只需要等用户的错误上报上来(最好永远别来),我就可以在 Sentry 上查看用户的原始错误堆栈,帮用户排查问题了,这时候实际上 Sentry Server 端偷偷帮我们做了根据用户的错误栈和用户的 SourceMap,帮我们反解错误栈的事情了。
总结一下,一个完整的 SourceMap 流程支持包括了如下这些步骤:
-
transformer: Babel、typescript、emscripten 、 esbuild
-
minifier: esbuild ,terser
-
bundler: esbuild, webpack, rollup
-
runtime: browser & node & deno
-
日志上报: sentry client
-
错误日志反解: sentry server && node-sourcemap-support
上面这些流程,基本上大多数工具都帮我们封装好了,我们只需要安心使用即可,但是当某天你需要自己开发一个自定义的 DSL 的 transformer 通过自研的 bundler 进行编译打包,运行在自研的 JS 引擎上并且使用自研的 monitor client 上报到自研的 apm 平台上,任何环节的出错都可能导致你线上的错误日志反解前功尽弃,你所能做的就是在整个链路上进行分析定位。
我们接下来就看看整个链路上有多少种出错的风险和可能,并且如何定位修复这些问题。
SourceMap 格式
首先我们需要了解下 SourceMap 的基本格式
我们将一个 .ts 文件编译为 .js 文件,看看其 SourceMap 信息是如何处理映射的。我们项目包含了原始的 ts 文件 add.ts、编译后的产物文件 add.js 和 SourceMap 文件 add.js.map,其内容如下
- add.ts
const add = (x:number, y:number) => {
return x + y;
}
- add.js
var add = function (x, y) {
return x + y;
};
//# sourceMappingURL=module.js.map
SourceMap 的规范本身十分精简和清晰,其本身是一个 JSON 文件,包含如下几个核心字段
{
version : 3, // SourceMap标准版本,最新的为3
file: “add.js”, // 转换后的文件名
sourceRoot : “”, // 转换前的文件所在目录,如果与转换前的文件在同一目录,该项为空
sources: [“add.ts”], // 转换前的文件,该项是一个数组,表示可能存在多个文件合并
names: [], // 转换前的所有变量名和属性名,多用于minify的场景
sourcesContent: [ // 原始文件内容
“const add = (x:number,y:number) => {\n return x+y;\n}”
]
mappings: “AAAA,IAAM,GAAG,GAAG,UAAC,CAAQ,EAAC,CAAQ;IAC5B,OAAO,CAAC,GAAC,CAAC,CAAC;AACb,CAAC,CAAA”,
}
简单介绍下 mapping 的格式,mapping 实际上是个三级结构,我们以上述的例子为例
- line:每个 mapping 包含由
;
分割的多行内容
lines = mappings.split(‘;’)
// [
‘AAAA,IAAM,GAAG,GAAG,UAAC,CAAQ,EAAC,CAAQ’, // var add = function (x, y) {
‘IAC5B,OAAO,CAAC,GAAC,CAAC,CAAC’, // return x + y;
‘AACb,CAAC,CAAA’ // };
]
其中每一行都对应生成代码的每行文件的位置映射信息,这里的三行分别对应了编译产物的三行信息
- segment:每一行同包含由
,
分割的多个 segment 信息,其中每个 segment 都对应了产物里每一行里每一个符合所在的列的信息
const segments = lines.map(x => {
return x.split(‘,’)
})
console.log(‘segments:’,segments)
// [
[
‘AAAA’, ‘IAAM’,
‘GAAG’, ‘GAAG’,
‘UAAC’, ‘CAAQ’,
‘EAAC’, ‘CAAQ’
],
[ ‘IAC5B’, ‘OAAO’, ‘CAAC’, ‘GAAC’, ‘CAAC’, ‘CAAC’ ],
[ ‘AACb’, ‘CAAC’, ‘CAAA’ ]
]
-
fields:每个 segment 实际上又包含了几个 field,每个 field 都编码了具体的行列映射信息,依次为
-
- 第一位: 转换后代码所处的列号,如果这是当前行的第一个 segment,那么是个绝对值,否则是相对于上一个 segment 的相对值
-
第二位:表示这个位置属于 sources 属性中的哪一个文件,相对于前一个 segment 的位置(区别于列号,下一行的第一个 segment 仍然是相对于上一行的最后一个 segment,并不会 reset)
-
第三位:表示这个位置属于转换前代码的第几行,相对位置,同第二列
-
第四位:表示这个位置属于转换前代码的第几列,相对位置,同第二列
-
第五位:表示这个位置属于 names 属性中的哪一个变量,相对位置,同第二列
这里 field 存储的值并非是直接的数字值,而是将数字使用 vlq 进行了编码,根据上述这些信息我们实际上就可以实现 SourceMap 的双向映射了,即可以根据 SourceMap 和原始代码的位置信息查找到生成代码的信息,也可以根据 SourceMap 和生成代码的位置信息,查找到原始代码的信息。接下来我们就实践下如何进行代码位置的双向查找。
双向查找流程
vlq 解码
首先第一步我们需要将 vlq 编码的 SourceMap 反解为原始的数字偏移信息,我们可以直接使用封装好的 vlq 库完成这一步
function decode() {
const { decode} = require(‘vlq’)
const mappings = JSON.parse(result.sourceMapText).mappings;
console.log(‘mappings:’, mappings)
/**
* @type {string[]}
*/
const lines = mappings.split(‘;’);
const decodeLines = lines.map(line => {
const segments = line.split(‘,’);
const decodedSeg = segments.map(x => {
return decode(x)
})
return decodedSeg;
})
console.log(decodeLines)
}
此时我们得到一个解码后的位置信息
[
[
[ 0, 0, 0, 0 ],
[ 4, 0, 0, 6 ],
[ 3, 0, 0, 3 ],
[ 3, 0, 0, 3 ],
[ 10, 0, 0, 1 ],
[ 1, 0, 0, 8 ],
[ 2, 0, 0, 1 ],
[ 1, 0, 0, 8 ]
],
[
[ 4, 0, 1, -28 ],
[ 7, 0, 0, 7 ],
[ 1, 0, 0, 1 ],
[ 3, 0, 0, 1 ],
[ 1, 0, 0, 1 ],
[ 1, 0, 0, 1 ]
],
[ [ 0, 0, 1, -13 ], [ 1, 0, 0, 1 ], [ 1, 0, 0, 0 ] ]
]
还原绝对位置索引
此时的这些位置信息都是相对位置,我们需要将其还原为绝对位置
const decoded = decodeLines.map((line) => {
absSegment[0] = 0; // 每行的第一个segment的位置要重置
if (line.length == 0) {
return [];
}
const absoluteSegment = line.map((segment) => {
const result = [];
for (let i = 0; i < segment.length; i++) {
absSegment[i] += segment[i];
result.push(absSegment[i]);
}
return result;
});
return absoluteSegment;
});
console.log(‘decoded:’, decoded)
}
结果如下,此时为绝对位置映射表
[
[
[ 0, 0, 0, 0 ],
[ 4, 0, 0, 6 ],
[ 7, 0, 0, 9 ],
[ 10, 0, 0, 12 ],
[ 20, 0, 0, 13 ],
[ 21, 0, 0, 21 ],
[ 23, 0, 0, 22 ],
[ 24, 0, 0, 30 ]
],
[
[ 4, 0, 1, 2 ],
[ 11, 0, 1, 9 ],
[ 12, 0, 1, 10 ],
[ 15, 0, 1, 11 ],
[ 16, 0, 1, 12 ],
[ 17, 0, 1, 13 ]
],
[ [ 0, 0, 2, 0 ], [ 1, 0, 2, 1 ], [ 2, 0, 2, 1 ] ]
]
双向映射
有了这个绝对位置映射,我们就可以构建源码和产物的双向映射了。我们可以实现两个核心 API
originalPositionFor
用于根据产物的行列号,查找对应源码的信息,而generatedPositionFor
则是根据源码的文件名、行列号,查找产物里的位置信息。
class SourceMap {
constructor(rawMap) {
this.decode(rawMap);
this.rawMap = rawMap
}
/**
*
* @param {number} line
* @param {number} column
*/
originalPositionFor(line, column){
const lineInfo = this.decoded[line];
if(!lineInfo){
throw new Error(不存在该行信息:${line}
);
}
const columnInfo = lineInfo[column];
for(const seg of lineInfo){
// 列号匹配
if(seg[0] === column){
const [column, sourceIdx,origLine, origColumn] = seg;
const source = this.rawMap.sources[sourceIdx]
const sourceContent = this.rawMap.sourcesContent[sourceIdx];
const result = codeFrameColumns(sourceContent, {
start: {
line: origLine+1,
column: origColumn+1
}
}, {forceColor:true})
return {
source,
line: origLine,
column: origColumn,
frame: result
}
}
}
throw new Error(不存在该行列号信息:${line},${column}
)
}
decode(rawMap) {
const {mappings} = rawMap
const { decode } = require(‘vlq’);
console.log(‘mappings:’, mappings);
/**
* @type {string[]}
*/
const lines = mappings.split(‘;’);
const decodeLines = lines.map((line) => {
const segments = line.split(‘,’);
const decodedSeg = segments.map((x) => {
return decode(x);
});
return decodedSeg;
});
const absSegment = [0, 0, 0, 0, 0];
const decoded = decodeLines.map((line) => {
absSegment[0] = 0; // 每行的第一个segment的位置要重置
if (line.length == 0) {
return [];
}
const absoluteSegment = line.map((segment) => {
const result = [];
for (let i = 0; i < segment.length; i++) {
absSegment[i] += segment[i];
result.push(absSegment[i]);
}
return result;
});
return absoluteSegment;
});
this.decoded = decoded;
}
}
const consumer = new SourceMap(rawMap);
console.log(consumer.originalPositionFor(0,21).frame)
我们还可以使用 codeFrame 直接可视化查找出源码的上下文信息
generatedPositionFor
的实现原理类似,不再赘述。
事实上上面这些反解流程并不需要我们自己去实现,https://github.com/mozilla/source-map 已经帮我们提供了很多的编译方法,包括不限于
-
originalPositionFor:查找源码位置
-
generatedPositionFor:查找生成代码位置
-
eachMapping:生成每个 segment 的详细映射信息
Mapping {
generatedLine: 2,
generatedColumn: 17,
lastGeneratedColumn: null,
source: ‘add.ts’,
originalLine: 2,
originalColumn: 13,
name: null
}
Mapping {
generatedLine: 3,
generatedColumn: 0,
lastGeneratedColumn: null,
source: ‘add.ts’,
originalLine: 3,
originalColumn: 0,
name: null
}
Mapping {
generatedLine: 3,
generatedColumn: 1,
lastGeneratedColumn: null,
source: ‘add.ts’,
originalLine: 3,
originalColumn: 1,
name: null
}
Mapping {
generatedLine: 3,
generatedColumn: 2,
lastGeneratedColumn: null,
source: ‘add.ts’,
originalLine: 3,
originalColumn: 1,
name: null
}
事实上 Sentry 的 SourceMap 反解功能也是基于此实现的。
SourceMap 全链路支持
===============
前面我们已经介绍的 SourceMap 的基本格式,以及如何基于 SourceMap 的内容,来实现 SourceMap 的双向查找功能,大部分的 sourcmap 相应的工具链都是基于此设计的,但是在给整个链路做 SourceMap 支持的时候,但是链路的每一步需要解决的问题却各有不同(的坑),我们来一步步的研(踩)究(坑)吧。
给 transformer 添加 SourceMap 映射
Web 社区的主流语言的工具链都已经有了内置的 SourceMap 支持了,但是如果你自行设计一个 DSL 要怎么给其添加 SourceMap 支持呢?事实上 SourceMapGenerator 给我们提供了便捷的生成 SourceMap 内容的方法,但是当我们处理各种字符串变换的时候,直接使用其 API 仍然较为繁琐。幸运的是很多工具封装了生成 SourceMap 的操作,提供了较为上层的 api。我们自己实现 transformer 主要分为两种场景,一种是基于 AST 的变换,另一种则是对字符串(可能压根不存在 AST)的增删改查。
- AST 变换
大部分的前端 transform 工具,都内置帮我们处理好了 SourceMap 的映射,我们只需要关心如何处理 AST 即可,以 babel 为例,并不需要我们手动的进行 SourceMap 节点的操作
import babel from ‘@babel/core’;
import fs from ‘fs’;
const result = babel.transform(‘a === b;’, {
sourceMaps: true,
filename: ‘transform.js’,
plugins: [
{
name: ‘my-plugin’,
pre: () => {
console.log(‘xx’);
},
visitor: {
BinaryExpression(path, t) {
let tmp = path.node.left;
path.node.left = path.node.right;
path.node.right = tmp;
}
}
}
自我介绍一下,小编13年上海交大毕业,曾经在小公司待过,也去过华为、OPPO等大厂,18年进入阿里一直到现在。
深知大多数前端工程师,想要提升技能,往往是自己摸索成长或者是报班学习,但对于培训机构动则几千的学费,着实压力不小。自己不成体系的自学效果低效又漫长,而且极易碰到天花板技术停滞不前!
因此收集整理了一份《2024年Web前端开发全套学习资料》,初衷也很简单,就是希望能够帮助到想自学提升又不知道该从何学起的朋友,同时减轻大家的负担。
既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,基本涵盖了95%以上前端开发知识点,真正体系化!
由于文件比较大,这里只是将部分目录大纲截图出来,每个节点里面都包含大厂面经、学习笔记、源码讲义、实战项目、讲解视频,并且后续会持续更新
如果你觉得这些内容对你有帮助,可以添加V获取:vip1024c (备注前端)
读者福利
========
由于篇幅过长,就不展示所有面试题了,想要完整面试题目的朋友(另有小编自己整理的2024大厂高频面试题及答案附赠)
884206)]
[外链图片转存中…(img-cTSfnclf-1711719884207)]
[外链图片转存中…(img-ntvXotCn-1711719884207)]
[外链图片转存中…(img-KqsCTvJo-1711719884208)]
既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,基本涵盖了95%以上前端开发知识点,真正体系化!
由于文件比较大,这里只是将部分目录大纲截图出来,每个节点里面都包含大厂面经、学习笔记、源码讲义、实战项目、讲解视频,并且后续会持续更新
如果你觉得这些内容对你有帮助,可以添加V获取:vip1024c (备注前端)
[外链图片转存中…(img-fQhfQIB9-1711719884208)]
读者福利
========
由于篇幅过长,就不展示所有面试题了,想要完整面试题目的朋友(另有小编自己整理的2024大厂高频面试题及答案附赠)