2198 字
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;
};
53 collapsed lines
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;
84 collapsed lines
}
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);
80 collapsed lines
/**
* 将驼峰命名转换为 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';
/*
* ==========================================================================
44 collapsed lines
* 全局基础样式 (@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