ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Babel插件实战:如何彻底清除AST节点删除后的幽灵注释

Babel插件实战:如何彻底清除AST节点删除后的幽灵注释 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看注释挂在哪个节点上再顺藤摸瓜去找删除逻辑。八成以上情况问题都出在删除前没有清理注释字段。把这个知识点在团队项目文档里固定下来后面再遇到类似问题大家看一眼就能自己解决了。
返回列表