本文目录导读:
- 核心思想:从“文本”到“树”再到“文本”
- 第一步:解析 (Parse)—— 从字符串到 AST
- 第二步:转换/美化 (Transform/Beautify) —— 从 AST 到 Doc IR
- 第三步:代码生成 (Print) —— 从 Doc IR 到字符串
- 一个简化的流程示意图
- 常见格式化工具的实现差异
这是一个非常有深度的问题。“源码数据格式化”通常指的是代码格式化(如 Prettier、Black、gofmt)或序列化(如 JSON、XML 的序列化/反序列化),我假设你指的是代码格式化工具(如 Prettier, ESLint --fix)的底层原理,因为这是前端/软件工程中更核心的机制。
下面我将从编译原理的角度,拆解代码格式化工具的底层工作流:
核心思想:从“文本”到“树”再到“文本”
格式化工具绝不是做简单的字符串替换或正则匹配(那样太脆弱,无法处理嵌套结构),它遵循一个严谨的“三步走”流程:
- 解析 (Parse):将源码字符串解析成抽象语法树 (AST,Abstract Syntax Tree)。
- 转换/美化 (Transform/Beautify):在 AST 层面抹掉所有原始格式信息(空格、换行、注释位置等),然后重新生成一个只包含“格式化规则”的中间表示(如 Doc IR,即文档中间表示)。
- 代码生成 (Code Generation/Printing):将中间表示“打印”成格式化的字符串。
下面详细拆解每一步。
第一步:解析 (Parse)—— 从字符串到 AST
格式化工具的“眼睛”是解析器,它会把你的源码(一个长字符串)转换成一种树形数据结构。
-
Why AST? AST 丢失了所有与逻辑无关的格式信息(如空格、缩进、分号位置),只保留了结构(变量声明、函数调用、表达式嵌套...),格式化器只关心结构,不关心原来的格式。
-
例子:
// 输入(混乱的源码) let a = 1 ; if ( a > 0 ) { console.log ( "hello" ) } // 解析后的 AST (简化表示) Program ├── VariableDeclaration │ ├── kind: "let" │ ├── declarations: [ │ │ ├── id: Identifier (name: "a") │ │ └── init: Literal (value: 1) │ │ ] ├── IfStatement │ ├── test: BinaryExpression (operator: ">", left: Identifier "a", right: Literal 0) │ ├── consequent: BlockStatement │ │ └── body: [ │ │ └── ExpressionStatement │ │ └── expression: CallExpression │ │ ├── callee: MemberExpression (object: console, property: log) │ │ └── arguments: [ Literal "hello" ] │ ] │ └── alternate: null -
关键技术:词法分析(Tokenization)、语法分析 (LL(1)/ LALR(1) / 手写递归下降解析器)。
第二步:转换/美化 (Transform/Beautify) —— 从 AST 到 Doc IR
这是最核心、最有趣的一步,格式化工具不会直接在 AST 节点上拼字符串,而是先构建一个中间表示 (IR,Intermediate Representation),通常被称为 Doc(Document,文档对象),Prettier 就是这方面的代表。
-
为什么要用 Doc IR? 因为代码格式化是一个二维排版问题(行宽限制 x 嵌套深度),直接拼字符串很难处理“这一行太长,需要换行;换行后,子元素要缩进”等问题,Doc IR 是一个声明式的排版描述语言。
-
Doc 的基本命令(以 Prettier 为例):
group(...):表示一个“组”,组内的内容能在一行放下就一行,放不下就整体换行(break)。indent(...)前增加一个缩进级别。line:表示一个“软换行”,如果在 group 内能放下,它输出空格;如果放不下,它输出换行 + 缩进。hardline:强制换行。concat(...):把多个 doc 拼接起来。
-
格式化过程(Mapper): 一个“美化器”函数会遍历 AST,递归地将每个 AST 节点映射成一组 Doc 命令。
举例:
if (a > 0) { ... }的映射// 假设我们有一个 AST 节点 IfStatement function printIfStatement(node) { return group( concat([ "if", " ", "(", indent( // 条件内部的缩进(可选,看规则) softline, // 软换行 printExpression(node.test), // 打印 a>0 softline ), ")", " ", printBlock(node.consequent) // 打印大括号块 ]) ); }关键算法:这其实是一个动态规划或贪心算法,当遍历到
group时,格式化器会尝试把组内所有内容放在当前行(计算宽度),如果超出printWidth(如 80 字符),则全部换行,并激活line和indent的效果。
第三步:代码生成 (Print) —— 从 Doc IR 到字符串
这一步称为 Render(渲染),它接收一个复杂的 Doc IR 树,输出最终的格式化字符串。
-
算法核心:Greedy(贪心) 或 Optimal(最优) 排版算法。
- Prettier:使用贪心算法,它维护一个“当前光标位置”(
pos),遇到group时,它预先计算出如果整个group排在一行,末尾的位置endPos。endPos <= printWidth(完全装得下),则按行内方式打印(line变空格)。endPos > printWidth(超长了),则触发group断开(line变换行 + 缩进)。
- 高级算法(如用于表格对齐的):使用动态规划,尝试多种可能的断行方案,选择“代价”(比如行长度离目标宽度越远代价越大)最小的那个。
- Prettier:使用贪心算法,它维护一个“当前光标位置”(
-
处理注释:这是格式化里最麻烦的部分,注释在 AST 中位置不定,工具需要将注释“附着”到最近的 AST 节点上,在打印节点时一并打印,并保证不改变代码逻辑。
一个简化的流程示意图
原始代码 (字符串)
│
▼
[词法分析器 (Lexer)]
│ (产生 Token 流: {type: 'let'}, {type: 'space'}, ...)
▼
[语法分析器 (Parser)]
│ (产生 AST, 忽略所有空格/换行 Token)
▼
AST 树
│
▼
[格式化器 (Formatter / Mapper)]
│ (遍历 AST, 根据规则生成 Doc IR)
│ ( group, indent, line, concat)
▼
Doc IR (中间表示)
│
▼
[渲染器 (Printer / Renderer)]
│ (贪心算法: 根据 printWidth 选择换行点)
│ (处理: 缩进, 空格, 换行)
▼
格式化后的代码 (字符串)
常见格式化工具的实现差异
| 工具 | 底层语言 | 核心原理 | 特点 |
|---|---|---|---|
| Prettier | JavaScript | 中间表示 (Doc IR) + 贪心渲染 | 可配置性极强,支持多语言,业界标杆 |
| gofmt | Go | 直接输出规范化的 AST 文本 | 强制统一风格,没有配置选项(唯一风格) |
| Black | Python | 类似 Prettier 的 IR 思想(使用 cst 库) |
追求最小化 diff,风格统一 |
| ClangFormat | C++ | 基于结构化算法的 Unwrapped Line 模型 | 高度可配置,用于 C/C++/Java/JS |
| ESLint --fix | JavaScript | 直接修改 AST 并原地重写(不是序列化) | 修复规则,不负责整体排版 |
源码数据格式化的底层原理是:“先拆解”(解析成 AST,丢弃原始空格)→ “再重组”(根据抽象语法结构和排版规则,动态规划最优换行/缩进方案)→ “再输出”(渲染成最终字符串)。
这是一个典型的编译原理应用场景,它将排版问题转化为了一个树遍历 + 排版算法问题。
标签: 底层原理