JavaScript工程化基础—AST、Babel插件、ESLint规则与Source Map原理|新宇宙博客Back to list代码质量与工程化基础
Site Owner
Published on 2026-05-21
系统讲解AST基本结构与遍历、Babel插件开发、ESLint规则编写、Source Map VLQ编码及代码覆盖率实现原理
代码质量与工程化基础
AST(抽象语法树)的基本结构与 Babel 插件开发流程;ESLint 规则的自定义编写;Source Map 的原理(VLQ 编码、mappings 字段解析);代码覆盖率的实现原理(Istanbul 的插桩机制)。
目录
- AST 与代码转换
- Babel 插件开发
- ESLint 自定义规则
- Source Map 原理
- 代码覆盖率原理(Istanbul)
- 手写验证:Babel 插件 & ESLint 规则
- 深度追问
AST 与代码转换
什么是 AST?
抽象语法树(Abstract Syntax Tree)是源代码的结构化树形表示,去除了语法噪声(括号、分号等),保留语义结构。
const add = (a, b) => a + b;
{
"type": "Program",
"body": [{
"type": "VariableDeclaration",
"kind": "const",
: [{
: ,
: { : , : },
: {
: ,
: [
{ : , : },
{ : , : }
],
: {
: ,
: ,
: { : , : },
: { : , : }
}
}
}]
}]
}
"declarations"
"type"
"VariableDeclarator"
"id"
"type"
"Identifier"
"name"
"add"
"init"
"type"
"ArrowFunctionExpression"
"params"
"type"
"Identifier"
"name"
"a"
"type"
"Identifier"
"name"
"b"
"body"
"type"
"BinaryExpression"
"operator"
"+"
"left"
"type"
"Identifier"
"name"
"a"
"right"
"type"
"Identifier"
"name"
"b"
代码转换三步骤
源代码 → [Parser] → AST → [Transformer] → 新 AST → [Generator] → 目标代码
↑ ↑ ↑ ↑
@babel/parser @babel/traverse 插件逻辑 @babel/generator
AST 在线工具
访问 astexplorer.net 可实时查看任意代码的 AST 结构,是开发 Babel 插件的必备工具。
常见 AST 节点类型
const nodeTypes = {
VariableDeclaration: { kind: 'const|let|var', declarations: [] },
FunctionDeclaration: { id, params, body },
ClassDeclaration: { id, superClass, body },
CallExpression: { callee, arguments: [] },
MemberExpression: { object, property, computed },
BinaryExpression: { operator, left, right },
AssignmentExpression:{ operator, left, right },
ArrowFunctionExpression: { params, body, expression },
IfStatement: { test, consequent, alternate },
ReturnStatement: { argument },
ExpressionStatement: { expression },
Identifier: { name },
StringLiteral: { value },
NumericLiteral:{ value },
BooleanLiteral:{ value },
NullLiteral: {},
};
Babel 插件开发
插件结构
module.exports = function(babel) {
const { types: t } = babel;
return {
name: 'my-babel-plugin',
pre(state) {
},
visitor: {
FunctionDeclaration(path, state) {
},
CallExpression: {
enter(path) { },
exit(path) { },
},
},
post(state) {
},
};
};
Path API 核心方法
visitor: {
Identifier(path) {
path.node
path.parent
path.parentPath
path.scope
path.get('name')
path.findParent(p => ...)
path.getSibling(index)
path.isIdentifier()
path.isReferenced()
path.inScope('varName')
path.replaceWith(newNode)
path.replaceWithMultiple([n1, n2])
path.insertBefore(node)
path.insertAfter(node)
path.remove()
path.skip()
path.stop()
}
}
手写验证:Babel 插件 & ESLint 规则
Babel 插件:自动注入函数名到 console.log
module.exports = function({ types: t }) {
function getFunctionName(path) {
let current = path.parentPath;
while (current) {
if (current.isFunctionDeclaration()) {
return current.node.id?.name ?? 'anonymous';
}
if (current.isFunctionExpression() || current.isArrowFunctionExpression()) {
const parent = current.parentPath;
if (parent.isVariableDeclarator()) {
return parent.node.id?.name ?? 'anonymous';
}
if (parent.isObjectProperty()) {
return parent.node.key?.name ?? 'anonymous';
}
return 'anonymous';
}
if (current.isClassMethod()) {
return current.node.key?.name ?? 'method';
}
current = current.parentPath;
}
return 'global';
}
return {
name: 'inject-function-name',
visitor: {
CallExpression(path) {
const { node } = path;
const callee = node.callee;
const isConsoleCall =
t.isMemberExpression(callee) &&
t.isIdentifier(callee.object, { name: 'console' }) &&
t.isIdentifier(callee.property) &&
['log', 'warn', 'error', 'info'].includes(callee.property.name);
if (!isConsoleCall) return;
const firstArg = node.arguments[0];
if (t.isStringLiteral(firstArg) && firstArg.value.startsWith('[')) return;
const fnName = getFunctionName(path);
const label = t.stringLiteral(`[${fnName}]`);
node.arguments.unshift(label);
}
}
};
};
function processUser(user) {
console.log('Processing user:', user);
}
const handleClick = () => {
console.log('clicked');
};
ESLint 自定义规则:禁止使用 var
module.exports = {
meta: {
type: 'suggestion',
docs: {
description: '禁止使用 var 声明,请使用 let 或 const',
category: 'Best Practices',
recommended: true,
},
fixable: 'code',
schema: [],
messages: {
noVar: '请使用 "{{ preferred }}" 替代 "var"',
},
},
create(context) {
return {
VariableDeclaration(node) {
if (node.kind !== 'var') return;
const isReassigned = node.declarations.some((decl) => {
const scope = context.getScope();
const variable = scope.variables.find(v => v.name === decl.id.name);
return variable?.references.some(ref => ref.isWrite() && ref.identifier !== decl.id);
});
const preferred = isReassigned ? 'let' : 'const';
context.report({
node,
messageId: 'noVar',
data: { preferred },
fix(fixer) {
return fixer.replaceText(
{ range: [node.range[0], node.range[0] + 3] },
preferred
);
},
});
}
};
}
};
const { RuleTester } = require('eslint');
const rule = require('./no-var-declaration');
const tester = new RuleTester({ parserOptions: { ecmaVersion: 2020 } });
tester.run('no-var-declaration', rule, {
valid: [
'let x = 1;',
'const y = 2;',
],
invalid: [
{
code: 'var x = 1;',
errors: [{ messageId: 'noVar', data: { preferred: 'const' } }],
output: 'const x = 1;',
},
{
code: 'var x = 1; x = 2;',
errors: [{ messageId: 'noVar', data: { preferred: 'let' } }],
output: 'let x = 1; x = 2;',
},
],
});
Source Map 原理
Source Map 文件结构
{
"version": 3,
"file": "bundle.js",
"sourceRoot": "",
"sources": ["src/app.js", "src/utils.js"],
"sourcesContent": ["...", "..."],
"names": ["add", "a", "b"],
"mappings": "AAAA,SAAS,GAAGA,CAAC,CAACC,CAAD,CAACC,CAAD"
}
VLQ 编码与 mappings 字段
mappings 是分号和逗号分隔的 Base64 VLQ 编码序列:
;:分隔行(输出文件中的换行)
,:分隔同一行中的不同映射
- 每段为 4-5 个 VLQ 值:
[生成列, 源文件索引, 源行, 源列, 名称索引]
const BASE64_MAP = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
function decodeVLQ(str) {
const result = [];
let i = 0;
while (i < str.length) {
let value = 0, shift = 0, digit;
do {
digit = BASE64_MAP.indexOf(str[i++]);
value |= (digit & 0x1F) << shift;
shift += 5;
} while (digit & 0x20);
result.push(value & 1 ? -(value >> 1) : value >> 1);
}
return result;
}
function parseMappings(mappings) {
const lines = mappings.split(';');
const result = [];
for (let line = 0; line < lines.length; line++) {
const segments = lines[line].split(',').filter(Boolean);
let prevCol = 0, prevSrc = 0, prevSrcLine = 0, prevSrcCol = 0;
for (const seg of segments) {
const [genCol, srcIdx, srcLine, srcCol] = decodeVLQ(seg).map((v, i) => {
if (i === 0) { prevCol += v; return prevCol; }
if (i === 1) { prevSrc += v; return prevSrc; }
if (i === 2) { prevSrcLine += v; return prevSrcLine; }
if (i === 3) { prevSrcCol += v; return prevSrcCol; }
});
result.push({ genLine: line, genCol, srcIdx, srcLine, srcCol });
}
}
return result;
}
代码覆盖率原理(Istanbul)
插桩(Instrumentation)机制
Istanbul(现为 nyc/c8)通过 AST 转换在代码中插入计数器:
function add(a, b) {
if (a > 0) {
return a + b;
}
return b;
}
const __cov = global.__coverage__['src/add.js'] = {
s: { 0: 0, 1: 0, 2: 0 },
b: { 0: [0, 0] },
f: { 0: 0 },
};
function add(a, b) {
__cov.f[0]++;
__cov.s[0]++;
if (a > 0) {
__cov.b[0][0]++;
__cov.s[1]++;
return a + b;
}
__cov.b[0][1]++;
__cov.s[2]++;
return b;
}
四种覆盖率指标
语句覆盖率 (Statement) = 执行过的语句数 / 总语句数
分支覆盖率 (Branch) = 执行过的分支数 / 总分支数(if/else/三元各算两个)
函数覆盖率 (Function) = 调用过的函数数 / 总函数数
行覆盖率 (Line) = 执行过的行数 / 总行数
深度追问
Q1:Babel 的三阶段(Parse → Transform → Generate)各做什么?
- Parse:
@babel/parser(原 babylon)将源码解析为 AST,支持 TypeScript、JSX 等语法扩展。
- Transform:遍历 AST,Babel 插件通过 visitor 模式修改节点。多个插件顺序执行,每个插件接收上一个的输出。
- Generate:
@babel/generator 将修改后的 AST 转换回代码字符串,同时生成 Source Map。
Q2:Source Map 中 mappings 字段为何使用 VLQ 编码而非直接存偏移量?
VLQ(Variable-length quantity,可变长度量)的好处:
- 相对偏移:存储相对于上一个段的偏移量,通常是小数,编码更短。
- 可变长度:小数字用少量字节,大数字才用多字节,压缩率高。
- Base64 友好:只使用 64 个 ASCII 可打印字符,便于 JSON 嵌入。
Q3:ESLint 规则的 fixable 和 hasSuggestions 有何区别?
fixable: 'code':可通过 --fix 自动修复,无需用户确认,适合确定性修改(如 var → const)。
hasSuggestions: true:提供建议(suggestions),用户在 IDE 中手动选择应用,适合可能有多种修复方案或影响语义的情况。建议通过 context.report({ suggest: [...] }) 提供。
Q4:Istanbul 的插桩会影响运行时性能吗?
- 每个语句增加一次数组写操作(约几纳秒)
- 大量密集循环场景影响明显(可能 10-20% 性能下降)
- 生产环境绝对不应启用覆盖率收集
- 现代工具(c8)利用 V8 内置覆盖率 API(
--coverage),零插桩,性能损耗更小