ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

Prettier 内部原理:Doc 中间表示与文档构建器命令全解

Prettier 内部原理:Doc 中间表示与文档构建器命令全解 Prettier 内部原理Doc 中间表示与文档构建器命令全解【免费下载链接】prettierPrettier is an opinionated code formatter.项目地址: https://gitcode.com/gh_mirrors/pr/prettierPrettier 的排版算法核心位于src/document/{printer,builders,utilities}/目录Printer 使用一套基础排版抽象来构造格式化结果而不是直接拼接字符串。本文围绕 Prettier 仓库根目录的commands.md文档完整讲解 Prettier 的中间表示Doc的三种形态字符串、文档数组、命令以及全部构建器命令group、conditionalGroup、fill、ifBreak、breakParent、各类换行符、缩进命令等的语义与底层实现并结合真实打印器源码如ArrayExpression的实现说明这些命令如何组合出能一行就一行、装不下就整体折行的格式化行为。DocPrettier 的中间表示所有打印器JS、TS、CSS、HTML、Markdown……都不直接输出最终代码而是先产出一棵文档树——Doc。其类型定义非常简洁type Doc string | Doc[] | DocCommand;三种形态的语义字符串原样直接输出。注意为了让排版算法正确工作字符串中不应包含换行符换行应由专门的换行命令表达数组将多个 Doc 顺序拼接成一个 Doc即依次打印列表中的所有文档DocCommand命令一组带语义的排版指令如group、line、indent等由构建器builders创建。从源码结构看构建器集中在 src/document/builders/每个文件导出一个或多个命令src/document/builders/types.js 定义了每种命令的DOC_TYPE_*标识group、fill、if-break、line、indent、align等以及对象型命令的合法类型集合VALID_OBJECT_DOC_TYPES而 src/document/printer/printer.js 是消费这些命令的解释器它通过fits函数模拟全平铺输出并统计宽度决定每个group应该平铺MODE_FLAT还是折行MODE_BREAK再逐命令渲染成最终字符串。命令类型常量到处理分支的对应关系可在 printer.js 的case DOC_TYPE_*分支中逐一查证。公开 APIprettier.doc.builders如果你正在编写 Prettier 插件不需要直接 import 内部文件。构建器通过 src/document/public.js 以builders、printer含printDocToString和utils三组命名空间对外暴露插件拿到Doc后可用printDocToString自行渲染调试。分组命令group与conditionalGroupgroup折行的基本决策单元type GroupOptions { shouldBreak?: boolean; id?: symbol; }; declare function group(doc: Doc, options?: GroupOptions): Doc;group标记一组打印器应当尽量放进一行的内容是告诉打印器在哪里可以换行的基本命令。典型用法是嵌套 group打印器先尝试把一切放进一行如果装不下就先折最外层的 group 再试直到所有内容都能放下或没有更多可折的 group 为止。强制折行规则——这是理解 Prettier 输出稳定性的关键以shouldBreak: true创建 group或group 内部包含breakParenthardline、literalline默认都隐式包含breakParent见下文 src/document/builders/line.js 的源码则该 group 一定折行。且折行会向上传播到所有父 group如果某个深层嵌套表达式里出现了硬换行整个外层都会跟着折行。这只对硬换行有意义——即无论是否装得下都会输出、且可静态分析的换行。直观例子短数组会尝试一行放下[1, foo, { bar: 2 }];但如果任何元素内部含硬换行比如对象里有个函数函数体后花括号必折数组就总是跟着折行以保证整体格式一致[ 1, function () { return 2; }, 3, ];id选项一个symbol则用于在ifBreak检查中精确指向已经打印过的某个历史 group的状态。源码视角group 的真实形态group的定义在 src/document/builders/group.jsfunction group(contents, options {}) { assertDoc(contents); assertDocArray(options.expandedStates, /* optional */ true); return { type: DOC_TYPE_GROUP, id: options.id, contents, break: Boolean(options.shouldBreak), expandedStates: options.expandedStates, }; }可以注意到两个文档未展开的实现细节group内部保存了break来自shouldBreak与expandedStates两个字段折行传播由 src/document/utilities/index.js 中的propagateBreaks在渲染前完成——它遍历整棵 Doc 树把含breakParent的子树标记为必须 break并逐层向父 group 传导。这正是深层硬换行导致外层全部折行这一行为的实现落点。conditionalGroup多候选的渐进展开这是最后手段因为它在嵌套使用时会触发指数级复杂度。declare function conditionalGroup( alternatives: Doc[], options?: GroupOptions, ): Doc;它按顺序尝试打印第一个候选装得下就用它否则换第二个依此类推。alternatives数组必须从最紧凑最平铺的表示排到最展开的表示conditionalGroup([a, b, c]);源码上它就是group的一个特化第一个候选作为contents其余候选填入expandedStates// src/document/builders/group.js function conditionalGroup(states, options) { return group(states[0], { ...options, expandedStates: states }); }因此 printer 对 group 的处理逻辑必须支持当前形态装不下时切换到下一个 expandedState 重试。仓库中conditionalGroup的高频使用点如 src/language-js/print/call-arguments.js调用参数在单行 / 换行 / 每个参数一行之间的选择和 src/language-js/print/member-chain.js成员链的两种展开形态conditionalGroup([oneLine, expanded])都是典型场景。fill像文本排版一样的逐词换行declare function fill(docs: Doc[]): Doc;fill是一种行为类似文本排版的 group它会在下一个元素放不进当前行时插入换行但它只折行尾的分隔符不会把所有分隔符都折开——这与group的全有或全无行为形成对比。fill([I, line, love, line, Prettier]);实现见 src/document/builders/fill.jsfill(parts)会经assertDocFillParts校验后生成{ type: DOC_TYPE_FILL, parts }。约束是docs必须是内容与换行符交替的数组即奇数下标的元素必须换行符如line、softline。一个真实用例src/language-js/print/array.js 中纯数字数组的紧凑模式concise formatting当元素全是数字字面量时会用fill输出——宽就一行窄了就在逗号后逐个换行而不是整体爆炸function printArrayElementsConcisely(path, options, print, trailingComma) { // ...每个元素后跟 , 换行line / hardline return fill(parts); }ifBreak按 group 状态选择内容declare function ifBreak( breakContents: Doc, flatContents?: Doc, options?: { groupId?: symbol }, ): Doc;当前group或fill的当前元素折行时打印breakContents平铺时打印flatContentsifBreak(;, );groupId允许检查另一个已经打印过的 group而不是当前 group——配合group的id选项使用。两个实现层面的注意点文档原文明确提示如果ifBreak的两个候选内容中任何一个含有hardline或breakParent无论实际打印哪个分支父 group 都会被强制折行因为propagateBreaks是静态遍历不看运行时选择。这是文档注明的设计局限通常可以换个写法绕开实在必须用hardline时考虑改用 [hardlineWithoutBreakParent](#hardlinewithoutbreakparent 与 literallinewithoutbreakparent) 避免意外的折行传播。从 src/document/builders/if-break.js 可确认默认值行为flatContents缺省为groupId缺省为当前 group。breakParent无条件强制祖先折行declare const breakParent: Doc;把它放在任何位置都能强制所有父 group 折行机制见group一节。例如group([ , expr, , breakParent]);定义见 src/document/builders/break-parent.js它在 src/document/printer/printer.js 中有一个专门的case DOC_TYPE_BREAK_PARENT处理分支。换行符家族line、softline、hardline、literalline四个换行命令共享同一个DOC_TYPE_LINE靠附加标志位区分源码一目了然src/document/builders/line.jsconst line { type: DOC_TYPE_LINE }; const softline { type: DOC_TYPE_LINE, soft: true }; const hardlineWithoutBreakParent { type: DOC_TYPE_LINE, hard: true }; const hardline [hardlineWithoutBreakParent, breakParent]; const literallineWithoutBreakParent { type: DOC_TYPE_LINE, hard: true, literal: true, }; const literalline [literallineWithoutBreakParent, breakParent];对照文档语义命令平铺fit时折行时是否含隐式 breakParentline输出一个空格换行且下一行按当前缩进级别缩进否softline输出空什么都没有同line换行并缩进否hardline总是输出换行同左下一行缩进是[hardlineWithoutBreakParent, breakParent]literalline总是输出换行且不缩进下一行同左是literalline与hardline的另一区别它保留行尾的尾部空白主要用于模板字符串template literal这类对空白敏感的场景。从源码结构看hardline/literalline之所以总是让父 group 折行正是因为它们在字面量层面就是无 breakParent 版本 breakParent的二维数组——这也解释了为何文档要求字符串里不要夹带真实换行符换行语义必须由这些命令承载算法才能统一处理。hardlineWithoutBreakParent与literallineWithoutBreakParentAdded in v2.3.0declare const hardlineWithoutBreakParent: Doc; declare const literallineWithoutBreakParent: Doc;极少使用的进阶命令与普通版唯一的区别是不包含隐式breakParent。文档给出的两个真实用例hardlineWithoutBreakParent用于 Prettier 的Markdown 打印器输出表格当proseWrap: never时只有当没有任何行超过printWidth时各列才对齐若换行符本身强制折行表格对齐的尝试就会被破坏literallineWithoutBreakParent被 Ruby 插件用于打印 heredoc 语法外部仓库plugin-ruby中的nodes/heredocs.js。尾部注释机制lineSuffix与lineSuffixBoundarylineSuffixdeclare function lineSuffix(suffix: Doc): Doc;用于实现尾随注释trailing comments。实践中不可能时刻检查当前行到哪里结束以避免把代码误印到注释末尾于是lineSuffix把传入的 doc缓冲起来在任何新换行出现之前才冲刷flush输出[a, lineSuffix( // comment), ;, hardline];输出为a; // comment而不是注释跑到a后面、;落到第二行的错误结果。printer.js 中对应的case DOC_TYPE_LINE_SUFFIX/DOC_TYPE_LINE_SUFFIX_BOUNDARY分支以及printDocToString结束前的冲刷逻辑负责这一缓冲与延迟输出。lineSuffixBoundarydeclare const lineSuffixBoundary: Doc;在把代码嵌入模板字符串等场景时注释不应逃逸出代码片段之外。lineSuffixBoundary是一个显式标记除了换行之外它也能触发lineSuffix缓冲的冲刷。[{, lineSuffix( // comment), lineSuffixBoundary, }, hardline];输出{ // comment }而不是{} // comment缩进命令indent、dedent、align、markAsRoot、dedentToRoot、trimindent与dedentdeclare function indent(doc: Doc): Doc; declare function dedent(doc: Doc): Doc;indent增加一级缩进实现见 src/document/builders/indent.js生成{ type: DOC_TYPE_INDENT, contents }dedent减少一级缩进。注意每个align也被算作一级缩进所以dedent对应align(-1, doc)。aligndeclare function align(widthOrString: number | string, doc: Doc);按固定空格数或字符串增加缩进是indent的变体。useTabs开启时的规则文档原文完整保留缩进中的尾部对齐仍是空格中间的对齐则每遇到一个align转成一个 tab。在空白敏感上下文如 Markdown中应传字符串形式的空格给align防止它们被替换成 tab。文档给出的具体推演useTabs开启tabWidth: 2indentalign 2indentalign 2→tabtabtab2 spaceindentalign 4indentalign 2→tabtabtab2 spacetabWidth: 4indentalign 2indentalign 2→tabtabtab2 spaceindentalign 4indentalign 2→tabtabtab2 spacealign.js 中还有一个makeAlign(alignType, size, tabWidth)辅助函数专门按tabWidth把align宽度换算为tab 数 尾部空格数是上述规则的落地实现。markAsRoot与dedentToRootdeclare function markAsRoot(doc: Doc): Doc; declare function dedentToRoot(doc: Doc): Doc;markAsRoot把当前缩进标记为根供dedentToRoot和literalline使用。dedentToRoot把当前缩进直接降回该根标记处。源码实现src/document/builders/align.js非常巧妙——两者都是align的特例function dedentToRoot(contents) { return align(Number.NEGATIVE_INFINITY, contents); } function markAsRoot(contents) { return align({ type: root }, contents); }即减到根等价于对齐Number.NEGATIVE_INFINITY标根是特殊 align 类型{ type: root }。trimdeclare const trim: Doc;去掉当前行的所有缩进可用于预处理器指令等场景应放在换行符之后使用例如[media](https://link.gitcode.com/i/32070a82ddcfa6bfe713ed9736b83a8b)这类顶格指令。对应 printer.js 的case DOC_TYPE_TRIM分支。v2.3.0 新增indentIfBreak与labelindentIfBreakAdded in v2.3.0declare function indentIfBreak( doc: Doc, opts: { groupId: symbol; negate?: boolean }, ): Doc;它是ifBreak(indent(doc), doc, { groupId })的优化版本negate: true时等价于ifBreak(doc, indent(doc), { groupId })。为什么对当前 group没意义因为当前 group 折行时加缩进本来就是indent的默认行为——所以groupId是必填的只能指向其他已打印的group。labelAdded in v2.3.0declare function label(label: any, doc: Doc): Doc;用一个任意真值给 doc 打标签。它不影响打印结果但可服务于基于 doc 内省introspection的启发式判断。文档给出的典型场景判断赋值表达式的右侧是否被打印成了方法调用链而非普通函数调用——如果方法链打印代码用label标记其结果条件检查就简单到rightHandSideDoc.label method-chain。实现细节label参数为 falsy 时doc原样返回不做任何包裹。光标占位符cursordeclare const cursor: Doc;这是原始输入中光标所在位置的占位值用于在格式化后的输出里定位光标应该落在哪里——编辑器集成如prettier.formatWithCursor依赖它实现格式化时不丢光标。对应 src/document/builders/cursor.js 与 printer.js 中的case DOC_TYPE_CURSOR分支记录光标偏移后不输出任何字符。实战示例ArrayExpression的打印实现把前面所有命令串起来就是 JS 数组节点的经典打印结构文档给出的ArrayExpression实现示例group( [ [, indent( [ line, join( [,, line], path.map(print, elements) ) ] ), line, ] ] );整体是一个group能一行就一行[1, foo]装不下才折行indent([...])包住首尾line与元素列表折行时内容相对[缩进一级join([,, line], ...)用join命令declare function join(sep: Doc, docs: Doc[]): Doc;——用分隔符连接一个 doc 数组见 src/document/builders/join.js为每个元素间插入, line平铺时变, 折行时变,\nindent。由于它是个group只要任一子表达式折行比如元素里有带硬换行的函数整个数组就跟着折行——这正是前文示例中[1, function () {...}, 3]必然展开的原因。仓库中的真实实现 src/language-js/print/array.js 在此基础上做了工程化增强可作为命令如何组合的进阶读物创建const groupId Symbol(array)并把数组包装成group(..., { shouldBreak, id: groupId })随后用ifBreak(,, , { groupId })精确控制尾随逗号只在折行时出现shouldBreak的计算体现了业务启发式当所有元素都是多元素数组/对象且类型一致时强制折行或节点上存在 dangling/line 注释时强制折行因为propagateBreaks也会因注释中的硬换行传导;纯数字数组走printArrayElementsConcisely用fill实现数字紧凑、逐词换行的第三种形态。也就是说文档示例是最小骨架生产代码则是groupidifBreakfill的完整组合拳。小结命令到实现的对照把commands.md的每个命令映射到仓库源码便于进一步深挖命令构建器源码关键点group/conditionalGroupsrc/document/builders/group.jsbreak、expandedStates字段conditionalGroup是group特化fillsrc/document/builders/fill.js奇数下标必须是换行符ifBreaksrc/document/builders/if-break.jsflatContents默认line/softline/hardline/literalline等src/document/builders/line.jshardline是无 breakParent 版 breakParent的数组breakParentsrc/document/builders/break-parent.js由propagateBreaks向上传导indent/align/dedent/markAsRoot/dedentToRootsrc/document/builders/indent.js / align.jsdedent align(-1)、dedentToRoot align(-∞)trim/cursor/lineSuffix/lineSuffixBoundarytrim.js / cursor.js / line-suffix.js / line-suffix-boundary.jslineSuffix缓冲至换行前冲刷indentIfBreak/label/joinindent-if-break.js / label.js / join.jsv2.3.0 的indentIfBreak、label为打印策略提供状态与启发式标签渲染与折行决策src/document/printer/printer.jsfits模拟平铺宽度、MODE_FLAT/MODE_BREAK双模式折行传播src/document/utilities/index.jspropagateBreaks静态遍历 Doc 树理解了这套Doc命令体系就读懂了 Prettier 各语言打印器共享的排版语言每个语言插件的 printer 只做一件事——把该语言 AST 的节点翻译成group、line、indent、ifBreak等命令的组合折行决策、宽度计算与缩进渲染全部交给src/document下的通用算法完成。【免费下载链接】prettierPrettier is an opinionated code formatter.项目地址: https://gitcode.com/gh_mirrors/pr/prettier创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表