从 Vue CLI 迁移到 Rsbuild 后,JSX 突然全挂了?一次排查记录

项目:(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.vuemodelDir.vue 也是同样的模式:在 el-treerender-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-loader to 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-jsxinjectH: true)。

它不会自动禁用 SWC,也不会自动让所有 .vue script 走 Babel。

4.2 pluginBabel() 不写 include 时的问题

根据 Rsbuild Babel 插件文档

  • 不配置 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.vuemodelDir.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_modulesexclude 掉,第三方包仍走 Rsbuild 默认的 SWC 快速通道,性能影响可控。

5.3 验证

重启 pnpm dev 后:

  1. 进入仓库列表页 --- 不再报 swc-loader 语法错误
  2. selectDirectory.vuecomponentDir.vuemodelDir.vue 均可正常编译
  3. 无需给 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.includesrc 下的业务代码送进 Babel 专属通道。

改动只有几行配置,却让整个仓库模块从「一进去就炸」恢复到和 Vue CLI 时代一致的行为。对存量 Vue 2 项目做构建工具迁移,先对齐编译语义,再谈性能优化 ------往往比改十几个 .vue 文件划算得多。


文档对应修复提交:rsbuild.config.jspluginBabel 增加 include: /[\\/]src[\\/]/

相关推荐
古夕1 小时前
多应用同域部署下 Favicon 异常问题排查与治理
前端·javascript
YIAN1 小时前
从零手写文件读取 MCP 服务:一文吃透 Model Context Protocol 全链路通信原理
前端·后端·mcp
Imchendiana1 小时前
《狂人日记NO.9》— 前后端一把梭,我的全栈实录
前端·后端
逍遥运德1 小时前
前端工程化-03:包管理NPM-package.json 和 package-lock.json 详解
前端·前端框架·前端工程化
逗逗豆巴吧1 小时前
WPS 加载项中实现当前文档直接上传
前端·javascript·ecmascript 6
慢功夫1 小时前
💡【调试】按下 F5 后,mini-vscode 到底做了什么
前端·visual studio code
妙码生花1 小时前
从 PHP 到 AI + Golang,程序员自救转型手记(二十一):网络请求封装优化
前端·javascript·vue.js
用户11481867894841 小时前
普通Fetch、Fetch流式、Axios、SSE:谁才是流式输出的正确操作?
前端·javascript
奇奇怪怪的1 小时前
多模态 RAG:图片与表格检索
前端