Bundleless、CSS 与资源

Bundleless 的目标

Bundleless 模式不是简单复制源码文件。Rslib 仍然使用 Rspack 编译每个入口,让源码经过 loader、插件、语法转换、CSS 处理和资源处理。它的目标是:保留源码模块结构,同时让输出 import 指向构建后的文件。

这比复制文件复杂,但它解决了几个实际问题:

  • TypeScript、JSX、Vue、Svelte 等仍需要转换。
  • CSS Modules 需要生成 JS mapping。
  • 全局 CSS 需要抽取成 .css 文件。
  • 资源 import 需要保持可发布的相对路径。
  • 无扩展名或 TS 扩展名 import 需要改成 JS 输出扩展名。
  • dts 输出路径需要与 JS 输出路径对齐。

Bundleless 的 entry 模型

bundleless 中,如果用户没有配置 entry,Rslib 默认使用:

source: {
  entry: {
    index: 'src/**',
  },
}

composeEntryConfig 会用 tinyglobby 扫描文件,并过滤声明文件。每个源文件都会成为一个 Rspack entry。entry name 不是简单 basename,而是相对于 outBase 的路径。

例如:

src/index.ts
src/components/Button.tsx
src/styles/base.css

如果 outBase 是 src,entry name 大致是:

index
components/Button
__rslib_css__/styles/base

全局 CSS entry 会带 __rslib_css__ 前缀,用来标记后续要删除的虚拟 JS asset。

outBase 是 bundleless 的坐标系

outBase 的默认值是所有非声明输入文件的最长公共路径。它决定:

  • 输出目录结构。
  • entry name。
  • bundleless external 是否处理某个请求。
  • 源文件之间相对路径如何改写。
  • CSS loader 如何计算路径。
  • watch 模式如何监听上下文。

如果用户显式设置 outBase,Rslib 会按用户值解析为绝对路径。维护时要特别注意用户 outBase 可能不是 src,也可能是绝对路径。

Bundleless external 的本质

bundleless 输出依靠 external function 来“阻止打包并改写路径”。当某个源文件 import 另一个源文件时,Rslib 不希望把被 import 文件打进当前 entry,而是希望当前输出文件继续 import 另一个输出文件。

流程如下:

这个机制让 Rspack 对每个 entry 独立编译,但源文件之间仍以 import 关系保留。

Vue loader 虚拟请求的例外

Bundleless external 的默认策略是“源码文件之间的 import 不打进当前 entry,而是改写成输出路径”。但 Vue SFC loader 生成的内部请求不能按这个策略处理。rspack-vue-loader 会在编译 .vue 文件时生成虚拟 block request 和 helper request,例如:

rspack-vue-loader/dist/exportHelper.js
Button.vue?vue&type=script&setup=true&lang.ts
Button.vue?vue&type=template
Button.vue?vue&type=style

这些 request 不是用户源码里希望保留到库产物中的公共 import,而是 loader 为了把 SFC 拆成 script、template、style 并重新组合组件而产生的内部模块。如果 Rslib 的 bundleless external 逻辑把它们当普通源码依赖处理,就可能在输出中泄露 loader 内部路径,甚至生成消费者项目无法解析的 import。

因此 composeBundlelessExternalConfig 里有一个明确的 Vue 专项例外:

const isRspackVueLoaderRequest = (
  request?: string,
  suffix?: string,
): request is string =>
  typeof request === 'string' &&
  request.includes('rspack-vue-loader') &&
  (suffix ? request.includes(suffix) : true);

当 request 命中以下两类情况时,Rslib 会直接 callback() 放行,让 Rspack 正常 bundle 这些内部模块:

isRspackVueLoaderRequest(request, 'dist/exportHelper.js') ||
  isRspackVueLoaderRequest(request, '?vue&type=');

这条规则的含义是:

  • 用户源码之间的 import 继续按 bundleless 规则保留模块结构。
  • Vue loader 生成的 helper 和虚拟 block request 不保留到产物中。
  • 最终产物不应该暴露 rspack-vue-loader 的内部实现细节。

这是 core 里非常明确的 Vue-specific hack。它存在的原因不是 Vue 组件库本身特殊,而是 Vue SFC loader 的模块图形态会和 Rslib 的 bundleless external 重写机制相冲突。

Redirect 配置

redirect 控制 bundleless 中不同资源类型的路径和扩展名改写:

类型默认行为
redirect.js.path改写为相对输出路径
redirect.js.extension改写为 JS 输出扩展名
redirect.style.path改写 CSS 输出路径
redirect.style.extension改写 CSS 或 JS 扩展名
redirect.asset.path改写 asset 输出路径
redirect.asset.extension必要时改写扩展名
redirect.dts.path改写声明文件 import 路径
redirect.dts.extension默认不改写 dts 扩展名

维护 redirect 时要用产物验证。配置对象看起来简单,但每一项都会影响最终 import 字符串。

CSS 分两类

