深入解析 Hermes hermes-transform 中基于 Prettier 的 Comment Attachment 注释挂接算法
2026/9/23 21:32:54 网站建设 项目流程
  • 语言运行时
  • 编译器
  • 移动开发

【免费下载链接】hermes

A JavaScript engine optimized for running React Native.

项目地址:https://gitcode.com/gh_mirrors/hermes/hermes
点击查看免费下载

导读

在 AST 变换类工具中,注释(comment)的归属处理一直是最棘手的问题之一:注释不像语句、表达式那样拥有明确的节点边界,它可能散落在任意两行代码之间、括号之内甚至函数名之后,而变换器在移动、删除、重写节点时,必须知道每一条注释应该跟着哪个节点走。本文以 Hermes 仓库中 hermes-transform 的 prettier 注释挂接模块 为切入点,深入讲解这套从 Prettier 移植而来的 comment attachment 算法:它如何为每条注释定位precedingNodeenclosingNodefollowingNode,如何按“独行 / 行尾 / 其余”三种形态决定挂接方式,以及 hermes-transform 如何在 AST 变换流水线中消费这些结果。读完本文,你将掌握这一通用注释挂接模型的原理,并能理解 Hermes 变换器在插入、克隆、移动注释时的底层机制。

一、模块定位:为什么 hermes-transform 需要一套“Prettier 的分叉”

1.1 仓库中的物理位置与目录结构

该模块位于 tools/hermes-parser/js/hermes-transform/src/transform/comments/prettier/,其目录结构刻意保持了与 Prettier 上游源码一致的布局,以便未来合入上游更新:

prettier/ ├── common/ │ └── util.js # 通用的字符跳过、换行检测、注释挂接辅助函数 ├── language-js/ │ ├── comments.js # 各类语法结构专用的注释处理器 │ ├── loc.js # locStart / locEnd 位置提取 │ ├── printer-estree.js# canAttachComment 与 handleComments 分发入口 │ └── utils.js # 节点类型判断、参数/实参缓存等工具 ├── main/ │ └── comments.js # 核心的 attach 算法(从 Prettier 移植) ├── utils/ │ └── get-last.js # 取数组最后一个元素 └── README.md

原 README 明确指出,这是 Prettier 注释挂接算法的一个 fork:只移植了src/main/comments.js中的attach方法,以及运行它所需的最小代码集。Prettier 的原始许可证保存在包根目录的 PRETTIER_LICENCE 文件中,许可证要求被完整保留。

1.2 为什么只“挖”出attach一个方法

Prettier 的注释处理横跨解析、打印两个阶段:attach负责在打印之前,把游离的注释与 AST 节点建立关联关系;后续打印器再依据这些关系决定注释输出的位置。hermes-transform 是 Hermes 的AST 变换框架,它借用 Prettier 的解析器来生成可打印的中间 AST,因此只需要“注释关联”这一环,不需要 Prettier 的整套打印器。

