shadcn/ui Dialog 里 buttonVariants 循环依赖炸运行时
某天我在一个后台管理系统里改 Dialog 的取消按钮样式,顺手从 button.tsx 引了一个 buttonVariants 工具函数。本地测没事,热更新一下——页面白屏,控制台飘出一行让人摸不着头脑的错误:
ReferenceError: buttonVariants is not defined
按钮明明已经 import 进来了,为什么说没有?
现象
- Dialog 的 Cancel / Close 按钮变成无样式裸元素
- 错误仅在热更新之后出现,冷启动有时正常
- 换调 import 顺序可以暂时消失,但下一次改动又会复现
- Next.js Turbopack / SWC 构建下稳定复现;Webpack 模式偶现
根因
shadcn/ui 的组件是粘贴进来的源码,不是外部包。这意味着你完全可以在这些文件之间随意引入——但也意味着循环依赖的风险完全由你自己承担。
dialog.tsx 引用了 @/components/ui/button 里的 buttonVariants;而 button.tsx 或它下游的某个模块(比如 cmdk、re-export barrel 文件)又传递地引用了 dialog.tsx 里的内容。ESM 模块有活绑定(live binding),循环时绑定在求值前是未初始化状态,即 TDZ(Temporal Dead Zone)。
Node.js / Webpack 对 TDZ 的容忍度相对高,往往在 CJS 模式下绕过去了;但 Turbopack 和 SWC 对 ESM 语义的执行更严格,TDZ 就会直接炸成 ReferenceError。
把 import { buttonVariants } from '@/components/ui/button' 挪到文件顶部或底部,有时能让错误暂时消失——但循环依赖本身还在,下次你或你的同事再编辑其中任一文件,热更新重新解析模块图,错误立刻回来。不要用顺序当修复。
正解
不要在 UI 原语层的组件之间跨模块引用 buttonVariants。dialog.tsx 只需要那个函数最终输出的 CSS 类字符串,而不需要函数本身。
做法:临时在一个 playground 文件里 console.log(buttonVariants({ variant: 'outline', size: 'default' })),把输出的字符串复制下来,直接在 dialog.tsx 顶部硬编码为常量:
// src/components/ui/dialog.tsx
import { cn } from "@/lib/utils"
// 内联 button 样式,避免跨模块引入 buttonVariants 触发循环依赖 → ESM TDZ
const outlineButtonClass =
"group/button inline-flex shrink-0 items-center justify-center rounded-lg " +
"border border-border bg-background bg-clip-padding text-sm font-medium " +
"whitespace-nowrap transition-all outline-none select-none " +
"hover:bg-muted hover:text-foreground " +
"focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 " +
"active:translate-y-px disabled:pointer-events-none disabled:opacity-50 h-8 gap-1.5 px-2.5"
function DialogCancelButton({ children = '取消', className, ...props }) {
return (
<DialogPrimitive.Close
className={cn(outlineButtonClass, className)}
{...props}
>
{children}
</DialogPrimitive.Close>
)
}把 buttonVariants(...) 的结果(一个普通字符串)硬编码为模块级常量。视觉效果与之前完全一致,同时彻底消除 dialog.tsx 对 button.tsx 的运行时依赖,循环链断开,TDZ 问题不复存在。
AlertDialog、Sheet、Drawer 里若有 DialogPrimitive.Close / SheetClose 等关闭按钮并套用了 buttonVariants,同样适用本文方案。凡是 UI 原语层两个组件互相"能看到对方"的,都值得检查一遍。
一句话外卖
shadcn/ui 是粘贴源码,不是包隔离——UI 原语之间跨模块调用工具函数时,要优先内联结果值而非引入函数,否则 ESM TDZ 会在最意想不到的热更新时刻给你一个 ReferenceError。