上一次博客交互升级,我引入了 Base UI 来接管 Tooltip、Toast、Dialog 这些"交互底座"。那次重构解决了手搓交互难以维护的问题,但也留下了一个遗憾:Base UI 的组件 API 偏底层,嵌套层级深,每个 Tooltip 写下来都是一大坨模板代码。
这次,我把目光投向了 reui——一个基于 Radix UI 的 copy-and-paste 组件库,风格类似 shadcn/ui,但组件更丰富、动画组件更齐全。
一句话概括本次升级的核心思路:
用更简洁的 API 替换现有交互基础设施,同时趁机补上博客一直缺少的 UI 功能。
Base UI 作为 headless primitives 没有问题,但在日常使用中有几个摩擦点:
Tooltip 写法过于冗长。 一个简单的提示需要 Root > Trigger > Portal > Positioner > Popup 五层嵌套:
<Tooltip.Root>
<Tooltip.Trigger render={<button>...</button>} />
<Tooltip.Portal>
<Tooltip.Positioner sideOffset={8}>
<Tooltip.Popup className="...">提示文字</Tooltip.Popup>
</Tooltip.Positioner>
</Tooltip.Portal>
</Tooltip.Root>Toast 系统需要手工维护全局 Provider 和 Viewport。 AppProviders.tsx 里堆了 50 多行 Toast 相关代码,包括自定义 Viewport 组件、样式、动画配置。
没有现成的高级组件。 面包屑、骨架屏、手风琴这些常见需求,每次都要从零开始写。
对比了几个方案后,reui 胜出的原因很直接:
替换后的写法清爽了很多:
<Tooltip>
<TooltipTrigger asChild>
<button>...</button>
</TooltipTrigger>
<TooltipContent>提示文字</TooltipContent>
</Tooltip>涉及 5 个文件(AppHeader、CopyLinkButton、GalleryGrid、Pre、AppProviders),每个文件的修改都是机械替换,但代码量普遍减少了 30-40%。
有一个小坑值得提:GalleryGrid 的 Lightbox 里,Tooltip 需要反色。 因为 Lightbox 背景是暗色遮罩,默认的暗底白字 Tooltip 会"隐身"。解决方案是通过 className 覆盖:
<TooltipContent className="bg-white text-gray-900 shadow-md dark:bg-gray-900 dark:text-gray-100">整个 Toast 系统的替换最令人满意。AppProviders.tsx 从 54 行缩减到 18 行,原来的 Toast.Provider、自定义 AppToastViewport 组件全部移除,换成一行 <Toaster />。
lib/toast.ts 的实现也从复杂的全局状态管理变成了两个纯函数:
import { toast } from 'sonner'
export function notifySuccess(title: string, description: string) {
toast.success(title, { description })
}
export function notifyError(title: string, description: string) {
toast.error(title, { description })
}Sonner 自动跟随 next-themes 的主题切换,深色/浅色模式下的 Toast 样式都不需要额外处理。
之前文章详情页、标签详情页、Gallery 详情页都用一个 ← Back to XXX 的链接做返回导航。功能上没问题,但语义上不够明确——用户不知道自己在站点结构的什么位置。
现在换成了面包屑:
Blog > 文章标题
Tags > #标签名
Gallery > 相册标题面包屑组件使用 @radix-ui/react-slot 的 asChild 模式,可以直接把 Next.js 的 <Link> 组件传进去,不会产生多余的 DOM 嵌套。
新增了两个 loading.tsx 文件,分别覆盖文章列表和文章详情的加载状态。Next.js App Router 会自动使用这些文件作为 Suspense fallback,在页面数据加载期间展示骨架屏而非空白页面。
同时在 GalleryGrid 的图片加载过程中也加入了 Skeleton 占位,替换了之前的 spinner 加载指示器。
标签页现在支持两种视图切换:
视图切换用 Radix Tabs 实现,带有平滑的切换动画。同时在页面顶部用 CountingNumber 动画组件展示标签总数和文章总数。
首页的个人简介标题现在带有打字机效果——文字逐字出现,结尾有闪烁的光标。使用 framer-motion 的 useInView 控制,只在首次进入视口时播放一次,不会反复触发。
因为 TypingText 依赖客户端 API,所以把它提取到了独立的 HomeHero 客户端组件中,首页的主体仍然是 Server Component。
在文章和 About 页面的 MDX 渲染器中注册了 Accordion 组件。这样在 MDX 文章中可以直接写:
<Accordion type="single" collapsible>
<AccordionItem value="faq-1">
<AccordionTrigger>常见问题</AccordionTrigger>
<AccordionContent>这里是可折叠的答案内容。</AccordionContent>
</AccordionItem>
</Accordion>适合用在 FAQ、补充说明、进阶内容等场景,既不占空间又保留信息完整性。
reui 组件内部使用 bg-foreground、text-muted-foreground 等 Tailwind 语义色类名。而项目的 CSS 变量体系是 --bg-color、--text-color 这套。
解决方案是在 globals.css 的 @theme 块中添加一组映射:
@theme {
--color-foreground: var(--text-color);
--color-background: var(--bg-color);
--color-muted: color-mix(in srgb, var(--bg-color) 90%, var(--text-color));
--color-muted-foreground: var(--text-grey);
--color-accent: color-mix(in srgb, var(--bg-color) 85%, var(--text-color));
--color-border: var(--border-color);
}用 color-mix() 来生成 muted 和 accent 色,保证在深色/浅色模式下都能自动适配,不需要写两套颜色值。
最初在 TagsView 组件内部用 tags.reduce((sum, t) => sum + t.count, 0) 计算文章总数。但一篇文章可以有多个标签,这种算法会重复计数。
修复方案:在 Server Component 层直接调用 getAllPosts(['slug']).length 获取真实文章数,通过 props 传递给客户端组件。
TypingText(依赖 useInView)、CountingNumber(依赖 useMotionValue)、Tabs(依赖用户交互状态)都是纯客户端组件。Next.js 的 Server Component 不能直接使用它们。
解决方式是创建薄薄的客户端包装组件(HomeHero、TagsView),只把必要的数据通过 props 从服务端传入,客户端组件内部处理交互逻辑。
Radix Accordion 的展开/收起动画依赖 --radix-accordion-content-height CSS 变量。需要在 globals.css 中手动定义 accordion-down 和 accordion-up 两组关键帧,并在 @theme 中注册对应的 --animate-* 变量,Tailwind v4 才能识别 animate-accordion-down 类名。
| 组件 | 改动类型 | 效果 |
|---|---|---|
| Tooltip | 替换 | API 简化,代码量减少 30%+ |
| Sonner | 替换 | AppProviders 从 54 行降至 18 行 |
| Breadcrumb | 新增 | 文章/标签/Gallery 详情页结构化导航 |
| Skeleton | 新增 | 页面加载不再空白 |
| Tabs | 新增 | 标签页支持云/列表双视图 |
| TypingText | 新增 | 首页打字机动画 |
| CountingNumber | 新增 | 标签页数字动画 |
| Accordion | 新增 | MDX 文章支持可折叠内容 |
构建验证通过,228 个页面全部正常生成,零错误。
这次升级的体感总结:
reui 的 copy-and-paste 模式很适合个人项目。 你拿到的是源码而不是 npm 包,意味着你可以直接根据项目需求裁剪样式,不用和组件库的设计系统"谈判"。
从 Base UI 迁移到 Radix UI 的成本很低。 两者的核心理念相同(headless、无障碍优先),主要差异在 API 命名和嵌套方式上。如果你当前在用 Base UI 并且觉得模板代码太多,可以考虑试试。
Sonner 比任何自建 Toast 方案都省心。 这不是因为它功能更强,而是因为它几乎不需要你做任何配置就能"正常工作"——自动跟随主题、自动堆叠、自动消失,你只需要调用 toast() 函数。
下一步可能会考虑把 GalleryGrid 中仍在使用的 @base-ui/react/dialog 也迁移到 Radix Dialog,完成 Base UI 的完全移除。但这不急,Dialog 的用法比较复杂(涉及 Lightbox 的缩放、拖拽交互),等有明确需求再动。