找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

1466

积分

0

好友

247

主题
发表于 8 小时前 | 查看: 0| 回复: 0

之前在浏览 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 的特性和应用场景,可以查阅相关的技术文档。

实现思路

整个方案的实现可以分为三个清晰的步骤:

  1. 定义变量与主题:首先在 :root 伪类下定义所有需要用到的 CSS 变量。然后,为不同的主题(如默认、星空、海洋)创建对应的 CSS 类(例如 .theme-default),并在这些类中重新定义上述变量的值。
  2. 动态切换主题:在应用运行时,通过 JavaScript 操作 document.documentElementclassList,动态添加或移除对应的主题类名,从而应用不同的变量值集。
  3. 跟随系统主题:利用 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。希望这个实践方案能对你的项目有所帮助,也欢迎在云栈社区与其他开发者交流讨论。




上一篇:Spring事务管理的6种进阶方案,解决@Transactional的局限性
下一篇:Wireshark网络流量包实战分析:从入侵溯源到数据库密码提取
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-1-18 16:27 , Processed in 0.222814 second(s), 43 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表