Prettier 拿到 AST 后,不是直接在上面做格式化决策。它先把 AST 转换成一种叫做 Doc 的中间表示。这个设计选择不是随意为之——它解决了"如何在不知道最终行宽的情况下描述排版意图"这个核心难题。
如果你的格式化器直接操作 AST 输出字符串,它必须在转换过程中就做出每一个换行决策。但很多换行决策取决于行宽约束——一行的内容是否超过 80 字符?这需要知道前面所有内容的总宽度。问题是,前面的内容可能还在被排版过程中,总宽度尚未确定。
Doc IR 的巧妙之处在于,它把"描述排版意图"和"执行排版决策"分开。Doc 只描述意图,不执行决策。"这段内容尽量放在一行,如果放不下就换行展开"——这个意图用一个 group 命令就能表达,不需要知道行宽是多少。行宽的考虑留给最终的打印算法。
这种"意图描述"的思想来源于 Philip Wadler 在 1997 年发表的论文《A Prettier Printer》,论文中提出了一套用代数数据类型描述文档布局的 DSL。Prettier 的 Doc IR 是对这套理论的工程实现。
Doc IR 由一组排版命令组合而成。这些命令是构建代码布局的"原子操作":
concat:把多个 Doc 拼接在一起。这是最基本的组合操作,类似于字符串拼接。
concat(["const ", "x", " = ", "1", ";"])
group:定义一个可折叠的代码块。打印器首先尝试把 group 的所有内容放在一行内;如果超过行宽,则展开为多行。group 是 Doc IR 中最重要的命令,几乎所有"该不该换行"的决策都由它表达。
indent:增加一级缩进。包含在 indent 内的内容在换行时会比外部多一级缩进。
softline:条件换行。在 group 内部,如果整个组能放在一行内,softline 变成空字符串;如果组需要换行展开,softline 变成真实的换行符。
hardline:无条件换行。不管行宽多少,这里一定要换行。
line:在 group 内行为类似 softline,在 group 外行为类似 hardline。
fill:类似于 group,但内容是逐个元素放置的——每个元素要么跟在前一个后面(不换行),要么换行开始,取决于空间。
breakParent:强制当前 group 展开。即使当前 group 能放在一行内,遇到 breakParent 也会展开。这用于在嵌套较深时强制打断外层布局。
ifBreak:根据当前 group 是否展开,选择不同的 Doc。展开时输出一个内容,不展开时输出另一个。
这些命令组合在一起,能表达出极其复杂的排版意图。一个完整的函数定义的 Doc 可能是这样:
concat([ "function ", name, group([ "(", indent([ softline, join(concat([",", line]), params) ]), softline, ")" ]), group([ " {", indent([ hardline, body ]), hardline, "}" ]) ])
这段 Doc 的含义:打印 function 关键字和函数名,然后参数列表是一个 group(尽量一行,放不下就展开换行),函数体是一个 group,左花括号在同一行,内容缩进,右花括号独占一行。
打印器拿到 Doc IR 后,执行一个贪心的布局算法。算法的核心思想是:
对于每个 group 节点,尝试把它的所有内容放在当前行。如果总宽度不超过 printWidth,就采用紧凑布局;如果超过,就展开这个 group,把其中的 softline 变成真实的换行。
这个算法的关键在于它的贪心特性——它不做全局优化,而是在每个 group 节点上做局部决策。这意味着它的时间复杂度是线性的(O(n)),不会因为代码复杂度增加而产生指数级爆炸。
但贪心算法有一个潜在问题:一个 group 的展开可能导致父级 group 也需要展开。例如,函数参数展开后,函数调用表达式的总行数增加了,如果它嵌在另一个表达式里,可能导致外层的 group 也放不下。
Prettier 的打印器通过一个高明的方式处理这个问题:它使用"拟合模式"(fit mode)来预判一个 group 是否能放在一行内。在拟合模式中,group 被标记为"不展开",所有 softline 被替换为空字符串,计算总宽度。如果总宽度超过 printWidth,就切换到正常模式,允许 group 展开。

让我们看看 if (condition) { doSomething(); } 这个 if 语句的 Doc 是怎样的:
// 简化后的伪代码表示 concat([ "if (", conditionDoc, ") ", group([ "{", indent(concat([ softline, statementDoc, ])), softline, "}" ]) ])
if 关键字和条件括号总是写在一行(不参与 group)。花括号部分是一个 group:如果 statementDoc 的内容很短,花括号内的 softline 变成空格,整个 if 语句在一行输出:
if (condition) { doSomething(); }
如果 statementDoc 的内容很长(比如包含一个复杂的多行函数调用),group 放不下,softline 变成换行:
if (condition) { doSomething( a, b, c ); }
同一个 Doc IR,根据内容的长度和行宽约束,自动产生了两种合理的布局。这就是 Doc IR 设计的威力——它把排版意图表达出来了,具体的排版决策交给打印算法根据当前约束动态计算。
实际代码中 group 经常多层嵌套。考虑一个链式调用:
const result = data .filter(x => x.active) .map(x => x.value) .reduce((sum, x) => sum + x, 0);
这里有两层 group:外层是整个赋值语句(是否在一行内),内层是链式调用的每个 .method() 是否在一行内。Prettier 的打印算法从外到内处理 group,内层的展开可能触发外层的展开。
理解嵌套 group 的行为,有助于解释一些看似奇怪的格式化结果。比如你修改了一个函数参数的名字(长度变了 1 个字符),导致原本单行的格式突然变成了多行——这是因为那个微小的长度变化让某个 group 超过了行宽阈值,触发了级联展开。
如果你经常因为这类"微小改动导致大面积极式变化"而困扰,可以考虑把 printWidth 调大一些(比如从 80 调到 100),给布局更多的缓冲空间,减少临界切换的频率。