Vitest 测 .tsx 报 React is not defined,构建却能过——esbuild classic runtime 惹的祸
加了一个新的字段渲染器,文件是标准的 .tsx,TypeScript 类型检查全绿,next build 一次过,提 PR 前跑单测——三个渲染相关的用例全报 ReferenceError: React is not defined,另外六个纯函数用例却完全正常。更奇怪的是,用同样方式写的其他 handler 文件一点问题没有。
现象
ReferenceError: React is not defined
❯ renderStringEditor src/engine/field-types/handlers/string.tsx:49:3
npm run build通过tsc --noEmit通过npm run test挂在含 JSX 返回值的函数上,纯逻辑函数不受影响- 错误堆栈指向
.tsx文件里第一处 JSX 语法(<Input ...>)
根因
这是 两个编译器对同一份 tsconfig 产生了不同行为。
项目的 tsconfig.json 里配置了:
{
"compilerOptions": {
"jsx": "preserve"
}
}jsx: "preserve" 是 Next.js 官方推荐值——它告诉 TypeScript 编译器「别动 JSX,原样保留,让下游工具处理」。
- Next.js 生产构建用 SWC 处理
.tsx,SWC 默认走 automatic runtime(React 17+ 引入的_jsx/_jsxstransform),不需要每个文件手动import React,所以代码写不写都能过 - Vitest 用 esbuild 转译
.tsx,esbuild 遇到jsx: "preserve"时不知道下游是 automatic 还是 classic,默认回退到 classic runtime,即把所有 JSX 编译成React.createElement(...),但React根本没有在当前模块作用域里——爆了
两个工具链,两种 JSX 运行时,同一份 tsconfig,行为正好相反。
Next.js SWC 走 automatic runtime,不需要显式 import React。
Vitest esbuild 遇到 jsx: "preserve" 时走 classic runtime,必须 React 在作用域里。
二者同时用时,单测里含 JSX 的函数会在运行时悄悄爆炸,而构建环境完全察觉不到。
正解
方案 A(推荐):在该文件顶部加一行 import
'use client'
import * as React from 'react' // ← 加这一行
import { Lock } from 'lucide-react'
// ...其他 import- 侵入最小,只改有问题的文件
- 与项目里其他 UI 组件文件(如 shadcn 的
input.tsx)保持一致 import * as React在 automatic runtime 构建时会被 tree-shake 掉,不影响 bundle 体积
方案 B:修改 vitest.config.ts 统一走 automatic runtime
// vitest.config.ts
export default defineConfig({
esbuild: {
jsx: 'automatic',
jsxImportSource: 'react',
},
// ...
})好处是所有 .tsx 文件都不用改,一劳永逸。坏处是和 tsconfig.json 里的 jsx: "preserve" 显式矛盾,新接手项目的人看到两个文件对 JSX 的描述不一致会困惑。
结论:优先方案 A。只有当项目里有大量新增 .tsx 都需要加 React 引入时才考虑方案 B。
在需要被 Vitest 测试的 .tsx 文件顶部加 import * as React from 'react',esbuild classic runtime 就能找到 React.createElement,测试通过;SWC/Webpack 构建时该 import 无副作用。
快速验证
写完新的 .tsx handler 后,先跑一次单文件测试确认:
npm run test -- path/to/new-handler.test.ts看到 ReferenceError: React is not defined → 加一行 import 即可,不需要其他任何改动。
一句话外卖
Next.js + Vitest 混用时,
tsconfig jsx: "preserve"让 SWC 走 automatic、让 esbuild 走 classic,同一份.tsx文件「构建过、测试挂」是两套运行时不对齐的必然结果——加一行import * as React是最小无副作用修复。