Bundleless external 深入说明

问题背景

Rslib 的 bundle: false 不是简单把源码复制到 dist。它仍然让每个入口经过 Rspack 编译,但希望保留源码模块结构。这样可以同时获得两类能力:

  • 让 TypeScript、JSX、Vue SFC、CSS、资源等继续经过 loader 和插件处理。
  • 不把所有模块打成一个 bundle,而是让输出文件之间继续用 import 或 require 连接。

这就需要一个关键机制:当源文件 A import 源文件 B 时,Rspack 默认会把 B 纳入 A 的依赖图;Rslib bundleless 则要阻止这个打包行为,并把 import 改写成“指向 B 的输出文件”的路径。

这个机制就是 composeBundlelessExternalConfig 里的 external function。

当前策略

Bundleless external 的核心策略是:

  1. 只在 bundle: false 下启用。
  2. 只处理有 issuer 的模块请求,避免 external entry 本身。
  3. 只处理来自 outBase 范围内的源码请求,避免改写 node_modules 内部依赖。
  4. 先让前面的 user external、auto external、target external 有机会命中。
  5. 对源码内部请求,使用 Rspack resolver 解析到绝对路径。
  6. 如果解析结果仍在 outBase 内,就改成相对于 issuer 的输出路径。
  7. 对 CSS 请求交给 cssExternalHandler
  8. 对 JS、asset、无扩展名、目录 import 分别应用 redirect 和 extension 规则。
  9. 对 Vue loader 内部请求放行,不做 external 重写。

换句话说,bundleless external 不是“全部 external”,而是“只把用户源码模块之间的引用 external 成正确的输出路径”。

关键源码

核心源码在 packages/core/src/config.ts:1367composeBundlelessExternalConfig

它在 composeLibRsbuildConfig 中的位置也很重要。externals 合并顺序是:

  1. externalsWarnConfig
  2. userExternalsConfig
  3. autoExternalConfig
  4. targetExternalsConfig
  5. bundlelessExternalConfig

这表示 bundleless external 是最后兜底。它依赖前面的 external 先处理掉 npm 包、Node builtins 和用户显式 external。

为什么必须最后执行

假设源码里有:

import React from 'react';
import { Button } from './Button';

Rslib 希望:

  • react 作为 peer dependency 或 dependency external。
  • ./Button 改写成 ./Button.js 或对应扩展名。

如果 bundleless external 太早执行,resolver 可能会尝试解析 react,如果项目 node_modules 中存在 react,就可能把它当成一个可解析文件处理。这样输出会指向本地 node_modules 或错误路径,发布到 npm 后消费者无法使用。

所以顺序必须是:先 external 包,再处理源码相对路径。

outBase 的作用

outBase 是 bundleless 的边界。Rslib 只希望改写源文件范围内的请求,不希望改写依赖内部请求。

例如:

src/index.ts
src/Button.tsx
node_modules/foo/index.js

src/index.ts import ./Button,这是 outBase 内部,应重写。

当某个被强制 bundle 的 node_modules/foo/index.js 自己 import ./utils,这不在 outBase 内,不应该被 Rslib external 成 ../../node_modules/foo/utils.js。依赖内部请求应该继续交给 Rspack 正常打包。

源码中通过 isPathInOutBase(context, outBase) 做这个保护。

路径改写规则

解析成功后,Rslib 会把绝对路径转成相对于 issuer 的路径:

path.relative(path.dirname(issuer), resolvedRequest);

然后确保相对路径有 ./../ 前缀。这个细节很重要,因为 Node 不会把 foo.js 当成相对路径,它会当成包名。

接着根据扩展名处理:

请求类型处理
JS/TS 源文件替换为输出 JS 扩展名
无扩展名请求根据 redirect.js.extension 补扩展名
目录请求必要时补 /index 再补扩展名
CSS 文件交给 CSS handler,可能变 .css 或 JS 扩展名
非 JS/CSS 资源根据 asset redirect 改写路径和扩展名

失败形态

如果 bundleless external 改错,常见坏产物如下。

1. 产物仍然 import .ts

export { Button } from './Button.ts';

Node ESM 不会按 TypeScript 源文件执行,消费者会失败。通常是 redirect.js.extensionautoExtension 或 JS extension 判断出问题。

2. 产物 import 没有相对前缀

import Button from 'Button.js';

这会被当成 npm 包 Button.js,不是同目录文件。通常是相对路径补 ./ 的逻辑坏了。

3. 产物泄露 loader 内部路径

import exportHelper from 'rspack-vue-loader/dist/exportHelper.js';
import script from './Button.vue?vue&type=script&setup=true';

这是 Vue SFC 场景。Rslib 的 Vue 特判就是为了避免这种情况。

4. 产物指向 node_modules 内部路径

import React from '../node_modules/react/index.js';

这是 external 顺序或 outBase 判断坏了。React 这类依赖应该保持 import React from "react" 或按用户 externals 输出。

5. CSS Modules 指向 .css

import styles from './Button.module.css';

如果它实际应该是 CSS Modules JS mapping,这会导致运行时拿不到 class map。通常是 cssExternalHandleroutput.cssModules.auto 判断出问题。

和 Vue loader 的关系

Vue SFC 是 bundleless external 最典型的例外。rspack-vue-loader 会把一个 .vue 文件拆成多个内部模块请求。这些请求看起来像 import,但不是用户希望保留的公共模块边界。

Rslib 对这些请求直接 callback(),让它们继续 bundle 进当前输出模块。这和普通源码 import 的处理正好相反。

这条例外说明了一个原则:bundleless 的目标是保留“用户源码结构”,不是保留“loader 内部模块结构”。

和 CSS 的关系

CSS 也不是普通 JS import。比如:

import './Button.css';
import styles from './Button.module.css';

第一个应输出 CSS 文件引用,第二个应输出 JS mapping。Rslib 把 CSS 请求交给 cssExternalHandler,由它根据 CSS Modules 规则判断。

这也是为什么 bundleless external 需要知道 cssModulesAutojsExtension

和 asset 的关系

资源请求更复杂,因为它既可能来自 JS,也可能来自 CSS。

import logo from './logo.svg';

和:

background: url('./logo.svg');

不能走同一套规则。JS 里的资源 import 可能需要 JS wrapper 或 preserve import,CSS 里的资源 url 应保持 CSS 资源语义。assetConfig.ts 会为 JS issuer 和 CSS issuer 拆不同 asset rule。

测试覆盖

相关测试主要在:

  • tests/integration/bundle-false
  • tests/integration/redirect
  • tests/integration/vue
  • tests/integration/asset
  • tests/integration/style/css-modules
  • tests/integration/auto-external
  • tests/integration/externals

如果改 composeBundlelessExternalConfig,不要只跑一个 bundleless case。至少要覆盖:

  • 相对 JS import。
  • 无扩展名 import。
  • 目录 import。
  • npm package external。
  • 用户 external。
  • CSS Modules。
  • 全局 CSS。
  • asset import。
  • Vue SFC。

修改风险

这段逻辑风险高,因为它处在多个系统交界处:

  • Rspack resolver。
  • output filename extension。
  • autoExternal。
  • CSS extract。
  • asset preserve。
  • Vue loader。
  • dts redirect。
  • watch context。

改动时最好先写一个失败用例,再改实现。否则很容易修好一个路径,破坏另一个路径。