- 编译器
- 开发工具
【免费下载链接】babel
🐠 Babel is a compiler for writing next generation JavaScript.
本文以 Babel 官方设计文档 comment-attachment.md 为核心骨架,结合 Babel 仓库中 Babel Parser(@babel/parser)的真实源码与测试用例,系统讲解 Babel 解析 JavaScript 时如何把注释挂接到 AST 节点。你将掌握:Comment Whitespace(注释空白块)的形式化定义与四条不变式、节点与注释空白块的四种关系(leading / trailing / containing / inner)、注释附加的三阶段实现(构造 → 挂接 → 收尾),以及 trailing comma 场景下的补偿性调整逻辑。阅读完本文,你既能理解 Babel AST 中leadingComments/trailingComments/innerComments的语义与产生规则,也能在二次开发 Babel 插件或分析 AST 时准确判断任意注释最终挂到哪个节点。
引言:注释为什么需要一个独立的附加机制
Babel 在解析 JavaScript 文件时,会为 AST 节点挂接注释。基本的直觉是:注释总是附加到它“相邻”的 AST 节点——如果存在前驱节点则作为前驱的 trailing comment,如果存在后继节点则作为后继的 leading comment;如果两侧都不存在相邻节点,则回退到“最内层的包含节点”(innermost containing node)作为 inner comment。
直接实现这个直觉并不容易:递归下降解析器在某一时刻只知道“当前正在解析的节点”,而注释可能出现在节点之前、之后甚至内部,位置关系需要精确的区间比较。因此,当前实现(对应上游 PR babel#13521,本文只引用仓库内文档,不展开外部链接)采取了一个“反向思路”:不把注释直接挂到 AST 节点上,而是把 AST 节点挂到一组“适用于该注释空白块的节点栈”上。当某个 Comment Whitespace 建立好它与节点的关系(含 leading、trailing、containing)之后,再把其中的注释转发给 AST 节点,并执行一些补偿调整——例如把 trailing comma 之后、列表结构内部的 inner comment 合并到最后一个元素的 trailing comments。
下面我们先精确定义 Comment Whitespace,再逐步展开三个阶段的具体实现。
Comment Whitespace(注释空白块)的定义与示例
什么是 Comment Whitespace
一个Comment Whitespace表示一段“由空白字符与注释组成的连续区间”,其中注释包括四类(源码见 packages/babel-parser/src/tokenizer/index.ts 中skipSpace对skipLineComment/skipBlockComment的调用):
//行注释(CommentLine)/* */块注释(CommentBlock)<!--HTML 开放注释(仅非 module 且启用 AnnexB 时按行注释解析)-->HTML 关闭注释(同上)
在仓库源码 packages/babel-parser/src/parser/comments.ts 中,CommentWhitespace类型被定义为:
export type CommentWhitespace = { /** 空白块的起始位置 */ start: number; /** 空白块的结束位置 */ end: number; /** 该空白块包含的注释 */ comments: Comment[]; /** 紧邻该空白块之前的 AST 节点 */ leadingNode: Node | null; /** 紧邻该空白块之后的 AST 节点 */ trailingNode: Node | null; /** 包含该空白块的最小 AST 节点 */ containingNode: Node | null; };文档中的标准示例
设计文档给出了如下代码片段(每行注释对应不同的空白块):
a// 1 /* 2 */ + <!-- 3 --> 2;解析时会产生两个 Comment Whitespace:
第一个对应// 1\n/* 2 */(从第 1 个字符的/到+前):
{ start: 1, // '/' 的位置 end: 15, // '+' 的位置 comments: [ CommentLine { start: 1, end: 5 }, CommentBlock { start: 6, end: 13 } ], leadingNode: Identifier("a"), trailingNode: null, containerNode: BinaryExpression, // 即 a + 2 这棵子树 }第二个对应<!-- 3\n-->\n(从+之后的空格到2前):
{ start: 16, // '+' 后空格的位置 end: 28, // '2' 的位置 comments: [ CommentLine { start: 17, end: 23 }, // <!-- 3 CommentLine { start: 24, end: 27 } // --> ], leadingNode: null, trailingNode: NumericLiteral(2), containerNode: BinaryExpression, }值得注意:<!-- 3与-->在 AnnexB 语义下都被解析成CommentLine(行注释),而非块注释。
四条关键性质(P1–P3 及推论)
给定一段程序源码,全体 Comment Whitespace 满足以下形式化性质:
- 非空性(Nonemptiness, P1):对任意空白块
w,有w.start < w.end,即区间长度恒大于 0。 - 隔离性(Isolation, P2):不存在两个空白块
w1、w2满足w1.start ≤ w2.start ≤ w1.end,即相邻注释空白块区间互不重叠。 - 完备性(Completeness, P3):对任意注释 AST 节点
c,都存在一个空白块w满足w.start ≤ c.start < c.end ≤ w.end,称w包络(encompasses)c。也就是说,任何注释都会被某个注释空白块完整覆盖。 - 单调性(Monotonicity,P1 与 P2 的推论):把按
start排序的空白块记为{ w1, w2, ..., w_n },则必然有:
w1.start < w1.end < w2.start < w2.end < ... < w_n.start < w_n.end单调性在实现中极为重要:它保证了commentStack(注释空白块栈)可以按顺序进出,注释区间天然有序,从而让processComment中“从栈顶逆序扫描、遇到commentEnd <= nodeStart即可break”的剪枝成立(见后文源码分析)。
节点与 Comment Whitespace 的关系:leading / trailing / containing / inner
三种基础关系的形式化定义
对任意注释空白块w与 AST 节点n,可以定义:
- Leading node:
n是w的前导节点,当且仅当n.end = w.start。它紧贴空白块左侧。 - Trailing node:
n是w的后继节点,当且仅当n.start = w.end。它紧贴空白块右侧。 - Containing node:
n是w的包含节点,当且仅当对所有满足N.start < w.start < w.end < N.end的节点N,下列命题成立:
N.start ≤ n.start < w.start < w.end < n.end ≤ N.end即n是“完整包裹住该空白块”的候选节点中位置最内层、尺寸最小的那一个。
需要强调:从w到n的关系不是单射的——一个注释空白块可能有多个 leading node、多个 trailing node、多个 containing node。这是因为嵌套节点会共享同一边界(例如a + 2的外层BinaryExpression与内层的Identifier("a")都可能满足end = w.start)。为此文档定义了极值(extrema):
- 最外层 leading/trailing node:
n是w的最外层前导/后继节点,当且仅当对w的每一个其他 leading/trailing 节点N,N都是n的后代。 - 最内层 containing node:
n是w的最内层包含节点,当且仅当对w的每一个其他 containing 节点N,n都是N的后代。
三类注释的(非)形式化定义
有了极值概念,就可以精确区分三类注释:
- Leading Comment(前导注释):
c是n的 leading comment,当且仅当存在空白块w,使得n是w的最外层 trailing node且w包络c。语义上即“紧贴在n之前的注释”,存入节点的leadingComments。 - Trailing Comment(后继注释):
c是n的 trailing comment,当且仅当存在空白块w,使得n是w的最外层 leading node且w包络c。语义上即“紧贴在n之后的注释”,存入节点的trailingComments。 - Inner Comment(内部注释):
c是n的 inner comment,当且仅当同时满足:- 存在空白块
w,使得n是w的最内层 containing node且w包络c; - 不存在空白块
w,使得n是w的最外层 leading 或 trailing node 且w包络c。
- 存在空白块
换言之:先看注释两侧有没有相邻节点,有则归为 leading/trailing;两侧都没有直接相邻节点时,才归入包含它的最内层节点的innerComments。这正好呼应文档开篇那句“如果没有相邻节点,则回退到最内层包含节点”。
隔离性(P2)还带来一个实用简化:如果两个注释c1、c2都属于n的 leading/trailing comments,那么它们必然被同一个空白块w包络。因此分类时可以“成组标记”而非逐个判断,这正是finalizeComment一次性把整个空白块的comments数组赋给节点的原因。
trailing comma 场景的补偿说明
文档特别指出(对应上游 PR babel#10369):Babel Parser 会把列表结构(如数组、对象、函数参数)中 trailing comma 之后的某些 inner comment 标记为列表中最后一个元素的 trailing comment。这是对 AST 中不存在TrailingCommaElement这种可挂注释节点的一种补偿行为。设计文档声明不深入讨论该行为的实现细节,但我们会在“收尾阶段”结合源码把这一补偿逻辑讲清楚。
实现详解一:构造 Comment Whitespace(Tokenizer#skipSpace)
第一阶段发生在词法层。nextToken()在读取每个 token 之前都会调用skipSpace()(packages/babel-parser/src/tokenizer/index.ts)。skipSpace的核心流程如下(对应 skipSpace 实现):
- 记录空白起点
spaceStart = state.pos;若启用了AttachComment选项,初始化一个临时comments数组。 - 进入
loop循环按字符推进:普通空白(空格、Tab、换行、CRLF、行/段分隔符)直接累加state.pos并维护行号;遇到/则分别尝试/* */(skipBlockComment)与//(skipLineComment);在非 module、启用 AnnexB 的前提下,遇到-->或<!--也按行注释解析(skipLineComment(3)/skipLineComment(4))。解析出的注释同时调用addComment写入parser.comments列表,并压入本地comments数组。 - 一旦遇到非空白、非注释字符(
default分支break loop),循环结束。此时若comments.length > 0,就构造一个CommentWhitespace,把start/end转换为源码偏移,初始leadingNode/trailingNode/containingNode均为null,然后state.commentStack.push(commentWhitespace)。
从源码可以确认两点事实:
- 文档中提到的“把
HTMLOpenComment与HTMLCloseComment的解析合并进skipSpace”已落地,且受inModule与OptionFlags.AnnexB双重条件约束(tokenizer/index.ts)。 commentStack存放在 tokenizer 的State中(packages/babel-parser/src/tokenizer/state.ts顶部引用了CommentWhitespace类型),与解析器共享。
实现详解二:把节点挂接到 Comment Whitespace(processComment)
第二阶段发生在语法层。每个 AST 节点完成时都会经过finishNode→finishNodeAt/finishNodeAtNode,并在设置好type、end、loc.end之后调用processComment(node)(packages/babel-parser/src/parser/node.ts),且受OptionFlags.AttachComment控制——这解释了attachComment: false时 AST 不携带注释字段的行为。
processComment的逻辑(comments.ts)分两步:
第一步:处理栈顶空白块。取commentStack栈顶元素;若lastCommentWS.start === node.end,说明该节点紧贴空白块左侧,把它设为leadingNode。这里隐含一个关键细节:节点在 finish 之前,其后的空白已被nextToken()读取完毕,因此“若此节点有 trailing comments,它必定是commentStack栈顶那个空白块的leadingNode”。
第二步:逆序扫描整条栈。对commentStack从栈顶向下迭代:
- 若
commentWS.end > node.start:由空白块定义可推出commentWS.start > node.start,于是node是 containing 候选,直接设置commentWS.containingNode = node,随后立即finalizeComment(commentWS)并把该空白块从栈中splice移除。之所以此刻可以收尾,是因为:该空白块的 leading/trailing node 若存在,不会再改变;containing node 已赋为当前节点,而当前节点就是“最小尺寸”的最内层包含者,也不会再改变。 - 否则(
commentEnd <= nodeStart):若commentEnd === nodeStart,设置commentWS.trailingNode = node;随后直接break终止循环。这个剪枝正是由注释空白块的单调性(P1+P2 推论)保证的:栈中更靠前的空白块区间必然更靠左,不可能再与当前节点发生关系。
两个“获胜者”规则
processComment里存在两个方向相反的竞争规则,与文档中的极值定义一一对应:
- leadingNode / trailingNode 的“后到者胜”:在同一位置连续调用多次
finishNode()(例如外层与内层节点共享边界)时,后一次调用会覆盖前一次设置,最后一次调用的节点胜出,得到的正是最外层leading/trailing node。对应lastCommentWS.leadingNode = node的覆盖式赋值。 - containingNode 的“先到者胜”:逆序迭代时对
commentEnd > nodeStart的空白块只做一次containingNode = node赋值(仅在“未定义”时设置),第一个完成 finish 的节点胜出,得到的正是最内层containing node。
由于递归下降解析器的自然属性,当 containing node 完成 finish 时,其内部的 leading/trailing node 必然已经解析完毕,因此相关节点不再会被processComment更新——这就是“收尾时机”成立的依据。文档脚注补充了一个例外:estree插件会在不同的 tokenizer 位置调用finishNodeAt,理论上可能破坏这一前提,但因为绝大多数estree用户走的是@babel/eslint-parser(它会移除挂接的注释),所以实际无碍。
实现详解三:收尾阶段(finalizeComment 与 trailing comma 调整)
finalizeComment:把注释分发给相关节点
当空白块的三类节点关系都已确定后,finalizeComment(commentWS)(comments.ts)执行真正的分发:
若
leadingNode或trailingNode存在:把整个comments数组setTrailingComments(leadingNode)/setLeadingComments(trailingNode)。注意setTrailingComments与setLeadingComments都会对已有注释使用unshift,把新注释放在旧注释之前,因为commentStack是逆序枚举的(comments.ts)。若两者皆为
null:走 containing 分支。先检查空白块前一字符是否为逗号(charCodeAt(commentStart - 1) === comma):- 不是逗号:直接
setInnerComments(containingNode, comments)。 - 是逗号:进入 trailing comma 补偿逻辑,按 containing node 的
type分发到adjustInnerComments(node, elements, commentWS),覆盖的列表节点类型包括:ObjectExpression/ObjectPattern→node.propertiesCallExpression/NewExpression/OptionalCallExpression→node.argumentsImportExpression→[node.source, node.options ?? null]FunctionDeclaration/FunctionExpression/ArrowFunctionExpression/ObjectMethod/ClassMethod/ClassPrivateMethod/TSTypeParameterDeclaration→node.paramsArrayExpression/ArrayPattern→node.elementsExportNamedDeclaration/ImportDeclaration→node.specifiersTSEnumBody→node.membersTSInterfaceBody→node.body- 其余类型 → 兜底
setInnerComments
adjustInnerComments(comments.ts)从末尾向前找到最后一个非null元素:若找不到(列表为空)或该元素的start大于空白块起点(即逗号前没有实际元素可挂),则回退为setInnerComments(node, comments);否则把注释setTrailingComments(lastElement, comments)——这正是文档所述“把 trailing comma 之后的注释合并到最后一个元素的 trailing comments”的源码实现。- 不是逗号:直接
finalizeRemainingComments:为 parseExpression 兜底
此外还有一个专用例程finalizeRemainingComments()(comments.ts),它会倒序把commentStack中残留的每个空白块都执行一遍finalizeComment,然后清空栈。它只在getExpression(即parseExpression的顶层入口)末尾被调用(packages/babel-parser/src/parser/expression.ts):因为此时顶层节点不是Program,而是某个表达式节点,正常解析流程没有机会去 finalize 那些挂在顶层表达式前/后的注释,所以需要显式排空。
用测试用例验证行为
仓库的解析器测试直接反映了上述机制的可观察行为。以 packages/babel-parser/test/fixtures/comments/basic/array-expression-trailing-comma 为例,输入片段:
const trailingAfterComma = [ "One", // One // Two "Two", // Three // Four ]对应的output.json中,"Two"这个元素被同时挂上了:
"trailingComments": [ { "type": "CommentLine", "value": " Three", "start": 142 }, { "type": "CommentLine", "value": " Four", "start": 153 } ], "leadingComments": [ { "type": "CommentLine", "value": " One", "start": 116 }, { "type": "CommentLine", "value": " Two", "start": 126 } ]可以看到:// Three与// Four都位于"Two",的 trailing comma之后,按照“inner comment 归属 containing node”的默认规则本应成为数组的 innerComments,但通过adjustInnerComments补偿,它们被合并到了最后一个元素"Two"的trailingComments,且// Three排在// Four之前(unshift逆序插入的净效果)。测试目录下还有大量同类用例(如arrow-function、async-arrow-function、call-expression-trailing-comma等,位于 comments/basic),以及attachComment-false/目录用于验证关闭注释附加时的行为差异。
关联实现位置速查
- 设计文档:packages/babel-parser/ast/comment-attachment.md
CommentWhitespace类型定义与三类注释的分发逻辑:packages/babel-parser/src/parser/comments.ts- 空白扫描与注释收集(
skipSpace、skipLineComment、skipBlockComment、HTML 注释):packages/babel-parser/src/tokenizer/index.ts - 节点完成钩子(
finishNode/finishNodeAt/finishNodeAtNode调用processComment):packages/babel-parser/src/parser/node.ts - 顶层表达式收尾(
getExpression调用finalizeRemainingComments):packages/babel-parser/src/parser/expression.ts - 行为验证测试:packages/babel-parser/test/fixtures/comments/basic
总结
Babel Parser 的注释附加机制可以概括为一条清晰的主线:词法层把连续的“空白 + 注释”折叠成有序的 Comment Whitespace(P1–P3 保证其不重叠、完备、单调),语法层在finishNode时把节点按“最外层 leading/trailing、最内层 containing”两个极值规则挂接到空白块上,最后由finalizeComment一次性地把整组注释分发给相关节点,并对 trailing comma 场景做adjustInnerComments补偿,将逗号后的注释合并到列表末元素的 trailingComments。这一设计把“注释挂到哪个节点”这个看似朴素的语义问题,落实为可形式化、可验证的区间算法;理解它之后,无论是阅读 Babel 生成的 AST(leadingComments/trailingComments/innerComments)、编写处理注释的 Babel 插件,还是研究@babel/eslint-parser的行为,你都能准确预测每一个注释的最终归宿。
- 编译器
- 开发工具
【免费下载链接】babel
🐠 Babel is a compiler for writing next generation JavaScript.
相关推荐
pandoc DOCX 注释范围(Comment Range)往返转换解析:从 `--track-changes=all` 到 comment-start/comment-end Span
pandoc DOCX 注释范围(Comment Range)往返转换解析:从 track changes=all 到 comment start/commen
文档开发工具CLI用注释生成文档:Meteor doctool.js 的 Doc Comment 解析与 Markdown 生成机制
用注释生成文档:Meteor doctool.js 的 Doc Comment 解析与 Markdown 生成机制 Meteor 仓库中的 scripts/do
后端前端开发工具移动开发Gutenberg 中 core/post-comment 块(已弃用)解析:块元数据、Context 机制与迁移到 Comments 块
Gutenberg 中 core/post comment 块(已弃用)解析:块元数据、Context 机制与迁移到 Comments 块 本文基于 Guten
后端前端
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考