文章

Nuxt 的 UI 库怎么选:从 Nuxt UI 迁到 shadcn-vue

这个站最早用的是 Nuxt UI,今年 7 月迁到了 shadcn-vue。这篇先把 Vue/Nuxt 生态里常见的几个 UI 库摆出来比一比,再讲我为什么迁、迁的时候踩了什么坑、迁完之后得到了什么。

候选

库 怎么装 样式 适合
Nuxt UI npm 包 + Nuxt 模块 Tailwind,靠主题配置覆盖 Nuxt 项目,后台、SaaS、文档站
shadcn-vue CLI 把源码复制进项目 Tailwind,直接改源码 有自己设计的站
PrimeVue npm 包 自带主题,也可以关掉,自己用 Tailwind 画 表格多、表单多的企业后台
Vuetify npm 包 Material Design 接受 Material 风格的应用
Element Plus / Naive UI npm 包 自带主题 国内常见的管理后台

如果你的项目是 Nuxt,又打算用 Tailwind,真正要选的其实只有前两个。PrimeVue 值得多提一句:它出了一个叫 Volt 的方案,把组件源码复制进你的项目,用 Tailwind 重新画样式。走的是和 shadcn 一样的路,可见「源码归你」这个思路已经不只是 shadcn 一家在做了。

同一个底座,两种分发方式

Nuxt UI 和 shadcn-vue 的底层几乎一样:交互和无障碍都靠 Reka UI(Vue 版的 Radix),样式都用 Tailwind v4。所以两边弹窗的焦点管理、下拉菜单的键盘操作,本质上是同一套实现。

区别在分发方式:

  • Nuxt UI 是一个库。 组件在 node_modules 里,你通过它提供的主题系统去改样式。
  • shadcn-vue 不是库。 npx shadcn-vue add button 会把 Button 的源码复制到你的 components/ui/button/ 里,之后这就是你自己的代码。

选哪个,其实就是在选:样式是通过配置去覆盖别人的默认值,还是直接改自己的源码。

Nuxt UI:开箱即用

先说它的好。2025 年 9 月发布的 Nuxt UI v4 把原来收费的 Pro 版合并进来,全部 MIT 开源,有 110 多个组件,还附带模板和 Figma 设计稿。装上之后它会自动注册 @nuxt/icon、@nuxt/fonts 和 @nuxtjs/color-mode:图标、字体、深浅色模式都不用自己配。做后台或者 SaaS,这种全家桶很省事。

改样式有四层,优先级从低到高:

  1. app.config.ts 里的全局主题:按组件的 slot(组件内部的各个元素)和 variant 改
  2. <UTheme> 组件:只影响它包住的部分
  3. 组件实例上的 ui prop:改这一个实例的某个 slot
  4. class:改根元素

底层用 tailwind-merge 合并 class,冲突的工具类后写的赢。

我遇到的问题:一直在关掉它的默认样式

问题出在我想要的样子和它的默认样子差得比较远。翻迁移前的代码,全站有 17 处 ui prop,大部分是这样的:

<UModal v-model:open="isOpen"
  :ui="{ content: 'bg-transparent shadow-none ring-0 border-none overflow-visible' }">

这一行的意思是:把弹窗自带的背景、阴影、描边全部关掉,然后我在里面自己再画一张卡片。同样一行在 5 个弹窗里各写了一遍。输入框也是:

<UInput :ui="{ base: 'px-3.5 py-2.5 bg-slate-100/50 dark:bg-slate-900/30 border border-slate-200/60 dark:border-slate-800/80 rounded-xl focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500/55 transition-all text-sm shadow-none' }" />

一模一样的一长串,复制了 6 遍。

公平地说,这些本来可以集中写进 app.config.ts,是我偷懒了。但即使写进全局配置,做的事情也一样:先知道弹窗里那一层叫 content、输入框那一层叫 base,再写一串 class 去压过它的默认值。slot 叫什么、默认值是什么,都得去翻文档或者主题源码。

另一个原因更主观:Nuxt UI 的默认审美偏后台 SaaS。拿来做管理界面很合适,但我想要的个人站是另一种感觉。

shadcn-vue:组件是自己的代码

