引言
「你的代码风格和我不同」——这是每个团队都吵过的架。代码格式化器的价值不是「把代码变好看」,而是用确定性消灭争论:格式化器是唯一正确输出,谁都不用再就「缩进几个空格」辩论。本文把格式化器从「配置一下」讲到「懂原理」:先讲格式化的两类架构(文本级 vs AST 级)的差异,再深入 AST 感知格式化的完整流水线(parse → format → print),接着讲打印算法——Prettier 的文档模型(Doc)如何解决「换行决策」,再讲配置与自定义(格式化范围/自定义插件)、格式化与 Lint 的分工、CI 与提交钩子落地,最后对比主流工具(Prettier/Black/gofmt/Rustfmt)的设计哲学与适用场景。
前置:/dsl-design/(解析器与 AST)、/regex-deep-dive/(文本处理)、/text-processing-toolkit/(工具链)。前端工具链见 前端专题。
目录
- 1. 为什么需要格式化器:确定性与争论消除
- 2. 两类架构:文本级 vs AST 级
- 3. 格式化流水线:parse-format-print
- 4. 打印算法:Prettier 的文档模型
- 5. 换行决策:宽度与策略
- 6. 配置与自定义:范围与插件
- 7. 格式化与 Lint 的分工
- 8. CI 与提交钩子落地
- 9. 主流工具对比
- 10. 速查表与一句话记忆
- 延伸阅读
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. 主流工具对比
不同语言的格式化「哲学光谱」:
| 工具 | 语言 | 哲学 | 配置 | 换行策略 |
|---|---|---|---|---|
| Prettier | JS/TS/CSS/MD 多语言 | 最小惊讶 | 丰富 | group 内尽量单行 |
| Black | Python | 激进统一 | 极少 | 几乎全换行 |
| gofmt | Go | 官方独裁 | 零 | 结构规则优先 |
| Rustfmt | Rust | 标准 + 可配置 | 适中 | printWidth + 配置 |
| Ruff format | Python | Black 兼容超集 | 同 Black | 同 Black + 改进 |
设计取舍的启示:
gofmt:零配置 = 零争论,但无法表达团队特殊偏好
Black:少配置 + 激进 = 换行统一到「无歧义」
Prettier:多配置 + 插件 = 灵活但可能「重新制造争论」
→ 没有最优,只有「团队对确定性的偏好程度」
多语言仓库的实践:
- 每语言用它的「默认 + 主流」格式化器(别跨语言强统一)
- 配置进各自配置文件,提交钩子统一调度
- CI 统一门禁:所有语言的格式检查一起跑
格式化器的「盲区」:
- 语义级重构(重命名/提取) → 交给 IDE/重构工具
- 跨文件一致性(重复代码) → 交给 linter/架构工具
- 动态/脚本生成代码 → 交给生成器 + 格式化
心智:格式化工具是一条「确定性偏好」光谱——gofmt 最独裁、Prettier 最灵活;选型看团队要「零争论」还是「可表达」,盲区交给 IDE 与 Lint。
10. 速查表与一句话记忆
全篇速查:
| 主题 | 结论 |
|---|---|
| 价值 | 确定性消灭争论,diff 变纯 |
| 架构 | AST 级主流,文本级脆弱 |
| 流水线 | parse → format(丢格式)→ print |
| Doc | group + 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 门禁体系
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。