Skip to content
第 140 / 250 章前端⏱ 12 分钟阅读

第 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 UIReact 主题方案
Next Themes暗色模式 Hook

十六、本章小结

方案优点缺点
CSS 变量实时切换、性能好旧浏览器不支持
SCSS 变量编译时静态需重新编译
动态 class简单大量样式覆盖
JS 主题对象灵活需手动应用

推荐:CSS 变量 + 少量 JS 控制。

动手练习

  1. 基础切换:实现亮色/暗色模式切换
  2. 跟随系统:实现 auto 模式,跟随系统主题
  3. 个性化:添加主色调自定义功能
  4. 组件库适配:让 Element Plus 跟随主题切换
  5. 过渡动画:为主题切换添加平滑过渡

推荐阅读


下一章第 141 章:Vue 3 生态与进阶资源汇总

本站基于 VitePress 构建 · 由 Codebook 团队维护