迁到 shadcn-vue 之后,同样的需求变成了直接改源码。比如输入框的聚焦样式,shadcn 默认是边框变色,外面再加一圈 3px 的半透明 ring,看起来像两道框。我想要一条干净的实线,就直接改 components/ui/input/Input.vue:

// 聚焦:边框变 brand,再贴一圈 1px 同色的 ring,看起来是一条 2px 的实线
'focus-visible:border-brand focus-visible:ring-1 focus-visible:ring-brand',

改一个文件,全站的输入框都变了。不用知道 slot 名,也不用和默认值比优先级,因为默认值就写在我眼前。

这个例子也说明,shadcn 的默认样式不是不用改,而是改的地方不一样:在 Nuxt UI 里,你是在覆盖别人的默认值;在 shadcn 里,默认值本身就是你的代码。

它的代价也很明确:

  • 没有全家桶。 深浅色模式、图标、字体都要自己装、自己配。
  • 一个一个组件装。 用到哪个,add 哪个。
  • 上游更新不会自动跟过来。 源码归你,维护也归你。shadcn 修了某个组件的 bug,你得自己去对比、合并。
  • 复杂组件少。 没有 Nuxt UI 那样现成的仪表盘、聊天界面,需要的话得自己拼。

还有一个附带的好处:组件源码就在项目里,用 AI 写代码时,它能直接读到、直接改,不用去猜 node_modules 里的实现。

迁移

实际迁移的量不大:用到的 Nuxt UI 组件一共 8 种,调用点 54 处。

Nuxt UI shadcn-vue
UButton Button
UInput / UTextarea Input / Textarea
UModal Dialog
USlideover Sheet
UDropdownMenu DropdownMenu
USelectMenu Select
useToast vue-sonner

有两处值得一说。

Toast。 shadcn-vue 的 toast 用的是 vue-sonner,API 和 Nuxt UI 的 useToast().add() 不一样。我写了一个同名的 useToast,保留 .add() 的签名,底层换成 vue-sonner。这样所有调用点一行都不用改。

深浅色模式悄悄坏了。 删掉 @nuxt/ui 之后,build 通过,页面也正常,但主题切换按钮点了没反应。原因是 @nuxtjs/color-mode 以前是 Nuxt UI 自动注册的,我从来没在 modules 里写过它。Nuxt UI 一删,它也跟着没了,而 useColorMode() 不会报错。解决方法是装回来,并且设置 classSuffix: '':color-mode 默认给 <html> 加的是 dark-mode 这个 class,而 Tailwind 的 dark: 只认 dark。

所以从 Nuxt UI 迁出去之前,先列一下它替你注册了哪些模块,迁移时一个一个补回来。

迁完之后

我把迁移前后的两个提交各构建了一次,比较首次打开页面时必须下载的入口 JS 和 CSS(gzip 之后):

迁移前 迁移后 变化
入口 JS 202 KB 192 KB −5%
入口 CSS 45 KB 33 KB −27%

JS 只少了 5%,比我预想的少。Nuxt UI 的组件是按需引入的,没用到的组件本来就不会打进包里,所以 JS 这边没什么可省的。省下来的主要是 CSS:Tailwind 会扫描 Nuxt UI 的主题文件,把每个组件所有颜色、所有变体的 class 都生成出来,哪怕我只用了其中一种;shadcn 的源码里只有我实际写下的 class。

还要说明一点:这次迁移的同一个提交里,Nuxt 也从 4.3 升到了 4.5,所以这组数字不完全是换 UI 库这一件事带来的。可以当作量级参考,别当成精确的基准测试。

现在 components/ui 里只有 8 个组件:button、dialog、dropdown-menu、input、select、sheet、sonner、textarea。站上用到什么就有什么,没有别的。

怎么选

  • 做后台、SaaS、文档站,想要开箱即用,也能接受它的默认审美:Nuxt UI。它的全家桶和现成的复杂组件,能省下大量时间。
  • 有自己的设计,组件用得不多,想把每个像素都掌握在自己手里:shadcn-vue。
  • 表格、表单多的企业后台:PrimeVue。想要 Tailwind 加源码归自己,可以看它的 Volt。
  • 就要 Material 风格:Vuetify。

对我这个站来说,迁移是值得的。一是好看:清爽、克制,更接近我想要的样子;二是轻:用到什么装什么,CSS 少了四分之一。多出来的配置工作,是一次性的。

更多