ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Parcel SWC Scope Hoisting 深度解析:从 Babel AST 三阶段到 Rust + 字符串拼接两阶段

Parcel SWC Scope Hoisting 深度解析:从 Babel AST 三阶段到 Rust + 字符串拼接两阶段 Parcel SWC Scope Hoisting 深度解析从 Babel AST 三阶段到 Rust 字符串拼接两阶段【免费下载链接】parcelThe zero configuration build tool for the web. 项目地址: https://gitcode.com/gh_mirrors/pa/parcel导读本文以 hoist.md 为骨架深入剖析 Parcel 基于 SWC 的 Scope Hoisting作用域提升实现它如何把多个 JavaScript 模块合并进单一作用域从而让 Tree Shaking死代码消除更彻底、并通过把跨模块引用从动态属性查找变成静态引用提升运行时性能。文章先对比旧实现Babel AST 三阶段与新实现Rust 字符串两阶段的整体架构再逐层拆解 Hoist 阶段的静态分析与变换、Packaging 阶段的字符串替换与包装逻辑最后用文档中的完整示例与 CommonJS 安全/非静态/包装模式清单帮助读者理解 Parcel 如何在 ESM 与 CommonJS 混用场景下保证副作用顺序与循环依赖的正确性。读完本文你将掌握 Parcel scope hoisting 的完整工作流、import module_id:specifier占位符协议、parcelRequire.register包装机制以及哪些 CommonJS 模式可以被静态分析、哪些必须回退到 namespace 或包装。什么是 Scope HoistingScope Hoisting 是把多个 JavaScript 模块的代码合并到一个统一作用域中的过程。合并之后Tree Shaking 更有效由于所有模块在同一作用域内编译器/打包器可以精确地看到哪些导出符号被真正使用未使用的导出可以直接删除运行时性能更好跨模块引用从动态属性查找如require(b).foo、namespace.foo变成静态变量引用直接引用一个已重命名的变量省去了模块运行时查找的开销。Parcel 历史上用 JavaScript 在 Babel AST 之上实现 scope hoisting而当前仓库采用的新实现则把核心工作迁移到了 Rust基于 SWC并在打包阶段完全改用字符串操作。从三阶段到两阶段架构对比旧实现Babel AST 三阶段旧实现依次执行三个阶段全部在 AST 上操作Hoist提升针对单个模块把所有顶层变量重命名为唯一名称并对 import/export 做静态分析以产生 symbol 数据为模块安全拼接做准备Concat拼接按照 Hoist 阶段插入的$parcel$require调用顺序把所有模块拼接进单一作用域同时负责包装那些从非顶层语句中被 require 的模块以保持副作用执行顺序Link链接把临时导入变量名替换为被导入模块中解析后的导出变量名同时处理输出格式和一些死代码消除。新实现Rust Hoist 字符串 Packaging 两阶段新实现只有两个阶段Hoist与旧版类似但重新设计为让后续阶段只使用字符串而非 AST即可完成工作以提升性能。这一阶段用 Rust 实现Packaging打包把旧版的 Concat 与 Link 合并为一个阶段通过简单的字符串替换来匹配 import 与 export在此同时完成资产包装保持副作用顺序以及必要时插入导出 namespace 对象。对应源码结构Hoist 阶段的 Rust 实现位于 core/src/hoist.rs配合 core/src/collect.rs 的静态收集JS 侧打包阶段的核心类位于 ScopeHoistingPackager.js。TransformingRust 中的 Hoist 阶段Hoist 阶段应尽可能多做工作因为它操作的是 AST且可以按文件并行执行它使用 SWC 在 Rust 中实现以获得性能。整个阶段分为两个 pass第一 pass收集Collect第一 pass 只做分析不改动代码。它收集的数据包括所有被 import/require 的变量名用于跟踪对这些变量的静态/非静态访问从而确定某个依赖的哪些符号被使用见 collect.rs 中imports、used_imports字段所有被导出的变量名便于后续统一重命名exports、exports_locals字段所有非静态成员表达式及其他访问用于判断后续是否需要访问 namespace 对象还是可以静态解析依赖的所有符号同时跟踪依赖被其他非静态方式访问的情况如非静态解构赋值non_static_access字段CommonJSexports对象是否被非静态引用若是则必须始终使用 namespace 而不是静态解析导出static_cjs_exports标志需要被包装的依赖当 require 出现在除顶层变量声明以外的任何位置例如函数内部时该依赖需要被包装以保持副作用顺序wrapped_requires字段本模块自身是否需要被包装例如使用了eval、顶层return、非静态module访问、exports被重新赋值等should_wrap标志。从 collect.rs 的Collect结构体可以看到这些状态的完整形态non_static_access、non_const_bindings、non_static_requires、wrapped_requires、static_cjs_exports、has_cjs_exports、is_esm、should_wrap等。收集结果最终通过CollectResult序列化输出。第二 pass变换Hoist第二 pass 基于收集到的数据实际改写模块每条 import 语句被替换为提升到模块顶部的import module_id:dep_specifier;其中module_id是当前资产 IDdep_specifier是依赖的说明符。这个带 ID 的 import 语句是打包阶段定位依赖代码应插入何处的占位符。ESM 导入会带有:esm后缀见 hoist.rs 中format!({}:{}:{}, self.module_id, import.src.value, esm)require调用在当前顶层语句之前插入上述 import如果 require 出现在除顶层变量声明以外的位置则标记为需包装比旧版更保守。require 本身被替换为引用依赖*符号的标识符如果处于可静态解析的成员表达式中则替换为对应符号。注意require与动态import()生成的占位符不带:esm后缀且动态导入会生成$id$importAsync$hash形式的标识符并记录在dynamic_imports中见 hoist.rs每条 export 语句被其声明/表达式替换若有并重命名CommonJS exports被替换为变量赋值如果上一阶段判定 CJS exports 对象被非静态访问则发出对对象的赋值否则每个导出是一个独立变量见 hoist.rs 的fold_assign_expr对 import/require 的引用被替换为重命名后的变量并记录进 symbol 表如果第一阶段判定该 import 被非静态访问则对该依赖的所有引用都改用成员表达式所有顶层变量被重命名以包含模块 ID从而保证拼接安全如果模块需要被包装例如使用了eval则跳过重命名。命名规则在 hoist.rs 中可以看到具体形态导入名形如$module_id$import$hash(source)$hash(local)namespace 导入省略第二个 hash导出名形如$module_id$export$hash(exported)exports/*符号对应$module_id$exports非 const 的 require 绑定对应$module_id$require$local。Rust 输出与 JSTransformer 的衔接Rust 侧的输出是一个对象被 JSTransformer.js 这个 Parcel 插件消费它把hoist_result中的符号添加到依赖和资产本身并把should_wrap、dynamic_imports、used_env等元数据写入asset.meta例如 JSTransformer.js 的asset.meta.id、asset.meta.usedHelpers以及 JSTransformer.js 中对use server指令强制asset.meta.shouldWrap true的处理。Rust 侧入口在 core/src/lib.rsConfig结构体含scope_hoist、source_type、module_id等字段定义了 JS 插件调用 Rust 的完整参数协议。Packaging纯字符串的两阶段打包打包阶段只操作字符串不再反序列化 AST 或执行 codegen因此速度更快。核心实现在 ScopeHoistingPackager.js。递归遍历与 import 占位符替换打包器从 bundle 入口开始递归访问依赖沿着 Hoist 变换留下的import语句把 import 说明符解析为依赖沿依赖找到已解析的资产然后递归处理该资产最后把import语句替换为依赖处理后的代码。该逻辑依赖一个核心正则 REPLACEMENT_REconst REPLACEMENT_RE /\n|import\s([0-9a-f]{16}:.?);|(?:\$[0-9a-f]{16}\$exports)|(?:\$[0-9a-f]{16}\$(?:import|importAsync|require)\$[0-9a-f](?:\$[0-9a-f])?)/g;它同时负责三件事匹配import module_id:specifier占位符以插入依赖代码、匹配$id$import$.../$id$importAsync$.../$id$require$...引用以做符号替换、统计换行数以生成 source map。符号解析与字符串替换对每个资产打包器查看每个依赖使用的符号沿着 re-export 链把它们解析到最终位置这部分由 Parcel core 在 bundle graph 中完成。然后对每个临时导入名执行字符串替换为最终解析后的符号如果解析后的资产是被包装的使用parcelRequire加载它如果它有非静态导出则对 namespace 对象使用成员表达式。按需合成 exports 对象与旧版在 link 阶段生成 exports 对象、若不需要再删除相反新版在以下任一情况下才合成 namespacenamespace 被使用资产有非静态导出资产被包装。此时声明一个 namespace并用$parcel$export帮助函数把每个被使用的符号加入 namespace。$parcel$export的定义在 esmodule-helpers.js它用Object.defineProperty在目标对象上定义一个可枚举的 getter实现按需取值的导出语义exports.export function (dest, destName, get) { Object.defineProperty(dest, destName, { enumerable: true, get: get, }); };被包装资产的注册如果资产被包装则使用parcelRequire.register将其注册进模块映射表。这发生在两种情况下资产在非顶层上下文中被 require资产含有无法静态分析的代码例如eval。旧版是在 hoist 阶段包装后者新版统一挪到了打包阶段。包装时传入module和exports对象使用它们而非本地声明的 exports 对象从而使循环依赖正常工作。在 ScopeHoistingPackager.js 的loadAssets中可以看到判定包装条件asset.meta.shouldWrap、script 类型的 bundle、被其他资产引用的资产以及存在dep.meta.shouldWrap的入边依赖。新旧实现差异总结文档给出了新旧实现的核心差异清单require/import 现在被替换为顶层的import module_id:specifier语句而不是内联的$parcel$require调用——这指明了依赖代码应插入的位置打包器无需再在语句内部搜索 requirehoist 变换保守得多只有指向require或含require的成员表达式的顶层变量声明才可免于包装。这应能解决 parcel-bundler/parcel#5606 这类问题该 issue 涉及require位于非顶层表达式时的行为。此外一旦某个 import 发生非静态访问就始终使用 namespace 对象而不是一部分符号静态引用、另一部分动态引用exports 同理Hoist 阶段不再做任何包装所有包装都在打包阶段完成打包器只操作字符串不需要任何 AST导出 namespace 对象不在 hoist 阶段生成仅在打包期间按需合成例如模块被非静态访问时。这对 ESM 和完全可静态分析的 CJS 都成立对于导出为非静态的 CJShoist 阶段仍执行对 namespace 对象的赋值包装使用parcelRequire.register而非旧的$init调用这允许在 bundle 之间共享模块映射表并解决一些副作用顺序与循环依赖问题。完整示例四种模块形态与两种回退文档提供了六个逐步示例覆盖 ESM/CJS 的静态组合以及非静态访问、包装等回退路径。下面完整展开。示例一ESM - ESM输入// a.js import {b} from ./b; b(); // b.js let b 2; export {b};Hoist 输出// a.js import id:./b; $id$import$b$b(); // b.js let $id$export$b 2;imported symbolsLocalSpecifierImported$id$import$b$b./bbexported symbolsExportedLocalb$id$export$bPackaged 输出let $id$export$b 2; $id$export$b$b();注意打包输出里b()的调用对象是$id$export$b即模块 b 中重命名后的导出变量$id$export$b这就是跨模块引用变成静态变量引用的直观体现。示例二ESM - 静态 CJS输入// a.js import {b} from ./b; b(); // b.js exports.b 2;Hoist 输出与示例一完全相同因为静态 CJS 导出exports.b 2被重写为独立的导出变量let $id$export$b 2;。Packaged 输出let $id$export$b 2; $id$export$b();示例三静态 CJS - 静态 CJS输入// a.js const {b} require(./b); b(); // b.js exports.b 2;Hoist 输出解构 requireconst {b} require(./b)被静态解析为$id$import$b$b与 ESM 导入的产物一致。Packaged 输出let $id$export$b 2; $id$export$b();示例四静态 CJS - ESM输入// a.js const {b} require(./b); b(); // b.js let b 2; export {b};Hoist 输出 / Packaged 输出与示例三相同。这四个示例说明只要访问是静态的ESM 与 CJS 在 hoist 之后会被统一为同一套重命名变量 占位符 import的中间形态。示例五非静态 require - 静态导出输入// a.js const b require(./b); b[something](); // b.js exports.foo 2;Hoist 输出// a.js import id:./b; $id$import$b[something](); // b.js let $id$export$foo 2;imported symbolsLocalSpecifierImported$id$import$b./b*Packaged 输出let $id$export$foo 2; let $id$exports {}; $parcel$export($id$exports, foo, () $id$export$foo); $id$exports[something]();这里因为b[something]()是非静态访问导入侧回退到*namespace打包时按需合成了$id$exports并用$parcel$export注册foo的 getter。示例六静态 require - 非静态导出输入// a.js const b require(./b); b.foo(); // b.js exports[something] 2;Hoist 输出// a.js import id:./b; $id$import$b$foo(); // b.js $id$exports[something] 2;exported symbolsExportedLocal*$id$exportsPackaged 输出let $id$exports {}; $id$exports[something] 2; $id$exports.foo();导出侧因为exports[something]无法静态分析回退为对整个$id$exports对象的赋值导入侧b.foo()是静态的直接引用 namespace 的属性。示例七非静态 require - 非静态导出输入// a.js const b require(./b); b[foo](); // b.js exports[something] 2;Hoist 输出 / Packaged 输出导入导出两侧都回退到 namespace最终输出let $id$exports {}; $id$exports[something] 2; $id$exports[foo]();。示例八包装的 require输入// a.js function test() { return require(./b).foo; } // b.js exports.foo 2;Hoist 输出// a.js import id:./b; function test() { return $id$import$b$foo; } // b.js let $id$export$foo 2;wrapped dependencies./bPackaged 输出parcelRequire.register(b, module { let $id$export$foo 2; $parcel$export(module.exports, foo, () $id$export$foo); }); function test() { return parcelRequire(b).foo; }这是包装机制的核心示例因为require(./b)出现在函数体内非顶层依赖./b被标记为 wrapped打包时用parcelRequire.register注册调用处替换为parcelRequire(b)从而保证模块 b 的副作用只在首次真正执行时发生。CommonJS 模式分类静态 / 非静态 / 包装文档把 CommonJS 中常见的写法分为三类这是理解 Parcel 静态分析能力边界的实用清单。安全模式Safe patterns这些模式可以被完全静态分析require 可以被替换为对被导入模块导出符号的静态变量引用导出是静态的因此每个导出符号被替换为独立变量。Requiresrequire(y); require(y).foo; require(y).foo(); const y require(y); const x require(y).x; const {x} require(y); const {x: y} require(y); const {x 2} require(y); // Safe but needs to be split into separate declarations. const a sideEffect(), b require(y);最后一个例子对应源码中 hoist.rs 的拆分声明逻辑当 require 不是 var 声明的第一个 declarator 时会把前面的声明拆成独立语句插入以保证副作用顺序// var x sideEffect(), y require(foo), z 2; // - var x sideEffect(); import foo; var y $id$import$foo, z 2;Exportsexports.foo 2; module.exports.foo 2; this.foo 2; exports[foo] 2; function test() { exports.foo 2; }其中this.foo 2的静态解析依赖模块顶层this非 ESM、非函数作用域、未包装对应 hoist.rs 的fold_expr中Expr::This分支。非静态模式Non-static patterns这些模式需要改用 namespace 对象而不是静态引用。Requiresrequire(x)[something]; const x require(x)[something]; const x require(x); x[something]; const {x, ...y} require(x); x require(y); ({x} require(y));Exportsexports[foo] 2; module.exports[foo] 2; this[foo] 2; sideEffect(exports); sideEffect(module.exports);其中sideEffect(exports)/sideEffect(module.exports)会把整个 exports 对象泄漏给不可分析的调用因此必须保留 namespace 对象。包装模式Wrap patterns这些模式要求被导入模块被包装以保持副作用顺序。Requiresfunction x() { const x require(y); // etc. } const x sideEffect() require(b); const x sideEffect(), require(b); const x sideEffect() || require(b); const x condition ? require(a) : require(b); if (condition) require(a); for (let x require(y); x 5; x) {} // etc.这些例子的共同点是 require 不在顶层变量声明直接指向 require 或其静态成员的形态中要么嵌套在函数里要么参与复杂表达式、||、三元、条件语句、循环初始化此时打包器必须把依赖包在parcelRequire.register里延迟执行才能保证相对宿主代码的副作用顺序正确。Exports// Exports re-assigned exports.foo 2; exports {}; exports.bar 3; ({exports} something); // Module accessed non-statically sideEffect(module); // Eval eval(exports.foo 2); // Top-level return return;exports被重新赋值exports {}或解构赋值({exports} something)整个模块的导出无法静态跟踪必须包装module被非静态访问sideEffect(module)模块对象可能被外部持有并修改必须包装eval字符串代码无法静态分析可能访问任意变量包括exports必须包装顶层return会让模块代码在顶层提前退出只有放在parcelRequire.register的函数体内才合法因此必须包装。这些标志最终汇集为Collect中的should_wrap并在 hoist 输出中通过HoistResult.should_wrap传给打包器。包装与静态分析的交汇点在 ScopeHoistingPackager.js当 bundle 的入口资产被包装时打包器会在产物中生成parcelRequire(publicId);调用以确保其执行。小结Parcel 的 scope hoisting 重构把分析 变换压进 Rust/SWC 的 Hoist 阶段两 pass先收集后变换把拼接 链接压进纯字符串的 Packaging 阶段并用import module_id:specifier占位符取代了内联$parcel$require调用作为跨阶段的唯一协议。这样做既避免了打包阶段反序列化 AST 的开销也通过更保守的静态分析规则非静态访问一律回退 namespace、非顶层 require 一律包装、包装统一交给parcelRequire.register系统性解决了副作用顺序与循环依赖的边界问题。理解这套机制后你可以从import占位符、$id$import$.../$id$export$...命名、parcelRequire.register包装三个锚点入手快速读懂任意 Parcel 打包产物的结构也可以借助文末的 CommonJS 模式清单判断自己的代码是否会被静态分析从而写出更利于 tree shaking 的模块代码。【免费下载链接】parcelThe zero configuration build tool for the web. 项目地址: https://gitcode.com/gh_mirrors/pa/parcel创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表