从源码结构看,这一取舍体现在两个层面:

  • 文件级:模块只保留了main/comments.js(核心算法)、language-js/*(JavaScript/Flow/TypeScript 语法相关处理器)和common/util.js(基础工具),未包含 Prettier 的 doc 打印、版面测量等庞大子系统。
  • 接口级:整个模块对外只导出attach一个函数(见 main/comments.js 末尾 的module.exports = {attach}),其余文件全部作为其内部依赖存在。

这种“保持目录结构不变、只裁剪功能”的做法,正是原 README 中“让未来合并上游更新更容易”这一设计意图的落地体现。

二、核心算法:attach如何决定注释归属

attach位于 main/comments.js,是整个模块的心脏。它的输入是:注释数组、AST 根节点、源码文本、以及包含locStart/locEnd/printer的 options。整体流程分为三步:定位(decorate)→ 分类(placement)→ 挂接(attach)

2.1 第一步:用二分搜索为每条注释定位相邻节点

decorateComment(见 main/comments.js#L84-L158)负责找出与注释位置相关的三个节点:

  • precedingNode:注释之前、且离它最近的节点;
  • followingNode:注释之后、且离它最近的节点;
  • enclosingNode:完全包含该注释的最内层节点。

其核心手段是getSortedChildNodes+ 二分搜索:

  1. getSortedChildNodes递归地收集节点的所有子节点,并按起始位置排序(见 main/comments.js#L22-L79)。这里有个值得注意的细节:排序用的是“反向插入排序”,因为子节点通常本来就按顺序遍历,插入基本是常数时间。
  2. 在有序子节点数组上做二分搜索:若注释被某个子节点完全包含(start <= commentStart && commentEnd <= end),则“下潜”到该子节点继续递归——这保证了最终找到的enclosingNode是最内层的;若子节点整体在注释之前/之后,则分别记录最近的precedingNode/followingNode(见 main/comments.js#L93-L129)。
  3. 若位置重叠到无法判定,直接throw new Error('Comment location overlaps with node location')——这是算法自检的兜底分支。

decorateComment还处理了一个特殊边界:TemplateLiteral(模板字符串)内部的注释。注释不能从模板的一个表达式漂移到另一个表达式,因此当enclosingNodeTemplateLiteral时,会通过findExpressionIndexForComment把注释与前后节点都限定在同一表达式索引内(见 main/comments.js#L131-L155)。

2.2 第二步:按形态将注释分成三类

定位完成之后,attach依据注释在源码中的物理形态,将其归入三类之一(见 main/comments.js#L227-L291):

形态判定依据默认挂接优先级
ownLine(独行)isOwnLineComment:注释之前存在换行(向前跳过空白后能遇到换行符)优先作为followingNode的 leading 注释;无followingNode时作为precedingNode的 trailing;再退化为enclosingNode/AST 根上的 dangling
endOfLine(行尾)isEndOfLineComment:注释之后存在换行(向后跳过空白后遇到换行符)优先作为precedingNode的 trailing 注释;其次作为followingNode的 leading;再退化到 dangling
remaining(其余,如行中注释)以上两者都不满足同时存在precedingNodefollowingNode时进入 tie-breaking;否则依次尝试 trailing / leading / dangling

形态判定的实现依赖 common/util.js 中的hasNewlineskip*系列函数:isOwnLineComment会先向前回找同一precedingNode下同一行的前一条注释,再检查是否存在换行(见 main/comments.js#L309-L330);isEndOfLineComment则对称地向后扫描(见 main/comments.js#L332-L357)。hasNewline本身是“跳过空白 + 跳过换行”两步比较的复合判断(见 common/util.js#L172-L176)。

2.3 第三步:tie-breaking——同行多注释的“算账”逻辑

当多条注释与同一对precedingNode/followingNode相邻(典型如const x = /* a */ /* b */ foo()),归属存在歧义,attach会把这些待定注释收集到tiesToBreak数组,最后统一调用breakTies裁决(见 main/comments.js#L359-L418)。

裁决规则是:从后向前检查每条注释与followingNode之间的“缝隙”(gap)。gap 只能由空白或左括号(组成(正则默认/^[\s(]*$/,也可由printer.getGapRegex定制)。若一条注释与followingNode之间被一串连续的合法 gap 连接,则它属于 leading;一旦遇到包含其他字符的 gap,则前面的注释都判定为 trailing。最终按“前段 trailing、后段 leading”切分整组注释,并重新按位置排序节点上的注释数组。

另外,attachbreakTies之后会清理注释上的precedingNode/enclosingNode/followingNode引用(见 main/comments.js#L296-L305),因为这些引用会形成 AST 中的循环引用,若不删除可能导致后续遍历无限递归。

2.4 JSON 等“叶子解析器”的特殊路径

attachjsonjson5__js_expression__vue_expression这几种解析器走简化逻辑:注释若在 AST 起点之前则直接作为根节点 leading,若在终点之后则作为 trailing,不再做节点级定位(见 main/comments.js#L201-L215)。

三、语言层分发:printer-estree.jscomments.js

attach是通用的,真正体现“JavaScript/Flow/TypeScript 语法特殊性”的是printer-estree.jslanguage-js/comments.js

3.1 从attach到语言处理器的桥接

attach从 options 中取出printer.handleComments(见 main/comments.js#L167-L178),其中三个回调分别接管三类注释的处理;printer-estree.js 把这些钩子组装起来:

module.exports = { canAttachComment, handleComments: { avoidAstMutation: true, // 用 context 对象而非直接改 AST 传递节点引用 ownLine: handleComments.handleOwnLineComment, endOfLine: handleComments.handleEndOfLineComment, remaining: handleComments.handleRemainingComment, }, getCommentChildNodes: handleComments.getCommentChildNodes, };

canAttachComment决定哪些节点可以作为注释的附着点:注释节点本身、EmptyStatementTemplateElementImportTSEmptyBodyFunctionExpression等都被排除(见 printer-estree.js#L15-L26)。

3.2 语法特化处理器清单

language-js/comments.js 按三类形态各维护了一个处理器列表,通过.some()依次尝试,命中即返回:

  • ownLine 处理器handleOwnLineComment,comments.js#L56-L75):包含prettier-ignore处理、最后一个函数参数、成员表达式、if/while/try、class、import 说明符、for、联合类型、match 模式、仅注释文件、import 声明、赋值模式、方法名、标签语句等 14 项特化。
  • endOfLine 处理器handleEndOfLineComment,comments.js#L81-L98):覆盖闭包类型转换注释(@type)、条件表达式、调用表达式、属性、类型别名、变量声明符等。
  • remaining 处理器handleRemainingComment,comments.js#L104-L119):覆盖空括号内注释、箭头函数参数后注释、函数名后注释、TS 映射类型、break/continue、TS 函数尾随注释等。

这些处理器解决的都是“仅凭位置关系无法得出正确归属”的语法场景。举两个典型例子:

if-else 前的注释if (1) {...} // comment \n else {...}中,注释若按默认规则会挂到else对应的块表达式上,输出时错位。handleIfStatementComments(comments.js#L173-L239)会把注释移入块内成为块首语句的 leading,或退化为块的 dangling;对于if (a /* comment */) {}这种写在条件括号内的注释,则通过getNextNonSpaceNonCommentCharacter探测下一个非空白字符是否为)来判定,并挂为前一个节点的 trailing。

空函数参数括号内的注释foo(/* comment */)中,handleCommentInEmptyParens(comments.js#L517-L544)只在函数参数或调用实参为空时才把它作为enclosingNode的 dangling 注释,避免误挂到非空参数列表上。

prettier-ignore特殊注释isPrettierIgnoreComment判断comment.value.trim() === 'prettier-ignore',相关处理器会为后续节点设置prettierIgnore标记(如联合类型与 match 模式,见 comments.js#L657-L720)。

3.3 位置函数与节点工具

  • language-js/loc.js 提供locStart/locEnd:优先取node.range,退化为node.start/node.end;且locStart会把装饰器(decorators)纳入起点。
  • language-js/utils.js 提供跨解析器兼容的节点类型判断(isBlockComment兼容Block/CommentBlock/MultiLine等命名差异),并用WeakMap缓存getFunctionParameters/getCallArguments的结果(见 utils.js#L64-L103)。
  • common/util.js 还实现了addLeadingComment/addTrailingComment/addDanglingComment三个挂接原语:它们设置注释的leading/trailing/marker标记、初始化printed = false,并写入node.comments数组(见 common/util.js#L280-L306)。

四、在 hermes-transform 流水线中的集成

4.1 从解析到打印的完整调用链

hermes-transform 的注释处理横跨解析与打印两个阶段,transform/comments/comments.js 是连接 Prettier fork 与变换框架的适配层:

  1. 解析阶段:transform/parse.js 在得到 AST 后调用attachComments(comments, ast, code),后者把参数打包成{locStart, locEnd, printer}传给 fork 的attach(见 comments.js#L39-L49),为后续变换提供“注释属于哪个节点”的定位信息。
  2. 变换阶段:用户在 AST 上做增删改。需要保留/转移注释时,使用 comments.js 导出的辅助函数——moveCommentsToNewNode把旧节点的注释整体搬移到新节点,cloneCommentsToNewNode连同leading/trailing标记一起克隆,cloneJSDocCommentsToNewNode只克隆/** ... */形式的 JSDoc 注释(判断条件是块注释且value*开头,见 comments.js#L129-L144)。
  3. 打印阶段:transform/print.js 调用mutateESTreeASTCommentsForPrettier(program, originalCode)生成交给 Prettier 的源码文本,再调用prettier.format完成最终输出。

4.2mutateESTreeASTCommentsForPrettier的两个关键副作用

该函数(见 comments.js#L51-L107)在打印前做了两件重要的事:

  1. 删除program.comments:若不删除,Prettier 在打印时会基于 AST 再跑一遍自己的注释挂接,导致注释在每个节点上重复出现、输出损坏(见 comments.js#L57-L61 的注释说明)。
  2. 处理 docblock:Hermes AST 把文件头注释放在program.docblock上而非任何节点。该函数把它取出来,若程序体非空则挂到第一条语句的 leading 位置并调用makeCommentOwnLine保证其独占一行,否则直接挂在 program 上;最后删除program.docblock(见 comments.js#L63-L106)。

4.3 变换中新增注释:appendCommentToSource

AddComments变换通过 MutationContext.appendCommentToSource 把新注释“写”进源码文本(AddComments.js#L45)。appendCommentToSource(comments.js#L259-L330)针对两种注释类型采用不同策略:

  • 块注释(Block):通过设置伪 range(如[firstNewline + 1, firstNewline])“骗过”Prettier——Prettier 打印时根据注释与节点之间源码文本中是否存在换行来决定是否空行,因此LEADING_OWN_LINE/TRAILING_OWN_LINEmakeCommentOwnLine让 range 两侧必然存在换行;LEADING_INLINE/TRAILING_INLINE则在空文件中追加$FORCE_INLINE_ON_EMPTY_FILE_TOKEN$;占位语句来保证找到非空白字符。
  • 行注释(Line):Prettier 打印行注释时会直接从源码切片(comments.js#L299-L302 的注释说明了这一点),因此新增的行注释必须真实地写进源码文本,注释本体为//${comment.value};行注释只能 trailing inline,此时追加$FORCE_END_OF_LINE_COMMENT_TOKEN$;占位符,帮助 Prettier 将其识别为行尾注释。

五、attach与 hermes-transform 的适配细节

5.1avoidAstMutation模式

fork 的attach支持printer.handleComments.avoidAstMutation(默认关闭,printer-estree.js中置为true)。两种模式的区别在于传给语言处理器的参数:

  • 默认模式:把precedingNode/enclosingNode/followingNode直接写到注释对象上,处理器接收[comment, text, options, ast, isLastComment](见 main/comments.js#L217-L225)。
  • avoidAstMutation模式:不写注释对象,而是把整个context(含comment、三个相邻节点、textoptionsastisLastComment)作为单一参数传入。hermes-transform 启用该模式,避免污染 AST 对象,符合其“只读 AST + 显式变换”的设计。

此外,isLastComment标记被handleOnlyComments用来处理“文件中只有注释”的边界:最后一根注释作为 AST 的 dangling,其余作为 leading(见 comments.js#L730-L773)。

5.2 与 Hermes 解析器生态的协作

这套注释挂接模块位于hermes-transform包中,其输入注释与 AST 来自同目录的hermes-parserhermes-estree(如 comments.js#L11 中import type {Comment, ESNode, Program} from 'hermes-estree')。同一 JS 目录下还有 prettier-plugin-hermes-parser,供 Prettier 直接使用 Hermes 解析器;两者都随包携带 PRETTIER_LICENCE,体现了 Meta 在复用 Prettier 代码时对许可证义务的严格遵循。

六、总结与后续阅读

Prettier 的 comment attachment 算法解决了一个本质困难:注释没有语法位置,只有文本位置attach用“定位 → 分类 → 挂接 → 平票裁决”四步把文本位置映射为 AST 归属关系,而language-js/comments.js中的几十个特化处理器则覆盖了if/else、函数参数、类装饰器、联合类型、TS 映射类型等语法结构的特殊形态。hermes-transform 在这个 fork 之上进一步封装了attachCommentsmutateESTreeASTCommentsForPrettierappendCommentToSource等面向变换场景的 API,使变换器可以在不触碰 Prettier 内部细节的前提下安全地移动、克隆、新增注释。

如果想继续深入,可以从以下路径着手:

  • 阅读算法主体 main/comments.js,重点跟踪decorateComment的二分搜索与breakTies的缝隙判定;
  • 阅读语法特化 language-js/comments.js,对照典型 JS 写法逐一验证各处理器的触发条件;
  • 阅读适配层 transform/comments/comments.js 与打印入口 transform/print.js,理解注释如何在“解析—变换—打印”全链路中保持稳定;
  • 对比 prettier-plugin-hermes-parser 的使用方式,观察同一套注释机制在“直接格式化”与“AST 变换”两种场景下的差异。
  • 语言运行时
  • 编译器
  • 移动开发

【免费下载链接】hermes

A JavaScript engine optimized for running React Native.

项目地址:https://gitcode.com/gh_mirrors/hermes/hermes
点击查看免费下载

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询