Rspack 源码解析(二十九):CopyRspackPlugin 如何用 glob 把静态文件复制进产物

本篇是 Rspack 源码解析系列第二十九篇。前面讲的插件要么构建模块、要么生成 chunk、要么注入 HTML。本文进入 rspack_plugin_copy,看一个「纯粹的文件复制」插件------它不碰模块图,只在 process_assets 阶段把文件从 from 复制到 to。

前言:不是所有资源都需要「编译」

有些文件不需要打包,只要原样搬过去:

js 复制代码
new CopyRspackPlugin({
  patterns: [
    { from: 'public', to: 'public' },
    { from: 'src/assets', to: 'assets', globOptions: { ignore: ['**/*.js'] } },
  ],
});

CopyRspackPlugin 就是做这件事:glob 匹配 → 读文件 → 计算 contenthash → 确定目标路径 → emit。

源码入口:process_assets

crates/rspack_plugin_copy/src/lib.rs 的 process_assets 是整个插件的入口:

rust 复制代码
#[plugin_hook(CompilationProcessAssets for CopyRspackPlugin, stage = Compilation::PROCESS_ASSETS_STAGE_ADDITIONAL)]
async fn process_assets(&self, compilation: &mut Compilation) -> Result<()> {
  let logger = compilation.get_logger("rspack.CopyRspackPlugin");
  let file_dependencies = DashSet::default();
  let context_dependencies = DashSet::default();
  let diagnostics = Arc::new(Mutex::new(Vec::new()));

  let mut copied_result: Vec<(i32, RunPatternResult)> =
    join_all(self.patterns.iter().enumerate().map(|(index, pattern)| {
      CopyRspackPlugin::run_patter(
        compilation, pattern, index,
        &file_dependencies, &context_dependencies,
        diagnostics.clone(), &logger,
      )
    })).await.into_iter().collect::<Result<Vec<_>>>()?
    .into_iter().flatten().flat_map(|item| {
      item.into_iter().flatten().map(|item| (item.priority, item)).collect::<Vec<_>>()
    }).collect();
  // ...
}

几个设计点:

  1. join_all 并行处理多个 pattern:不同 pattern 之间互不依赖;
  2. DashSet 收集依赖:多线程安全地记录 file/context dependencies;
  3. priority 字段:最后按 priority 排序,决定文件冲突时谁覆盖谁。

run_patter:确定 from 类型

run_patter 先判断 from 是目录、文件还是 glob:

rust 复制代码
let from_type = if let Ok(meta) = compilation.input_filesystem.metadata(&abs_from).await {
  if meta.is_directory {
    FromType::Dir
  } else if meta.is_file {
    FromType::File
  } else {
    FromType::Glob
  }
} else {
  FromType::Glob
};

三种 FromType 对应不同的 glob 构造:

rust 复制代码
let glob_query = match from_type {
  FromType::Dir => {
    context_dependencies.insert(abs_from.clone().into_std_path_buf());
    context = abs_from.as_path().into();
    // dir -> dir/**/*
    let mut escaped = Utf8PathBuf::from(GlobPattern::escape(abs_from.as_str()));
    escaped.push("**/*");
    escaped.as_str().to_string()
  }
  FromType::File => {
    file_dependencies.insert(...);
    context = abs_from.parent().unwrap_or(...).into();
    GlobPattern::escape(abs_from.as_str())  // 精确匹配单个文件
  }
  FromType::Glob => {
    need_add_context_to_dependency = true;
    let glob_query = if Path::new(orig_from).is_absolute() { orig_from.into() }
      else { context.join(orig_from).as_str().to_string() };
    // 处理 /** 结尾
    if glob_query.ends_with("/**") {
      format!("{glob_query}/*")
    } else { glob_query }
  }
};

GlobPattern::escape 把路径里的特殊字符转义,避免被当成 glob 通配符。注释解释了一个边界:

text 复制代码
// A glob pattern ending with /** should match all files within a directory,
// not just the directory itself.
// Since the standard glob only matches directories, we append /* to align with webpack's behavior.

glob 匹配与 ignore

用 glob crate 的 glob_with 执行匹配:

