之前在浏览 V 站时,看到一个关于 JSON 格式化工具信息泄漏的帖子,有条评论说:“V 站不是人手一个工具站吗?”。受此感召,我也决定搭建一个自己的工具站。
在搭建工具站的过程中,需要实现多主题、亮/暗主题切换的功能,于是便有了这篇文章的实践总结。
备注:工具站目前支持的工具还不多,但项目已开源,并部署在 Github Pages 上。文中介绍的主题切换源码也在其中,感兴趣的朋友可以随意取用。后续我也会将自己常用或感兴趣的工具集成进去。
再备注:本文介绍的多主题与模式切换方案基于 Vue3 环境实现,其他框架或环境的朋友可以参考核心思路自行实现。
工具站与源码地址
项目仓库地址:https://github.com/the-wind-is-rising-dev/endless-quest
工具站线上地址可在仓库的 README 文件中找到。
实现原理
主题切换的核心是结合了 CSS 变量(Custom Properties) 和 CSS 的 Class 覆盖特性。
- Class 覆盖特性:后加载的 class 样式会覆盖之前加载的同名 class 样式,其中定义的 CSS 变量值也会被覆盖。
- CSS 变量:定义时以
-- 开头,具有继承性,可以在整个文档中复用。例如:
:root {
/* ========== 品牌主色调 ========== */
--brand-primary: #4f46e5; /* 主色:靛蓝 */
--brand-secondary: #0ea5e9; /* 次要色:天蓝 */
--brand-accent: #8b5cf6; /* 强调色:紫色 */
}
如果你想更深入地了解 CSS Variables 的特性和应用场景,可以查阅相关的技术文档。
实现思路
整个方案的实现可以分为三个清晰的步骤:
- 定义变量与主题:首先在
:root 伪类下定义所有需要用到的 CSS 变量。然后,为不同的主题(如默认、星空、海洋)创建对应的 CSS 类(例如 .theme-default),并在这些类中重新定义上述变量的值。
- 动态切换主题:在应用运行时,通过 JavaScript 操作
document.documentElement 的 classList,动态添加或移除对应的主题类名,从而应用不同的变量值集。
- 跟随系统主题:利用
window.matchMedia API 监听 (prefers-color-scheme: dark) 媒体查询的变化,当系统主题改变时,自动切换到对应的亮/暗模式。
代码实现详解
1. 基础变量定义 (:root)
首先,在项目的全局样式文件(例如 src/themes/index.css)中,于 :root 下定义所有颜色、间距、字体等变量。这相当于声明了一套“设计令牌”。
:root {
/* 背景与表面色 */
--bg-primary: #f8fafc; /* 主背景 */
--bg-secondary: #ffffff; /* 次级背景/卡片 */
--bg-tertiary: #f1f5f9; /* 工具栏/三级背景 */
--bg-sidebar: #e2e8f0; /* 侧边栏背景 */
}
2. 主题样式定义
接着,为每个主题创建单独的 CSS 文件,通过特定的类选择器来覆盖 :root 中的变量。
默认主题 - 明亮模式 (src/themes/default/light.css)
html.theme-default {
/* 背景与表面色 */
--bg-primary: #f8fafc;
--bg-secondary: #ffffff;
--bg-tertiary: #f1f5f9;
--bg-sidebar: #e2e8f0;
}
默认主题 - 暗夜模式 (src/themes/default/dark.css)
html.theme-default.dark {
/* 背景与表面色 */
--bg-primary: #0f172a;
--bg-secondary: #1e293b;
--bg-tertiary: #334155;
--bg-sidebar: #1e293b;
}
注意,暗夜模式的类名是 .theme-default.dark,它将在 .theme-default 的基础上,进一步覆盖变量值为深色系。
3. 核心主题管理逻辑 (src/themes/theme.ts)
这是整个功能的大脑,负责状态管理、持久化和 DOM 操作。
定义类型与存储键
// 存储主题配置的键
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
);
}
应用主题函数
这是最关键的函数,它负责将配置应用到 HTML 根元素上。
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
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);
}
// 获取当前配置(从 localStorage 或返回默认)
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() {
window
.matchMedia("(prefers-color-scheme: dark)")
.addEventListener("change", (e) => {
const themeConfig = getCurrentThemeConfig();
if (!themeConfig.model.followSystem) return;
changeThemeMode(themeConfig.model);
});
}
export function removeDarkListener() { ... }
// 切换主题模式(亮/暗)
export function changeThemeMode(themeModel: ThemeModel) {
const themeConfig = getCurrentThemeConfig();
themeConfig.model = themeModel;
if (themeModel.followSystem) {
themeConfig.model.value = isDarkMode() ? "dark" : "light";
}
applyTheme(themeConfig);
}
// 切换主题(如默认、星空、海洋)
export function changeTheme(theme: Theme) {
const themeConfig = getCurrentThemeConfig();
themeConfig.theme = theme;
applyTheme(themeConfig);
}
// 获取支持的主题列表
export function getThemeList(): Theme[] {
return [
{ name: "默认", className: "theme-default" },
{ name: "星空", className: "theme-starry" },
{ name: "海洋", className: "theme-ocean" },
];
}
4. 主题切换组件 (src/themes/Theme.vue)
这是一个基于 Vue 3 和 Ant Design Vue 的 UI 组件,为用户提供可视化的切换控件。
<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);
}
// 组件挂载时,启动定时器同步状态(也可用 watch 更优雅地实现)
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>
/* 样式略,详见源码 */
</style>
5. 集成到 Vue 应用
引入主题样式 (src/main.js)
确保全局样式文件被引入。
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)
在根组件中初始化主题,并设置系统主题监听。
import { onMounted, onUnmounted } from 'vue';
import { initializeTheme, addDarkListener, removeDarkListener } from './themes/theme';
function initialize() {
// 初始化主题样式
initializeTheme();
}
initialize();
// 组件生命周期钩子
onMounted(() => {
initialize();
// 添加暗黑模式监听器
addDarkListener();
});
onUnmounted(() => {
// 移除暗黑模式监听器
removeDarkListener();
});
总结
这个方案通过 CSS 变量 集中管理设计值,利用 Class 覆盖 实现值的动态切换,再结合 TypeScript 提供类型安全的逻辑层,最终在 Vue 3 组件中提供友好的用户交互。它具备了主题持久化、跟随系统、多主题扩展等现代化前端应用所需的特性,代码结构清晰,易于维护和扩展。
本方案的完整代码已开源,你可以在 开源实战 板块找到更多类似的项目源码和最佳实践。项目地址为:https://github.com/the-wind-is-rising-dev/endless-quest。希望这个实践方案能对你的项目有所帮助,也欢迎在云栈社区与其他开发者交流讨论。