Rspack 源码解析(二十二):ExternalsPlugin 如何在 Factorize 阶段换掉整个模块

本篇是 Rspack 源码解析系列第二十二篇。第十四篇讲了 NormalModuleFactory 的 factorize 链,但那时只关注「从依赖变成普通模块」。本文进入 rspack_plugin_externals,看 externals 如何利用同一个 factorize Hook,直接产出一个完全不同的 ExternalModule,从而让 react、lodash 这类依赖不被打包。

前言:externals 是「在模块创建前拦截」

很多人以为 externals 是「打包时排除某些依赖」。更准确地说,它是在 NormalModuleFactory 把依赖变成一个真实模块之前,抢先返回一个「外部模块」。

text 复制代码
遇到 import React from 'react'
  -> factorize Hook 先被 ExternalsPlugin 拦截
  -> 返回 ExternalModule(不读取 node_modules 源码)
  -> 普通模块创建流程不再执行

ExternalModule 的 code generation 会生成「从全局变量/require 取模块」的代码,而不是「把源码包进来」。

源码入口:一个插件只 tap 一个 Hook

crates/rspack_plugin_externals/src/plugin.rs 的 apply 非常短:

rust 复制代码
impl Plugin for ExternalsPlugin {
  fn name(&self) -> &'static str {
    "rspack.ExternalsPlugin"
  }

  fn apply(&self, ctx: &mut rspack_core::ApplyContext<'_>) -> Result<()> {
    ctx
      .normal_module_factory_hooks
      .factorize
      .tap(factorize::new(self));
    Ok(())
  }
}

整个插件只做一件事:在 normal_module_factory_hooks.factorize 上 tap。这个 Hook 的返回值是 Option<BoxModule>------返回 Some 就代表「这个依赖已经处理完了,别走后面的模块创建」。

ExternalsPlugin 的结构

rust 复制代码
#[plugin]
#[derive(Debug)]
pub struct ExternalsPlugin {
  externals: Vec<ExternalItem>,
  r#type: ExternalType,
  place_in_initial: bool,
}

三个字段:

  • externals:用户配置的 external 规则列表;
  • type:默认 external 类型(如 commonjs、var、module);
  • place_in_initial:是否只在 initial chunk 生效。

factorize:遍历规则并命中

factorize Hook 的核心逻辑是一个 for external_item in &self.externals 循环:

rust 复制代码
#[plugin_hook(NormalModuleFactoryFactorize for ExternalsPlugin, tracing=false)]
async fn factorize(&self, data: &mut ModuleFactoryCreateData) -> Result<Option<BoxModule>> {
  let dependency = data.dependencies[0]
    .as_module_dependency()
    .expect("should be module dependency");
  let context = &data.context;
  for external_item in &self.externals {
    match external_item {
      ExternalItem::Object(eh) => { /* 精确 key 匹配 */ }
      ExternalItem::RegExp(r) => { /* 正则匹配 request */ }
      ExternalItem::String(s) => { /* 字符串相等匹配 */ }
      ExternalItem::Fn(f) => { /* 异步函数,返回是否 external */ }
    }
  }
  Ok(None)
}

四种 ExternalItem 分别对应四种配置写法:

js 复制代码
externals: {
  react: 'react',              // Object
  /^lodash$/: 'lodash',        // RegExp
  jquery: 'jQuery',            // String
}
// 或
externals: ({ context, request }, callback) => { ... }  // Fn

命中后调用 handle_external 生成 ExternalModule,然后 return Ok(maybe_module.map(|i| i.boxed()))。

注意:函数类型的 externals 是异步的

ExternalItem::Fn 分支里调用了 .await:

rust 复制代码
ExternalItem::Fn(f) => {
  let request = dependency.request();
  let result = f(ExternalItemFnCtx {
    context: context.to_string(),
    request: request.to_string(),
    dependency_type: dependency.category().to_string(),
    context_info: ContextInfo {
      issuer: data.issuer.clone().map(|i| i.to_string()).unwrap_or_default(),
      issuer_layer: data.issuer_layer.clone(),
    },
    resolve_options_with_dependency_type: ResolveOptionsWithDependencyType {
      resolve_options: data.resolve_options.clone().map(|r| Box::new(Arc::unwrap_or_clone(r))),
      resolve_to_context: false,
      dependency_category: *data.dependencies.first().expect("Expected at least one dependency").category(),
    },
    resolver_factory: data.resolver_factory.clone(),
  })
  .await?;
  // ...
}

ExternalItemFnCtx 提供了 context、request、issuer、resolver_factory 等信息,JS 侧的 externals 函数可以据此做任意复杂判断。

handle_external:四种配置值形态

handle_external 处理 ExternalItemValue,它也是四种:

rust 复制代码
pub enum ExternalItemValue {
  String(String),
  Array(Vec<String>),
  Bool(bool),
  Object(HashMap<String, Vec<String>>),
}

对应配置:

js 复制代码
externals: {
  a: 'commonjs react',       // String(带类型前缀)
  b: ['var', 'lodash'],      // Array
  c: true,                   // Bool
  d: {                      // Object
    commonjs: 'react',
  },
}

注意 parse_external_type_from_str:

rust 复制代码
fn parse_external_type_from_str(v: &str) -> Option<(ExternalType, String)> {
  if UNSPECIFIED_EXTERNAL_TYPE_REGEXP.is_match(v)
    && let Some((t, c)) = v.split_once(' ')
  {
    return Some((t.to_owned(), c.to_owned()));
  }
  None
}

它用正则 ^[a-z0-9-]+ 判断字符串是否带类型前缀。'commonjs react' 会被解析成 type=commonjs、request=react;'react' 则用插件默认 type。

ExternalModule 的构造

handle_external 最终调用:

