Complier&AST-前端工具链开发基础
我不知道啊我写
1.介绍
在现代前端开发中,工程化已经成为不可或缺的一部分,而 Bundle(打包工具) 则是前端工程化的基石。通过 Bundle 工具,我们可以将复杂的开发环境中的代码转化为浏览器可以直接运行的 JavaScript 文件。这一过程的核心在于 编译,而编译的本质则是对代码进行解析、转换和生成。
本文将讲述现代前端编译的核心原理,重点聚焦于 抽象语法树(Abstract Syntax Tree) 的构建与应用。无论是 Webpack、Rollup 还是 Babel,这些现代化工具都依赖 AST 来完成代码的分析、优化和转换。
1.1.什么是 AST?
AST(Abstract Syntax Tree,抽象语法树)是一种将源代码结构化表示的数据结构。它把一段文本形式的代码(如 JavaScript)解析成一棵树形对象,其中每个节点代表一个语法结构(如变量声明、函数调用、表达式等)。
比如,这段 JavaScript 代码:
const sum = (a, b) => a + b;
会被解析为如下 AST(遵循 ESTree 规范):
{
"type": "Program",
"body": [
{
"type": "VariableDeclaration",
"kind": "const",
"declarations": [
{
"type": "VariableDeclarator",
"id": { "type": "Identifier", "name": "sum" },
"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" }
}
}
}
]
}
]
}
通过遍历这棵树,我们可以精确控制每个语法节点的处理逻辑
把
ArrowFunctionExpression转换成普通函数(Babel 的 ES6 → ES5 转译);检查是否存在未声明的变量(各种Lint工具的变量检查);
自动插入埋点代码(监控 SDK 的静态注入);
正是这种“可分析、可修改、可生成”的能力,让 AST 成为现代前端工程化的基石。
编译器
编译器的工作流程通常分为以下几个主要阶段,每个阶段都有其特定的任务和目标:
词法分析:首先,词法不是语法。这步是为了原代码拆解为词元(tokens),例如关键字、操作符、标识符等,还要分析 [] {}这些的层级关系。
语法分析:根据语言的语法规则解析这些标记,并构建抽象语法树(AST)。这是整个编译过程中最重要的一步,因为 AST 是后续所有操作的基础。
语义分析:检查程序的意义是否合法,例如类型检查。这一步确保代码符合语言规范,避免运行时错误。前端常使用ESLint,Biome(我觉得Biome必秒ESLint)等工具来实现静态代码检查,其原理也就是语义分析。
中间代码生成:将 AST 转换为一种更接近目标机器语言的形式。这一步的目的是为后续优化和生成目标代码做准备。
代码优化:对中间代码进行优化以提高执行效率或减少资源消耗。常见的优化手段包括常量折叠、死代码消除、循环展开等。
代码生成:优化后的中间代码转换为目标机器码或目标语言代码。对于前端ts编译器来说,这通常是生成浏览器可执行的 JS 代码。
链接:将多个文件的目标代码组合成一个可执行文件。在前端工程中,这一步通常由打包工具(如 Webpack 或 Rollup)完成。
2. 用JS实现一个简单的编译器
词法分析
整一个分词器,根据词法把每个词转化为一个节点。
Javascript// 词性类型 const WHITESPACE = /\s/ // 空格 const NUMBER = /[0-9]/ // 数字 const LETTERS = /[a-zA-Z_]/ // 字母与下划线 module.export = function tokenizer(sourceCode){ let index = 0 const tokens = [] while(index < sourceCode.length){ let char = sourceCode[cur_index] if (char === "("){ token.push({ type: "paren", value: "(" }) index++ continue // 进入下一次循环 前进一个 token/词 } if (char === ")"){ // ...省略 } if (WHITESPACE.test(char)){ // 正则匹配空格 index++ continue } if (NUMBER.test(char)){ // 正则匹配数字 let value = "" while(NUMBER.test(sourceCode[index])){ value += sourceCode[index] index++ } token.push({ type: "number", value: value }) continue } if (LETTERS.test(char)){ // 正则匹配字符 这也是为什么变量开头不能是数字 let value = "" // 收集字母、数字和下划线 while (index < sourceCode.length && (LETTERS.test(char) || NUMBER.test(char))) { value += char; char = sourceCode[++index]; } tokens.push({ type: "name", value }); continue; token.push({ type: "name", value: value }) continue } if ("+-*/=".includes(char)) { // 处理其他符号(如操作符、括号等) tokens.push({ type: "operator", value: char }); index++; continue; } throw new Error(`Unexpected character: ${char}`); // 遇到未知字符 } return tokens }语法分析
这一步是使用Parser进行语法分析,构建 AST (抽象语法树)
Javascript// 假设有这么一个token 其原字符串是 "abc123 + 456 _var = 789" const tokens = [ { type: "name", value: "abc123" }, { type: "operator", value: "+" }, { type: "number", value: 456 }, { type: "name", value: "_var" }, { type: "operator", value: "=" }, { type: "number", value: 789 } ] // AST 示例 const AST = { "type": "Program", "body": [ { "type": "AssignmentExpression", "operator": "=", "left": { "type": "Identifier", "name": "_var" }, "right": { "type": "BinaryExpression", "operator": "+", "left": { "type": "Identifier", "name": "abc123" }, "right": { "type": "NumberLiteral", "value": 456 } } } ] }下面是Parser的实现
function parser(tokens){
const AST = {
type: 'Program', // 这是AST的根
body: []
}
let index = 0
// 处理函数 递归
function walk(){
let token = tokens[index]
if (token.type === 'number'){
index++
return{
type: 'NumberLiteral',
value: token.value
}
}
if (token.type === 'paren' && token.value === '('){
token = tokens[++index]
// 创造节点 层级 + 1
let node = {
type:'CallExpression',
name: token.value
params: []
}
token = tokens[++index]
// 搜索并创造子节点 直到发现右括号 则根据函数入栈的顺序 返回到最开始建立node的层级
while (token.type !== 'paren' || (token.type === 'paren' && token.value !== ')')){
node.params.push(walk()) // 递归生成子节点
token = tokens[index]
}
index++
return node
}
throw TypeError('Unexpected token: ', token.type, token.value)
}
// 主循环
while(index < tokens.length){
AST.body.push(walk())
}
return AST // 所有执行完返回 AST
}
代码转换
这一步是将Parser返回的 AST 作进一步处理,生成一个新的 AST。 导入一个工具——estraverse,它是一个 traverse,有了它我们可以方便快捷的实现遍历 AST节点,并在其提供的两个钩子函数(enter & leave)中实现修改节点或动态插入节点。
const esprima = require('esprima'); // 一个生成AST的工具
const estraverse = require('estraverse');
const code = ` let a = 1; let b = 2; console.log(a + b); `; // 源代码
const ast = esprima.parseScript(code); // 生成ast
// 转换 AST,深度优先遍历,且只会遍历节点的 type 属性
estraverse.traverse(ast, {
// 进入-hook
enter(node) {
console.log('enter -> node.type', node.type);
},
// 离开-hook
leave(node) {
console.log('leave -> node.type', node.type);
},
});
输出结果如下
enter -> node.type Program
enter -> node.type VariableDeclaration
enter -> node.type VariableDeclarator
enter -> node.type Identifier
leave -> node.type Identifier
enter -> node.type Literal
leave -> node.type Literal leave -> node.type
.......
如果我们想修改某个树节点的某个值,则可以在enter钩子中实现如下代码
// 假设有个函数名字叫做 privateFunc 我们要修改其名字为 _Func
enter(node) {
(()=>{ // 立即执行函数
if(node.type === "FunctionDeclaration" && node.id.name === 'privateFunc'){
node.id.name = '_Func'
}
})()
}
代码生成 根据 AST 以及预设的代码规范,生成出目标代码。这也是 Babel 实现代码降级的核心部分。
3.体验AST
在线分析AST网站: https://astexplorer.net/ (可能需要魔法
