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;};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里头,直接将主题变量导入到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>到这里也就结束啦,其实都是些代码片段,没什么很实质性的内容。