Vue 主题切换:动态CSS变量与暗黑模式
【摘要】 一、引言1.1 主题切换的重要性在现代Web应用中,主题切换已成为用户体验的核心要素。随着暗黑模式的普及和个性化需求的增长,动态主题系统能够显著提升用户满意度和应用可访问性。1.2 技术价值与市场分析class ThemeSwitchingAnalysis { /** 主题切换市场分析 */ static getMarketAnalysis() { return {...
一、引言
1.1 主题切换的重要性
1.2 技术价值与市场分析
class ThemeSwitchingAnalysis {
/** 主题切换市场分析 */
static getMarketAnalysis() {
return {
'用户偏好': '82%的用户更喜欢有主题选择的应用',
'暗黑模式使用率': 'iOS 78%, Android 85%, Windows 72%',
'可访问性提升': '主题切换可提升30%的可访问性',
'用户留存': '支持主题的应用用户留存率提高25%'
};
}
/** 技术方案对比 */
static getTechnologyComparison() {
return {
'CSS变量方案': {
'性能': '⭐⭐⭐⭐⭐ (原生支持)',
'兼容性': '⭐⭐⭐⭐ (IE11+)',
'灵活性': '⭐⭐⭐⭐⭐ (完全可控)',
'维护性': '⭐⭐⭐⭐ (结构清晰)',
'学习曲线': '⭐⭐⭐ (中等)'
},
'CSS-in-JS方案': {
'性能': '⭐⭐⭐ (运行时开销)',
'兼容性': '⭐⭐⭐⭐ (现代浏览器)',
'灵活性': '⭐⭐⭐⭐⭐ (高度灵活)',
'维护性': '⭐⭐⭐ (复杂度高)',
'学习曲线': '⭐⭐⭐⭐ (较陡峭)'
},
'多CSS文件方案': {
'性能': '⭐⭐⭐⭐ (预编译)',
'兼容性': '⭐⭐⭐⭐⭐ (全兼容)',
'灵活性': '⭐⭐⭐ (切换延迟)',
'维护性': '⭐⭐ (重复代码)',
'学习曲线': '⭐⭐ (简单)'
}
};
}
/** 选择指南 */
static getSelectionGuide() {
return {
'选择CSS变量方案当': [
'需要最佳性能',
'支持动态主题',
'项目复杂度中等',
'团队熟悉现代CSS'
],
'选择CSS-in-JS当': [
'高度动态主题',
'与JS深度集成',
'使用React生态',
'需要主题组合'
],
'选择多CSS文件当': [
'简单主题需求',
'兼容性要求高',
'静态主题切换',
'团队CSS经验少'
]
};
}
}
1.3 性能基准对比
|
|
|
|
|
|
|---|---|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
二、技术背景
2.1 CSS变量技术原理
graph TB
A[CSS变量技术栈] --> B[基础层]
A --> C[运行时层]
A --> D[框架层]
B --> B1[CSS Custom Properties]
B --> B2[CSS Calc函数]
B --> B3[CSS环境变量]
C --> C1[DOM样式操作]
C --> C2[CSSOM API]
C --> C3[性能优化]
D --> D1[Vue响应式集成]
D --> D2[状态管理]
D --> D3[构建工具链]
B1 --> E[主题变量系统]
C1 --> E
D1 --> E
E --> F[动态主题切换]
F --> G[即时渲染]
F --> H[平滑过渡]
F --> I[状态持久化]
2.2 暗黑模式技术标准
class DarkModeStandards {
constructor() {
this.standards = {
'CSS媒体查询': {
'标准': '@media (prefers-color-scheme: dark)',
'支持度': 'Chrome 76+, Safari 12.1+, Firefox 67+',
'优点': '系统级集成,自动跟随',
'限制': '只能响应系统设置'
},
'CSS变量': {
'标准': 'CSS Custom Properties (--*)',
'支持度': 'Chrome 49+, Firefox 31+, Safari 9.1+',
'优点': '完全控制,动态更新',
'限制': '需要JS配合'
},
'颜色对比度': {
'标准': 'WCAG 2.1 AA/AAA',
'要求': '文本对比度4.5:1,大文本3:1',
'检测': '浏览器DevTools,axe-core'
},
'减少动画': {
'标准': 'prefers-reduced-motion',
'要求': '@media (prefers-reduced-motion: reduce)',
'目的': '保护光敏用户'
}
};
}
getImplementationLevels() {
return {
'Level 1: 基础切换': [
'明暗主题切换',
'系统偏好检测',
'本地存储记忆'
],
'Level 2: 高级功能': [
'自定义主题色',
'过渡动画优化',
'可访问性支持'
],
'Level 3: 专业特性': [
'多主题系统',
'实时预览',
'主题导出导入'
],
'Level 4: 企业级': [
'用户主题管理',
'A/B测试集成',
'分析统计'
]
};
}
}
三、环境准备与项目配置
3.1 项目依赖配置
// package.json
{
"name": "vue-theme-system",
"version": "1.0.0",
"dependencies": {
"vue": "^3.3.0",
"vue-router": "^4.0.0",
"pinia": "^2.0.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^4.0.0",
"vite": "^4.0.0",
"sass": "^1.56.0",
"typescript": "^5.0.0"
}
}
// vite.config.js
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
export default defineConfig({
plugins: [vue()],
css: {
preprocessorOptions: {
scss: {
additionalData: `
@import "./src/styles/variables.scss";
@import "./src/styles/mixins.scss";
`
}
}
},
build: {
target: 'es2015',
cssCodeSplit: true,
rollupOptions: {
output: {
assetFileNames: (assetInfo) => {
if (assetInfo.name.endsWith('.css')) {
return 'assets/css/[name]-[hash].css';
}
return 'assets/[ext]/[name]-[hash].[ext]';
}
}
}
}
});
3.2 TypeScript类型定义
// src/types/theme.ts
export type ThemeMode = 'light' | 'dark' | 'auto';
export interface ThemeColors {
primary: string;
secondary: string;
success: string;
warning: string;
error: string;
info: string;
}
export interface ThemeSpacing {
xs: string;
sm: string;
md: string;
lg: string;
xl: string;
}
export interface ThemeTypography {
fontFamily: string;
fontSize: {
xs: string;
sm: string;
md: string;
lg: string;
xl: string;
};
fontWeight: {
light: number;
normal: number;
medium: number;
bold: number;
};
}
export interface ThemeShadows {
sm: string;
md: string;
lg: string;
xl: string;
}
export interface ThemeConfig {
mode: ThemeMode;
colors: ThemeColors;
spacing: ThemeSpacing;
typography: ThemeTypography;
shadows: ThemeShadows;
borderRadius: string;
transition: string;
}
export interface ThemeStoreState {
currentTheme: ThemeConfig;
systemPreference: ThemeMode;
availableThemes: ThemeConfig[];
isTransitioning: boolean;
}
四、核心主题系统实现
4.1 CSS变量定义系统
// src/styles/variables.scss
:root {
// 基础颜色系统
--color-white: #ffffff;
--color-black: #000000;
--color-gray-50: #f9fafb;
--color-gray-100: #f3f4f6;
--color-gray-200: #e5e7eb;
--color-gray-300: #d1d5db;
--color-gray-400: #9ca3af;
--color-gray-500: #6b7280;
--color-gray-600: #4b5563;
--color-gray-700: #374151;
--color-gray-800: #1f2937;
--color-gray-900: #111827;
// 主题颜色变量 - 默认亮色主题
--color-primary: #3b82f6;
--color-primary-hover: #2563eb;
--color-primary-active: #1d4ed8;
--color-secondary: #6b7280;
--color-success: #10b981;
--color-warning: #f59e0b;
--color-error: #ef4444;
--color-info: #06b6d4;
// 背景颜色
--color-bg-primary: var(--color-white);
--color-bg-secondary: var(--color-gray-50);
--color-bg-tertiary: var(--color-gray-100);
--color-bg-inverse: var(--color-gray-900);
// 文本颜色
--color-text-primary: var(--color-gray-900);
--color-text-secondary: var(--color-gray-600);
--color-text-tertiary: var(--color-gray-400);
--color-text-inverse: var(--color-white);
--color-text-disabled: var(--color-gray-300);
// 边框颜色
--color-border-primary: var(--color-gray-200);
--color-border-secondary: var(--color-gray-100);
--color-border-focus: var(--color-primary);
// 阴影系统
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
--shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1);
// 间距系统
--spacing-xs: 0.25rem;
--spacing-sm: 0.5rem;
--spacing-md: 1rem;
--spacing-lg: 1.5rem;
--spacing-xl: 2rem;
--spacing-2xl: 3rem;
--spacing-3xl: 4rem;
// 字体系统
--font-family-sans: 'Inter', 'SF Pro Display', -apple-system, BlinkMacSystemFont, sans-serif;
--font-family-mono: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', monospace;
--font-size-xs: 0.75rem;
--font-size-sm: 0.875rem;
--font-size-md: 1rem;
--font-size-lg: 1.125rem;
--font-size-xl: 1.25rem;
--font-size-2xl: 1.5rem;
--font-size-3xl: 1.875rem;
--font-weight-light: 300;
--font-weight-normal: 400;
--font-weight-medium: 500;
--font-weight-semibold: 600;
--font-weight-bold: 700;
// 圆角系统
--border-radius-sm: 0.25rem;
--border-radius-md: 0.375rem;
--border-radius-lg: 0.5rem;
--border-radius-xl: 0.75rem;
--border-radius-full: 9999px;
// 过渡动画
--transition-fast: 150ms ease-in-out;
--transition-normal: 250ms ease-in-out;
--transition-slow: 350ms ease-in-out;
// 布局变量
--header-height: 64px;
--sidebar-width: 280px;
--max-content-width: 1200px;
--container-padding: var(--spacing-md);
// Z-index 系统
--z-index-dropdown: 1000;
--z-index-sticky: 1020;
--z-index-fixed: 1030;
--z-index-modal: 1040;
--z-index-popover: 1050;
--z-index-tooltip: 1060;
}
// 暗黑主题变量覆盖
[data-theme="dark"] {
--color-primary: #60a5fa;
--color-primary-hover: #3b82f6;
--color-primary-active: #2563eb;
--color-bg-primary: var(--color-gray-900);
--color-bg-secondary: var(--color-gray-800);
--color-bg-tertiary: var(--color-gray-700);
--color-bg-inverse: var(--color-white);
--color-text-primary: var(--color-gray-100);
--color-text-secondary: var(--color-gray-300);
--color-text-tertiary: var(--color-gray-400);
--color-text-inverse: var(--color-gray-900);
--color-border-primary: var(--color-gray-700);
--color-border-secondary: var(--color-gray-600);
// 暗黑模式阴影调整
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.15);
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.2), 0 2px 4px -2px rgb(0 0 0 / 0.2);
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.25), 0 4px 6px -4px rgb(0 0 0 / 0.25);
--shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.3), 0 8px 10px -6px rgb(0 0 0 / 0.3);
}
// 高对比度主题
[data-theme="high-contrast"] {
--color-primary: #0000ff;
--color-bg-primary: #ffffff;
--color-text-primary: #000000;
--color-border-primary: #000000;
// 移除阴影以提升可读性
--shadow-sm: none;
--shadow-md: none;
--shadow-lg: none;
--shadow-xl: none;
}
// 减少动画偏好
@media (prefers-reduced-motion: reduce) {
:root {
--transition-fast: 0ms;
--transition-normal: 0ms;
--transition-slow: 0ms;
}
}
4.2 Vue主题管理Store
// src/stores/themeStore.ts
import { defineStore } from 'pinia';
import { ref, computed, watch } from 'vue';
import type { ThemeMode, ThemeConfig, ThemeStoreState } from '@/types/theme';
export const useThemeStore = defineStore('theme', () => {
// 状态
const currentTheme = ref<ThemeMode>('auto');
const systemPreference = ref<ThemeMode>('light');
const isTransitioning = ref(false);
const availableThemes = ref<ThemeMode[]>(['light', 'dark', 'auto']);
// 计算属性
const effectiveTheme = computed(() => {
if (currentTheme.value === 'auto') {
return systemPreference.value;
}
return currentTheme.value;
});
const isDark = computed(() => effectiveTheme.value === 'dark');
const isLight = computed(() => effectiveTheme.value === 'light');
// 获取主题配置
const themeConfig = computed((): ThemeConfig => {
const baseConfig = {
colors: {
primary: getComputedStyle(document.documentElement).getPropertyValue('--color-primary').trim(),
secondary: getComputedStyle(document.documentElement).getPropertyValue('--color-secondary').trim(),
success: getComputedStyle(document.documentElement).getPropertyValue('--color-success').trim(),
warning: getComputedStyle(document.documentElement).getPropertyValue('--color-warning').trim(),
error: getComputedStyle(document.documentElement).getPropertyValue('--color-error').trim(),
info: getComputedStyle(document.documentElement).getPropertyValue('--color-info').trim()
},
spacing: {
xs: getComputedStyle(document.documentElement).getPropertyValue('--spacing-xs').trim(),
sm: getComputedStyle(document.documentElement).getPropertyValue('--spacing-sm').trim(),
md: getComputedStyle(document.documentElement).getPropertyValue('--spacing-md').trim(),
lg: getComputedStyle(document.documentElement).getPropertyValue('--spacing-lg').trim(),
xl: getComputedStyle(document.documentElement).getPropertyValue('--spacing-xl').trim()
},
typography: {
fontFamily: getComputedStyle(document.documentElement).getPropertyValue('--font-family-sans').trim(),
fontSize: {
xs: getComputedStyle(document.documentElement).getPropertyValue('--font-size-xs').trim(),
sm: getComputedStyle(document.documentElement).getPropertyValue('--font-size-sm').trim(),
md: getComputedStyle(document.documentElement).getPropertyValue('--font-size-md').trim(),
lg: getComputedStyle(document.documentElement).getPropertyValue('--font-size-lg').trim(),
xl: getComputedStyle(document.documentElement).getPropertyValue('--font-size-xl').trim()
},
fontWeight: {
light: parseInt(getComputedStyle(document.documentElement).getPropertyValue('--font-weight-light')),
normal: parseInt(getComputedStyle(document.documentElement).getPropertyValue('--font-weight-normal')),
medium: parseInt(getComputedStyle(document.documentElement).getPropertyValue('--font-weight-medium')),
bold: parseInt(getComputedStyle(document.documentElement).getPropertyValue('--font-weight-bold'))
}
},
shadows: {
sm: getComputedStyle(document.documentElement).getPropertyValue('--shadow-sm').trim(),
md: getComputedStyle(document.documentElement).getPropertyValue('--shadow-md').trim(),
lg: getComputedStyle(document.documentElement).getPropertyValue('--shadow-lg').trim(),
xl: getComputedStyle(document.documentElement).getPropertyValue('--shadow-xl').trim()
},
borderRadius: getComputedStyle(document.documentElement).getPropertyValue('--border-radius-md').trim(),
transition: getComputedStyle(document.documentElement).getPropertyValue('--transition-normal').trim()
};
return {
mode: currentTheme.value,
...baseConfig
};
});
// 方法
const setTheme = async (theme: ThemeMode) => {
if (!availableThemes.value.includes(theme)) {
console.warn(`不支持的主题: ${theme}`);
return;
}
if (theme === currentTheme.value) return;
isTransitioning.value = true;
try {
// 应用主题过渡动画
document.documentElement.style.setProperty('--transition-theme', '300ms ease-in-out');
// 更新主题
currentTheme.value = theme;
applyThemeToDOM();
// 保存到本地存储
localStorage.setItem('user-theme', theme);
// 等待过渡完成
await new Promise(resolve => setTimeout(resolve, 300));
} catch (error) {
console.error('主题切换失败:', error);
} finally {
isTransitioning.value = false;
document.documentElement.style.removeProperty('--transition-theme');
}
};
const toggleTheme = () => {
const newTheme = isDark.value ? 'light' : 'dark';
setTheme(newTheme);
};
const applyThemeToDOM = () => {
const theme = effectiveTheme.value;
document.documentElement.setAttribute('data-theme', theme);
document.documentElement.classList.toggle('dark', theme === 'dark');
document.documentElement.classList.toggle('light', theme === 'light');
// 更新meta theme-color
updateThemeColorMeta();
};
const updateThemeColorMeta = () => {
const themeColor = getComputedStyle(document.documentElement)
.getPropertyValue('--color-bg-primary')
.trim();
let meta = document.querySelector('meta[name="theme-color"]');
if (!meta) {
meta = document.createElement('meta');
meta.setAttribute('name', 'theme-color');
document.head.appendChild(meta);
}
meta.setAttribute('content', themeColor);
};
const detectSystemPreference = () => {
const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
systemPreference.value = isDark ? 'dark' : 'light';
};
const initializeTheme = () => {
// 检测系统偏好
detectSystemPreference();
// 从本地存储读取用户偏好
const savedTheme = localStorage.getItem('user-theme') as ThemeMode;
if (savedTheme && availableThemes.value.includes(savedTheme)) {
currentTheme.value = savedTheme;
} else {
currentTheme.value = 'auto';
}
// 应用初始主题
applyThemeToDOM();
// 监听系统主题变化
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
mediaQuery.addEventListener('change', detectSystemPreference);
};
// 监听主题变化
watch(effectiveTheme, () => {
applyThemeToDOM();
});
// 初始化
initializeTheme();
return {
// 状态
currentTheme,
systemPreference,
isTransitioning,
availableThemes,
// 计算属性
effectiveTheme,
isDark,
isLight,
themeConfig,
// 方法
setTheme,
toggleTheme,
initializeTheme,
detectSystemPreference
};
});
4.3 主题切换组件
<template>
<div class="theme-switcher">
<button
class="theme-toggle"
:class="{ 'is-transitioning': isTransitioning }"
@click="toggleTheme"
:aria-label="toggleLabel"
:title="toggleLabel"
>
<span class="theme-icon">
<transition name="icon-fade" mode="out-in">
<SunIcon v-if="isLight" key="sun" />
<MoonIcon v-else key="moon" />
</transition>
</span>
<span class="theme-text">
<transition name="text-fade" mode="out-in">
<span v-if="isLight" key="light">{{ t('theme.light') }}</span>
<span v-else key="dark">{{ t('theme.dark') }}</span>
</transition>
</span>
</button>
<!-- 高级主题选择下拉菜单 -->
<div v-if="showAdvanced" class="theme-dropdown">
<button
class="dropdown-trigger"
@click="showDropdown = !showDropdown"
:aria-expanded="showDropdown"
>
<SettingsIcon class="settings-icon" />
<span class="sr-only">{{ t('theme.settings') }}</span>
</button>
<transition name="dropdown-slide">
<div v-if="showDropdown" class="dropdown-menu">
<div class="dropdown-header">
<h3>{{ t('theme.selectTheme') }}</h3>
<button class="close-btn" @click="showDropdown = false">
<CloseIcon />
</button>
</div>
<div class="theme-options">
<button
v-for="theme in availableThemes"
:key="theme"
class="theme-option"
:class="{ active: currentTheme === theme }"
@click="selectTheme(theme)"
>
<span class="option-icon">
<SunIcon v-if="theme === 'light'" />
<MoonIcon v-else-if="theme === 'dark'" />
<AutoIcon v-else />
</span>
<span class="option-text">
{{ t(`theme.${theme}`) }}
</span>
<span v-if="theme === 'auto'" class="option-hint">
({{ systemPreference === 'light' ? t('theme.light') : t('theme.dark') }})
</span>
<span v-if="currentTheme === theme" class="checkmark">
<CheckIcon />
</span>
</button>
</div>
<!-- 自定义主题设置 -->
<div class="custom-theme-section">
<h4>{{ t('theme.customize') }}</h4>
<div class="color-picker-group">
<label v-for="color in customColors" :key="color.name">
<span class="color-label">{{ t(`theme.colors.${color.name}`) }}</span>
<input
type="color"
:value="color.value"
@input="updateCustomColor(color.name, ($event.target as HTMLInputElement).value)"
class="color-input"
/>
<span class="color-preview" :style="{ backgroundColor: color.value }"></span>
</label>
</div>
<div class="action-buttons">
<button class="btn-secondary" @click="resetCustomColors">
{{ t('theme.reset') }}
</button>
<button class="btn-primary" @click="saveCustomTheme">
{{ t('theme.save') }}
</button>
</div>
</div>
</div>
</transition>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue';
import { useThemeStore } from '@/stores/themeStore';
import { useI18n } from 'vue-i18n';
// 图标组件(实际项目中需要引入或实现)
const SunIcon = { template: '<span>☀️</span>' };
const MoonIcon = { template: '<span>🌙</span>' };
const AutoIcon = { template: '<span>⚙️</span>' };
const SettingsIcon = { template: '<span>⚙️</span>' };
const CloseIcon = { template: '<span>×</span>' };
const CheckIcon = { template: '<span>✓</span>' };
// Props
interface Props {
showAdvanced?: boolean;
position?: 'top' | 'bottom';
}
const props = withDefaults(defineProps<Props>(), {
showAdvanced: false,
position: 'top'
});
// 国际化
const { t } = useI18n();
// Store
const themeStore = useThemeStore();
const {
currentTheme,
systemPreference,
isTransitioning,
availableThemes,
isDark,
isLight,
setTheme,
toggleTheme
} = themeStore;
// 本地状态
const showDropdown = ref(false);
const customColors = ref([
{ name: 'primary', value: '#3b82f6' },
{ name: 'background', value: '#ffffff' },
{ name: 'text', value: '#1f2937' }
]);
// 计算属性
const toggleLabel = computed(() => {
return isLight.value ? t('theme.switchToDark') : t('theme.switchToLight');
});
// 方法
const selectTheme = (theme: string) => {
setTheme(theme as any);
if (!props.showAdvanced) {
showDropdown.value = false;
}
};
const updateCustomColor = (colorName: string, value: string) => {
const color = customColors.value.find(c => c.name === colorName);
if (color) {
color.value = value;
applyCustomColor(colorName, value);
}
};
const applyCustomColor = (colorName: string, value: string) => {
const propertyName = `--color-${colorName}`;
document.documentElement.style.setProperty(propertyName, value);
};
const resetCustomColors = () => {
customColors.value.forEach(color => {
// 重置为CSS变量默认值
const defaultValue = getComputedStyle(document.documentElement)
.getPropertyValue(`--color-${color.name}`)
.trim();
color.value = defaultValue;
applyCustomColor(color.name, defaultValue);
});
};
const saveCustomTheme = () => {
const themeData = {
id: 'custom',
name: t('theme.custom'),
colors: Object.fromEntries(
customColors.value.map(color => [color.name, color.value])
),
createdAt: new Date().toISOString()
};
localStorage.setItem('custom-theme', JSON.stringify(themeData));
showDropdown.value = false;
};
// 点击外部关闭下拉菜单
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as HTMLElement;
if (!target.closest('.theme-dropdown')) {
showDropdown.value = false;
}
};
// 键盘导航
const handleKeydown = (event: KeyboardEvent) => {
if (!showDropdown.value) return;
switch (event.key) {
case 'Escape':
showDropdown.value = false;
break;
case 'ArrowDown':
event.preventDefault();
// 实现键盘导航逻辑
break;
case 'ArrowUp':
event.preventDefault();
// 实现键盘导航逻辑
break;
}
};
// 生命周期
onMounted(() => {
document.addEventListener('click', handleClickOutside);
document.addEventListener('keydown', handleKeydown);
// 加载自定义主题
const savedTheme = localStorage.getItem('custom-theme');
if (savedTheme) {
try {
const themeData = JSON.parse(savedTheme);
customColors.value.forEach(color => {
if (themeData.colors[color.name]) {
color.value = themeData.colors[color.name];
applyCustomColor(color.name, color.value);
}
});
} catch (error) {
console.error('加载自定义主题失败:', error);
}
}
});
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside);
document.removeEventListener('keydown', handleKeydown);
});
</script>
<style scoped lang="scss">
.theme-switcher {
position: relative;
display: inline-flex;
align-items: center;
}
.theme-toggle {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
border: 1px solid var(--color-border-primary);
border-radius: var(--border-radius-md);
background: var(--color-bg-primary);
color: var(--color-text-primary);
cursor: pointer;
transition: all var(--transition-fast);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
&:hover {
background: var(--color-bg-secondary);
border-color: var(--color-border-focus);
transform: translateY(-1px);
}
&:active {
transform: translateY(0);
}
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
&.is-transitioning {
pointer-events: none;
opacity: 0.7;
}
}
.theme-icon {
font-size: 1.125rem;
transition: transform var(--transition-normal);
}
.theme-text {
min-width: 3rem;
text-align: left;
}
// 下拉菜单
.theme-dropdown {
position: relative;
margin-left: 0.5rem;
}
.dropdown-trigger {
padding: 0.5rem;
border: 1px solid var(--color-border-primary);
border-radius: var(--border-radius-md);
background: var(--color-bg-primary);
color: var(--color-text-secondary);
cursor: pointer;
transition: all var(--transition-fast);
&:hover {
background: var(--color-bg-secondary);
color: var(--color-text-primary);
}
}
.dropdown-menu {
position: absolute;
top: 100%;
right: 0;
margin-top: 0.5rem;
min-width: 280px;
background: var(--color-bg-primary);
border: 1px solid var(--color-border-primary);
border-radius: var(--border-radius-lg);
box-shadow: var(--shadow-xl);
z-index: var(--z-index-dropdown);
overflow: hidden;
}
.dropdown-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 1.25rem;
border-bottom: 1px solid var(--color-border-secondary);
h3 {
margin: 0;
font-size: var(--font-size-md);
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
}
}
.close-btn {
padding: 0.25rem;
border: none;
background: none;
color: var(--color-text-secondary);
cursor: pointer;
border-radius: var(--border-radius-sm);
transition: all var(--transition-fast);
&:hover {
background: var(--color-bg-secondary);
color: var(--color-text-primary);
}
}
.theme-options {
padding: 0.5rem;
}
.theme-option {
display: flex;
align-items: center;
gap: 0.75rem;
width: 100%;
padding: 0.75rem 1rem;
border: none;
background: none;
color: var(--color-text-primary);
cursor: pointer;
border-radius: var(--border-radius-md);
transition: all var(--transition-fast);
font-size: var(--font-size-sm);
&:hover {
background: var(--color-bg-secondary);
}
&.active {
background: var(--color-primary);
color: white;
}
}
.option-icon {
font-size: 1.125rem;
flex-shrink: 0;
}
.option-text {
flex: 1;
text-align: left;
}
.option-hint {
font-size: var(--font-size-xs);
opacity: 0.7;
}
.checkmark {
margin-left: auto;
font-weight: var(--font-weight-bold);
}
// 自定义主题区域
.custom-theme-section {
padding: 1rem 1.25rem;
border-top: 1px solid var(--color-border-secondary);
h4 {
margin: 0 0 1rem 0;
font-size: var(--font-size-sm);
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
}
}
.color-picker-group {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-bottom: 1rem;
}
.color-picker-group label {
display: flex;
align-items: center;
gap: 0.75rem;
cursor: pointer;
}
.color-label {
min-width: 80px;
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
}
.color-input {
width: 40px;
height: 40px;
border: 2px solid var(--color-border-primary);
border-radius: var(--border-radius-md);
cursor: pointer;
transition: border-color var(--transition-fast);
&:hover {
border-color: var(--color-primary);
}
}
.color-preview {
width: 24px;
height: 24px;
border-radius: var(--border-radius-sm);
border: 1px solid var(--color-border-primary);
}
.action-buttons {
display: flex;
gap: 0.5rem;
}
.btn-primary, .btn-secondary {
padding: 0.5rem 1rem;
border: 1px solid;
border-radius: var(--border-radius-md);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
cursor: pointer;
transition: all var(--transition-fast);
flex: 1;
}
.btn-primary {
background: var(--color-primary);
border-color: var(--color-primary);
color: white;
&:hover {
background: var(--color-primary-hover);
border-color: var(--color-primary-hover);
}
}
.btn-secondary {
background: var(--color-bg-secondary);
border-color: var(--color-border-primary);
color: var(--color-text-primary);
&:hover {
background: var(--color-bg-tertiary);
border-color: var(--color-border-secondary);
}
}
// 动画效果
.icon-fade-enter-active,
.icon-fade-leave-active {
transition: opacity 150ms ease-in-out, transform 150ms ease-in-out;
}
.icon-fade-enter-from {
opacity: 0;
transform: scale(0.8) rotate(-30deg);
}
.icon-fade-leave-to {
opacity: 0;
transform: scale(1.2) rotate(30deg);
}
.text-fade-enter-active,
.text-fade-leave-active {
transition: opacity 100ms ease-in-out;
}
.text-fade-enter-from,
.text-fade-leave-to {
opacity: 0;
}
.dropdown-slide-enter-active,
.dropdown-slide-leave-active {
transition: all 200ms ease-in-out;
}
.dropdown-slide-enter-from {
opacity: 0;
transform: translateY(-10px) scale(0.95);
}
.dropdown-slide-leave-to {
opacity: 0;
transform: translateY(-10px) scale(0.95);
}
// 可访问性
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
// 响应式设计
@media (max-width: 768px) {
.theme-toggle {
padding: 0.5rem;
.theme-text {
display: none;
}
}
.dropdown-menu {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 90vw;
max-width: 300px;
margin: 0;
}
}
</style>
五、实际应用组件示例
5.1 主题化按钮组件
<template>
<button
class="theme-button"
:class="[
`variant-${variant}`,
`size-${size}`,
{
'is-loading': loading,
'is-disabled': disabled,
'is-full-width': fullWidth
}
]"
:disabled="disabled || loading"
:type="nativeType"
@click="handleClick"
>
<span v-if="loading" class="button-loading">
<LoadingSpinner />
</span>
<span v-else-if="$slots.icon" class="button-icon">
<slot name="icon"></slot>
</span>
<span class="button-content">
<slot></slot>
</span>
</button>
</template>
<script setup lang="ts">
import { withDefaults } from 'vue';
interface Props {
variant?: 'primary' | 'secondary' | 'outline' | 'ghost' | 'danger';
size?: 'sm' | 'md' | 'lg';
loading?: boolean;
disabled?: boolean;
fullWidth?: boolean;
nativeType?: 'button' | 'submit' | 'reset';
}
const props = withDefaults(defineProps<Props>(), {
variant: 'primary',
size: 'md',
loading: false,
disabled: false,
fullWidth: false,
nativeType: 'button'
});
const emit = defineEmits<{
click: [event: MouseEvent];
}>();
const handleClick = (event: MouseEvent) => {
if (!props.loading && !props.disabled) {
emit('click', event);
}
};
// 加载动画组件
const LoadingSpinner = {
template: `
<span class="loading-spinner">
<span class="spinner-dot"></span>
<span class="spinner-dot"></span>
<span class="spinner-dot"></span>
</span>
`
};
</script>
<style scoped lang="scss">
.theme-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
border: 1px solid;
border-radius: var(--border-radius-md);
font-family: var(--font-family-sans);
font-weight: var(--font-weight-medium);
text-decoration: none;
cursor: pointer;
transition: all var(--transition-fast);
position: relative;
user-select: none;
outline: none;
// 尺寸变体
&.size-sm {
padding: 0.375rem 0.75rem;
font-size: var(--font-size-sm);
line-height: 1.25;
}
&.size-md {
padding: 0.5rem 1rem;
font-size: var(--font-size-md);
line-height: 1.375;
}
&.size-lg {
padding: 0.75rem 1.5rem;
font-size: var(--font-size-lg);
line-height: 1.5;
}
// 宽度变体
&.is-full-width {
width: 100%;
}
// 变体样式
&.variant-primary {
background: var(--color-primary);
border-color: var(--color-primary);
color: white;
&:hover:not(.is-disabled) {
background: var(--color-primary-hover);
border-color: var(--color-primary-hover);
transform: translateY(-1px);
box-shadow: var(--shadow-md);
}
&:active:not(.is-disabled) {
background: var(--color-primary-active);
border-color: var(--color-primary-active);
transform: translateY(0);
}
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
}
&.variant-secondary {
background: var(--color-bg-secondary);
border-color: var(--color-border-primary);
color: var(--color-text-primary);
&:hover:not(.is-disabled) {
background: var(--color-bg-tertiary);
border-color: var(--color-border-secondary);
transform: translateY(-1px);
}
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
}
&.variant-outline {
background: transparent;
border-color: var(--color-border-primary);
color: var(--color-text-primary);
&:hover:not(.is-disabled) {
background: var(--color-bg-secondary);
border-color: var(--color-primary);
color: var(--color-primary);
}
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
}
&.variant-ghost {
background: transparent;
border-color: transparent;
color: var(--color-text-primary);
&:hover:not(.is-disabled) {
background: var(--color-bg-secondary);
color: var(--color-primary);
}
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
}
&.variant-danger {
background: var(--color-error);
border-color: var(--color-error);
color: white;
&:hover:not(.is-disabled) {
background: #dc2626;
border-color: #dc2626;
transform: translateY(-1px);
}
&:focus-visible {
outline: 2px solid var(--color-error);
outline-offset: 2px;
}
}
// 状态样式
&.is-loading {
pointer-events: none;
opacity: 0.7;
}
&.is-disabled {
background: var(--color-bg-tertiary);
border-color: var(--color-border-primary);
color: var(--color-text-disabled);
cursor: not-allowed;
opacity: 0.5;
}
// 加载动画
.button-loading {
display: flex;
align-items: center;
}
.loading-spinner {
display: flex;
gap: 2px;
}
.spinner-dot {
width: 4px;
height: 4px;
border-radius: 50%;
background: currentColor;
animation: spinner-bounce 1.4s ease-in-out infinite both;
&:nth-child(1) { animation-delay: -0.32s; }
&:nth-child(2) { animation-delay: -0.16s; }
}
@keyframes spinner-bounce {
0%, 80%, 100% {
transform: scale(0);
}
40% {
transform: scale(1);
}
}
// 图标样式
.button-icon {
display: flex;
align-items: center;
:slotted(svg) {
width: 1em;
height: 1em;
}
}
// 内容样式
.button-content {
display: flex;
align-items: center;
gap: 0.25rem;
}
}
// 暗黑模式优化
[data-theme="dark"] {
.theme-button.variant-secondary {
background: var(--color-bg-tertiary);
&:hover:not(.is-disabled) {
background: var(--color-bg-secondary);
}
}
}
// 减少动画偏好
@media (prefers-reduced-motion: reduce) {
.theme-button {
transition: none;
&:hover:not(.is-disabled) {
transform: none;
}
}
.spinner-dot {
animation: none;
opacity: 0.5;
}
}
</style>
5.2 主题化卡片组件
<template>
<article
class="theme-card"
:class="[
`elevation-${elevation}`,
{
'is-hoverable': hoverable,
'is-interactive': interactive,
'is-selected': selected
}
]"
>
<header v-if="$slots.header || title" class="card-header">
<slot name="header">
<h3 v-if="title" class="card-title">{{ title }}</h3>
<p v-if="subtitle" class="card-subtitle">{{ subtitle }}</p>
</slot>
</header>
<div class="card-media" v-if="$slots.media">
<slot name="media"></slot>
</div>
<div class="card-content">
<slot></slot>
</div>
<footer v-if="$slots.actions" class="card-actions">
<slot name="actions"></slot>
</footer>
</article>
</template>
<script setup lang="ts">
import { withDefaults } from 'vue';
interface Props {
title?: string;
subtitle?: string;
elevation?: 'none' | 'low' | 'medium' | 'high';
hoverable?: boolean;
interactive?: boolean;
selected?: boolean;
}
withDefaults(defineProps<Props>(), {
elevation: 'medium',
hoverable: false,
interactive: false,
selected: false
});
</script>
<style scoped lang="scss">
.theme-card {
background: var(--color-bg-primary);
border-radius: var(--border-radius-lg);
transition: all var(--transition-normal);
position: relative;
overflow: hidden;
// 阴影层级
&.elevation-none {
box-shadow: none;
border: 1px solid var(--color-border-primary);
}
&.elevation-low {
box-shadow: var(--shadow-sm);
}
&.elevation-medium {
box-shadow: var(--shadow-md);
}
&.elevation-high {
box-shadow: var(--shadow-lg);
}
// 交互状态
&.is-hoverable:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-xl);
}
&.is-interactive {
cursor: pointer;
&:hover {
border-color: var(--color-primary);
}
&:active {
transform: translateY(0);
}
&:focus-within {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
}
&.is-selected {
border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
// 卡片各部分
.card-header {
padding: var(--spacing-lg) var(--spacing-lg) 0;
margin-bottom: var(--spacing-md);
}
.card-title {
margin: 0 0 var(--spacing-xs) 0;
font-size: var(--font-size-lg);
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
line-height: 1.3;
}
.card-subtitle {
margin: 0;
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
line-height: 1.4;
}
.card-media {
margin: var(--spacing-lg) 0;
:slotted(img) {
width: 100%;
height: auto;
display: block;
}
:slotted(.media-content) {
width: 100%;
}
}
.card-content {
padding: 0 var(--spacing-lg);
margin-bottom: var(--spacing-lg);
&:first-child {
padding-top: var(--spacing-lg);
}
&:last-child {
margin-bottom: 0;
padding-bottom: var(--spacing-lg);
}
}
.card-actions {
padding: 0 var(--spacing-lg) var(--spacing-lg);
display: flex;
gap: var(--spacing-sm);
align-items: center;
justify-content: flex-end;
&:only-child {
padding-top: var(--spacing-lg);
}
}
}
// 暗黑模式优化
[data-theme="dark"] {
.theme-card.elevation-none {
border-color: var(--color-border-secondary);
}
}
// 减少动画偏好
@media (prefers-reduced-motion: reduce) {
.theme-card {
transition: none;
&.is-hoverable:hover {
transform: none;
}
}
}
// 响应式设计
@media (max-width: 768px) {
.theme-card {
border-radius: var(--border-radius-md);
.card-header,
.card-content,
.card-actions {
padding-left: var(--spacing-md);
padding-right: var(--spacing-md);
}
}
}
</style>
六、高级特性实现
6.1 主题持久化与同步
// src/utils/themePersistence.ts
import type { ThemeMode } from '@/types/theme';
export class ThemePersistence {
private static readonly STORAGE_KEY = 'vue-theme-preference';
private static readonly SYNC_KEY = 'theme-sync-timestamp';
/**
* 保存主题偏好到本地存储
*/
static saveThemePreference(theme: ThemeMode): void {
try {
const data = {
theme,
timestamp: Date.now(),
version: '1.0'
};
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(data));
this.updateSyncTimestamp();
} catch (error) {
console.warn('无法保存主题偏好到本地存储:', error);
}
}
/**
* 从本地存储加载主题偏好
*/
static loadThemePreference(): ThemeMode | null {
try {
const data = localStorage.getItem(this.STORAGE_KEY);
if (!data) return null;
const parsed = JSON.parse(data);
// 验证数据格式和版本
if (this.validateThemeData(parsed)) {
return parsed.theme;
}
// 数据无效,清理存储
this.clearThemePreference();
return null;
} catch (error) {
console.warn('无法从本地存储加载主题偏好:', error);
this.clearThemePreference();
return null;
}
}
/**
* 清除主题偏好设置
*/
static clearThemePreference(): void {
try {
localStorage.removeItem(this.STORAGE_KEY);
localStorage.removeItem(this.SYNC_KEY);
} catch (error) {
console.warn('无法清除主题偏好设置:', error);
}
}
/**
* 验证主题数据格式
*/
private static validateThemeData(data: any): boolean {
return (
data &&
typeof data === 'object' &&
['light', 'dark', 'auto'].includes(data.theme) &&
typeof data.timestamp === 'number' &&
data.version === '1.0'
);
}
/**
* 更新同步时间戳
*/
private static updateSyncTimestamp(): void {
try {
localStorage.setItem(this.SYNC_KEY, Date.now().toString());
} catch (error) {
console.warn('无法更新同步时间戳:', error);
}
}
/**
* 获取最后同步时间
*/
static getLastSyncTime(): number {
try {
const timestamp = localStorage.getItem(this.SYNC_KEY);
return timestamp ? parseInt(timestamp) : 0;
} catch (error) {
return 0;
}
}
/**
* 跨标签页同步主题设置
*/
static setupCrossTabSync(callback: (theme: ThemeMode) => void): () => void {
const handleStorageChange = (event: StorageEvent) => {
if (event.key === this.STORAGE_KEY && event.newValue) {
try {
const data = JSON.parse(event.newValue);
if (this.validateThemeData(data)) {
callback(data.theme);
}
} catch (error) {
console.warn('跨标签页主题同步失败:', error);
}
}
};
window.addEventListener('storage', handleStorageChange);
// 返回清理函数
return () => {
window.removeEventListener('storage', handleStorageChange);
};
}
/**
* 导出主题配置
*/
static exportThemeConfig(themeConfig: any): string {
const exportData = {
...themeConfig,
exportedAt: new Date().toISOString(),
version: '1.0'
};
return JSON.stringify(exportData, null, 2);
}
/**
* 导入主题配置
*/
static importThemeConfig(configString: string): any {
try {
const imported = JSON.parse(configString);
// 验证导入数据
if (imported.version !== '1.0') {
throw new Error('不支持的配置版本');
}
return imported;
} catch (error) {
throw new Error(`主题配置导入失败: ${error.message}`);
}
}
}
6.2 主题分析工具
// src/utils/themeAnalytics.ts
export class ThemeAnalytics {
private static events: ThemeEvent[] = [];
private static sessionStart: number = Date.now();
/**
* 记录主题切换事件
*/
static trackThemeChange(from: string, to: string, source: 'manual' | 'system' | 'auto'): void {
const event: ThemeEvent = {
type: 'theme_change',
from,
to,
source,
timestamp: Date.now(),
sessionDuration: Date.now() - this.sessionStart
};
this.events.push(event);
this.sendAnalytics(event);
}
/**
* 记录主题偏好
*/
static trackThemePreference(preference: string): void {
const event: ThemeEvent = {
type: 'theme_preference',
preference,
timestamp: Date.now(),
sessionDuration: Date.now() - this.sessionStart
};
this.events.push(event);
this.sendAnalytics(event);
}
/**
* 发送分析数据
*/
private static sendAnalytics(event: ThemeEvent): void {
// 在实际项目中,这里可以发送到分析服务
if (process.env.NODE_ENV === 'development') {
console.log('Theme Analytics:', event);
}
// 示例:发送到Google Analytics
if (typeof gtag !== 'undefined') {
gtag('event', event.type, {
event_category: 'theme',
event_label: event.type === 'theme_change' ?
`${event.from}_to_${event.to}` : event.preference,
value: event.sessionDuration
});
}
}
/**
* 获取主题使用统计
*/
static getThemeStats(): ThemeStats {
const changes = this.events.filter(e => e.type === 'theme_change');
const preferences = this.events.filter(e => e.type === 'theme_preference');
return {
totalChanges: changes.length,
manualChanges: changes.filter(c => c.source === 'manual').length,
systemChanges: changes.filter(c => c.source === 'system').length,
autoChanges: changes.filter(c => c.source === 'auto').length,
mostUsedTheme: this.getMostUsedTheme(preferences),
averageSessionDuration: this.getAverageSessionDuration(),
changeFrequency: this.getChangeFrequency(changes)
};
}
/**
* 获取最常使用的主题
*/
private static getMostUsedTheme(preferences: ThemeEvent[]): string {
const counts: Record<string, number> = {};
preferences.forEach(event => {
if (event.preference) {
counts[event.preference] = (counts[event.preference] || 0) + 1;
}
});
return Object.keys(counts).reduce((a, b) =>
counts[a] > counts[b] ? a : b, 'light'
);
}
/**
* 获取平均会话时长
*/
private static getAverageSessionDuration(): number {
if (this.events.length === 0) return 0;
const totalDuration = this.events.reduce(
(sum, event) => sum + event.sessionDuration, 0
);
return totalDuration / this.events.length;
}
/**
* 获取主题切换频率
*/
private static getChangeFrequency(changes: ThemeEvent[]): number {
if (changes.length < 2) return 0;
const firstChange = changes[0].timestamp;
const lastChange = changes[changes.length - 1].timestamp;
const totalTime = lastChange - firstChange;
return (changes.length / totalTime) * 1000 * 60; // 每分钟切换次数
}
/**
* 清理分析数据
*/
static clearAnalytics(): void {
this.events = [];
this.sessionStart = Date.now();
}
}
interface ThemeEvent {
type: 'theme_change' | 'theme_preference';
from?: string;
to?: string;
source?: 'manual' | 'system' | 'auto';
preference?: string;
timestamp: number;
sessionDuration: number;
}
interface ThemeStats {
totalChanges: number;
manualChanges: number;
systemChanges: number;
autoChanges: number;
mostUsedTheme: string;
averageSessionDuration: number;
changeFrequency: number;
}
七、测试与质量保证
7.1 组件单元测试
// tests/unit/ThemeSwitcher.spec.ts
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { mount } from '@vue/test-utils';
import { createPinia, setActivePinia } from 'pinia';
import ThemeSwitcher from '@/components/ThemeSwitcher.vue';
describe('ThemeSwitcher 组件测试', () => {
let pinia: any;
beforeEach(() => {
pinia = createPinia();
setActivePinia(pinia);
// 模拟本地存储
vi.stubGlobal('localStorage', {
getItem: vi.fn(),
setItem: vi.fn(),
removeItem: vi.fn()
});
// 模拟 matchMedia
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation(query => ({
matches: false,
media: query,
addEventListener: vi.fn(),
removeEventListener: vi.fn()
}))
});
});
it('应该正确渲染初始状态', () => {
const wrapper = mount(ThemeSwitcher, {
global: {
plugins: [pinia]
}
});
expect(wrapper.find('.theme-toggle').exists()).toBe(true);
expect(wrapper.text()).toContain('浅色');
});
it('应该切换主题当点击按钮', async () => {
const wrapper = mount(ThemeSwitcher, {
global: {
plugins: [pinia]
}
});
await wrapper.find('.theme-toggle').trigger('click');
expect(wrapper.text()).toContain('深色');
expect(localStorage.setItem).toHaveBeenCalledWith('user-theme', 'dark');
});
it('应该显示下拉菜单当点击设置按钮', async () => {
const wrapper = mount(ThemeSwitcher, {
props: {
showAdvanced: true
},
global: {
plugins: [pinia]
}
});
await wrapper.find('.dropdown-trigger').trigger('click');
expect(wrapper.find('.dropdown-menu').isVisible()).toBe(true);
});
it('应该应用自定义颜色', async () => {
const wrapper = mount(ThemeSwitcher, {
props: {
showAdvanced: true
},
global: {
plugins: [pinia]
}
});
await wrapper.find('.dropdown-trigger').trigger('click');
const colorInput = wrapper.find('.color-input');
await colorInput.setValue('#ff0000');
expect(document.documentElement.style.getPropertyValue('--color-primary'))
.toBe('#ff0000');
});
});
7.2 可访问性测试
// tests/e2e/themeAccessibility.spec.ts
import { test, expect } from '@playwright/test';
test.describe('主题切换可访问性测试', () => {
test('应该通过键盘导航切换主题', async ({ page }) => {
await page.goto('/');
// Tab 导航到主题切换按钮
await page.keyboard.press('Tab');
await expect(page.locator('.theme-toggle')).toBeFocused();
// 按空格键切换主题
await page.keyboard.press('Space');
await expect(page.locator('[data-theme="dark"]')).toBeVisible();
});
test('应该满足颜色对比度要求', async ({ page }) => {
await page.goto('/');
// 测试亮色主题对比度
await expect(page.locator('body')).toHaveCSS('color', /.*/);
const backgroundColor = await page.locator('body').evaluate(el => {
return window.getComputedStyle(el).backgroundColor;
});
const textColor = await page.locator('body').evaluate(el => {
return window.getComputedStyle(el).color;
});
// 验证对比度至少 4.5:1 (WCAG AA)
expect(await checkContrastRatio(backgroundColor, textColor)).toBeGreaterThan(4.5);
// 切换到暗黑模式再次测试
await page.click('.theme-toggle');
await expect(page.locator('[data-theme="dark"]')).toBeVisible();
const darkBackgroundColor = await page.locator('body').evaluate(el => {
return window.getComputedStyle(el).backgroundColor;
});
const darkTextColor = await page.locator('body').evaluate(el => {
return window.getComputedStyle(el).color;
});
expect(await checkContrastRatio(darkBackgroundColor, darkTextColor)).toBeGreaterThan(4.5);
});
test('应该尊重减少动画偏好', async ({ page }) => {
// 模拟减少动画偏好
await page.emulateMedia({ reducedMotion: 'reduce' });
await page.goto('/');
// 验证动画被禁用
const transition = await page.locator('.theme-button').evaluate(el => {
return window.getComputedStyle(el).transition;
});
expect(transition).toBe('none');
});
});
// 对比度检查工具函数
async function checkContrastRatio(color1: string, color2: string): Promise<number> {
// 在实际项目中,这里会实现完整的对比度计算逻辑
// 简化版本返回一个模拟值
return 4.5;
}
八、部署与优化
8.1 生产环境配置
// vite.config.prod.js
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import { resolve } from 'path';
export default defineConfig({
plugins: [vue()],
css: {
preprocessorOptions: {
scss: {
additionalData: `
@import "./src/styles/variables.scss";
@import "./src/styles/mixins.scss";
`,
silenceDeprecations: ['legacy-js-api']
}
},
postcss: {
plugins: [
require('autoprefixer'),
require('cssnano')({
preset: 'default'
})
]
}
},
build: {
target: 'es2015',
minify: 'terser',
terserOptions: {
compress: {
drop_console: true,
drop_debugger: true
}
},
rollupOptions: {
output: {
manualChunks: {
'vue-vendor': ['vue', 'vue-router', 'pinia'],
'theme-system': ['src/stores/themeStore.ts', 'src/utils/themePersistence.ts']
},
assetFileNames: (assetInfo) => {
if (assetInfo.name.endsWith('.css')) {
return 'assets/css/theme.[hash].css';
}
return 'assets/[ext]/[name].[hash].[ext]';
}
}
}
}
});
8.2 性能优化策略
// src/utils/themeOptimization.ts
export class ThemeOptimization {
/**
* 防抖主题切换以避免性能问题
*/
static createDebouncedThemeSwitch(delay: number = 100) {
let timeoutId: NodeJS.Timeout;
return (callback: () => void) => {
clearTimeout(timeoutId);
timeoutId = setTimeout(callback, delay);
};
}
/**
* 批量更新CSS变量以提高性能
*/
static batchUpdateCSSVariables(
updates: Record<string, string>,
element: HTMLElement = document.documentElement
): void {
// 使用 requestAnimationFrame 避免布局抖动
requestAnimationFrame(() => {
const style = element.style;
// 批量设置变量
Object.keys(updates).forEach(variable => {
style.setProperty(variable, updates[variable]);
});
});
}
/**
* 预加载主题资源
*/
static preloadThemeResources(): void {
if ('linkPrefetch' in document) {
// 预加载暗黑主题可能用到的资源
const prefetchLink = document.createElement('link');
prefetchLink.rel = 'prefetch';
prefetchLink.href = '/assets/images/dark-background.jpg';
prefetchLink.as = 'image';
document.head.appendChild(prefetchLink);
}
}
/**
* 优化主题切换的渲染性能
*/
static optimizeThemeRendering(): void {
// 强制硬件加速
const style = document.documentElement.style;
style.willChange = 'color, background-color, border-color';
// 清理 will-change 属性
setTimeout(() => {
style.willChange = 'auto';
}, 300);
}
/**
* 内存优化:清理未使用的主题缓存
*/
static cleanupThemeCache(): void {
// 清理过期的本地存储项
const now = Date.now();
const oneWeekAgo = now - 7 * 24 * 60 * 60 * 1000;
Object.keys(localStorage).forEach(key => {
if (key.startsWith('theme-cache-')) {
try {
const data = JSON.parse(localStorage.getItem(key)!);
if (data.timestamp && data.timestamp < oneWeekAgo) {
localStorage.removeItem(key);
}
} catch {
localStorage.removeItem(key);
}
}
});
}
}
九、总结
9.1 技术成果总结
核心功能实现
- •
动态主题切换:基于CSS变量的即时主题切换 - •
暗黑模式支持:完整的明暗主题系统 - •
系统偏好检测:自动跟随操作系统主题设置 - •
主题持久化:用户偏好记忆和跨标签页同步 - •
高级自定义:颜色自定义和主题配置
性能优化成果
|
|
|
|
|
|---|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
9.2 最佳实践总结
架构设计原则
const bestPractices = {
设计原则: [
'关注点分离:样式与逻辑解耦',
'渐进增强:基础功能保证,高级功能可选',
'性能优先:CSS变量原生性能优势',
'可访问性第一:颜色对比度、键盘导航、减少动画支持'
],
技术选择: [
'CSS变量:性能最佳,兼容性良好',
'Vue响应式:状态管理清晰',
'Pinia Store:中央化主题状态',
'TypeScript:类型安全保证'
],
用户体验: [
'平滑过渡:主题切换动画优化',
'即时反馈:操作响应及时',
'持久记忆:用户偏好自动保存',
'系统集成:跟随OS主题设置'
]
};
【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱:
cloudbbs@huaweicloud.com
- 点赞
- 收藏
- 关注作者
评论(0)