Parcel 的 swc Visitors 实战指南:用 Visit / Fold / VisitMut 编写 Rust AST 变换
Parcel 的 swc Visitors 实战指南用 Visit / Fold / VisitMut 编写 Rust AST 变换【免费下载链接】parcelThe zero configuration build tool for the web. 项目地址: https://gitcode.com/gh_mirrors/pa/parcel本文面向想要为 Parcel 贡献 JS 变换逻辑、或者想在自己项目中用 swc 编写 Rust 版 Babel 插件的开发者。Parcel 的 JS 变换器parcel/transformer-js是纯 Rust 实现的 swc 管道本文以 docs/swc Visitors.md 为核心骨架完整讲解Visit/Fold/VisitMut三种遍历器的用法与陷阱并结合仓库中typeof_replacer.rs、global_replacer.rs、env_replacer.rs、modules.rs、collect.rs等真实实现展示这些模式如何在 Parcel 中落地。读完你将掌握如何让遍历器带状态地分析或改写 AST、如何在 swc 中删除/替换节点、如何用JsWordSyntaxContext判断变量绑定以及 Parcel 的完整 swc 变换管线。什么是 swc visitorswc 的 visitor 本质上是一个实现了Visit/Fold/VisitMuttrait 的 Rust 结构体。拿到任意 AST 节点例如模块最顶层的Module之后调用visit_with或fold_with/visit_mut_with即可对该节点及其全部后代做遍历。一个最小的分析型 visitor 长这样来自 docs/swc Visitors.mdstruct Foo { some_state: VecJsWord } impl Visit for Foo { // 其它节点类型没有显式实现走默认实现 // fn visit_module(mut self, node: Ident) { // node.visit_children_with(self); // } fn visit_expr(mut self, node: Expr) { println!(Some expression!); node.visit_children_with(self); } fn visit_ident(mut self, node: Ident) { self.some_state.push(node.sym); } } fn main(){ // ... let mut myVisitor Foo { some_state: vec![] }; module.visit_with(mut myVisitor); // ... }两个关键规则如果某个节点类型没有在impl中声明对应函数默认实现会调用visit_children_with继续遍历所有子节点最终命中那些被显式声明的函数。这意味着默认行为是全量递归。反过来如果重写了某个节点的函数但没有在函数体内对子节点调用visit_*那么这个子树将完全不会被访问。这本质上就是一个直截了当的递归遍历没有隐式魔法。swc 文档还提到一个实用的起点官方维护了一个名为swc-example的模板工程其中包含了解析输入 → 拿到 AST → 再序列化回代码所需的全部样板代码。如果你要基于 swc 写独立于 Parcel transformer 的工具可以直接以它为脚手架。三种 visitorVisit、Fold 与 VisitMutParcel 用到的 visitor 一共有三种下表同时给出对应的入口函数与签名特征Visitor trait入口方法函数签名以表达式为例适用场景Visitvisit_with/visit_children_withfn visit_expr(mut self, node: Expr)只读分析不做任何改动Foldfold_with/fold_children_withfn fold_expr(mut self, node: Expr) - Expr按值改写需返回同类型节点VisitMutvisit_mut_with/visit_mut_children_withfn visit_mut_expr(mut self, node: mut Expr)原地改写性能最优Visit拿到的是Expr这种不可变引用适合做纯分析、收集信息不改动树。Parcel 中的符号收集器Collect就实现了impl Visit for Collect见 collect.rs用于统计模块的 imports / exports /should_wrap等元信息结果被 scope hoisting 与 tree shaking 使用。Fold拿到的是节点的所有权Expr并且必须返回同类型的节点。它非常适合每个节点都替换成新节点的场合。Parcel 的 ESM→CJS 转换器ESMFold就是impl Fold for ESMFold见 modules.rs。VisitMut拿到mut Expr原地修改不需要构造并返回整棵树。理论上它比Fold更快因为Fold即使什么都没改也必须把节点值搬来搬去。VisitMut是 Parcel 中使用最多的类型。例如typeof替换器TypeofReplacertypeof_replacer.rsimpl VisitMut for TypeofReplacer { fn visit_mut_expr(mut self, node: mut Expr) { let Some(replacement) self.get_replacement(node) else { node.visit_mut_children_with(self); return; }; *node replacement; } }注意它先尝试把typeof module/typeof require等一元表达式替换成字面量字符串object/function若匹配失败则显式调用visit_mut_children_with(self)继续下钻——这正是上一节提到的不调用就跳过子树规则的正面运用。删除节点或替换为不同类型把逻辑上提到父节点swc 的Fold/VisitMut都要求返回与入参相同的节点类型。因此把export function Foo(){}替换成function Foo(){}时不能在fn fold_export_decl(self, node: ExportDecl)里直接返回一个VarDecl——类型对不上。正确的做法是把逻辑上提到一层在ModuleItem层面做模式匹配来自 docs/swc Visitors.mdfn fold_module_item(mut self, node: ModuleItem) - ModuleItem { match node { ModuleItem::ModuleDecl(ModuleDecl::ExportDecl(ExportDecl { decl: func Decl::Fn(_), .. })) { return ModuleItem::Stmt(Stmt::Decl(func)); }, _ { return node; }, } }同理swc 的 visitor 一次只能返回一个节点无法直接返回多个节点。要增删节点无论是语句还是VarDeclarator也必须访问父节点并操作子节点数组——对模块而言就是bodyfn fold_module(mut self, node: Module) - Module { let mut res node.fold_children_with(self); if let Some(foo) self.something { res.body.insert(0, ast::ModuleItem::Stmt(foo)); } res }这种重写fold_module/visit_mut_module再批量拼接body的模式在 Parcel 源码里反复出现GlobalReplacerglobal_replacer.rs在visit_mut_module中先node.visit_mut_children_with(self)完成子树处理再把收集到的全局声明splice到模块头部fn visit_mut_module(mut self, node: mut Module) { node.visit_mut_children_with(self); node.body.splice( 0..0, self.globals.drain(..).map(|(_, (_, stmt))| ast::ModuleItem::Stmt(stmt)), ); }它利用update_bindingglobal_replacer.rs为process、Buffer、__dirname、__filename、global这些全局标识符在模块顶部生成var process require(process)之类的声明并同步把依赖描述符DependencyDescriptor写入items列表供后续 resolver 收集。其单元测试test_globals_visitor_with_require_processglobal_replacer.rs验证了console.log(process.test)会被改写为var process require(process); console.log(process.test);。ESMFoldmodules.rs在fold_module里分两趟处理——第一趟收集所有 import 声明并生成对应的require与 interop 辅助调用第二趟把requires、exports如parcelHelpers.export(x, ...)依次splice进模块body的头部并在需要时注入parcelHelpers的 require。这也是先遍历收集、再统一改父节点数组这一文档模式的完整工程化范例。Identifiers 与作用域JsWord、SyntaxContext 与 Idswc 中表示标识符字符串的类型是JsWord在 swc 21 及本仓库的别名写法中对应ecma::atoms::Atom见 global_replacer.rs而不是普通String或str。JsWord是一种字符串驻留interned类型比较开销极低适合大量 AST 标识符的判等。let x: JsWord something.into(); // 任意字符串用 .into() let y: JsWord js_word!(require); // 预驻留词表内的字符串用宏更快 let ident: Ident; // AST 节点 ident.sym // 其 JsWord 名称 ident.span.ctxt // 其语法上下文 SyntaxContextswc 维护了一份硬编码的驻留词表如require、URL、default、eval等。对这些词使用js_word!宏可以零成本取出驻留实例但如果传入一个不在词表中的字符串编译期就会报错——这正是该宏的安全保证。Parcel 源码中大量使用了js_word!/JsWord::from例如 utils.rs 构造require调用时用require.into()env_replacer.rs 则用hasOwnProperty等普通str与JsWord直接比较。为什么只用字符串不够SyntaxContextbabel/traverse依赖独立的 scope 概念来判断两个变量是否指向同一个绑定swc 则使用SyntaxContext内部就是一个唯一编号。配对(JsWord, SyntaxContext)才能唯一指代一个变量绑定——即便 AST 里存在多个同名字符串的Ident节点它们各自的上下文也不同。因此如果你要把变量绑定存下来例如记录某个顶层绑定不要只存JsWord而应该存(JsWord, SyntaxContext)这一对。swc 为它提供了类型别名Id以及便捷助手ident.to_id()。Parcel 大量使用Id。例如 utils.rs 导出的id!宏就是$ident.to_id()的缩写collect.rs 中imports: HashMapId, Import、exports_locals: HashMapId, JsWord、used_imports: HashSetId都以Id为键modules.rs 的imports: HashMapId, (JsWord, JsWord)同样如此。swc 的hygiene()visitor 会把SyntaxContext信息冲刷成真正唯一的标识符名——这一步必须在 codegen 之前执行因为最终输出的文本 JS 格式并不认识语法上下文。Parcel 在 lib.rs 中给出了精确用法scope hoisting 且运行了 preset-env 时先hygiene()再跑一次resolver以重设所有节点的global_mark。判断未遮蔽的全局引用unresolved mark实际判断某个标识符是否指向未被遮蔽的全局绑定是借助resolver注入的 mark 完成的。Parcel 的 utils.rs 封装了核心判断pub fn is_unresolved(ident: Ident, unresolved_mark: Mark) - bool { ident.ctxt.outer() unresolved_mark }TypeofReplacer之所以能把typeof module替换成object正是因为它在resolver之后运行且只对is_unresolved为真的标识符动手typeof_replacer.rs。对应测试test_visitor_typeof_replacer_with_shadowingtypeof_replacer.rs证明函数参数{ require, exports }遮蔽后的typeof require会被保留原样而未被遮蔽的typeof module仍被替换——这正是(JsWord, SyntaxContext)语义的直接体现。visit_ident的陷阱Ident 不只是变量名需要注意Ident节点不仅仅表示指向变量绑定的引用它还承载 AST 中所有名称含义——解构模式、成员访问的.foo、类的私有字段#foo等都是Ident。因此重写fold_ident/visit_ident/visit_mut_ident时必须格外小心。原文档给出了一个生动的例子把每个Ident都改名为foo之后以下输入fn fold_ident(mut self, node: Ident) - Ident { Ident::new(foo.into(), DUMMY_SP) } // and the other visit, visit_mut variants...会把整段代码污染成function foo(foo) { foo.foo(foo); } const foo {foo: foo}; class foo { #foo; foo() { foo(this.#foo); } }可以看到函数名、参数、调用、对象字面量键、类名、私有字段、方法名、this.#foo的访问……全部被无差别改写。这说明按名替换标识符通常不是你想要的行为更稳妥的做法是基于Id即带上SyntaxContext做定向替换或者只在明确的上下文如visit_mut_expr中的引用位置里处理。基于祖先存在性的决策模式有些场景只需要知道是否存在某个满足条件的祖先节点例如仅当不在函数内部时才替换this而不需要真正读取或修改那个祖先。原文档给出的模式是在 visitor 结构体上维护一个状态变量在进入/离开相关节点时保存并恢复。struct Foo { in_function_scope: bool, } impl Visit for Foo { fn visit_function(mut self, node: Function) { let old self.in_function_scope; self.in_function_scope true; node.visit_children_with(self); self.in_function_scope old; } fn visit_expr(mut self, node: Expr) { if let Expr::This(_this) node { println!(self.in_function_scope); } } }要点是进入时保存旧值、遍历子节点、退出时恢复旧值这样不同深度的兄弟子树之间不会互相串扰。Parcel 把这一模式做成了宏。在 modules.rs 中macro_rules! modules_visit_fn { ($name:ident, $type:ident) { fn $name(mut self, node: $type) - $type { let in_function_scope self.in_function_scope; self.in_function_scope true; let res node.fold_children_with(self); self.in_function_scope in_function_scope; res } }; }随后ESMFold用modules_visit_fn!(fold_function, Function)、fold_class、fold_getter_prop、fold_setter_prop一次性为所有会引入新作用域的节点注册进入即置位、退出即恢复的逻辑modules.rs并在fold_expr中据此处理thisExpr::This(_this) { if !self.in_function_scope { Expr::Ident(get_undefined_ident(self.unresolved_mark)) // 顶层 this - undefined } else { node } }见 modules.rs。这同样验证了Enter/Leave 保存恢复状态这一模式的工程价值。在 Parcel 中实战swc 变换管线全貌parcel/transformer-js的核心 crate 是parcel-js-swc-coreCargo.toml它直接依赖swc_core21并启用ecma_ast、ecma_parser、ecma_codegen、ecma_visit、ecma_transforms等 feature。入口是 lib.rs 的transform()函数整条 visitor 流水线lib.rs大致如下解析按资产类型选择 ES/TS/JSX/MDX 语法parseresolver注入global_mark/unresolved_mark为后续所有 visitor 提供作用域信息lib.rs可选decorators、typescript::tsx/strip、react等语法层变换TypeofReplacerVisitMut替换typeof module/exports/require与浏览器端的typeof processlib.rsEnvReplacerVisitMut内联process.env.X/process.browser并对foo in process.env、解构赋值、delete process.env.X等做静态替换lib.rs实现见 env_replacer.rsexpr_simplifierdead_branch_remover简化表达式、剪掉恒假分支避免把死条件内的依赖打进包可选inline_fsFold内联readFileSyncNodeReplacerVisitMut在 Node 环境下替换__dirname/__filename占位GlobalReplacerVisitMut为process/Buffer等插入 require 声明preset_envinject_helpers按 targets 降级语法、注入swc/helpersscope hoisting 时hygiene() 再次resolver然后dependency_collectorFold见 dependency_collector.rs收集依赖CollectVisit做符号分析scope hoist 路径走hoist()hoist.rs否则走ESMFold的esm2cjsmodules.rs收尾reserved_words、hygiene()、fixer处理括号与括号去除lib.rs最后 codegen 输出默认 ES5 target、ASCII-only见 emit。可见原文档讲授的三种 visitor 类型、节点增删模式、Id/SyntaxContext语义与祖先状态技巧正是这一整条管线的基石。如何为自己的 visitor 编写测试Parcel 在 test_utils.rs 提供了两个测试助手帮你省去手写 Lexer → Parser → resolver → visitor → Emitter的样板run_visit(code, make_visit)解析代码 → 运行resolver创建unresolved_mark/global_mark→ 对Module调用module.visit_mut_with(mut visit)→ codegen返回RunVisitResult { output_code, visitor }run_fold(code, make_fold)等价物但对Module调用module.take().fold_with(mut visit)。RunTestContext暴露source_map、global_mark、unresolved_mark方便你在构造 visitor 时传入这些上下文正如TypeofReplacer::new(unresolved_mark, is_node)的用法。一个最简示例test_utils.rsstruct Visitor; impl VisitMut for Visitor { fn visit_mut_lit(mut self, n: mut Lit) { *n Lit::Str(Str::from(replacement)); } } let code r#console.log(test!)#; let RunVisitResult { output_code, .. } run_visit(code, |_: RunTestContext| Visitor); assert_eq!(output_code, r#console.log(replacement);#);TypeofReplacer、GlobalReplacer、EnvReplacer、ESMFold的单元测试都基于这套工具编写例如test_visitor_typeof_replacer_without_shadowingtypeof_replacer.rs和test_transforms_computed_propertyglobal_replacer.rs。如果你要为 Parcel 新增一个 swc 变换最直接的方式就是照着这些文件的结构写一个新的 visitor 一组run_visit测试。小结与下一步三种遍历器按需选择纯分析用Visit逐节点替换用Fold追求性能的原地改写用VisitMut。改节点类型/增删节点一律在父节点层操作ModuleItem、Module.body。标识符绑定用Id (JsWord, SyntaxContext)只在必要时依赖js_word!宏与hygiene()冲刷。祖先条件用进入/退出保存恢复的状态字段Parcel 甚至用宏统一生成这类逻辑。动手前先读lib.rs 的transform()找准你的变换应该插入管线的哪一步并用 test_utils.rs 的run_visit快速验证。【免费下载链接】parcelThe zero configuration build tool for the web. 项目地址: https://gitcode.com/gh_mirrors/pa/parcel创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考