返回博客

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 顺序只是掩盖症状

import { buttonVariants } from '@/components/ui/button' 挪到文件顶部或底部,有时能让错误暂时消失——但循环依赖本身还在,下次你或你的同事再编辑其中任一文件,热更新重新解析模块图,错误立刻回来。不要用顺序当修复。

正解

不要在 UI 原语层的组件之间跨模块引用 buttonVariantsdialog.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.tsxbutton.tsx 的运行时依赖,循环链断开,TDZ 问题不复存在。

Tip:哪些 shadcn 复合组件也可能中招

AlertDialogSheetDrawer 里若有 DialogPrimitive.Close / SheetClose 等关闭按钮并套用了 buttonVariants,同样适用本文方案。凡是 UI 原语层两个组件互相"能看到对方"的,都值得检查一遍。

一句话外卖

shadcn/ui 是粘贴源码,不是包隔离——UI 原语之间跨模块调用工具函数时,要优先内联结果值而非引入函数,否则 ESM TDZ 会在最意想不到的热更新时刻给你一个 ReferenceError。