2195 字
11 分钟
老项目新折腾——记NaiveUI与Tailwind CSS V4整合尝试

0. 依赖版本一览#

名称版本
tailwindcss4.1.12
naive-ui2.42.0
typescript5.8.3
vue3.5.20

1. 相关文档#

不多赘述,毕竟有兴趣点进来看也基本都是老油条互相参考一下而已。

2. 思路#

NaiveUICSS-In-JS的典型后台管理UI库,优缺点都很明显,优点是对比DaisyShacnUI之类基于Tailwind CSS发展而来的新兴UI库来说,其提供了相对复杂的数据展示组件(如TableCascader等组件),缺点就是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 const

2.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里头,直接将主题变量导入到NaiveUIn-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>

到这里也就结束啦,其实都是些代码片段,没什么很实质性的内容。

老项目新折腾——记NaiveUI与Tailwind CSS V4整合尝试
https://blog.astro777.cfd/posts/development-environment/integrate-naiveui-and-tailwindcssv4/
作者
ASTRO
发布于
2026-01-07
许可协议
CC BY-NC-SA 4.0