代码格式化器:AST 感知的格式化架构与工具对比

系统覆盖代码格式化器的工程原理:格式化的价值(消除争议/统一风格)、格式化器的两类架构(文本级 vs AST 级)、AST 感知格式化的流水线(parse→format→print)、打印算法(Prettier 的文档模型/doc 组合)、配置与自定义、格式化在 CI/提交钩子的落地、以及主流工具(Prettier/Black/gofmt/Rustfmt)的设计对比。

引言

「你的代码风格和我不同」——这是每个团队都吵过的架。代码格式化器的价值不是「把代码变好看」,而是用确定性消灭争论:格式化器是唯一正确输出,谁都不用再就「缩进几个空格」辩论。本文把格式化器从「配置一下」讲到「懂原理」:先讲格式化的两类架构(文本级 vs AST 级)的差异,再深入 AST 感知格式化的完整流水线(parse → format → print),接着讲打印算法——Prettier 的文档模型(Doc)如何解决「换行决策」,再讲配置与自定义(格式化范围/自定义插件)、格式化与 Lint 的分工、CI 与提交钩子落地,最后对比主流工具(Prettier/Black/gofmt/Rustfmt)的设计哲学与适用场景。

前置:/dsl-design/(解析器与 AST)、/regex-deep-dive/(文本处理)、/text-processing-toolkit/(工具链)。前端工具链见 前端专题。


目录


1. 为什么需要格式化器:确定性与争论消除

格式化器的核心价值不是「好看」,是「确定」:

没有格式化器:
  风格争论 → 审稿噪音 → 团队内耗
  风格不统一 → 认知负担 → 迁移成本

有格式化器:
  唯一正确输出 → 争论归零
  风格随改随正 → 统一无负担
  机器可复现 → 迁移/合并零摩擦

「无争议」意味着什么:

- 代码审查聚焦「逻辑」而非「格式」
- 新人无需学习团队风格约定
- diff 变小(无格式化噪音),审查更高效
- 工具链(生成代码/模板)输出可自动校正

格式化的成本:

- 需要解析器支持(新语法/方言要跟随)
- 大仓库历史格式化 = 一次大规模 diff(改全库)
- 极端风格场景(单行压缩/刻意排版)会被「拉平」

心智:格式化器卖的不是「美学」,是「确定性」——争论归零、diff 变纯、审查聚焦逻辑。


2. 两类架构:文本级 vs AST 级

格式化器的实现有两条路线:

文本级(模板/正则):
  - 只做「可预测」的文本操作(缩进、空行、空格归一)
  - 不解析语义 → 简单但脆弱
  - 代表:早期 HTML/CSS 格式化、部分轻量工具

AST 级(语法树感知):
  - 先 parse 成 AST → 丢注释/格式 → 重新打印
  - 知道「这是什么结构」→ 可做语义级换行决策
  - 代表:Prettier/Black/gofmt/Rustfmt

为什么 AST 级是主流:

文本级问题:
  - 字符串里的内容可能被误改(不知道字符串边界)
  - 注释位置/嵌套上下文无法正确判断
  - 字符串模板/多行语句无法智能换行
AST 级优势:
  - 只格式化「代码」,不动字符串/注释内容
  - 理解语句边界 → 换行/对齐有依据
  - 注释有「归属」(挂在哪个节点)→ 随节点走
