ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Rolldown 插件 `this.resolve` 的 `skipSelf` 参数:语义、默认值与防无限递归机制

Rolldown 插件 `this.resolve` 的 `skipSelf` 参数:语义、默认值与防无限递归机制 Rolldown 插件this.resolve的skipSelf参数语义、默认值与防无限递归机制【免费下载链接】rolldownFast Rust bundler for JavaScript/TypeScript with Rollup-compatible API.项目地址: https://gitcode.com/GitHub_Trending/ro/rolldownskipSelf是 Rolldown 插件 API 中this.resolve(source, importer, options)第三参options下的一个布尔选项用于控制本次手动解析是否会再次触发调用方插件自身的resolveId钩子。它是构建插件如别名重写、懒编译代理、浏览器字段探测在resolveId内继续解析时避免无限递归的关键开关默认值为true。读完本文你将理解skipSelf的精确语义、Rolldown 在 Rust 层的匹配实现仅对相同的 source importer 生效以及如何安全地显式关闭它并自行承担防递归责任。本文依据 plugin-context-resolve-skipself.md 展开并结合packages/rolldownJS/TS 绑定层与crates/rolldown_pluginRust 插件驱动层中的源码与测试进行印证。一、skipSelf是什么一次自我跳过的精确语义在插件开发中this.resolve允许你在任意构建钩子尤其是resolveId、buildStart、load、transform等内手动发起一次模块解析走与 Rolldown 自身解析完全相同的管线先依次调用所有插件的resolveId钩子失败后再回退到内部解析器见 resolve_id_with_plugins.rs 中先跑插件resolve_id返回None再用内部 resolver 兜底的注释与逻辑。skipSelf描述的是以下这一种连锁调用场景插件 A 的resolveId钩子正在处理一次由 Rolldown 发起的解析即原始this.resolve调用在钩子内部插件 A 又调用了this.resolve(source, importer)这一次调用又会遍历所有插件的resolveId钩子包括插件 A 自己的若插件 A 在收到相同的sourceimporter时再次调用this.resolve就会陷入无限递归。skipSelf的语义正如文档所述当其他插件在自己的resolveId钩子中以完全相同的source和importer调用this.resolve时原插件发起方的resolveId钩子也会被一并跳过。其设计理由是既然该插件在此刻对这个sourceimporter组合返回了null即我不知道怎么解析它那么再次以相同参数触发它属于纯粹的重复劳动与递归隐患Rolldown 直接替你跳过。关键点跳过的判定条件是source与importer都完全一致并且匹配的是调用链上每一层的记录。也就是说skipSelf的防护对象不是调用方这一个插件而是整个解析调用栈中的每一级调用者。二、API 形态与默认值skipSelf定义在PluginContextResolveOptions接口上位于 plugin-context.tsexport interface PluginContextResolveOptions { kind?: BindingPluginContextResolveOptions[importKind]; isEntry?: boolean; /** * Whether the resolveId hook of the plugin from which this.resolve is called * will be skipped when resolving. */ skipSelf?: boolean; custom?: CustomPluginOptions; }skipSelf的默认值为true接口注释与default true标注一致调用方式为this.resolve(source, importer, { skipSelf: true | false })source为待解析的模块说明符importer为引用方模块路径可选。该选项在 TS 绑定层被原样透传给原生层PluginContextImpl.resolve将其放进BindingPluginContextResolveOptions的skipSelf字段后调用this.context.resolve(...)见 plugin-context.ts。三、Rust 层的实现跳过记录如何产生、如何生效3.1 选项结构与默认值Rust 侧的PluginContextResolveOptions定义于 plugin_context_resolve_options.rs包含import_kind、is_entry、skip_self、custom四个字段且Default实现中skip_self: true与 TS 层默认值保持一致impl Default for PluginContextResolveOptions { fn default() - Self { Self { import_kind: ImportKind::Import, is_entry: false, skip_self: true, custom: Arc::default(), } } }3.2 跳过记录的构建NativePluginContext核心逻辑在 native_plugin_context.rs 的resolve方法中。当skip_self为true时Rolldown 会把本次调用的发起插件、importer、specifier打包成一条HookResolveIdSkipped记录追加到当前上下文中已有的跳过记录链上再连同后续解析一起传入resolve_id_check_externallet skipped_resolve_calls if normalized_extra_options.skip_self { let mut skipped_resolve_calls Vec::with_capacity(self.skipped_resolve_calls.len() 1); skipped_resolve_calls.extend(self.skipped_resolve_calls.clone()); skipped_resolve_calls.push(Arc::new(HookResolveIdSkipped { plugin_idx: self.plugin_idx, importer: importer.map(Into::into), specifier: specifier.into(), })); Some(skipped_resolve_calls) } else if !self.skipped_resolve_calls.is_empty() { // 关闭 skipSelf 时仍透传已有的跳过记录 Some(self.skipped_resolve_calls.clone()) } else { None };HookResolveIdSkipped结构体见 hook_resolve_id_skipped.rs仅含三个字段plugin_idx发起插件的索引、importer、specifier。注意两个细节记录是链式累积的skipped_resolve_calls会extend当前上下文NativePluginContextImpl.skipped_resolve_calls中已有的记录再追加新记录。这保证了多级递归时每一层发起者的resolveId都会被跳过。skipSelf: false并不清空历史记录此时只是不再追加发起者自身这一条但调用链上层已有的跳过记录仍会透传因此关闭skipSelf只影响当前这一层调用是否回环到自身不会破坏上层已经建立好的防递归边界。3.3 精确匹配source 与 importer 都相同才跳过跳过记录如何作用于后续的钩子遍历关键函数是 build_hooks.rs 中的get_resolve_call_skipped_pluginsfn get_resolve_call_skipped_plugins( specifier: str, importer: Optionstr, skipped_resolve_calls: OptionVecArcHookResolveIdSkipped, plugin_count: usize, ) - IndexBitSetPluginIdx { let mut skipped_plugins IndexBitSet::new(plugin_count); if let Some(skipped_resolve_calls) skipped_resolve_calls { for skip_resolve_call in skipped_resolve_calls { if skip_resolve_call.specifier specifier skip_resolve_call.importer.as_deref() importer { skipped_plugins.set_bit(skip_resolve_call.plugin_idx); } } } skipped_plugins }该函数把插件索引集合建模为IndexBitSet仅当某条记录的specifier且importer都与当前待解析参数完全相等时才把对应插件标记为跳过随后的resolve_id遍历中skipped_plugins.has_bit(plugin_idx)为true的插件会被直接continue跳过build_hooks.rs动态导入路径resolve_dynamic_import使用同一套跳过逻辑同文件 L176 之后保证ImportKind::DynamicImport场景下语义一致。也就是说只有同样的 source、同样的 importer、同样的发起插件这一组合才会被跳过只要source或importer任一不同调用方插件的resolveId钩子依然会正常参与解析——这保证了skipSelf不会误伤对其他模块的解析能力。3.4 上下文 fork把跳过记录带给钩子内的this.resolve为了让resolveId钩子内部再次调用this.resolve时能感知到上层的跳过记录Rolldown 在调用每个插件的钩子前会基于跳过记录 fork 出新的插件上下文PluginContext::fork_with_skipped_resolve_calls(ctx, skipped_resolve_calls.clone())build_hooks.rs。这套上下文沿调用链传递的设计正是文档所述连锁调用也会跳过的底层来源。四、关闭skipSelf的风险与手动防递归文档明确提醒如果你不想要这种自我跳过行为请将skipSelf设为false并自行实现无限循环防护机制。skipSelf: false意味着你的插件在自身resolveId钩子中再次解析同一个sourceimporter时会再次进入自己的钩子——此时若不加防护直接再次调用this.resolve(同参)就会产生调用栈无限增长。仓库中的测试用例恰好展示了两种典型写法resolve-error-cause/_config.ts在buildStart里以skipSelf: false调用this.resolve(./sub.js)其意图是让resolveId钩子真正处理该请求并抛出预期错误。该用例还说明skipSelf只在resolveId钩子内部调用this.resolve时才有跳过自身的递归风险在buildStart等钩子中调用时resolveId依然会正常执行。resolve2/_config.ts在resolveId内部同时演示两种模式。插件tester对test-skip-self-false用skipSelf: false解析此时会回环进入自己因此钩子开头就做了防递归守卫——如果 id 是test-skip-self-false直接返回return-by-tester而对test-skip-self-true用skipSelf: true解析tester被跳过最终由tester2返回return-by-tester2async resolveId(id) { fnA(); if (id test-skip-self-false) { // Prevent recursive call return return-by-tester; } const skipSelfTrue await this.resolve(test-skip-self-true, undefined, { skipSelf: true, }); expect(skipSelfTrue?.id).toBe(return-by-tester2); const skipSelfFalse await this.resolve(test-skip-self-false, undefined, { skipSelf: false, }); expect(skipSelfFalse?.id).toBe(return-by-tester); // ... }afterTest中断言fnA被调用恰好 2 次一次来自原始解析一次来自skipSelf: false的回环fnB也被调用 2 次——精确验证了skipSelf: true跳过自身、skipSelf: false回环自身的行为同目录下 resolve3、resolve4 的断言与之类似。五、仓库内的真实实践内置插件如何使用skipSelfRolldown 内置插件是最直接的实践范本二者都在resolveId钩子内调用this.resolve并显式指定skip_self: true注意 Rust 侧字段为 snake_casevite alias 插件rolldown_plugin_vite_alias/src/lib.rs命中别名规则、重写specifier后用skip_self: true重新解析重写后的路径避免别名插件对已经重写过的 specifier再次做别名匹配从而防止别名规则自我递归lazy compilation 插件lazy_compilation_plugin.rs为动态导入解析原始 id时同样以skip_self: true发起ctx.resolve其注释还点明了更深一层的原因——ctx.resolve会触发其他插件的resolveId这些钩子也可能再次调用ctx.resolve从而让本插件被重复调用需要幂等处理见同文件 L119-L126 的注释。这两个例子共同说明了skipSelfskip_self的典型使用范式在resolveId内部做二次解析时几乎总是需要跳过自身否则要么无限递归要么别名/懒标记被重复应用。六、总结与建议要点结论默认值trueTS 与 Rust 两侧一致作用域仅在resolveId含动态导入解析钩子内部调用this.resolve时体现递归风险跳过条件完全相同的sourceimporter 发起插件三者同时匹配才跳过关闭方式this.resolve(src, imp, { skipSelf: false })关闭后责任自行防无限循环如对特定 id 直接返回结果、维护已处理集合、限制深度编写 Rolldown 插件时的实践建议在resolveId钩子中继续调用this.resolve完成重写后再解析时保持默认的skipSelf: true让 Rolldown 替你维护防递归边界仅当你确实需要让自己的resolveId再次参与处理例如想捕获自身钩子对该请求的返回/抛错才显式传skipSelf: false并参照 resolve2/_config.ts 在钩子开头加入短路守卫理解skipSelf是按 source importer 精确匹配的因此它不会影响你对其他模块的解析能力可以放心用于别名、代理模块、懒编译等需要二次解析的场景。参考路径速查文档原文plugin-context-resolve-skipself.mdTS API 定义与透传plugin-context.ts、plugin-context.tsRust 选项与默认值plugin_context_resolve_options.rs跳过记录构建native_plugin_context.rs精确匹配与钩子遍历build_hooks.rs行为测试resolve2、resolve3、resolve4、resolve-error-cause、resolve-browser-false内置插件实践rolldown_plugin_vite_alias/src/lib.rs、lazy_compilation_plugin.rs【免费下载链接】rolldownFast Rust bundler for JavaScript/TypeScript with Rollup-compatible API.项目地址: https://gitcode.com/GitHub_Trending/ro/rolldown创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进