返回博客

HeroUI v3 的 variant 不向后兼容:从 NextUI v2 迁移时所有 variant= 都是嫌疑人

把一个 React 项目从 NextUI v2 升级到 HeroUI v3,大多数组件名没动(Card、Chip、Button、Badge……),看起来是无痛升级。但跑 tsc --noEmit 时,迎面而来的是一堆 TS2322——每一行都在说某个 variant 值不合法。

这不是类型定义写错了,是整个 variant 命名空间被重新设计了。

现象

升级 @heroui/react^3.0.0 后,原本在 NextUI v2 下工作良好的代码:

<Chip size="sm" variant="flat">{label}</Chip>
<Button variant="bordered">取消</Button>
<Card variant="shadow">...</Card>

tsc --noEmit 时全部报错:

error TS2322: Type '"flat"' is not assignable to type
  '"secondary" | "tertiary" | "primary" | "soft" | undefined'.

error TS2322: Type '"bordered"' is not assignable to type
  '"primary" | "secondary" | "tertiary" | "outline" | "ghost" | "danger" | undefined'.

根因

HeroUI v3 对 variant 做了一次概念重构,不是改名,是换维度:

  • NextUI v2:variant 表达视觉处理(solid / bordered / light / flat / faded / shadow / ghost)
  • HeroUI v3:variant 表达语义角色(primary / secondary / tertiary / soft / danger / success / warning)

更麻烦的是,HeroUI v3 的 variant 集合每个组件各不相同,没有全局共享枚举。ChipVariants["variant"]CardVariants["variant"] 是两个互不相交的 literal union。

坑:variant 迁移不能靠肌肉记忆,也不能靠 AI 补全

AI 训练数据大量来自 NextUI v2 代码,会自信地补全 variant="flat"variant="solid"——这些在 HeroUI v3 里全部不存在。Stack Overflow 上的旧答案同理。唯一的真相在组件的 .d.ts 里。

v2 → v3 常用 variant 对照

| NextUI v2 旧值 | HeroUI v3 最近似替代 | |---|---| | variant="solid" | variant="primary"(Chip/Button),或直接省略(多数是默认值) | | variant="flat" | variant="soft"(Chip/Badge) | | variant="bordered" | variant="outline"(Button),Chip 无对应——用 soft 或去掉 | | variant="light" | variant="ghost"(Button),variant="soft"(Chip) | | variant="faded" | variant="tertiary"(粗略近似) | | variant="shadow" | 已删除——改用 Tailwind className="shadow-*" 手动加 | | variant="ghost" | 仅 Button 保留,其他组件已删 |

各组件 v3 可用 variant 一览(@heroui/styles v3.1.0 验证):

| 组件 | v3 可用 variant | 默认 | |---|---|---| | Card | default secondary tertiary transparent | default | | Chip | primary secondary tertiary soft | secondary | | Button | primary secondary tertiary outline ghost danger | primary | | Input | primary secondary | primary |

正解

迁移前验证,而不是迁移后猜测。 三种查法:

# 1. 看 tailwind-variants 配置(最完整)
cat node_modules/@heroui/styles/dist/components/chip/chip.styles.js | grep -A20 "variant:"
 
# 2. 看生成的类型定义
grep -A5 "variant:" node_modules/@heroui/styles/dist/components/chip/chip.styles.d.ts
 
# 3. 直接写一个错的,让 tsc 告诉你全集(最快)
npx tsc --noEmit 2>&1 | grep "is not assignable"

第 3 种最省事——TypeScript 的错误信息本身就包含完整的 allowed union literal,读一次就知道所有选项。

正解:按语义意图映射,不按名字匹配

不要做字符串替换,要问「我想表达什么」:

  • 想要一个低调的标签 → <Chip variant="soft">
  • 想要主要行动按钮 → <Button variant="primary">(或直接省略)
  • 想要无阴影的卡片 → <Card variant="transparent"><Card variant="secondary">

每换完一个组件就跑一次 tsc --noEmit,不要批量换完再统一调试。

别漏掉 color 和 variant 的区别

HeroUI v3 里不少组件同时有 colorvariant 两个 prop,两者是独立的 literal union,都会独立报 typecheck 错误:

// 合法:variant 控制视觉处理,color 控制调色板
<Chip variant="soft" color="danger">删除</Chip>
 
// 非法:danger 不在 Chip 的 variant 集合里
<Chip variant="danger">删除</Chip>

迁移时如果原来用 variant="solid" color="danger",不能只改 variant 不查 color。

迁移后的扫尾

改完之后,跑一遍 grep 找可能被 any 掩盖的漏网之鱼:

git grep -nE 'variant="(solid|bordered|light|flat|faded|shadow|dot)"' src/

any 类型会让这些错误在 typecheck 时静默通过,但运行时样式会对不上。

一句话外卖

HeroUI v3 不是 NextUI v2 的包名替换,是一次 API 重设计——所有带 variant= 的地方都是嫌疑人,真相在 .d.ts,不在记忆里。