1. 先看现象:一个“删不干净”的注释
做 Babel 插件的人大多都经历过这种诡异时刻:插件看起来什么都没做,编译产物里却多出一串删不掉的注释。调用path.remove()的时候,明明人肉对照过 AST,节点确实没了,注释却像幽灵一样挂在代码里。我前段时间清理老项目里一堆废弃 import 时,就被这个“Babel 幽灵注释”问题卡了大半天。
先给一个最小复现。假设输入文件长这样:
// 旧版本初始化逻辑,六月下掉 import { legacyInit } from "./init"; export function bootstrap() { return legacyInit(); }我写了一个再简单不过的插件:只要发现从./init导入,就把整个ImportDeclaration节点干掉。
module.exports = function () { return { visitor: { ImportDeclaration(path) { if (path.node.source.value === "./init") { path.remove(); } }, }, }; };我当时的预期是把整个 import 连头顶的注释一起拔掉,产物应该是:
export function bootstrap() { return legacyInit(); }但实际输出是:
// 旧版本初始化逻辑,六月下掉 export function bootstrap() { return legacyInit(); }import 没了,注释还在代码最顶上挂着,好像一个失去身体的组织,继续占着文件的一行。更气人的是,如果你把这个输出再交给同一套编译流程跑一遍,它仍然原封不动地输出这行注释。它既不会影响执行,也不会让打包报错,但每个人打开产物都会问一句:“这注释说的是谁的旧版本?”
这个现象在 Babel 社区里被戏称为“幽灵注释”。它不挑插件,不挑代码风格,只要你的转换逻辑里涉及“删节点”,就随时可能撞上。
2. 幽灵注释到底是从哪一步“飘”出来的
2.1 注释在 Babel AST 里不是“词法字符”
要理解幽灵注释,得先从 Babel 对注释的定位说起。你我在源码里看到的//和/* */,在 Babel 的词法阶段确实会被识别成 token,但进入 AST 之后,它们并不是某个语句的内部组成部分。Babel 不会把一段注释解析成CommentStatement这样的独立节点,而是把它当作“附属于某个节点旁边的元数据”。
具体来说,注释会挂到三类属性上:
leadingComments:挂在节点前面,也就是节点“头顶”的注释。trailingComments:挂在节点后面,通常是同一行尾部或右括号附近的注释。innerComments:挂在节点内部,比如对象字面量、数组字面量、括号内部夹着的注释。
这几个字段都直接挂在 AST 节点对象上。你随便在 AST Explorer 里点开一个节点,往下翻就能看到它们。
关键点就在这里:注释不是节点的一部分,它更像是寄居在节点上的附件。所以你只做“删除节点”这一个动作,并不会自然地让附件一起消失。谁负责拆附件?答案是 Babel 自己的删除逻辑,而它默认选择的处理方式是“把附件搬走”,不是“把附件扔进垃圾桶”。
2.2 remove() 内部发生了两次“好心”搬运
path.remove()并不是简单地把节点从父节点数组里splice掉。Babel 为了保证注释不丢失,在移除路径上注册了一套 removal hooks。如果你看过@babel/traverse的源码,会发现删除过程中有这么几步:
- 先把节点从父容器的
body、properties、elements等位置断开。 - 在断开前后,触发一系列钩子,其中就有专门处理注释的钩子。
- 这个钩子会把被删节点身上的
leadingComments、trailingComments、innerComments拿出来,尝试“转交”给相邻节点或者父节点。
说白了,Babel 在这里做了一件好事:它不希望因为一个节点的删除,连带把开发者写的重要说明文档、TODO、版本注释一起弄丢。所以当它发现“你删掉了一个带注释的节点”,它会默认认为“你只是想删代码,但注释可能是想留下的”。
这个默认策略在多数场景下是合理的。毕竟,删除一段业务逻辑时,注释里可能写了“为什么删掉”“这个方案被谁替代”这类重要背景。但如果你的删除动作本来就是想“把这块东西整体移除”,Babel 的“好心”就成了干扰,注释被搬到旁边,看起来就是删不干净。
2.3 为什么直接删父级也会中招
有人可能会想:我干脆不删子节点,直接删父容器,问题是不是就没了?实际上,幽灵注释在所有删除姿势里都可能出现。
打个比方,Babel 的注释转交规则是“就近原则”:被删节点的前一个兄弟节点优先接收注释;如果没有前兄弟,就找后兄弟;如果前后都没有,就挂到父节点上。你删除的层级不同,只是决定了注释最终落脚在哪一层,并不能阻止“搬运”这件事本身。
更麻烦的是,有些节点在被删除之前,注释其实已经不在它身上了。比如一个对象属性,它内部的注释可能被挂在属性值节点上,也可能挂在属性节点自身,取决于注释写在哪里。你排查时只盯着目标节点本身的leadingComments,往往会扑空,真正的注释藏在子节点的某个角落里。
这也是幽灵注释难以定位的另一个原因:你以为删的是 A 节点,注释却挂在 A 的value子节点上;你以为删的是整个对象属性,注释却挂到了属性 name 节点上。清理工作必须同时覆盖这些“不显眼”的挂载点。
3. 最容易触发残留的几类写法
3.1 文件尾部节点:注释变成真“游魂”
最典型的场景是删除文件末尾的最后一个节点。比如这个文件:
const keep = 1; // 废弃配置,后续要清掉 export const legacyConfig = {};如果我写插件删掉export const legacyConfig = {},Babel 的注释转交规则会先找前一个兄弟节点。此时前兄弟是const keep = 1;,于是注释就被挂到了keep节点的trailingComments上。输出就变成:
const keep = 1; // 废弃配置,后续要清掉这还算幸运的。如果文件里只剩孤零零一个要删的节点,前后都没有兄弟,注释就会被提到文件最顶部,成为没有任何宿主节点的顶层悬挂注释。这种注释是最狠的幽灵:你在 AST 里找不到任何节点携带它,但它确实存在于file.ast.comments数组里,生成器会老老实实地把它打印出来。
3.2 对象成员之间的注释错位
对象字面量是另一个高发区。看这段配置:
const appConfig = { baseURL: "https://api.example.com", // 旧的限流开关,已经默认关闭 legacyRateLimit: true, newRateLimit: false, };插件把legacyRateLimit属性删除后,按“就近原则”,注释会被挂到下一个兄弟属性newRateLimit上。于是你得到:
const appConfig = { baseURL: "https://api.example.com", // 旧的限流开关,已经默认关闭 newRateLimit: false, };代码本身没问题,但语义完全错乱了:一条描述“旧开关”的注释,现在压在了“新开关”头上。维护者一看,极可能误以为newRateLimit也即将废弃,甚至直接手滑删掉正确配置。
这种场景比文件尾部的游魂更坑,因为它表面上看不出是“注释没删掉”,反而像“注释被移动了”。你只检查删除后还有没有那行注释,根本发现不了问题。
3.3 import 与 export 声明块的特殊性
ImportDeclaration和ExportNamedDeclaration这类声明,在 AST 里结构比较特殊:它们自己是一个节点,内部又包着specifiers、source等子节点。注释可能挂在外层节点,也可能挂在某个ImportSpecifier上。
我在实际项目里见过一种情况:代码里有成串的 import,中间夹着分段注释:
// utils import { debounce } from "./utils"; import { noop } from "./utils"; // services import { getUserApi } from "./api/user"; import { saveReport } from "./api/report";我原本只想删掉某个import { noop } from "./utils",结果第一行的// utils注释被转交到另一个 utils 相关 import 上,看着还挺正常。但如果批量删除时顺序不对,注释就可能落进下一组 import 区域,分段结构瞬间被打乱。更尴尬的是,删除整个ImportDeclaration时,注释常常跑到下一个 import 的leadingComments里,你无法直接把它和“被删的那个 import”对应起来。
4. 从源头清干净:删除前先摘“附件”
4.1 最简单有效的三板斧
既然幽灵注释的根源是 Babel 的注释转交机制,那最直接的对抗方式,就是在调用path.remove()之前,先把目标节点上的注释全部摘掉。下面这个辅助函数是我现在写删除类插件的标配:
function clearNodeComments(node) { node.leadingComments = null; node.trailingComments = null; node.innerComments = null; node.comments = null; } function removeNodeAndComments(path) { clearNodeComments(path.node); path.remove(); }这里把注释字段设为null而不是空数组[],是因为 Babel 内部有些逻辑会判断“是否存在注释字段”。如果字段本身还在,只是空数组,某些版本仍然会走一遍注释转移逻辑;直接置空,等于告诉 Babel“这节点从来没带过注释”,转移逻辑就没有东西可搬了。
4.2 别忘了同时清掉 path 层级的缓存
只清path.node上的字段还不够保险。Babel 的NodePath在某些场景下会缓存注释信息。更稳的做法是调用path.removeComments(),这是@babel/traverse提供的方法,内部会同时处理节点与路径两边的注释引用。
完整的版本大概是:
function removeNodeAndComments(path) { const node = path.node; if (!node) return; node.leadingComments = null; node.trailingComments = null; node.innerComments = null; node.comments = null; if (typeof path.removeComments === "function") { path.removeComments(); } path.remove(); }有读者可能担心:path.removeComments()会不会把父节点或者祖先节点上应该保留的注释也清了?不用担心,它只处理当前 path 对应节点上的注释集合,不会向上蔓延。真正需要额外处理的反而是“相邻节点”,也就是 Babel 可能已经搬运过去的那些注释。
4.3 顺手清理相邻兄弟节点上的“脏数据”
如果幽灵注释已经存在,最常见的位置就是目标节点的前一个兄弟节点或后一个兄弟节点。所以删除前,我们还可以主动检查并过滤相邻节点上的注释。
假设我们要删掉的注释集合已经确定,比如按注释位置范围或注释内容判断,可以这样清理:
const discardedCommentIds = new Set(); function removeNodeAndComments(path) { const node = path.node; if (!node) return; collectCommentIds(node); node.leadingComments = null; node.trailingComments = null; node.innerComments = null; node.comments = null; if (typeof path.removeComments === "function") { path.removeComments(); } pruneAdjacent(path.getPrevSibling()); pruneAdjacent(path.getNextSibling()); path.remove(); } function collectCommentIds(node) { for (const key of ["leadingComments", "trailingComments", "innerComments", "comments"]) { if (!Array.isArray(node[key])) continue; for (const comment of node[key]) { discardedCommentIds.add(comment.start); } } } function pruneAdjacent(path) { const node = path && path.node; if (!node) return; for (const key of ["leadingComments", "trailingComments", "innerComments", "comments"]) { if (!Array.isArray(node[key])) continue; node[key] = node[key].filter((comment) => !discardedCommentIds.has(comment.start)); } }这段代码的思路很直接:先把被删节点身上所有注释的start记录下来,然后清空被删节点的注释字段,再去前、后兄弟节点上,把和这些start相同的注释过滤掉。这样一来,即使 Babel 在path.remove()之前已经做过一次转移,只要转移目标是相邻兄弟,我们也能把脏数据揪出来。
我承认,这种方式看起来有点“重”,但对那种“一删删一片”的批量场景非常有效。如果你只是删一两个节点,第 4.1 节的简单版本通常就够用了。
4.4 用 replaceWith 来规避删除钩子
还有一个取巧的办法:如果你不想和注释转交逻辑硬碰硬,可以先不用path.remove(),而是用path.replaceWith(t.emptyStatement())把节点替换成一个空语句。空语句本身没有注释字段,注释转移机制就不会被触发。等遍历全部结束后,再单独写一个EmptyStatement访问器,把所有空语句删掉。
const t = require("@babel/types"); module.exports = function () { return { visitor: { ImportDeclaration(path) { if (path.node.source.value === "./init") { path.replaceWith(t.emptyStatement()); } }, EmptyStatement(path) { if (path.inList) { path.remove(); } }, }, }; };这样做的缺点是会让 AST 多一轮“占位再清理”,性能上略有一点损耗;而且如果替换发生在表达式上下文里,空语句不一定合法。所以它更适合用在语句块列表里,比如函数体、程序体等位置。优点是代码写起来直观,不用手动处理注释字段。
5. 清理孤儿注释的兜底策略
5.1 删除后扫描file.ast.comments
有些幽灵注释不挂在任何节点上,而是直接作为顶层注释存在file.ast.comments数组里。这种时候,光靠节点清理是找不回来的,必须从文件的注释列表下手。
在post(file)钩子里,我们可以拿到整个文件的 AST,然后对被删除注释的集合做一次过滤。具体做法是先维护一个“要丢弃的注释 ID 集合”,再把file.ast.comments里匹配到的注释移除:
module.exports = function () { const discard = new Set(); return { visitor: { ImportDeclaration(path) { const node = path.node; for (const key of ["leadingComments", "trailingComments", "innerComments", "comments"]) { if (Array.isArray(node[key])) { for (const comment of node[key]) discard.add(comment.start); } } node.leadingComments = null; node.trailingComments = null; node.innerComments = null; node.comments = null; path.remove(); }, post(file) { file.ast.comments = (file.ast.comments || []).filter( (comment) => !discard.has(comment.start) ); }, }, }; };这里的关键是comment.start。Babel 给每个注释都记录了解析时的起始位置,同一个注释在节点字段里和file.ast.comments里是同一个引用,所以按start去重是安全且准确的。
5.2 用 shouldPrintComment 做生成期拦截
如果上面这些手段你都没来得及用,或者幽灵注释是第三方插件产生的,那还有最后一道保险:在调用生成器时,通过shouldPrintComment挡住不想打印的注释。
这个方法只适用于你能控制generate调用参数的场景,也就是写构建脚本、CLI 工具或自定义打包配置时。用法是这样的:
const generate = require("@babel/generator").default; const result = generate(ast, { shouldPrintComment: (comment) => !discardCommentIds.has(comment.start), });shouldPrintComment会在生成器决定是否打印某条注释时回调一次。返回false,注释就不会出现在产物里。
我一般把它当“最后一道防火绳”用,而不是主要手段。原因很简单:它只是让注释不打印,AST 里依然残留着这些节点,如果后续还有其他插件基于注释信息做处理,可能会产生意料之外的连锁反应。根治思路还是要回到“从源头上摘掉注释”这一层。
5.3 调试幽灵注释的实用套路
如果你写完了插件但不确定到底哪一步有问题,别闷头猜,我建议直接上 AST Explorer。把代码粘贴进去,在解析选项里勾选tokens和comments,再点击对应节点,右侧面板会展示这个节点的leadingComments、trailingComments、innerComments完整内容。
这样你就能直观看到两件事:
- 幽灵注释当前挂在哪个节点上。
- 被删节点原本挂了哪些注释。
还有一个小技巧是把 Babel 的@babel/traverse版本固定一下。Babel 6 和 Babel 7 对注释转移行为的处理细节并不完全一样,Babel 7 内部的小版本重构也可能影响转移结果。很多看起来“换个环境就好了”的玄学问题,其实就是依赖版本不一致导致的。
5.4 警惕 attachComment 和 comments:false 这些“大杀器”
搜索引擎里常有人问“为什么注释没被删除”,然后下面的回复直接让关掉解析注释。这个说法我是不太认同的。@babel/parser确实提供parserOpts: { attachComment: false }这类选项,但它会把整个文件的所有注释都停掉,包括那些你本来想保留的说明性注释。
而且,并不是所有解析器入口都支持这个选项。在 Babel 7 里,comments解析选项有时只影响 token 层的注释收集,不影响 AST 节点上的注释挂载。贸然去配置,注释没了是小事,搞不好还会让某些依赖注释的插件报错。
所以我的建议是:不要为清理幽灵注释而关闭全量注释。注释是有价值的代码资产,我们只应该精准删除与目标节点绑定的那几条,而不是一刀切。
6. 写删除类插件时,我会养成的几个习惯
做了几年 Babel 插件开发后,我现在凡是涉及删除逻辑,都会默认带上几道“工序”。
第一,删除前必清注释字段,不管目标节点看起来有没有注释。因为很多注释藏在子节点里,不主动清理就会漏掉。
第二,删除后必查相邻兄弟。前兄弟和后兄弟是最容易被注释转移机制污染的宿主,检查它们的trailingComments和leadingComments是最快定位问题的方式。
第三,批量删除用 Set 记录注释 ID。插件跑完后,统一在post阶段过滤file.ast.comments。这样即使中途出现注释转移,最终产物也被拦住了。
第四,写测试时把“注释保留”当第一优先级。我见过太多插件测试只断言节点删除成功,完全没检查注释的最终去向。等合入主分支后在真实项目里炸出问题,排查成本比写测试时多十倍。测试用例里加一行“产物中不得包含// 旧配置”这种断言,成本极低,价值极高。
第五,注意插件执行顺序。如果项目里同时挂了多个 Babel 插件,A 插件删掉了节点,B 插件随后可能对遗留注释做二次处理。为了减少互相干扰,我会在插件的pre钩子里统一建立注释 ID 集合,在post钩子里统一清理,避免每个 visitor 各自为政。
最后再分享一个小经验:当同事拿着产物问你“这注释哪来的”时,不要一上来就怀疑生成器配置。先让他打开 AST Explorer,看注释挂在哪个节点上,再顺藤摸瓜去找删除逻辑。八成以上情况,问题都出在删除前没有清理注释字段。把这个知识点在团队项目文档里固定下来,后面再遇到类似问题,大家看一眼就能自己解决了。