第 140 章:主题与暗色模式
学习目标
- 掌握 Vue 3 中实现主题切换的方案
- 学会 CSS 变量、SCSS 主题、动态换肤
- 理解暗色模式、跟随系统、个性化配置
- 实现可定制的主题系统
一、主题方案对比
二、CSS 变量方案
2.1 定义变量
css
/* styles/theme.css */
:root {
--primary-color: #42b883;
--danger-color: #f56c6c;
--warning-color: #e6a23c;
--success-color: #67c23a;
--bg-color: #ffffff;
--text-color: #303133;
--border-color: #dcdfe6;
--font-size-sm: 12px;
--font-size-base: 14px;
--font-size-lg: 16px;
--spacing-sm: 8px;
--spacing-md: 16px;
--spacing-lg: 24px;
}
[data-theme="dark"] {
--primary-color: #5fb889;
--bg-color: #1a1a1a;
--text-color: #e4e7ed;
--border-color: #4c4d4f;
}2.2 使用
vue
<style scoped>
.button {
background: var(--primary-color);
color: var(--text-color);
padding: var(--spacing-sm) var(--spacing-md);
border: 1px solid var(--border-color);
}
</style>2.3 优势
三、主题切换实现
3.1 简单切换
typescript
// composables/useTheme.ts
import { ref, watchEffect } from 'vue';
export type Theme = 'light' | 'dark';
const theme = ref<Theme>(
(localStorage.getItem('theme') as Theme) || 'light'
);
export function useTheme() {
function setTheme(t: Theme) {
theme.value = t;
localStorage.setItem('theme', t);
}
function toggle() {
setTheme(theme.value === 'light' ? 'dark' : 'light');
}
watchEffect(() => {
document.documentElement.dataset.theme = theme.value;
});
return { theme, setTheme, toggle };
}3.2 使用
vue
<template>
<button @click="toggle">
切换到 {{ theme === 'light' ? '暗色' : '亮色' }} 模式
</button>
</template>
<script setup lang="ts">
import { useTheme } from '@/composables/useTheme';
const { theme, toggle } = useTheme();
</script>四、跟随系统
4.1 检测系统主题
typescript
function getSystemTheme(): Theme {
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
return 'dark';
}
return 'light';
}
const theme = ref<Theme>('auto');
function applyTheme() {
const actual = theme.value === 'auto' ? getSystemTheme() : theme.value;
document.documentElement.dataset.theme = actual;
}4.2 监听系统切换
typescript
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
mediaQuery.addEventListener('change', () => {
if (theme.value === 'auto') {
applyTheme();
}
});4.3 完整 Hook
typescript
// composables/useTheme.ts
import { ref, watch, onMounted, onUnmounted } from 'vue';
export const useTheme = () => {
const theme = ref<Theme>(
(localStorage.getItem('theme') as Theme) || 'auto'
);
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
function getActualTheme(): 'light' | 'dark' {
if (theme.value === 'auto') {
return mediaQuery.matches ? 'dark' : 'light';
}
return theme.value;
}
function applyTheme() {
const actual = getActualTheme();
document.documentElement.dataset.theme = actual;
}
function handleSystemChange() {
if (theme.value === 'auto') {
applyTheme();
}
}
onMounted(() => {
applyTheme();
mediaQuery.addEventListener('change', handleSystemChange);
});
onUnmounted(() => {
mediaQuery.removeEventListener('change', handleSystemChange);
});
watch(theme, (val) => {
localStorage.setItem('theme', val);
applyTheme();
});
function setTheme(t: Theme) {
theme.value = t;
}
return { theme, setTheme };
};五、Pinia 集成
typescript
// stores/theme.ts
import { defineStore } from 'pinia';
import { ref, computed, watch } from 'vue';
export const useThemeStore = defineStore('theme', () => {
const theme = ref<Theme>(
(localStorage.getItem('theme') as Theme) || 'auto'
);
const actualTheme = computed(() => {
if (theme.value === 'auto') {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
return theme.value;
});
function setTheme(t: Theme) {
theme.value = t;
}
watch(actualTheme, (val) => {
document.documentElement.dataset.theme = val;
}, { immediate: true });
return { theme, actualTheme, setTheme };
}, {
persist: {
paths: ['theme']
}
});六、个性化颜色
6.1 颜色选择器
vue
<template>
<input type="color" :value="primaryColor" @input="changeColor" />
</template>
<script setup lang="ts">
import { ref } from 'vue';
const primaryColor = ref('#42b883');
function changeColor(e: Event) {
const color = (e.target as HTMLInputElement).value;
document.documentElement.style.setProperty('--primary-color', color);
localStorage.setItem('primary-color', color);
}
</script>6.2 加载时恢复
typescript
// main.ts
const savedColor = localStorage.getItem('primary-color');
if (savedColor) {
document.documentElement.style.setProperty('--primary-color', savedColor);
}七、SCSS 主题
7.1 变量文件
scss
// styles/_themes.scss
$themes: (
light: (
bg: #ffffff,
text: #303133,
primary: #42b883
),
dark: (
bg: #1a1a1a,
text: #e4e7ed,
primary: #5fb889
)
);7.2 Mixin
scss
@mixin theme() {
@each $name, $colors in $themes {
[data-theme="#{$name}"] & {
@content($colors);
}
}
}
.button {
@include theme using ($colors) {
background: map-get($colors, primary);
color: map-get($colors, text);
}
}7.3 编译期切换
scss
// styles/_light.scss
$primary: #42b883;
$bg: #ffffff;
// styles/_dark.scss
$primary: #5fb889;
$bg: #1a1a1a;typescript
// vite.config.ts
export default defineConfig({
css: {
preprocessorOptions: {
scss: {
additionalData: `@use "@/styles/variables.scss" as *;`
}
}
}
});八、组件库集成
8.1 Element Plus
typescript
// main.ts
import ElementPlus from 'element-plus';
import 'element-plus/dist/index.css';
import { zhCn, en, dateZhCn, dateEn } from 'element-plus/es/locale';
const app = createApp(App);
app.use(ElementPlus, {
locale: themeStore.actualTheme === 'dark' ? en : zhCn
});8.2 暗色 CSS
typescript
// main.ts
import 'element-plus/theme-chalk/dark/css-vars.css';
app.use(ElementPlus);
// 切换暗色
document.documentElement.classList.toggle('dark', themeStore.actualTheme === 'dark');8.3 Naive UI
vue
<template>
<n-config-provider :theme="naiveTheme">
<app />
</n-config-provider>
</template>
<script setup lang="ts">
import { darkTheme } from 'naive-ui';
import { useThemeStore } from '@/stores/theme';
const themeStore = useThemeStore();
const naiveTheme = computed(() => themeStore.actualTheme === 'dark' ? darkTheme : null);
</script>九、组件级主题
9.1 自定义主题组件
vue
<!-- ThemeProvider.vue -->
<script setup lang="ts">
import { provide, ref } from 'vue';
const theme = ref({
primary: '#42b883',
borderRadius: '4px'
});
provide('theme', theme);
</script>
<template>
<div :style="themeVars">
<slot />
</div>
</template>
<script lang="ts">
import { computed } from 'vue';
export default {
setup() {
const theme = inject('theme');
const themeVars = computed(() => ({
'--primary-color': theme.value.primary,
'--border-radius': theme.value.borderRadius
}));
return { themeVars };
}
};
</script>9.2 主题编辑器
vue
<template>
<div class="theme-editor">
<label>
主色:
<input type="color" v-model="config.primary" />
</label>
<label>
圆角:
<input type="range" v-model.number="config.borderRadius" min="0" max="20" />
</label>
</div>
</template>
<script setup lang="ts">
const config = reactive({
primary: '#42b883',
borderRadius: 4
});
watch(config, (val) => {
document.documentElement.style.setProperty('--primary-color', val.primary);
document.documentElement.style.setProperty('--border-radius', `${val.borderRadius}px`);
});
</script>十、过渡动画
css
/* 全局过渡 */
* {
transition: background-color 0.3s, color 0.3s, border-color 0.3s;
}十一、SSR 支持
typescript
// entry-server.ts
export function renderToString(url: string, theme: string) {
return renderToString({
template: `
<html data-theme="${theme}">
...
</html>
`
});
}十二、可访问性
12.1 prefers-color-scheme
css
/* 跟随系统 */
@media (prefers-color-scheme: dark) {
:root {
--bg-color: #1a1a1a;
--text-color: #e4e7ed;
}
}12.2 prefers-reduced-motion
css
@media (prefers-reduced-motion: reduce) {
* {
transition: none !important;
animation: none !important;
}
}12.3 高对比度
css
@media (prefers-contrast: more) {
:root {
--border-color: #000000;
}
}十三、主题预览
vue
<template>
<div class="theme-gallery">
<div
v-for="item in themes"
:key="item.name"
class="theme-item"
:class="{ active: theme === item.name }"
@click="setTheme(item.name)"
>
<div :style="item.style" class="preview" />
<p>{{ item.label }}</p>
</div>
</div>
</template>
<script setup lang="ts">
const themes = [
{ name: 'light', label: '亮色', style: { background: '#ffffff' } },
{ name: 'dark', label: '暗色', style: { background: '#1a1a1a' } },
{ name: 'sepia', label: '护眼', style: { background: '#f4ecd8' } }
];
const { theme, setTheme } = useTheme();
</script>十四、暗色模式图片
vue
<template>
<picture>
<source :srcset="darkSrc" media="(prefers-color-scheme: dark)" />
<img :src="lightSrc" alt="..." />
</picture>
</template>css
.logo {
content: url('logo-light.png');
}
[data-theme="dark"] .logo {
content: url('logo-dark.png');
}十五、推荐工具
| 工具 | 特点 |
|---|---|
| CSS 变量 | 原生、实时切换 |
| Patch-package | 修改第三方样式 |
| Style Dictionary | 设计 token 转换 |
| Theme UI | React 主题方案 |
| Next Themes | 暗色模式 Hook |
十六、本章小结
| 方案 | 优点 | 缺点 |
|---|---|---|
| CSS 变量 | 实时切换、性能好 | 旧浏览器不支持 |
| SCSS 变量 | 编译时静态 | 需重新编译 |
| 动态 class | 简单 | 大量样式覆盖 |
| JS 主题对象 | 灵活 | 需手动应用 |
推荐:CSS 变量 + 少量 JS 控制。
动手练习
- 基础切换:实现亮色/暗色模式切换
- 跟随系统:实现
auto模式,跟随系统主题 - 个性化:添加主色调自定义功能
- 组件库适配:让 Element Plus 跟随主题切换
- 过渡动画:为主题切换添加平滑过渡
推荐阅读
- 📖 CSS 变量 — MDN
- 📖 prefers-color-scheme — 媒体查询
- 📖 Element Plus 暗色 — 集成示例
- 🌐 暗色模式最佳实践 — web.dev
下一章:第 141 章:Vue 3 生态与进阶资源汇总 →