rust 复制代码
let glob_entries = glob::glob_with(
  &glob_query,
  MatchOptions {
    case_sensitive: pattern.glob_options.case_sensitive_match.unwrap_or(true),
    require_literal_separator: Default::default(),
    require_literal_leading_dot: !dot_enable.unwrap_or(false),
  },
);

require_literal_leading_dot 控制是否匹配点开头的文件(隐藏文件)。目录匹配时默认开 dot:

rust 复制代码
FromType::Dir => {
  // ...
  if dot_enable.is_none() {
    dot_enable = Some(true);  // 目录默认包含隐藏文件
  }
}

analyze_every_entry:逐个处理

匹配到的每个 entry 交给 analyze_every_entry:

rust 复制代码
async fn analyze_every_entry(
  entry: Utf8PathBuf,
  pattern: &CopyPattern,
  context: &Utf8Path,
  output_path: &Utf8Path,
  from_type: FromType,
  // ...
) -> Result<Option<RunPatternResult>> {
  // 跳过目录
  if entry.is_dir() { return Ok(None); }
  // ignore 过滤
  if let Some(ignore) = &pattern.glob_options.ignore
    && ignore.iter().any(|ignore| ignore.matches(entry.as_str()))
  {
    return Ok(None);
  }
  // ...
}

然后确定目标路径。to_type 的自动推断是关键:

rust 复制代码
let to_type = if let Some(to_type) = pattern.to_type.as_ref() {
  to_type.clone()
} else if TEMPLATE_RE.is_match(&to) {
  ToType::Template          // 含 [name]/[contenthash] 等占位符
} else if Path::new(&to).extension().is_none() || to.ends_with(MAIN_SEPARATOR) {
  ToType::Dir               // 无扩展名或 / 结尾 -> 目录
} else {
  ToType::File              // 有扩展名 -> 文件
};

TEMPLATE_RE 是:

rust 复制代码
static TEMPLATE_RE: LazyLock<Regex> =
  LazyLock::new(|| Regex::new(r"\[\\*([\w:]+)\\*\]").expect("This never fail"));

匹配 [name]、[contenthash]、[ext] 这类占位符。

Dir 类型的相对路径拼接

ToType::Dir 时,目标路径 = to + 相对 context 的路径:

rust 复制代码
let relative = pathdiff::diff_utf8_paths(&absolute_filename, context);
let filename = if matches!(to_type, ToType::Dir) {
  if let Some(relative) = &relative {
    Utf8PathBuf::from(&to).join(relative)
  } else { to.into() }
} else { to.into() };

这样 { from: 'public', to: 'dist' } 会把 public/logo.png 复制到 dist/logo.png,保持目录结构。

绝对路径归一化

如果算出的 filename 是绝对路径,要转成相对 output_path:

rust 复制代码
let filename = if filename.is_absolute() {
  if let Some(filename) = pathdiff::diff_utf8_paths(filename, output_path) {
    filename
  } else { return Ok(None); }
} else { filename };

transform 钩子

CopyPattern 支持 transform_fn:

rust 复制代码
pub type TransformerFn =
  Box<dyn for<'a> Fn(Vec<u8>, &'a str) -> BoxFuture<'a, Result<BoxSource>> + Sync + Send>;

读文件后可以转换内容:

rust 复制代码
if let Some(transformer) = &pattern.transform_fn {
  handle_transform(transformer, source_vec, absolute_filename.clone(), &mut source, diagnostics).await
}

handle_transform 失败时记录诊断,不中断整个构建。

Template 类型的 contenthash

ToType::Template 时支持 [contenthash]:

rust 复制代码
let content_hash = Self::get_content_hash(
  &source,
  &compilation.options.output.hash_function,
  &compilation.options.output.hash_digest,
  &compilation.options.output.hash_salt,
);
let content_hash = content_hash.rendered(compilation.options.output.hash_digest_length);
let template_str = compilation.get_asset_path(
  &Filename::from(filename.to_string()),
  PathData::default().filename(source_filename.as_str()).content_hash(content_hash)
    .hash_optional(compilation.get_hash()),
).await?;

contenthash 是文件字节的 hash,和第 24 篇 asset 的 contenthash 同一套逻辑。

emit_asset 与 force 覆盖

最后 emit。如果目标已存在且 force 为 false,跳过;否则覆盖:

