0. 依赖版本一览
| 名称 | 版本 |
|---|---|
| tailwindcss | 4.1.12 |
| naive-ui | 2.42.0 |
| typescript | 5.8.3 |
| vue | 3.5.20 |
1. 相关文档
不多赘述,毕竟有兴趣点进来看也基本都是老油条互相参考一下而已。
2. 思路
NaiveUI是CSS-In-JS的典型后台管理UI库,优缺点都很明显,优点是对比Daisy、ShacnUI之类基于Tailwind CSS发展而来的新兴UI库来说,其提供了相对复杂的数据展示组件(如Table、Cascader等组件),缺点就是CSS-In-JS对比Tailwin CSS来说有点笨重,组件天生嵌套了很多层div也不好多做修改。
当然,其本身定位也不是让你出细活的,只是提供性能不错的组件让你快速完成任务罢了。
恰好这些年是LLM的飞速发展年,Tailwind CSS确实是很契合目前结合LLM的开发模式,在布局以及展示上对比老前辈来说有天然优势。
既然如此,那就只使用NaiveUI的表单组件,布局方面交给Tailwind CSS来做会不会更好呢?抱着这样的想法我开始了这场无聊的折腾。
做这种工作无非就是定义上一个中间层来连接两边,于是我先想到了以Tailwind CSS为主做配置。
问题也出在这,其实NaiveUI的并不能直接识别定义的Tailwind CSS变量。
逻辑链条大致如下:
ThemeToken.ts -> GenerateTheme.ts -> token.css -> Tailwind
-> useTheme.ts-> NaiveUI Config
2.1 定义设计token
既然不能直接定义CSS变量,还是得从JS上面入手。
先定义一套接口规范吧,类似于各个主题通用性质的大小,阴影,布局方面,直接放在commonTokens中,涉及到不同主题的colorTokens则单独针对主题声明:
export type ColorTokens = { primary: string primaryHover: string primaryPressed: string bgBody: string bgCard: string textMain: string textBody: string textLight: string textDisabled: string border: string}export const commonTokens = { // 圆角 radiusSm: '4px', radiusMd: '8px', radiusLg: '12px',
// 布局尺寸 siderWidth: '240px', siderCollapsedWidth: '64px', headerHeight: '64px',
layoutMaxWidth: '1440px' /* 框架最大宽度 (适用于大屏) */, layoutContentMaxWidth: '1024px' /* 内容区最大宽度 (保证阅读性) */, layoutPaddingXMobile: '1rem' /* 移动端左右边距 (16px) */, layoutPaddingXDesktop: '2rem' /* 桌面端左右边距 (32px) */,}export const colorTokens = { light: { primary: '#3b91f6', primaryHover: '#60a5fa', primaryPressed: '#2563eb', bgBody: '#f2f5f8', bgCard: '#ffffff', textMain: '#1f2937', textBody: '#4b5563', textLight: '#9ca3af', textDisabled: '#d1d5db', border: '#e5e7eb', }, dark: { primary: '#60a5fa', primaryHover: '#93c5fd', primaryPressed: '#3b82f6', bgBody: '#111827', bgCard: '#1f2937', textMain: '#f1f5f9', textBody: '#cbd5e1', textLight: '#94a3b8', textDisabled: '#475569', border: '#374151', }, sakura: { primary: '#f472b6', primaryHover: '#fb7185', primaryPressed: '#ec4899',
bgBody: '#fdf2f8', bgCard: '#ffffff',
textMain: '#502d3c', textBody: '#755563', textLight: '#a1808d', textDisabled: '#e2cdd4',
border: '#fbcfe8', },} as const2.2 与NaiveUI主题建立映射
import { $t } from '@/_utils/i18n'import { useStorage } from '@vueuse/core'import { computed, watchEffect } from 'vue'import { commonTokens, colorTokens, type ColorTokens } from '../ThemeToken'import type { GlobalThemeOverrides } from 'naive-ui'import type { Ref } from 'vue'
export type Theme = 'light' | 'dark' | 'sakura'
export interface ThemeOption { key: Theme label: string}
export const themes: ThemeOption[] = [ { key: 'light', label: $t('theme.style.light') }, { key: 'dark', label: $t('theme.style.dark') }, { key: 'sakura', label: $t('theme.style.sakura') },]const createThemeBridge = (colors: ColorTokens): GlobalThemeOverrides => ({ common: { // 颜色部分来自 `colors` primaryColor: colors.primary, primaryColorHover: colors.primaryHover, primaryColorPressed: colors.primaryPressed, primaryColorSuppl: colors.primaryHover, bodyColor: colors.bgBody, cardColor: colors.bgCard, popoverColor: colors.bgCard, modalColor: colors.bgCard, textColorBase: colors.textBody, textColor1: colors.textMain, textColor2: colors.textBody, textColor3: colors.textLight, textColorDisabled: colors.textDisabled, borderColor: colors.border, dividerColor: colors.border, // 非颜色部分来自 `common` borderRadius: commonTokens.radiusMd, borderRadiusSmall: commonTokens.radiusSm, }, Card: { borderRadius: commonTokens.radiusLg, }, Button: { borderRadiusMedium: commonTokens.radiusMd, borderRadiusSmall: commonTokens.radiusSm, }, Layout: { siderWidth: commonTokens.siderWidth, siderCollapsedWidth: commonTokens.siderCollapsedWidth, headerHeight: commonTokens.headerHeight, borderColor: colors.border, headerBorderColor: colors.border, siderBorderColor: colors.border, layoutMaxWidth: commonTokens.layoutMaxWidth, layoutContentMaxWidth: commonTokens.layoutContentMaxWidth, layoutPaddingXMobile: commonTokens.layoutPaddingXMobile, layoutPaddingXDesktop: commonTokens.layoutPaddingXDesktop, }, Input: { border: `1px solid ${colors.border}`, borderHover: `1px solid ${colors.primaryHover}`, borderFocus: `1px solid ${colors.primary}`, },})const themeOverridesMap = new Map( themes.map((item) => [item.key, createThemeBridge(colorTokens[item.key])]),)export function useTheme() { // 从 LocalStorage 创建一个响应式 ref,默认值为 'light' const currentTheme: Ref<Theme> = useStorage<Theme>('app-theme', 'light')
const activeThemeOverrides = computed(() => themeOverridesMap.get(currentTheme.value)) // 切换主题的函数 function setTheme(theme: Theme) { currentTheme.value = theme }
// 使用 watchEffect 监听 currentTheme 的变化,并更新 <html> 的 data-theme watchEffect(() => { const htmlEl = document.documentElement // 如果是默认主题,移除data-theme属性,让 :root 生效 if (currentTheme.value === 'light') { htmlEl.removeAttribute('data-theme') } else { htmlEl.setAttribute('data-theme', currentTheme.value) } }) // 判断是否为暗黑主题,主要是dark风格的NaiveUI组件肯定比我们定义的要完善,考虑到这个不如以覆盖其dark主题为主。 // 这样其实就把主题分成了暗黑与其它两大类 const isDark = computed(() => currentTheme.value === 'dark') return { themes, currentTheme, setTheme, isDark, activeThemeOverrides, }}2.3 自动生成对应的Tailwind CSS样式文件
前面提到了其实NaiveUI的并不能直接识别定义的Tailwind CSS变量,所以才有了对应的ThemeToken文件做中间商,但如果再通过ThemeToken去手写一套Tailwind CSS对应的变量又会显得很蠢,两头要维护两头都不讨好,长此以往肯定要出问题。
于是我想到了不如直接根据ThemeToken定义的语义化变量去自动生成对应的CSS声明,这样就一定程度上解决了这个问题,哪怕并不是很优雅。
import fs from 'fs'import path from 'path'import { fileURLToPath } from 'url'
import { commonTokens, colorTokens } from '../src/components/theme/ThemeToken.ts'
// 获取当前文件路径 (ESM 环境下替代 __dirname)const __filename = fileURLToPath(import.meta.url)const __dirname = path.dirname(__filename)
/** * 将驼峰命名转换为 kebab-case * 例如: radiusSm -> radius-sm, primaryHover -> primary-hover */function toKebabCase(str: string): string { return str.replace(/([A-Z])/g, '-$1').toLowerCase()}
/** * 生成 CSS 内容 */function generateCss() { let cssContent = `/* * ========================================================================== * THIS FILE IS AUTO-GENERATED BY scripts/GenerateTheme.ts * DO NOT EDIT DIRECTLY.========================================================================== */
@theme { /* --- 通用 CSS 生成 --- */` // 处理 commonTokens (不需要 --color- 前缀) // 映射规则: key -> --key-kebab for (const [key, value] of Object.entries(commonTokens)) { cssContent += ` --${toKebabCase(key)}: ${value};\n` }
cssContent += ` /* --- 默认主题颜色 (Light Theme Colors) --- */` // 处理 light 主题作为默认值放入 @theme (添加 --color- 前缀) // 映射规则: key -> --color-key-kebab if (colorTokens.light) { for (const [key, value] of Object.entries(colorTokens.light)) { cssContent += ` --color-${toKebabCase(key)}: ${value};\n` } }
cssContent += `}
/* * ========================================================================== * 其他主题 (Other Themes) * ========================================================================== */`
// 处理其他主题 (Dark, Sakura...) for (const [themeKey, tokens] of Object.entries(colorTokens)) { // 跳过 light,因为已经放在 @theme 里作为默认值了 // 配合你的 useTheme 逻辑:light 时移除 data-theme 属性,默认读取 @theme if (themeKey === 'light') continue
cssContent += `\n[data-theme='${themeKey}'] {\n`
for (const [key, value] of Object.entries(tokens)) { cssContent += ` --color-${toKebabCase(key)}: ${value};\n` }
cssContent += `}\n` }
return cssContent}
/** * 写入文件 */function writeToFile() { try { const css = generateCss() // 输出路径:src/components/theme/generated-theme.css (根据需要调整) const outputPath = path.resolve(__dirname, '../src/components/theme/styles/generated-theme.css')
fs.writeFileSync(outputPath, css, 'utf-8') console.log(`✅ Theme CSS generated successfully at: ${outputPath}`) } catch (error) { console.error('❌ Error generating theme CSS:', error) process.exit(1) }}
// 执行writeToFile()然后我们在packages.json中,写入如下命令:
"scripts": { ... "theme:gen": "tsx ./scripts/GenerateTheme.ts" },没装tsx的自己装一下,或者说直接转为js文件。
不过做到这里,既然都脚本自动生成了,那为什么不做Vite插件呢?
先修改一下脚本,让其最后导出一个函数:
// ....前面略/** * 封装为导出函数 */export function writeThemeCss() { try { const css = generateCss() // 确保路径相对于项目根目录是正确的 // 在 Vite 插件中执行时,process.cwd() 通常是项目根目录 const outputPath = path.resolve( process.cwd(), 'src/components/theme/styles/generated-theme.css', )
fs.writeFileSync(outputPath, css, 'utf-8') console.log(`[ThemeGen] CSS updated at ${new Date().toLocaleTimeString()}`) } catch (error) { console.error('[ThemeGen] Error:', error) }}当然,你也可以直接把脚本的逻辑放进下面的插件中,毕竟有了插件就不需要这个单独放置外边的脚本:
import { writeThemeCss } from '../scripts/GenerateTheme'export const themeGeneratorPlugin = () => { return { name: 'vite-plugin-naive-tailwind-theme', // Vite 服务启动/构建开始时,先生成一次 buildStart() { writeThemeCss() }, // 热更新处理 handleHotUpdate({ file, server }) { // 监听 ThemeToken.ts 文件的变化 if (file.includes('ThemeToken.ts')) { writeThemeCss() // 显式触发 CSS 文件的更新 // 或者简单地打印日志,让 Tailwind 的监听器去捕获生成的 CSS 变化 server.ws.send({ type: 'full-reload', path: '*', }) } }, }}跟用一般的Vite插件那样,在vite.config.ts中使用即可:
// https://vite.dev/config/export default defineConfig(({ mode }) => {{ plugins: [..., themeGeneratorPlugin()]}}2.4 应用自动生成的CSS文件并导入入口CSS文件
@import 'tailwindcss';/* * ========================================================================== * Tailwind CSS v4 主题配置========================================================================== */
// 导入自动生成的CSS文件@import './generated-theme.css';
/* * ========================================================================== * 全局基础样式 (@layer base)========================================================================== */@layer base { body { background-color: var(--color-bg-body); color: var(--color-text-body); /* 默认正文颜色 */ /* 优化字体渲染 */ -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; }
/* 标题默认使用 main 颜色 */ h1, h2, h3, h4, h5, h6 { color: var(--color-text-main); }}
/* * ========================================================================== * 通用工具类 (@layer utilities)========================================================================== */@layer utilities { /* 这是我自己用在a标签上面的shake样式 */ .hover-shake { @apply inline-block transition-transform duration-200 ease-in-out; &:hover { @apply scale-110 translate-x-0.5 rotate-3; } &:active { @apply translate-x-0 rotate-0; } } /* iconfont 全局样式,你没有的话就不需要 */ .zw-icon { width: 1em; height: 1em; vertical-align: -0.15em; fill: currentColor; overflow: hidden; }}然后在index.html中作为入口导入
文件名称自己参考
...<link href="/src/components/theme/styles/token.css" rel="stylesheet" />3. 让NaiveUI应用主题
在App.vue里头,直接将主题变量导入到NaiveUI的n-config-provider中
<script setup lang="ts">import { zhCN, dateZhCN, NConfigProvider, darkTheme } from 'naive-ui'import { useTheme } from '@/components/theme/hooks/useTheme'import { computed } from 'vue'const { isDark, activeThemeOverrides } = useTheme()const naiveTheme = computed(() => (isDark.value ? darkTheme : null))</script><template> <n-config-provider :locale="zhCN" :date-locale="dateZhCN" :theme-overrides="activeThemeOverrides" :theme="naiveTheme" > <RouterView></RouterView> </n-config-provider></template>到这里也就结束啦,其实都是些代码片段,没什么很实质性的内容。