项目:(Vue 2.7 + Element UI)
背景:构建工具从 Vue CLI / Webpack 迁移到 Rsbuild / Rspack
现象:开发服务器能启动,但进入「仓库」等页面时构建失败
结论:不是业务代码写错了,是 Babel 与 SWC 的编译分工 没配对
一、事情是怎么开始的
迁移完成后,我们在本地执行 pnpm dev,Rsbuild 在 9827 端口正常拉起。首页、布局页大致能打开,但一旦点击进入 仓库(repo) 模块,终端立刻刷红:
vbnet
error Build error:
File: ./src/components/repo/dialog/selectDirectory.vue?vue&type=script&lang=jsx:1:1
× Module build failed (from builtin:swc-loader):
├─▶ × x Expected '>', got 'class'
│ │ 50 | return <div class={{
随后又出现两个同类错误:
| 文件 | script 标签 | 报错位置 |
|---|---|---|
selectDirectory.vue |
lang="jsx" |
return <div class={{ |
componentDir.vue |
普通 <script> |
return <div style={{ |
modelDir.vue |
普通 <script> |
return <div title={node.label} |
第一反应很自然:是不是 JSX 写错了? 但团队里有个关键事实------这些文件在 Vue CLI 时代 跑了很久都没问题 。所以更合理的怀疑是:迁移后的构建配置缺了某块拼图。
二、先排除「代码问题」
2.1 报错代码长什么样
以 selectDirectory.vue 为例,nodeRender 里用了 Vue 2 JSX 写法:
jsx
return (
<div
class={{
'el-tree-node__label': true,
disable: this.$secretLevelModule && /* ... */,
}}
>
<projectSvg data={data} treeNode={node}></projectSvg>
<span class="tree-label">{node.label}</span>
</div>
);
componentDir.vue、modelDir.vue 也是同样的模式:在 el-tree 的 render-content 回调里 return (<div>...</div>)。
2.2 这类写法在项目里有多少
全局搜了一遍,至少 10 个 .vue 文件在 renderContent / nodeRender 里写了 JSX,其中只有 1 个标了 lang="jsx",其余都是普通 <script>。
这说明:不是三个文件的偶发问题,而是整条 JSX 编译链路没通。
三、对比 Vue CLI 时代:为什么以前没事?
3.1 Vue CLI 的 Babel 配置
项目里还留着当年的 babel.config.js:
js
module.exports = {
presets: ['@vue/cli-plugin-babel/preset'],
};
@vue/cli-plugin-babel/preset 内部集成了 @vue/babel-preset-jsx。在 Webpack 链路里,几乎所有业务 JS 都走 babel-loader,包括:
- 独立的
.js/.jsx文件 .vue单文件组件里抽出来的<script>块(不管有没有写lang="jsx")
所以 renderContent 里随手写 JSX,Babel 会在 SWC 之前就把它转成 h(...) 调用,构建器根本见不到 <div 这种「非法 JS 语法」。
3.2 Rsbuild 的默认行为(迁移后)
Rsbuild 为了性能,默认用 builtin:swc-loader 编译 JavaScript 。而 SWC 对 JSX 的支持对象是 React JSX,不是 Vue JSX。
官方文档和 Rspack issue 里都写得很直白:
SWC only supports compiling React JSX, so you cannot use
swc-loaderto compile Vue JSX syntax.
迁移后的 rsbuild.config.js 虽然已经注册了:
js
plugins: [
pluginBabel(), // ← 当时没有 include
pluginVue2(),
pluginVue2Jsx(), // ← 注入了 @vue/babel-preset-jsx
],
看起来「该有的插件都有」,但 插件注册了 ≠ 编译链路走对了。
四、根因:插件有了,但 SWC 抢跑
可以把问题拆成三层:
makefile
┌─────────────────────────────────────────────────────────┐
│ Vue CLI(Webpack) │
│ .vue script → babel-loader(含 vue jsx preset)→ 完成 │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ Rsbuild(迁移后,未配 include) │
│ .vue script → swc-loader 先解析 → 💥 语法错误 │
│ (babel 根本没机会处理 Vue JSX) │
└─────────────────────────────────────────────────────────┘
4.1 pluginVue2Jsx 实际干了什么
读 @rsbuild/plugin-vue2-jsx 源码可知,它只做了一件事:通过 modifyBabelLoaderOptions 往 babel-loader 的 presets 里塞 @vue/babel-preset-jsx(injectH: true)。
它不会自动禁用 SWC,也不会自动让所有 .vue script 走 Babel。
4.2 pluginBabel() 不写 include 时的问题
- 不配置
include:Babel 和 SWC 叠在同一条默认 JS 规则上;对含 Vue JSX 的模块,SWC 仍可能先尝试解析纯文本,直接报Expected '>', got 'class'。 - 配置了
include:Rsbuild 会 单独建一条只用 babel-loader、不走 swc-loader 的规则 ,命中include的文件完全由 Babel 处理。
这就是「插件都装了还是报错」的原因:不是 preset 没注入,而是含 JSX 的模块没进 Babel 专属通道。
4.3 为什么 lang="jsx" 的文件也挂
selectDirectory.vue 已经写了 lang="jsx",照样被 swc-loader 报错。原因是 vue-loader 开了 experimentalInlineMatchResource(@rsbuild/plugin-vue2 默认开启),script 块会被抽成类似下面的虚拟资源:
ini
selectDirectory.vue.jsx?vue&type=script&lang=jsx
如果没有 include 把这类资源导向纯 Babel 规则,它仍会落到 SWC 那条链路上。
4.4 普通 <script> 里的 JSX 更惨
componentDir.vue、modelDir.vue 没有 lang="jsx"。Vue CLI 时代靠全局 Babel preset 兜底;Rsbuild 官方建议是给 script 加 lang="jsx",或者扩展 Babel 的 include 范围------否则 SWC 必定不认识里面的 <div>。
五、解决方案:方案 A(只改配置,不动业务代码)
我们评估了三种做法:
| 方案 | 做法 | 优点 | 缺点 |
|---|---|---|---|
| A | pluginBabel({ include: /src/ }) |
一处配置,行为最接近 Vue CLI | src 下 JS 全走 Babel,略慢 |
| B | 窄 include + 给 9 个文件补 lang="jsx" |
Babel 范围小 | 改多个文件,易漏 |
| C | 把 JSX 全改成 h() 渲染函数 |
不依赖 JSX 插件 | 改动面大,可读性下降 |
最终采用方案 A------用最小配置成本,恢复 Vue CLI 时代的编译语义。
5.1 改动内容
rsbuild.config.js 中,把:
js
pluginBabel(),
改为:
js
pluginBabel({
include: /[\\/]src[\\/]/,
exclude: /[\\/]node_modules[\\/]/,
}),
插件注册顺序保持不变:
js
plugins: [
pluginBabel({ include: /[\\/]src[\\/]/, exclude: /[\\/]node_modules[\\/]/ }),
pluginNodePolyfill(),
pluginSass({ /* ... */ }),
pluginVue2(),
pluginVue2Jsx(),
],
5.2 改完之后链路变成什么样
css
src 下的 .js / .vue.js / .vue.jsx 等
↓
命中 pluginBabel.include
↓
babel-loader(@vue/babel-preset-jsx 由 pluginVue2Jsx 注入)
↓
JSX → h('div', { class: {...} }, [...])
↓
构建成功 ✅
node_modules 被 exclude 掉,第三方包仍走 Rsbuild 默认的 SWC 快速通道,性能影响可控。
5.3 验证
重启 pnpm dev 后:
- 进入仓库列表页 --- 不再报
swc-loader语法错误 selectDirectory.vue、componentDir.vue、modelDir.vue均可正常编译- 无需给 9 个历史文件补
lang="jsx",无需改renderContent实现
六、踩坑备忘
6.1 别急着改业务代码
看到 Expected '>', got 'class' 很容易想去改 JSX 或改成 h()。如果 Vue CLI 时代能跑,优先查构建链,尤其是 Babel / SWC 分工。
6.2 pluginVue2Jsx 不是银弹
装了 @rsbuild/plugin-vue2-jsx 只解决「Babel 认不认 Vue JSX」;还要保证含 JSX 的文件 真的进了 babel-loader 且没先被 SWC 误伤。
6.3 code-inspector-plugin 是干扰项
排查时终端报错里出现过 data-insp-path="..." 被注入进 JSX 的痕迹。那是 code-inspector-plugin 在开发态改写了源码。如果配置修完后个别文件仍报错,可以临时关掉该插件对比------本次问题的根因不在它,但在 JSX 场景下它确实可能雪上加霜。
6.4 性能与正确性的权衡
方案 A 让 src 全量走 Babel,编译会比纯 SWC 慢一些。对一个 Vue 2 存量项目、迁移求稳的场景,这是合理取舍。如果后续要优化,可以再收窄 include(例如只匹配 .vue.js / .vue.jsx),并给遗漏文件补 lang="jsx"。
七、给同类迁移项目的检查清单
从 Vue CLI 迁到 Rsbuild + Vue 2,如果项目里用过 JSX,建议按下面过一遍:
- 已安装
@rsbuild/plugin-babel和@rsbuild/plugin-vue2-jsx -
pluginBabel配置了include,让含 JSX 的src文件走纯 Babel 通道 -
pluginVue2Jsx()放在pluginVue2()之后 - 确认
render-content/ 自定义render里的 JSX 能正常编译 - 开发态如有
code-inspector-plugin,留意是否污染 JSX 源码 - 重启 dev server 后再验(
rsbuild.config.js改动通常不会热更新)
八、总结
这次问题的本质一句话:
Vue CLI 用 Babel 统一消化了 Vue JSX;Rsbuild 默认用 SWC 编译 JS,而 SWC 不懂 Vue JSX。只装
pluginVue2Jsx不够,还必须用pluginBabel.include把src下的业务代码送进 Babel 专属通道。
改动只有几行配置,却让整个仓库模块从「一进去就炸」恢复到和 Vue CLI 时代一致的行为。对存量 Vue 2 项目做构建工具迁移,先对齐编译语义,再谈性能优化 ------往往比改十几个 .vue 文件划算得多。
文档对应修复提交:rsbuild.config.js 中 pluginBabel 增加 include: /[\\/]src[\\/]/。