最后
整理面试题,不是让大家去只刷面试题,而是熟悉目前实际面试中常见的考察方式和知识点,做到心中有数,也可以用来自查及完善知识体系。
《前端基础面试题》,《前端校招面试题精编解析大全》,《前端面试题宝典》,《前端面试题:常用算法》
开源分享:docs.qq.com/doc/DSmRnRG… [ 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 的实现原理类似,不再赘述。
事实上上面这些反解流程并不需要我们自己去实现,github.com/mozilla/sou… 已经帮我们提供了很多的编译方法,包括不限于
-
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;
}
}
}
]
});
console.log(result.code, result.map);
// 结果
b === a;
{
version: 3,
sources: [ 'transform.js' ],
names: [ 'b', 'a' ],
mappings: 'AAAMA,CAAN,KAAAC,CAAC',
sourcesContent: [ 'a === b;' ]
}
- 但是 AST 并不能覆盖所有场景,例如我们如果需要将 c++ 或者 brainfuck 编译为 js,就很难找到便捷的工具,或者我们只需要替换代码里的部分内容,AST 分析就是大才小用了。此时我们可以使用 magic-string 来实现。
const MagicString = require('magic-string');
const s = new MagicString('problems = 99');
s.overwrite(0, 8, 'answer');
s.toString(); // 'answer = 99'
s.overwrite(11, 13, '42'); // character indices always refer to the original string
s.toString(); // 'answer = 42'
s.prepend('var ').append(';'); // most methods are chainable
s.toString(); // 'var answer = 42;'
const map = s.generateMap({
source: 'source.js',
file: 'converted.js.map',
includeContent: true
}); // generates a v3 SourceMap
console.log('code:', s.toString());
console.log('map:', map);
// 结果
code: var answer = 42;
map: SourceMap {
version: 3,
file: 'converted.js.map',
sources: [ 'source.js' ],
sourcesContent: [ 'problems = 99' ],
names: [],
mappings: 'IAAA,MAAQ,GAAG'
}
我们发现对于简单的字符串处理,magic-string 比使用 AST 的方式要方便和高效很多。
SourceMap 验证
当我们给我们的 transformer 加了 SourceMap 支持后,我们怎么验证我们的 SourceMap 是正确的呢?你除了可以使用上面提到的 SourceMap 库的双向反解功能进行验证外,一个可视化的验证工具将大大简化我们的工作。esbuild 作者就开发了一个 SourceMap 可视化验证的网站 evanw.github.io/source-map-… 来帮我们简化 SourceMap 的验证工作。
SourceMap 合并
当我们处理好 transformer 的 SourceMap 生成之后,接下来就需要将 transformer 接入到 bundler 了,一定意义上 bundler 也可以视为一种 transformer,只是此时其输入不再是单个源文件而是多个源文件。但这里牵扯到的一个问题是将 A 进行编译生成了 B with SourceMap1 接着又将 B 进一步进行编译生成了 C with SourceMap2,那么我们如何根据 C 反解到 A 呢?很明显使用 SourceMap2 只能帮助我们将 C 反解到 B,并不能反解到 A,大部分的反解工具也不支持自动级联反解,因此当我们将 B 生成 C 的时候,还需要考虑将 SourceMap1 和 SourceMap2 进行合并,不幸的是很多 transformer 并不会自动的处理这种合并,如 TypeScript,但是大部分的 bundler 都是支持自动的 SourceMap 合并的。
如在 Rollup 里,你可以在 load 和 transform 里返回 code 的同时,返回 mapping。Rollup 会自动将该 mapping 和 builder 变换的 mapping 进行合并,vite 和 esbuild 也支持类似功能。如果我们需要自己处理 SourceMap 合并该如何操作,社区上已经有库帮我们处理这个事情。我们简单看下
import ts from 'typescript';
import { minify } from 'terser';
import babel from '@babel/core';
import fs from 'fs';
import remapping from '@ampproject/remapping';
const code = `
const add = (a,b) => {
return a+b;
}
`;
const transformed = babel.transformSync(code, {
filename: 'origin.js',
sourceMaps: true,
plugins: ['@babel/plugin-transform-arrow-functions']
});
console.log('transformed code:', transformed.code);
console.log('transformed map:', transformed.map);
const minified = await minify(
{
'transformed.js': transformed.code
},
{
sourceMap: {
includeSources: true
}
}
);
console.log('minified code:', minified.code);
console.log('minified map', minified.map);
const mergeMapping = remapping(minified.map, (file) => {
if (file === 'transformed.js') {
return transformed.map;
} else {
return null;
}
});
fs.writeFileSync('remapping.js', minified.code);
fs.writeFileSync('remapping.js.map', minified.map);
//fs.writeFileSync('remapping.js.map', JSON.stringify(mergeMapping));
我们来简单验证下效果
- 使用 mergeMapping 之前
- 使用 mergeMapping 之后
我们可以看出做了 mergeSourcemap 后可以成功的还原出最初的源码
性能 matters
我们支持好了上面的 SourceMap 生成和 SourceMap 合并了,迫不及待的在业务中加以使用了,但是却“惊喜”的发现整个构建流程的速度直线下降,因为 SourceMap 操作的开销实际上是非常可观的,在不需要 SourceMap 的情况下或者在对性能极其敏感的场景下(服务端构建),实际是不建议默认开启 SourceMap 的,事实上 SourceMap 对性能极其敏感,以至于 source-map 库的作者们重新用 rust 实现了 source-map,并将其编译到了 webassembly。
错误日志上报和反解
当我们处理好 SourceMap 的生成后,就可以进行日志上报了
Sentry
错误上报需要解决的一个问题就是统一上报格式问题,我们生产环境遇到的错误并非直接将原始的 Error 信息上报给服务端的,而是需要先进行格式化处理,如下面这种错误
function inner() {
myUndefinedFunction();
}
function outer() {
inner();
}
setTimeout(() => {
outer();
}, 1000);
原始的错误堆栈如下
Sentry Client 会将其先进行格式化处理,Sentry 发送给后端的错误堆栈格式下面这种格式化数据
问题来了,为啥 Sentry 要经过这样一番格式化处理,以及格式化处理中可能会发生什么问题呢。
V8 StackTrace API
按理来讲 Error 对象作为标准里规定的 Ordinary Object ,其在不同的 JS 引擎上表现行为应该一致,但是很不幸,标准里虽然规定了 Error 对象是个 Ordinary Object,但也只规定了 name 和 message 两个属性的行为,对于最广泛使用的 stack 属性,并没有加以定义,这导致了 JS 引擎在 stack 属性的表现差别很大(目前已经有一个标准化 stack 的 proposal),甚至有的引擎实现已经突破了标准的限定,使得 Error 表现的更像一个 Exotic Object。我们来具体看看各引擎对于 Error 对象的实现差异。
V8 支持了 stack 属性,并且给 stack 属性提供了丰富的配置,如下是一个基本的 stack 信息。
function inner() {
myUndefinedFunction();
}
function outer() {
inner();
}
function main() {
try {
outer();
} catch (err) {
console.log(err.stack);
}
}
main();
我们可以使用 github.com/GoogleChrom… 来很方便的测试不同 JS 引擎的表现差异
V8 的 stacktrace默认最多展示 10 个 frame,但是该数目可以通过 Error.stackLimit 进行配置,同时 V8 也支持了 async stacktrace,默认也会展示 async|await 的错误栈。
stacktrace 的捕获不仅仅可以在出现异常时触发,还可以业务主动捕获当前的 stacktrace,这样我们就可以基于此实现自定义 Error 对象。
Error.captureStackTrace
V8 提供了 Error.captureStackTrace 支持用户自定义收集 stackTrace。
这个 API 主要有两种功能,一个是给自定义对象追加 stack 属性,达到模拟 Error 的效果
function CustomError(message) {
this.message = message;
this.name = CustomError.name;
Error.captureStackTrace(this); // 给对象追加stack属性
}
try {
throw new CustomError('msg');
} catch (e) {
console.error(e.name); // CustomError
console.error(e.message); //msg
console.error(e.stack);
/*
CustomError: msg
at new CustomError (custom_error.js:4:9)
at custom_error.js:7:9
*/
}
另一个作用就是可以隐藏部分实现细节,这一方面可以避免一些对用户无用的信息泄露给用户,而对用户造成困扰;另一方面可能有一些内部信息涉及一些敏感信息,需要防止泄露给用户。比如一般用户是不需要关心 native 的调用栈,因此就需要将 native 的调用栈进行隐藏。下面的例子就简单的演示了如何通过 captureStackTrace 来隐藏部分调用栈信息。
function CustomError(message, stripPoint) {
this.message = message;
this.name = CustomError.name;
Error.captureStackTrace(this, stripPoint);
}
function leak_secure() {
throw new CustomError('secure泄漏了');
}
function hidden_secure() {
throw new CustomError('secure没泄露', outer_api);
}
function outer_api() {
try {
leak_secure();
} catch (err) {
console.error('stk:', err.stack);
}
try {
hidden_secure();
} catch (err) {
console.error('stk2:', err.stack);
}
}
outer_api();
Error.prepareStackTrace
另一个值得注意点的是,虽然 stack 名义上应该是一个 frame 的数组,但是实际上 stack 却是个字符串(历史包袱,兼容性问题吧),因此 V8 同时提供了一个结构化的 stack 信息,方便用户根据结构化的 stack 信息来自定义 stack 结构。我们可以通过 Error.prepareStackTrace 来获取原始的栈帧结构:
Error.prepareStackTrace = (error, structedStackTrace) => {
for (const frame of structedStackTrace) {
console.log('frame:', frame.getFunctionName(), frame.getLineNumber(), frame.getColumnNumber());
}
};
其中的 structedStackTrace 是个包含了 frame 信息的数组,其中包含了很多我们感兴趣的信息,更详细的信息可参考 stack-trace-api。
另外 stack 虽然是个 value property ,但是实际表现却是个 getter property,这也是 V8 的实现违反 EcmaScript 规范的地方。
这里的 stack 虽然是个 value,但是其表现其实更像是一个 getter,因为其访问 stack 的属性会触发 prepareStackTrace 回调。这导致 error.stack 实际上是可能存在副作用的。不仅如此, stack 属性的计算也是惰性计算的,当 error 触发的时候并不会进行 stack 的计算,而只有当首次访问 stack 的时候才会触发计算,因为本身 stack 的计算实际上是有一定的性能开销的,实际上 chrome devtools 就因为 stackstrace 的性能问题出过问题(faster-stack-trace),笔者也因为 stack-trace 的性能问题导致严重影响了编译工具的编译性能(github.com/evanw/esbui…
Stack Trace Format
前面已经提到 V8 的 stack 是个字符串,如果需要将一个字符串解析为一个结构化数据,势必该字符串需要符合某个格式规范,幸运的是 V8 的有一套格式规范,具体格式可见 stack-trace-format。虽然 V8 引擎定义了一套格式规范,不幸的是其他引擎的 stack 格式规范与 V8 截然不同(不带重样的),我们来看看不同引擎的格式规范。
V8(Chrome)
SpiderMonkey(Firefox)
JavaScriptCore(Safari)
QuickJS
我们发现四个 JS 引擎的 stack 格式各不相同,因此需要我们在上报错误前需要将这些格式进行分别的格式化处理,幸运的是 Sentry Client 已经帮我们给主流的 JS 引擎做了适配。
sentry-compute-stack-trace
不幸的是,如果你的 JS 引擎是自研的或者 stack 格式和上述的引擎都不一致,你可能需要修改 Sentry 加以支持。
这里我们还发现了一个比较严重的问题就是 QuickJS 引擎的报错是没有行列号信息的,这对于平时的开发可能足够了,但是如果你将你的代码压缩成一行,那么就会导致行列号信息都被丢失了,那么上报的错误是根本没法进行反解的。更加不幸的是 QuickJS 至今仍然不支持列信息,如果你的系统里使用了 QuickJS,可能需要修改 QuickJS 自行添加列号支持。
eval is eval
如果你的代码是在 eval 里执行,那么将会碰到另一个问题,有的 JS 引擎针对 eval 的代码并不会包含错误的行列号信息。
const code = `function inner() {
myUndefinedFunction();
}
function outer() {
inner();
}
function main() {
try {
outer();
} catch (err) {
console.log(err.stack);
}
}
function foo() {
bar();
}
function bar() {
main();
}
foo();
eval(code);
比如我们看一下不同引擎的结果
- V8 虽然包含了行列号信息,但是堆栈里含有了两个行列号信息,可能导致 sentry-client 识别出错
- JavascriptCore 则彻底没有行列号信息
- SpiderMonkey 虽然能够显示,但与非 eval 版本格式完全不同,很难兼容
其实为了解决 eval 的错误堆栈的行列号问题,我们可以借助 sourceURL 进行还原,我们来看一看结果
const code = `function inner() {
myUndefinedFunction();
}
function outer() {
inner();
}
function main() {
try {
outer();
} catch (err) {
console.log(err.stack);
}
}
function foo() {
bar();
}
function bar() {
main();
}
foo()
//# sourceURL=my-foo.js
`; // 这里通过sourceURL支持还原
eval(code);
V8:成功还原
JavaScriptCore:不支持
SpiderMonkey:成功还原
anonymous function is bad
解决了 eval 的问题后,似乎可以高枕无忧了,但是实际开发中仍然碰到了一些匪夷所思的问题,线上反解仍然失败,后来定位发现 JavaScriptCore 在生成异常的时候,其行列号可能计算错误,以及给 QuickJS 引擎添加的行列号功能也存在不少 bug。那么在行列号不靠谱的情况下如何查问题。那就只能借助于 function name 去全局搜索了,不幸的是我们的业务中和 SDK 中存在大量的匿名函数,这无异于雪上加霜。
对 YDKJS 的观点深感赞同,不幸的是 JavaScript 里将 anonymous function 和 lexical this 两个 feature 糅合在一起了,你除了通过变量声明的方式,没有其他更简洁的方式来给一个 arrow function 进行命名
const xxx = () => {} // xxx.name is xxx
call('login', () => {
this.crash()
}) // 如果这里crash了,很不幸调用栈里拿不到函数名了
const cb = () => {this.crash()}
call('login', cb) // 太绕了。。。。
call('login', function loginCb(){
this.crash()
}.bind(this)) // 还是很绕
call('login', loginCb() => { // 这样就最好了,可惜不支持,we need a proposal
this.crash();
})
如果 arrow function 也能便捷的命名就好了。
SourceMap 的局限
代码反解只是 SourceMap 的一个功能,其实还扮演着源码调试等功能,但是 SourceMap 在源码调试上却面临着更大的问题和挑战,比如难以应对其他高级语言的转换问题,例如 C++ 编译到 asm.js 或者 C++编译为 wasm,如何处理 wasm 或者 asm.js 的源码调试和代码反解,是另一个比较复杂的话题了。
参考文献
最后
整理面试题,不是让大家去只刷面试题,而是熟悉目前实际面试中常见的考察方式和知识点,做到心中有数,也可以用来自查及完善知识体系。
开源分享:【大厂前端面试题解析+核心总结学习笔记+真实项目实战+最新讲解视频】
《前端基础面试题》,《前端校招面试题精编解析大全》,《前端面试题宝典》,《前端面试题:常用算法》