rust 复制代码
Some(ExternalModule::new(
  external_module_config,
  r#type.unwrap_or(external_module_type),
  dependency.request().to_owned(),
  dependency_meta,
  self.place_in_initial,
))

external_module_config 是 ExternalRequest,区分 Single(单请求)和 Map(多请求映射)。dependency_meta 记录了 external_type、attributes 等信息,其中 external_type 会根据依赖类型判断:

rust 复制代码
let mut dependency_meta: DependencyMeta = DependencyMeta {
  attributes: dependency.get_attributes().cloned(),
  external_type: {
    if dependency.as_any().downcast_ref::<ImportDependency>().is_some() {
      Some(ExternalTypeEnum::Import)
    } else if dependency.as_any().downcast_ref::<ESMImportSideEffectDependency>().is_some() {
      Some(ExternalTypeEnum::Module)
    } else {
      None
    }
  },
  source_type: None,
};

import 语句的外部模块类型是 Import,带副作用的 ESM import 是 Module------这影响后续 code generation 怎么生成导入代码。

三个 target 插件

lib.rs 还导出了三个 target 相关插件:

rust 复制代码
pub use electron_target_plugin::{ElectronTargetContext, electron_target_plugin};
pub use http_externals_plugin::http_externals_rspack_plugin;
pub use node_target_plugin::node_target_plugin;
  • node_target_plugin:target 为 node 时,自动把内置模块(fs、path 等)当 externals;
  • electron_target_plugin:electron 的 renderer/main 环境处理;
  • http_externals_plugin:把 HTTP(S) 请求当 externals。

它们本质上都是「在 factorize 阶段按规则返回 ExternalModule」的具体策略。

一个具体例子

js 复制代码
// rspack.config.js
module.exports = {
  externals: {
    react: 'React',
    lodash: 'commonjs lodash',
  },
};
js 复制代码
import React from 'react';
import _ from 'lodash';

factorize 阶段:

text 复制代码
request "react"
  -> 命中 Object,value = "React"
  -> 默认 type,生成 ExternalModule("React")

request "lodash"
  -> 命中 Object,value = "commonjs lodash"
  -> 解析出 type=commonjs, request=lodash
  -> 生成 ExternalModule("lodash", type=commonjs)

最终生成的代码大致是:

js 复制代码
// react(默认 var 类型)
var react = React;

// lodash(commonjs 类型)
var lodash = require("lodash");

为什么 externals 这么「轻」

对比 NormalModule 的创建:解析路径、读文件、跑 loader、parse、分析依赖------整个流程非常重。而 externals 只需要:

text 复制代码
匹配 request
  -> 构造 ExternalModule
  -> 返回

它完全不读磁盘、不 parse、不分析依赖。所以 externals 是「构建期排除依赖」最便宜的方案,也是 library 开发(把 react 交给宿主应用)的标准做法。

与 Module Federation 的呼应

第 30 篇会看到 RemoteModule。其实 externals 和 remote 是同一个思路的两种形态:用一个「假模块」代替「真实模块」,把加载逻辑推迟到运行时。

区别在于:

text 复制代码
externals:运行时从全局变量或 require 取
remote:运行时从远程容器取(异步加载)

两者都在 factorize/build 阶段拦截,返回非 NormalModule 的 Module 实现。

Rust 角度:拦截点就是扩展点

ExternalsPlugin 演示了 Rspack 插件体系的核心价值:Hook 不只是「通知」,而是「可以改变控制流」。

factorize 的返回值 Option<BoxModule> 就是控制流开关:

text 复制代码
Some(module) -> 跳过后续模块创建
None -> 继续走 NormalModuleFactory 的默认流程

这比「先创建模块,再在事后删除」要高效得多,也说明 Rspack 把「模块创建」设计成一个可插拔的决策点。

这一篇应该带走什么

  1. externals 是在 factorize 阶段拦截,返回 ExternalModule,跳过真实模块创建;
  2. ExternalsPlugin 只 tap normal_module_factory_hooks.factorize;
  3. ExternalItem 有 Object/RegExp/String/Fn 四种规则,ExternalItemValue 有 String/Array/Bool/Object 四种形态;
  4. 字符串可带类型前缀(commonjs react),由正则解析;
  5. externals 是「构建期排除依赖」最便宜的方案,与 remote 共享同一思路。

写在最后

externals 处理的是「不打包的模块」。但还有一种更基础、更常见的资源------JSON 文件。它如何被 parse、如何参与 tree shaking(只保留用到的字段)、如何 code generate?下一篇进入 rspack_plugin_json,看 JSON 模块的完整生命周期。

相关推荐
青烟小生1 小时前
前端性能优化实战:从指标监控到极致体验
前端
可乐ea1 小时前
2000 个智能体把 Agent 重写成 Rust:编排流水线拆解
开发语言·后端·rust·多智能体协作·ai编程助手·智能体编排·rust重写
deli0071 小时前
0.1 加 0.2 为什么不等于 0.3?IEEE 754 双精度逐位实测
前端
Momo__1 小时前
前端 Code Review 实战:识别 AI 写代码的 15 个常见坑
前端·vibecoding
拖孩2 小时前
全程 AI 写的小程序,刚接上会员虚拟支付,上线第二天就有人真金白银买单了|双签名、定价、iOS 的坑我全踩了一遍
前端·后端·微信小程序
小蚂蚁i2 小时前
React 基础
前端
ZoeLandia2 小时前
TypeScript 02:interface、type 与类型收窄
前端·typescript
Bode_20022 小时前
构建制造业知识库
前端·智能制造系统·业务流程再造
cindershade2 小时前
接口协作的交接面:让并行开发从约定走到发布
前端
JAVA老架构AI智能化2 小时前
多图叠加比对
前端·javascript·chrome