示例(文本级会破坏的东西):
  "a: b"            ← 字符串里的冒号不能动
  # 保留这一行      ← 注释内容不能动
  if (x) {          ← 大括号位置由 AST 语义决定

心智:AST 级格式化把代码当「结构」而非「文本」——字符串不误改、注释有归属、换行有依据,所以是主流。


3. 格式化流水线:parse-format-print

AST 感知格式化的三段:

① Parse:源码 → AST(含注释附着、位置信息)
② Format:AST → 不可变文档模型(丢原始格式,只保留语义)
③ Print:文档模型 → 渲染成目标宽度下的文本

关键:格式化阶段「丢格式」——这是 AST 格式化的精髓:

# 示意:源码 → AST → 只保留「语义结构」
source = "foo(a,  b ,  c)"        # 随意间距
ast    = parse(source)            # Call(foo, [a, b, c])
doc    = to_doc(ast)              # 语义结构(间距信息已丢)
out    = print_doc(doc, width=80) # foo(a, b, c)

AST 节点到文档的映射:

CallExpr  → group([text("foo"), text("("), ...])
IfStmt    → group(["if", space, cond, space, body])
BinaryExpr → indent(join(line, [left, "&&", right]))

注释的处理:

- 注释节点在 parse 时保留并「附着」到最近的代码节点
- 打印时按归属位置还原(行首注释 / 行尾注释 / 块注释)
- 特殊注释(// prettier-ignore)→ 原样保留该节点格式

不可变文档:doc 一旦生成就不改(Prettier 的核心),打印阶段纯函数,便于缓存与并行。

心智:AST 格式化 = parse 保留语义 + format 丢弃格式 + print 重建文本——「丢格式」正是它能把任何输入归一化的原因。


4. 打印算法:Prettier 的文档模型

打印阶段的核心问题:一行放不下时,怎么换行? Prettier 用「文档模型(Doc)」抽象回答这个问题。

Doc 的三种基本操作:

Concat(拼接)   :a 后接 b
Group(分组)    :一组内容「要么全在一行,要么全部换行」
Break(可换行点):
  line   → 当前组不换行时输出空格,换行时输出换行
  softline → 不换行时空串,换行时输出换行
  hardline → 无条件换行
  ifBreak → 依据「是否在换行组内」选择输出
# 示意:二元表达式的 Doc 构建
def binary_expr_doc(left, op, right):
    return group([                    # 一个 group
        left,
        ' ', line, op, line, ' ',    # 空格 + 可换行点
        indent(right),               # 换行时右侧缩进
    ])

打印决策:

一个 group 能放下一行(≤ printWidth)→ 不换行
放不下 → 所有 line 变成换行,嵌套 group 递归判断

示例效果:

// 一行放得下 → 单行
const result = foo(a, b, c)

// 放不下 → 换行并缩进
const result = someFunction(
  argumentOne,
  argumentTwo,
  argumentThree
)

Doc 的优势:

- 打印是纯函数:同样 doc 同样输出,可缓存
- 决定论:输入格式不影响输出(任何输入 → 唯一输出)
- 可测试:doc 模型可单元测试换行行为

心智:Prettier 把「换行」抽象成 doc 的 group + line——组内放得下就单行、放不下就整体换行,纯函数保证确定性。


5. 换行决策:宽度与策略

换行是格式化器最「玄学」的部分,各工具的决策策略不同:

宽度阈值:
  printWidth(Prettier 默认 80)
  line_length(Black 默认 88)
  gofmt 无配置(80 列内联、超长强制拆分)

策略差异:
  Prettier:group 内「能放下就不换」,强调「最小惊讶」
  Black:激进换行(几乎所有调用都换行),强调「统一」
  gofmt:结构规则优先(如复合字面量、select),不做行宽微调

不同语言的不同难点:

- JS/TS:调用链、箭头函数、对象字面量的平衡
- Python:无大括号,缩进即结构 → 换行必须「显式续行」
- Go:gofmt 不做「好看」优化,只保证「一致 + 可读」
- Rust:rustfmt 对标 printWidth,宏与泛型的复杂换行
# Black 对「过长函数调用」的换行
result = some_function(
    argument_one,
    argument_two,
    argument_three,
    argument_four,
)

# 多行调用的括号引导(Black 强调的「magic trailing comma」)

换行与 diff 稳定:

- 增删一行是否引起大范围重排 → 好格式化器尽量「局部化」
- 末尾逗号策略(trailing comma)影响换行稳定
- 单行 vs 多行的「边界抖动」是换行策略的核心权衡

心智:换行策略是「宽度阈值 + 语言结构规则」的平衡——Prettier 最小惊讶、Black 激进统一、gofmt 结构优先,各有取舍。


6. 配置与自定义:范围与插件

格式化器不是「一把梭」——要能控制范围与扩展:

配置层级:
  项目配置(.prettierrc / pyproject / rustfmt.toml)
  命令行覆盖(--print-width)
  忽略清单(.prettierignore / .gitattributes)

范围控制:

- 忽略文件/目录:生成代码、vendor、minified
- 忽略行/块:// prettier-ignore、# fmt: off
- 渐进接入:先格式化新代码,历史代码分批格式化
// .prettierrc.json
{
  "printWidth": 100,
  "tabWidth": 2,
  "semi": true,
  "singleQuote": false,
  "trailingComma": "es5",
  "plugins": ["prettier-plugin-tailwindcss"]
}

插件体系:

Prettier:插件可解析新语言(Tailwind/GraphQL/Markdown)
Black:Python 专属,配置极少(保持「意见统一」哲学)
gofmt:零配置,无插件(Go 官方立场「就这一种风格」)
rustfmt:配置适中,支持自定义样式子集

格式化器 vs 手动风格:

- 无法表达的风格(如 kebab-case 属性 vs camelCase)→ 格式化器不做
- 语义/命名规范 → 交给 Linter,不归格式化器
- 格式化器只做「机械且确定」的部分

心智:配置控制范围(忽略/渐进/插件),哲学各不同——gofmt 零配置最「独裁」,Prettier 插件体系最「民主」。


7. 格式化与 Lint 的分工

格式化器与 Linter 是「两件事」,别混用:

维度格式化器(Prettier/Black/gofmt)Linter(ESLint/Ruff)
管什么排版:缩进/换行/引号/空格规范:未用变量/命名/潜在 bug
是否可自动修总是(确定性)部分(autofix,语义风险)
报错语义无「对错」,只有「不一致」有「对错」,能抓 bug
触发时机写代码时/保存时CI/审查时

配合模式:

格式化器在前(保存即格式化)→ 消除排版噪音
Linter 在后(CI 拦截)→ 抓规范与隐患
两者不冲突:Linter 的「风格类规则」关掉,交给格式化器
示例分工:
  ESLint 管:no-unused-vars、no-undef、camelcase
  Prettier 管:semi、quotes、indent、printWidth
  (ESLint 里这些风格规则用 eslint-config-prettier 关闭)

工作流:

编辑器:保存时自动格式化 + Lint 实时提示
提交钩子:pre-commit 跑「格式化 + 快速 Lint」
CI:合并前跑「完整 Lint + 格式化检查(--check)」

心智:格式化管「排版一致」,Lint 管「规范与隐患」——保存即格式化、CI 跑 Lint,风格规则交给格式化器,Linter 专注抓 bug。


8. CI 与提交钩子落地

格式化器的价值在「全流程强制」——只装不跑等于没有:

提交钩子(pre-commit 生态):

# .pre-commit-config.yaml
repos:
  - repo: https://github.com/pre-commit/mirrors-prettier
    rev: v4.0.0-alpha.8
    hooks:
      - id: prettier
        types_or: [javascript, typescript, css, markdown]
  - repo: https://github.com/psf/black
    rev: 24.3.0
    hooks:
      - id: black

CI 校验(合并门禁):

# 格式检查(不改文件,只校验)——CI 里用
npx prettier --check .
black --check .
gofmt -l .
cargo fmt --check

渐进接入策略:

存量代码:
  方案一:一次性全库格式化(历史 diff 变大,一次痛)
  方案二:只格式化改动行(blame 友好,但风格不彻底)
  方案三:目录分片推进(先核心后边缘)
新代码:从第一天就强制

格式化的「纪律」:

- 别手动绕过(除非 prettier-ignore 有充分理由)
- 生成代码(模板/脚手架输出)接格式化器自动校正
- 格式化器版本锁定(升级要全团队同步 + 一次性重格式化)

心智:格式化落地 = 提交钩子保「写入即一致」+ CI 保「合并前一致」+ 渐进策略处理存量,版本锁定避免漂移。


9. 主流工具对比

不同语言的格式化「哲学光谱」:

工具语言哲学配置换行策略
PrettierJS/TS/CSS/MD 多语言最小惊讶丰富group 内尽量单行
BlackPython激进统一极少几乎全换行
gofmtGo官方独裁零结构规则优先
RustfmtRust标准 + 可配置适中printWidth + 配置
Ruff formatPythonBlack 兼容超集同 Black同 Black + 改进

设计取舍的启示:

gofmt:零配置 = 零争论,但无法表达团队特殊偏好
Black:少配置 + 激进 = 换行统一到「无歧义」
Prettier:多配置 + 插件 = 灵活但可能「重新制造争论」
→ 没有最优,只有「团队对确定性的偏好程度」

多语言仓库的实践:

- 每语言用它的「默认 + 主流」格式化器(别跨语言强统一)
- 配置进各自配置文件,提交钩子统一调度
- CI 统一门禁:所有语言的格式检查一起跑

格式化器的「盲区」:

- 语义级重构(重命名/提取) → 交给 IDE/重构工具
- 跨文件一致性(重复代码) → 交给 linter/架构工具
- 动态/脚本生成代码 → 交给生成器 + 格式化

心智:格式化工具是一条「确定性偏好」光谱——gofmt 最独裁、Prettier 最灵活;选型看团队要「零争论」还是「可表达」,盲区交给 IDE 与 Lint。


10. 速查表与一句话记忆

全篇速查:

主题结论
价值确定性消灭争论,diff 变纯
架构AST 级主流,文本级脆弱
流水线parse → format(丢格式)→ print
Docgroup + line,放不下整体换行
换行宽度阈值 + 语言结构规则
配置忽略/渐进/插件,哲学不同
分工格式化管排版、Lint 管规范
落地保存即格式化 + CI –check
渐进新代码强制、存量分片
工具gofmt 独裁、Black 激进、Prettier 灵活

一句话记忆:格式化器的价值是「确定性」——AST 级格式化 parse 保语义、format 丢格式、print 重建文本,Prettier 用 doc 的 group + line 做换行决策;配置管范围与插件,格式化管排版、Lint 管规范;落地靠保存即格式化 + CI 门禁 + 存量渐进,gofmt 独裁零争论、Black 激进统一、Prettier 灵活可表达——选型看团队对「零争论」的偏好程度。


延伸阅读

  • /dsl-design/ — 解析器、AST 与语法树处理
  • /regex-deep-dive/ — 文本处理与格式化器的词法基础
  • /text-processing-toolkit/ — 命令行格式化与文本工具
  • /others-json-yaml-processing/ — 配置文件的格式化与 Schema
  • 前端专题 — 前端工程化与 Prettier/ESLint 落地
  • DevOps 专题 — pre-commit 与 CI 门禁体系

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「others」更多文章

  1. Markdown 与文档工程:写作规范、静态生成与 LaTeX 排版
  2. 终端与 Shell 生态进阶:zsh、tmux 与高效命令行工作流
  3. 概率统计基础实战:贝叶斯、随机变量、分布与推断