rust 复制代码
if let Some(exist_asset) = compilation.assets_mut().get_mut(&result.filename) {
  if !result.force {
    return;  // 不覆盖
  }
  exist_asset.set_source(Some(Arc::new(result.source)));
  // ...
} else {
  let mut asset_info = AssetInfo {
    source_filename: Some(result.source_filename.to_string()),
    copied: Some(true),
    ..Default::default()
  };
  compilation.emit_asset(result.filename, CompilationAsset::new(...));
}

source_filename 和 copied 两个 AssetInfo 字段记录了「这个资产是复制来的」。

copy_permissions:复制文件权限

还有一个少见的特性 copy_permissions:

rust 复制代码
for (pattern_index, source_path, dest_path) in permission_copies.iter() {
  if let Some(pattern) = self.patterns.get(*pattern_index)
    && pattern.copy_permissions.unwrap_or(false)
    && let Ok(Some(permissions)) = compilation.input_filesystem.permissions(source_path).await
  {
    compilation.output_filesystem.create_dir_all(parent).await...;
    compilation.output_filesystem.set_permissions(dest_path, permissions).await...;
  }
}

把源文件的 Unix 权限(如可执行位)复制到目标文件。

依赖追踪:watch 能感知变化

复制的文件会被加入 file_dependencies 和 context_dependencies:

rust 复制代码
compilation.file_dependencies.extend(file_dependencies.into_iter().map(Into::into));
compilation.context_dependencies.extend(context_dependencies.into_iter().map(Into::into));

这让 watch 模式能感知「被复制的文件变了」和「glob 匹配目录变了」,触发重新构建。这正是增量构建(第 11 篇)的依赖追踪基础。

Rust 角度:并行 + 排序 + 确定性

CopyRspackPlugin 的三个工程细节值得学习:

  1. join_all 并行 pattern,rayon 之外用 futures 并发;
  2. priority 排序解决文件冲突的确定性------同样的 pattern 集合,结果顺序稳定;
  3. DashSet + Mutex<Vec<Diagnostic>> 安全地并发收集副作用。

它没有引入共享可变状态的锁竞争,而是把「读文件」和「写 assets」两阶段清晰分开。

这一篇应该带走什么

  1. CopyRspackPlugin 只在 process_assets 阶段工作,不碰模块图;
  2. from 分 Dir/File/Glob 三种,to 分 Dir/File/Template 三种;
  3. GlobPattern::escape 转义路径,/** 结尾补 /* 对齐 webpack;
  4. contenthash 是文件字节 hash,force 控制覆盖,priority 保证确定性;
  5. 复制的文件进 file/context dependencies,watch 可感知。

写在最后

CopyRspackPlugin 是「纯文件操作」的代表。接下来进入「调试体验」的领域------SourceMap。source map 不是模块、不是 chunk,而是「为每个产物生成一份映射」,并在产物末尾追加 sourceMappingURL 注释。下一篇进入 rspack_plugin_devtool,看 source map 如何生成与关联。

相关推荐
铁皮饭盒1 小时前
刷到 FDE 岗位那一刻,我还以为前端又活了
前端·后端
酷炫码神1 小时前
Day 9|Vue Router:把单页后台拆成能跳转的多页面
前端·javascript·vue.js
肖永威1 小时前
python版微电网仿真工具的前端托管选型:从http.server到FastAPI StaticFiles
前端·python·http·fastapi
风骏时光牛马1 小时前
AI插件开发与效能提效工具箱
前端
IT_陈寒1 小时前
为什么你应该学习Java?
前端·人工智能·后端
weixin_417396832 小时前
监控画面卡顿与录像缺失:码率、NVR与存储容量的排查顺序
前端·网络·技术·酒店·综合布线·弱电
曹牧2 小时前
Eclipse 工作空间(Workspace)的默认 JDK 配置
前端
其实防守也摸鱼3 小时前
Cylab在线靶场——挑战图书馆初学者指南
服务器·前端·数据库·chrome·安全·网络安全·靶场
拆房老料5 小时前
【第四部分:Agentic 智能体设计模式】16. 一个 Prompt 搞不定复杂任务?试试 Prompt Chaining
java·前端·人工智能·设计模式·prompt