Vue3 多主题/明暗模式切换:CSS 变量 + class 覆盖的完整工程方案(附开源代码)
2026年1月17日 00:17
文章简介
之前逛 V 站的时候刷到一个讲 JSON 格式化工具信息泄漏的帖子,有条评论说:“V 站不是人手一个工具站吗?”受此感召,我给自己做了一个工具站。
在搭建工具站的时候有做多主题、亮/暗主题切换,于是有了这篇文章。
备注:工具站当前支持的工具还不多,但已开源,也有部署在 Github page 中,文中介绍的主题切换源码也在其中,感兴趣的朋友可随意取用,后续我也会将自己要用的、感兴趣的工具集成进去。
再备注:此处介绍的多主题、模式切换是在 vue3 中实现,其他环境请感兴趣的朋友自行实现。
工具站源码地址
工具站地址:the-wind-is-rising-dev.github.io/endless-que…
实现原理
主题切换使用了 CSS 变量和 class 覆盖两种特性。
- class 覆盖特性,后加载的 class 样式会覆盖之前加载的 class 样式,变量也会被覆盖。
- CSS 变量定义时以 -- 开头,如下:
:root {
/* ========== 品牌主色调 ========== */
--brand-primary: #4f46e5; /* 主色:靛蓝 */
--brand-secondary: #0ea5e9; /* 次要色:天蓝 */
--brand-accent: #8b5cf6; /* 强调色:紫色 */
}
实现思路
- 首先在 :root 伪 class 下定义所有需要用到的变量,然后定义拥有相同变量的不同主题 class
- 切换主题时通过 document 直接设置对应主题的 class
- 跟随系统主题可以通过监听 (prefers-color-scheme: dark) 来切换
:root 伪 class 定义
源码在 src/themes/index.css 文件内,此处只贴出部分变量
:root {
/* 背景与表面色 */
--bg-primary: #f8fafc; /* 主背景 */
--bg-secondary: #ffffff; /* 次级背景/卡片 */
--bg-tertiary: #f1f5f9; /* 工具栏/三级背景 */
--bg-sidebar: #e2e8f0; /* 侧边栏背景 */
}
默认主题明亮模式 class 定义
源码在 src/themes/default/light.css 文件内,此处只贴出部分变量
html.theme-default {
/* 背景与表面色 */
--bg-primary: #f8fafc; /* 主背景 */
--bg-secondary: #ffffff; /* 次级背景/卡片 */
--bg-tertiary: #f1f5f9; /* 工具栏/三级背景 */
--bg-sidebar: #e2e8f0; /* 侧边栏背景 */
}
默认主题暗夜模式 class 定义
源码在 src/themes/default/dark.css 文件内,此处只贴出部分变量
html.theme-default.dark {
/* 背景与表面色 */
--bg-primary: #0f172a; /* 主背景 */
--bg-secondary: #1e293b; /* 次级背景/卡片 */
--bg-tertiary: #334155; /* 工具栏/三级背景 */
--bg-sidebar: #1e293b; /* 侧边栏背景 */
}
主题切换源码
源码位置:src/themes/theme.ts
切换主题后会将当前主题保存至本地,下次打开站点时会自动加载上次设置的主题
- 对象定义
- Theme:用来定义主题信息
- ThemeModel:用来定义当前模式(明亮/暗夜),以及是否跟随系统
- ThemeConfig:用来定义当前主题与模式
- 函数定义
- isDarkMode:用来判断当前系统是否为暗夜模式
- applyTheme:用来应用主题与模式
- initializeTheme:初始化主题,用来加载之前设置的主题与模式
- getCurrentThemeConfig:获取当前主题配置(主题与模式)
- addDarkListener:添加暗夜模式监听
- removeDarkListener:移除暗夜模式监听
- changeThemeMode:切换主题模式(亮/暗模式)
- changeTheme:切换主题,默认主题、星空主题、海洋主题等
- getThemeList:获取支持的主题列表 备注:主题初始化、暗夜模式监听/移除监听函数需要在主页面加载时调用、设置
// 存储主题配置的键
const THEME_STORAGE_KEY = "custom-theme";
// 主题
export interface Theme {
name: string; // 主题名称
className: string; // 对应的 CSS 类名
}
// 模式
export interface ThemeModel {
name: string; // 模式名称
followSystem: boolean; // 是否跟随系统
value: "light" | "dark"; // 模式值
}
// 主题配置
export interface ThemeConfig {
theme: Theme; // 主题
model: ThemeModel; // 默认主题模式
}
/**
* 检测当前系统是否启用暗黑模式
*/
function isDarkMode() {
return (
window.matchMedia &&
window.matchMedia("(prefers-color-scheme: dark)").matches
);
}
/**
* 应用主题
* @param themeConfig 主题配置
*/
function applyTheme(themeConfig: ThemeConfig) {
const className = themeConfig.theme.className;
const mode = themeConfig.model;
// 移除旧的主题类
const classes = document.documentElement.className.split(" ");
const themeClasses = classes.filter(
(c) => !c.includes("theme-") && c !== "dark"
);
document.documentElement.className = themeClasses.join(" ");
// 添加新的主题类
document.documentElement.classList.add(className);
// 判断是否启用暗黑模式
if (mode.value === "dark") {
document.documentElement.classList.add("dark");
}
// 存储当前主题配置
localStorage.setItem(THEME_STORAGE_KEY, JSON.stringify(themeConfig));
}
/**
* 初始化主题
*/
export function initializeTheme() {
// 获取当前主题配置并应用
const themeConfig = getCurrentThemeConfig();
// 初始化当前主题类型
if (themeConfig.model.followSystem) {
themeConfig.model.value = isDarkMode() ? "dark" : "light";
}
applyTheme(themeConfig);
}
/**
* 获取当前主题配置
* @returns 主题配置
*/
export function getCurrentThemeConfig(): ThemeConfig {
let theme: any = localStorage.getItem(THEME_STORAGE_KEY);
return theme
? JSON.parse(theme)
: {
theme: getThemeList()[0], // 默认主题
model: {
name: "跟随系统",
followSystem: true,
value: isDarkMode() ? "dark" : "light",
},
};
}
/**
* 添加暗黑模式监听
*/
export function addDarkListener() {
// 监听暗黑模式变化, auto 模式动态切换主题
window
.matchMedia("(prefers-color-scheme: dark)")
.addEventListener("change", (e) => {
const themeConfig = getCurrentThemeConfig();
if (!themeConfig.model.followSystem) return;
changeThemeMode(themeConfig.model);
});
}
/**
* 移除暗黑模式监听
*/
export function removeDarkListener() {
window
.matchMedia("(prefers-color-scheme: dark)")
.removeEventListener("change", () => {});
}
/**
* 切换主题模式
* @param mode 模式
*/
export function changeThemeMode(themeModel: ThemeModel) {
const themeConfig = getCurrentThemeConfig();
themeConfig.model = themeModel;
if (themeModel.followSystem) {
themeConfig.model.value = isDarkMode() ? "dark" : "light";
}
applyTheme(themeConfig);
}
/**
* 切换主题
* @param theme 主题
*/
export function changeTheme(theme: Theme) {
const themeConfig = getCurrentThemeConfig();
themeConfig.theme = theme;
applyTheme(themeConfig);
}
/**
* 获取主题列表
* @returns 主题列表
*/
export function getThemeList(): Theme[] {
return [
{
name: "默认",
className: "theme-default",
},
{
name: "星空",
className: "theme-starry",
},
{
name: "海洋",
className: "theme-ocean",
},
];
}
主题、模式手动切换组件
源码位置:src/themes/Theme.vue
组件内会自动加载站点支持的主题与模式,也会根据系统模式变化自动切换状态信息,源码内有注释,此处不赘述
<script setup lang="ts">
import { SettingOutlined, BulbFilled } from "@ant-design/icons-vue";
import { onMounted, onUnmounted, ref } from "vue";
import {
Theme,
getThemeList,
getCurrentThemeConfig,
changeTheme,
changeThemeMode,
} from "./theme";
const themeList = ref<Theme[]>(getThemeList());
const currentTheme = ref<Theme>(getCurrentThemeConfig().theme);
const followSystem = ref<boolean>(getCurrentThemeConfig().model.followSystem);
const isLightModel = ref<boolean>(
getCurrentThemeConfig().model.value == "light"
);
// 切换主题
function onChangeTheme(theme: Theme) {
currentTheme.value = theme;
changeTheme(theme);
}
// 切换跟随系统
function onFollowSystemChange() {
followSystem.value = !followSystem.value;
let themeConfig = getCurrentThemeConfig();
themeConfig.model.followSystem = followSystem.value;
changeThemeMode(themeConfig.model);
}
// 切换主题模式
function onChangeThemeModel(value: boolean) {
isLightModel.value = value;
let themeConfig = getCurrentThemeConfig();
themeConfig.model.value = value ? "light" : "dark";
changeThemeMode(themeConfig.model);
}
// 添加主题模式监听
let interval: NodeJS.Timeout | null = null;
onMounted(() => {
// 定时更新主题信息
interval = setInterval(() => {
const themeConfig = getCurrentThemeConfig();
currentTheme.value = themeConfig.theme;
followSystem.value = themeConfig.model.followSystem;
isLightModel.value = themeConfig.model.value == "light";
}, 200);
});
onUnmounted(() => {
// 移除定时更新主题信息
interval && clearInterval(interval);
});
</script>
<template>
<div class="theme-root center">
<a-dropdown placement="bottom">
<div class="theme-btn center">
<SettingOutlined />
</div>
<template #overlay>
<a-menu>
<div
class="theme-item"
v-for="theme in themeList"
:key="theme.className"
@click="onChangeTheme(theme)"
>
<div class="row">
<div
style="width: var(--space-xl); font-size: var(--font-size-sm)"
>
<BulbFilled
class="sign"
v-if="theme.className == currentTheme.className"
/>
</div>
<div>{{ theme.name }}-主题</div>
</div>
</div>
<div class="theme-model-item row">
<a-radio
v-model:checked="followSystem"
@click="onFollowSystemChange()"
>🖥️</a-radio
>
<a-switch
checked-children="☀️"
un-checked-children="🌑"
v-model:checked="isLightModel"
:disabled="followSystem"
@change="onChangeThemeModel"
/>
</div>
</a-menu>
</template>
</a-dropdown>
</div>
</template>
<style scoped>
.theme-root {
padding: var(--space-lg);
}
.theme-btn {
padding: var(--space-xs) var(--space-lg);
font-size: var(--font-size-2xl);
color: var(--brand-primary);
}
.theme-item {
padding: var(--space-sm) var(--space-md);
border-radius: var(--radius-sm);
color: var(--text-primary);
user-select: none;
cursor: pointer;
.sign {
color: var(--brand-accent);
}
&:hover {
background: var(--brand-secondary);
color: var(--text-inverse);
}
&:active {
background: var(--brand-primary);
color: var(--text-inverse);
.sign {
color: var(--text-inverse);
}
}
}
.theme-model-item {
padding: var(--space-sm) var(--space-md);
color: var(--text-primary);
user-select: none;
}
</style>
vue main.js 文件内容
源码位置:src/main.js
该文件内需引入 "src/themes/index.css" 文件,如下
import { createApp } from "vue";
import Antd from "ant-design-vue";
import "./themes/index.css";
import App from "./App.vue";
createApp(App).use(Antd).mount("#app");
主题初始化、模式监听
源码位置:src/App.vue
src/App.vue 文件是 vue 所有的页面基础,在此处初始化主题信息、监听模式变化比较合适。
- 初始化主题样式只需要调用 src/themes/theme.ts 内的 initializeTheme() 函数即可
- 监听模式变化需要在组件挂载之后,在 onMounted 函数内调用 addDarkListener() 函数即可
- 移除监听需要在组件卸载之后,在 onUnmounted 函数内调用 removeDarkListener() 函数即可
src/App.vue 文件内 script 块部分源码如下
function initialize() {
// 初始化主题样式
initializeTheme();
}
initialize();
// 组件生命周期钩子
onMounted(() => {
initialize();
// 添加暗黑模式监听器
addDarkListener();
});
onUnmounted(() => {
// 移除暗黑模式监听器
removeDarkListener();
});