Rslib 在 bundleless 中区分两类 CSS:

CSS 类型产物
全局 CSS输出 .css,源码中的 import 指向 .css
CSS Modules输出 JS module,源码中的 import 指向 JS 扩展名

原因是 CSS Modules 的 import 需要得到 class name mapping,运行时看到的是 JS 对象;全局 CSS import 只需要让样式文件存在并被引用。

cssExternalHandler 是 CSS 请求重写的中心。它会:

  • 跳过 css-loader helper。
  • 对 CSS 文件内部的 asset import 放行,不把 asset external 掉。
  • 对 CSS Modules 改成 JS 输出扩展名。
  • 对全局 CSS 改成 .css

自定义 CSS extract loader

css/libCssExtractLoader.ts 是 bundleless CSS 的关键。普通应用构建里的 CSS extract 逻辑不完全适合库产物,因为库产物需要保留更精确的相对路径和 CSS Modules 语义。

Rslib 的 pluginLibCss 会在 Rsbuild bundler chain 中:

  • 找到 CSS、SASS、LESS、STYLUS 规则。
  • 把默认 mini css extract loader 替换成 libCssExtractLoader
  • 把默认 CSS extract plugin 替换成 LibCssExtractPlugin
  • 在 assets 阶段删除带 __rslib_css__ 标记的虚拟 JS asset。

维护 CSS 逻辑时,要同时检查普通 CSS、CSS Modules、预处理器、CSS 中 url、source map、banner/footer。

资源处理的目标

库构建里的资源处理和应用不同。应用构建通常可以把资源复制到 dist 并通过 publicPath 拼接 URL;库构建更需要保留相对 import,让消费者的 bundler 或运行时继续处理。

asset/assetConfig.ts 的核心目标:

  • 默认 dataUriLimit = 0,不内联资源。
  • 默认 assetPrefix = "auto"
  • 对 JS issuer 的资源设置 preserve import。
  • 对 CSS issuer 使用复制出来的 asset rule,避免 CSS url 受到 JS preserve 影响。
  • 修补 SVGR 相关 publicPath 行为。

JS issuer 和 CSS issuer

同一个 foo.svg,从 JS import 和从 CSS url 引入,语义不同:

import icon from './foo.svg';
.button {
  background: url('./foo.svg');
}

JS import 通常需要保留为模块引用,CSS url 则需要保持 CSS 资源路径语义。pluginLibAsset 会基于 issuer 区分规则,给 CSS issuer 创建单独 oneOf。

SVGR 的特殊性

SVGR 同时涉及 SVG 作为组件和 SVG 作为 url。Rslib 做了几件修补:

  • 如果用户使用 SVGR url-loader,替换 publicPath 为占位符。
  • 使用 LibSvgrPatchPlugin 在后续阶段移除不适合库产物的 runtime publicPath。
  • bundle 模式下,?url 也设置 importMode preserve。
  • bundleless 模式下,不支持 query import 时调整 issuer,让 SVG 走合适 loader。

SVGR 相关逻辑容易受 Rsbuild 和 loader 版本影响。改动时最好写 E2E 或 integration case 验证真实输出。

Watch 行为

bundleless entry 是 glob 展开的,普通文件变化可能需要重新扫描 entry。EntryChunkPlugin 会把 outBase 加入 Rspack compilation contextDependencies,帮助 watch 感知目录级变化。

这不是源码热更新意义上的 HMR,而是让 bundleless 多入口场景中新增文件、删除文件、改名等操作能被 watch 发现。

常见问题和排查

输出 import 仍然指向 .ts

检查:

  1. 当前是否 bundle: false
  2. autoExtension 是否关闭。
  3. redirect.js.extension 是否关闭。
  4. 请求是否被用户 externals 提前命中。
  5. 请求是否来自 outBase 外部。

CSS Modules import 指向了 .css

检查:

  1. CSS Modules 文件名是否符合 .module.*
  2. output.cssModules.auto 是否改变匹配规则。
  3. cssExternalHandler 是否拿到了正确的 redirectedPath。

全局 CSS 生成空 JS 文件

全局 CSS entry 会产生虚拟 JS asset,Rslib 应在 pluginLibCss 的 processAssets 阶段删除它。如果还存在,检查 asset 名是否包含 __rslib_css__ 标记,以及插件是否只在 bundleless 下启用。

资源路径变成绝对 publicPath

检查用户是否显式设置了 publicPath。Rslib 只有在 publicPath 是 auto 时才会尽量 preserve import;用户显式设置 publicPath 时,应尊重用户配置。

测试建议

Bundleless 改动至少应覆盖:

  • 单入口和多入口。
  • 嵌套目录 entry。
  • 无扩展名 import。
  • .ts.mjs.cjs 重写。
  • CSS Modules。
  • 全局 CSS。
  • CSS 中 asset url。
  • JS 中 asset import。
  • Vue 或其他 loader 生成的虚拟请求。
  • user externals 和 auto external 组合。