读懂 Prettier 的设计哲学:格式选择背后的规则、选项与边界
【免费下载链接】prettierPrettier is an opinionated code formatter.项目地址: https://gitcode.com/gh_mirrors/pr/prettier
Prettier 是一款「有主见的(opinionated)」代码格式化工具,docs/rationale.md 系统解释了它在引号、空行、对象换行、装饰器、模板字符串、分号、行宽、JSX、注释等场景下「为什么这样格式化」以及「它不做什么」。读完本文,你既能理解每个格式决策背后的工程权衡,也能掌握objectWrap、singleQuote、printWidth等关键选项的准确语义,并看到这些规则在 Prettier 源码中的真实落点。
正确性是第一位的要求
Prettier 的第一原则是正确性:格式化的输出必须是合法代码,且行为与格式化前完全一致。官方文档明确指出,如果某段代码在 Prettier 处理下行为发生变化,那就是一个必须修复的 bug。
这条原则贯穿后文几乎所有设计:Prettier 不自动修复 ASI(自动分号插入)相关的既有 bug,不重排 import,不转换字符串写法——因为任何「顺手优化」都可能引入行为差异,威胁这个最高目标。
字符串与引号选择
Prettier 对双引号还是单引号的选择规则是:选能让转义符最少的那个。例如应输出"It's gettin' better!",而不是'It\'s gettin\' better!'。当两种引号的转义数相同、或字符串内不含引号时,默认使用双引号;这一点可通过singleQuote选项改为单引号。
这一逻辑在源码中体现得很直接。get-preferred-quote.js 中的getPreferredQuote函数会遍历字符串文本,分别统计首选引号与备选引号出现的次数(preferredQuoteCount/alternateQuoteCount),若字符串中备选引号出现次数更少,就改用备选引号作为包裹符:
// src/utilities/get-preferred-quote.js 核心逻辑 if (codePoint === preferred.codePoint) { preferredQuoteCount++; } else if (codePoint === alternate.codePoint) { alternateQuoteCount++; } return (preferredQuoteCount > alternateQuoteCount ? alternate : preferred).character;选项定义见 common-options.evaluate.js:singleQuote是布尔型、默认false(即默认双引号)。
JSX 有自己独立的引号选项jsxSingleQuote。文档给出的理由是:JSX 源于 HTML,而 HTML 属性中双引号是主流写法;浏览器开发者工具也总是用双引号展示 HTML——即使源码用的是单引号。独立的选项让用户可以对 JS 使用单引号、对「HTML 味」的 JSX 使用双引号,各取所需。
另外,Prettier 会保留字符串原有的转义方式:"🙂"不会被格式化成"\uD83D\uDE42",反之亦然。
空行的保留策略
自动生成空行(比如「在函数之间留一个空行」)在实践中非常困难,所以 Prettier 的策略是:按原样保留源码中的空行,并附加两条规则:
- 连续的多个空行会合并为一个空行;
- 块(以及整个文件)开头和结尾的空行会被删除(但文件始终以单个换行符结尾)。
在 JS 对象打印的源码中可以找到空行保留的佐证。object.js 在逐个打印属性时,用isNextLineEmpty检查原源码中该属性之后是否为空行,是则插入hardline,从而把原有的空行「带进」格式化结果;isNextLineEmpty的实现在 is-next-line-empty.js。
多行对象与 objectWrap 选项
Prettier 的打印算法默认是「能放进一行就放一行」。但对象字面量在 JS 里用途太广——对象列表、嵌套配置、样式表、带键方法等场景下,保持多行往往更利于阅读。文档承认团队没能找到覆盖所有这些场景的通用规则,于是采用了启发式:只要原源码中{和第一个键之间存在换行,对象就保持多行。由此推论:长的单行对象会被自动展开,但短的多行对象永远不会被折叠。
这个条件行为可以由objectWrap选项控制。选项定义在 common-options.evaluate.js:
| 取值 | 默认 | 行为 |
|---|---|---|
preserve | 是 | 若开括号与第一个属性之间有换行,则保持多行 |
collapse | 否 | 尽可能折进单行 |
对应的源码在 object.js:当options.objectWrap === "preserve"且通过hasNewLineAfterOpeningBrace(用hasNewlineInRange检查{与第一个属性起点之间的原始文本是否含换行)判定为真时,shouldBreak被置为true,随后group(content, { shouldBreak })强制该分组破折成多行。
合并与展开对象:一个实用技巧
由于「换行即保持多行」,你可以用一种半手动的方式控制对象布局。
想把这个多行对象合并成单行,只需删掉{后面的换行:
const user = { name: "John Doe", age: 30 };然后运行 Prettier,得到:
const user = { name: "John Doe", age: 30 };想再变回多行,就在{后加回一个换行:
const user = { name: "John Doe", age: 30 };再运行 Prettier,得到规范的多行形式:
const user = { name: "John Doe", age: 30, };关于格式可逆性的说明
文档诚实地指出:上述对对象字面量的半手动控制其实是一个变通方案(workaround),而非特性——当年是急需修复且尚未找到好的启发式才引入的。Prettier 作为整体策略上会避免这类「不可逆(non-reversible)」的格式化。
什么叫不可逆?对象字面量一旦变成多行,Prettier 就不会再折叠它。设想:你在已格式化的代码里给对象加了一个属性,运行 Prettier 后对象变多行;随后你改变主意删掉该属性,再运行 Prettier,结果可能与最初格式不一致。这种无意义的 diff 甚至可能被提交进代码库——而这正是 Prettier 立志要消除的东西。团队仍在寻找能彻底移除(或至少收窄适用场景)这一行为的启发式。
装饰器:保留你的原始写法
和对象类似,装饰器(decorators)也用于非常多样的场景:有时写在被装饰行的上方更合理,有时同行更美观。团队同样找不到通用规则,所以 Prettier 会保留你书写时的位置(前提是能放进一行)。这不是理想方案,却是对难题的务实处理:
@Component({ selector: "hero-button", template: `<button>{{ label }}</button>`, }) class HeroButtonComponent { // These decorators were written inline and fit on the line so they stay // inline. @Output() change = new EventEmitter(); @Input() label: string; // These were written multiline, so they stay multiline. @readonly @nonenumerable NODE_TYPE: 2; }唯一的例外是类:文档认为类装饰器写成行内没有任何合理场景,因此总是被移到独立行:
// Before running Prettier: @observer class OrderLine { @observable price: number = 0; }// After running Prettier: @observer class OrderLine { @observable price: number = 0; }源码印证了这一点:decorators.js 中shouldBreak在节点是ClassExpression/ClassDeclaration时直接为真,否则取决于装饰器之间或之后是否存在换行(hasNewlineBetweenOrAfterDecorators)——即「保留原样」的实现就是检查原始文本中的换行分布。
注意:Prettier 1.14.x 及更早版本曾尝试自动移动装饰器,如果你用过旧版本处理过代码,可能需要手动合并一些装饰器,避免同一代码库内风格不一致:
@observer class OrderLine { @observable price: number = 0; @observable amount: number = 0; }模板字符串与插值的换行
模板字符串可以包含插值,而「在插值内部换行是否合适」取决于模板的语义内容——例如在一句自然语言中间断开通常是不 desirable 的。Prettier 不具备判断语义的能力,因此沿用了与对象相同的启发式:只有当原始源码的插值内部已经存在换行时,才允许把插值表达式拆成多行。
这意味着下面的字面量即使超过打印宽度也不会被拆分:
`this is a long message which contains an interpolation: ${format(data)} <- like this`;想让 Prettier 拆分插值,就必须先在${...}内部引入一个换行;否则无论多长都会保持单行。文档也坦承:团队不希望如此依赖原始格式,但目前这是最优的启发式。
分号与 ASI 保护
这一节针对noSemi(即semi: false)用法。考虑这段代码:
if (shouldAddLines) { [-1, 1].forEach(delta => addLine(delta * 20)) }它没有分号也能正常运行,但 Prettier 会把它变成:
if (shouldAddLines) { ;[-1, 1].forEach(delta => addLine(delta * 20)) }这是为了防止你犯错。设想 Prettier 不插入这个分号,而你在其后加了一行:
if (shouldAddLines) { + console.log('Do we even get here??') [-1, 1].forEach(delta => addLine(delta * 20)) }糟糕——上面这段实际会被解析成:
if (shouldAddLines) { console.log('Do we even get here??')[-1, 1].forEach(delta => addLine(delta * 20)) }在[前有一个分号,这类问题就永远不会发生。它让该行独立于其他行,你可以随意增删、移动行而不用思考 ASI 规则。这一实践在 Standard 风格这类「无分号」流派中也同样常见。
实现细节可见 semicolon.js:shouldExpressionStatementPrintLeadingSemicolon只在options.semi为假时工作,随后由expressionNeedsAsiProtection判断表达式是否需要 ASI 保护——以[、(、模板字符串、正则开头的表达式,前缀+/-的一元表达式等都会命中,从而在行首补上分号。
重要边界:如果你的程序已经存在分号相关的 bug,Prettier 不会替你自动修复——它只重排格式,不改变行为。例如开发者漏写(前分号的这段代码:
console.log('Running a background task') (async () => { await doBackgroundWork() })()喂给 Prettier 后,它不会改变代码实际运行时的行为,只会把它格式化成能「暴露真实行为」的样子:
console.log("Running a background task")(async () => { await doBackgroundWork(); })();printWidth 是指南,不是硬上限
printWidth对 Prettier 来说更像一条指南而非硬性行宽上限,它表达的是「我希望行大概有这么长」。Prettier 既会输出更短的行,也会输出更长的行,但总体上会努力贴近指定宽度。
存在一些无法断行的边界情况:超长字符串字面量、正则表达式、注释、变量名都无法跨行拆开(因为拆它们属于代码转换,见下文「Prettier 不关心什么」)。又或者你把代码嵌套 50 层深,行宽自然被缩进吃光——这是正常现象。
除此之外,还有几处 Prettier有意超出打印宽度的场景:
import 语句
Prettier 可以把长的import拆到多行:
import { CollectionDashboard, DashboardPlaceholder, } from "../components/collections/collection-dashboard/main";但下面这个超出打印宽度的例子,Prettier 仍会保持单行:
import { CollectionDashboard } from "../components/collections/collection-dashboard/main";这可能让部分人意外,但原因很实际:用户普遍要求「只引入一个名字时保持单行 import」,require调用同理。
测试函数
另一个高频需求是让冗长的测试描述保持单行,因为把参数折到多行对此类场景帮助有限:
describe("NodeRegistry", () => { it("makes no request if there are no nodes to prefetch, even if the cache is stale", async () => { // The above line exceeds the print width but stayed on one line anyway. }); });Prettier 对describe、it、test等常见测试框架函数设有专门处理。
JSX 的特殊排版
当代码涉及 JSX 时,Prettier 的打印方式与其他 JS 代码略有不同:
function greet(user) { return user ? `Welcome back, ${user.name}!` : "Greetings, traveler! Sign up today!"; } function Greet({ user }) { return ( <div> {user ? ( <p>Welcome back, {user.name}!</p> ) : ( <p>Greetings, traveler! Sign up today!</p> )} </div> ); }原因有二。第一,大量开发者本来就把 JSX 包在括号里(尤其是在return语句中),Prettier 顺应了这一主流风格。第二,这种替代表达方式让 JSX 更容易编辑:JSX 中残留的分号不同于普通 JS——它会变成纯文本直接显示在页面上:
<div> <p>Greetings, traveler! Sign up today!</p>; {/* <-- Oops! */} </div>把 JSX 独立成块并自带括号,恰好降低了这类残留分号造成的困扰。
注释:格式化边界的所在
注释的内容:Prettier 基本无能为力。注释里可能是散文、被注释掉的代码,也可能是 ASCII 图——什么都能装,Prettier 无从知道该如何格式化或换行它们,所以一律原样保留。唯一的例外是JSDoc 风格注释(每行以*开头的块注释),Prettier 会修正它们的缩进。
注释的位置则是一个真正的难题:注释几乎可以出现在任何位置,Prettier 会尽力让注释停留在大致原来的位置,但结果并不总能完美。文档给出的实用建议:
- 注释独占一行的效果通常优于行尾注释;
- 优先用
// eslint-disable-next-line而不是// eslint-disable-line; - 「魔法注释」(如
eslint-disable-next-line、$FlowFixMe)在 Prettier 把一个表达式拆成多行后,可能需要你手动挪动位置。
举个例子:
// eslint-disable-next-line no-eval const result = safeToEval ? eval(input) : fallback(input);之后你加了一个条件:
// eslint-disable-next-line no-eval const result = safeToEval && settings.allowNativeEval ? eval(input) : fallback(input);Prettier 会把它折成:
// eslint-disable-next-line no-eval const result = safeToEval && settings.allowNativeEval ? eval(input) : fallback(input);此时eslint-disable-next-line已经失效了,你需要手动把注释移到表达式内部:
const result = // eslint-disable-next-line no-eval safeToEval && settings.allowNativeEval ? eval(input) : fallback(input);文档进一步建议:尽可能使用作用于行范围(如eslint-disable/eslint-enable)或语句级别(如/* istanbul ignore next */)的注释,它们更稳妥;也可以通过 ESLint 插件(如eslint-plugin-eslint-comments)直接禁用eslint-disable-line与eslint-disable-next-line的写法。
关于非标准语法的免责声明
Prettier 常常能够识别并格式化非标准语法,例如 ECMAScript 早期提案(early-stage proposals)和任何规范都未定义的 Markdown 语法扩展。对此类语法的支持属于尽力而为且实验性的:任何版本都可能引入不兼容变化,且不应被视为 breaking change。
关于机器生成文件的免责声明
package.json、composer.lock这类文件由包管理器生成并定期更新。如果 Prettier 对其套用与一般 JSON 文件相同的格式规则,就会持续与这些工具产生冲突。为避免这种麻烦,Prettier 对这类文件改用基于JSON.stringify的格式器。你可能会注意到差异——比如垂直空白的消失——但这正是预期行为。
Prettier 不关心什么:它只打印,不转换
Prettier 只做打印(print),不做转换(transform)。这是为了限定 Prettier 的作用域——把打印这件事做到极致。以下都在明确的作用域之外:
- 单/双引号字符串与模板字符串之间的互相转换;
- 用
+把超长字符串字面量拆成能放进行宽的片段; - 增删语法上可省略的
{}和return; - 把
?:三元表达式改写为if-else语句; - 对 import、对象键、类成员、JSX 键、CSS 属性或任何其他内容做排序/移动——除属于「转换」而非打印外,排序还可能因副作用(import 即为典型)而不安全,并让最核心的正确性目标难以验证。
小结
Prettier 的设计哲学可以概括为三层:正确性优先——任何格式化不得改变代码行为;启发式务实——空行、多行对象、装饰器、插值换行等「没有完美规则」的场景,宁可保留原始写法中的换行线索,也绝不猜测语义;边界清晰——只打印、不转换,对实验性语法与机器生成文件明确声明免责。理解这些规则后,遇到 Prettier 的「反直觉」输出时,你既能判断它是否合理,也能通过objectWrap、singleQuote、jsxSingleQuote、semi、printWidth等选项精确地表达团队偏好,而不是与格式化工具搏斗。
【免费下载链接】prettierPrettier is an opinionated code formatter.项目地址: https://gitcode.com/gh_mirrors/pr/prettier
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考