主题切换与夜间模式
概述
主题切换是现代 Web 应用的常见需求,包括亮色/暗色模式切换、品牌色定制等。本文详细介绍各种主题切换方案的实现。
CSS 变量方案
基础实现
css
/* 定义主题变量 */
:root {
/* 亮色主题(默认) */
--color-bg: #ffffff;
--color-bg-secondary: #f5f5f5;
--color-text: #333333;
--color-text-secondary: #666666;
--color-primary: #1890ff;
--color-border: #e8e8e8;
--shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
/* 暗色主题 */
[data-theme="dark"] {
--color-bg: #1a1a1a;
--color-bg-secondary: #2d2d2d;
--color-text: #e5e5e5;
--color-text-secondary: #a0a0a0;
--color-primary: #177ddc;
--color-border: #434343;
--shadow: 0 2px 8px rgba(0, 0, 0, 0.4);
}
/* 使用变量 */
body {
background-color: var(--color-bg);
color: var(--color-text);
}
.card {
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
box-shadow: var(--shadow);
}
.button-primary {
background: var(--color-primary);
color: #fff;
}JavaScript 切换
javascript
// 切换主题
function setTheme(theme) {
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem('theme', theme);
}
// 获取当前主题
function getTheme() {
return localStorage.getItem('theme') || 'light';
}
// 初始化主题
function initTheme() {
const savedTheme = localStorage.getItem('theme');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const theme = savedTheme || (prefersDark ? 'dark' : 'light');
setTheme(theme);
}
// 切换主题
function toggleTheme() {
const current = getTheme();
setTheme(current === 'light' ? 'dark' : 'light');
}
// 监听系统主题变化
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
// 如果用户没有手动设置主题,则跟随系统
if (!localStorage.getItem('theme')) {
setTheme(e.matches ? 'dark' : 'light');
}
});
// 页面加载时初始化
initTheme();媒体查询方案
跟随系统主题
css
/* 默认亮色 */
:root {
--color-bg: #ffffff;
--color-text: #333333;
}
/* 系统暗色模式 */
@media (prefers-color-scheme: dark) {
:root {
--color-bg: #1a1a1a;
--color-text: #e5e5e5;
}
}
body {
background: var(--color-bg);
color: var(--color-text);
/* 平滑过渡 */
transition: background-color 0.3s, color 0.3s;
}结合用户偏好和系统主题
css
/* 基础变量 */
:root {
--color-bg: #ffffff;
--color-text: #333333;
}
/* 系统暗色(用户未设置时) */
@media (prefers-color-scheme: dark) {
:root:not([data-theme]) {
--color-bg: #1a1a1a;
--color-text: #e5e5e5;
}
}
/* 用户手动选择暗色 */
:root[data-theme="dark"] {
--color-bg: #1a1a1a;
--color-text: #e5e5e5;
}
/* 用户手动选择亮色(覆盖系统暗色) */
:root[data-theme="light"] {
--color-bg: #ffffff;
--color-text: #333333;
}Vue 3 实现
主题 Hook
typescript
// composables/useTheme.ts
import { ref, watch, onMounted } from 'vue';
type Theme = 'light' | 'dark' | 'system';
export function useTheme() {
const theme = ref<Theme>('system');
const isDark = ref(false);
// 获取实际应用的主题
const getAppliedTheme = (): 'light' | 'dark' => {
if (theme.value === 'system') {
return window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light';
}
return theme.value;
};
// 应用主题
const applyTheme = () => {
const appliedTheme = getAppliedTheme();
isDark.value = appliedTheme === 'dark';
document.documentElement.setAttribute('data-theme', appliedTheme);
};
// 设置主题
const setTheme = (newTheme: Theme) => {
theme.value = newTheme;
localStorage.setItem('theme', newTheme);
applyTheme();
};
// 切换主题
const toggleTheme = () => {
const current = getAppliedTheme();
setTheme(current === 'light' ? 'dark' : 'light');
};
// 初始化
onMounted(() => {
const saved = localStorage.getItem('theme') as Theme | null;
theme.value = saved || 'system';
applyTheme();
// 监听系统主题变化
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
if (theme.value === 'system') {
applyTheme();
}
});
});
return {
theme,
isDark,
setTheme,
toggleTheme
};
}主题切换组件
vue
<template>
<div class="theme-switcher">
<button
v-for="option in options"
:key="option.value"
:class="['theme-btn', { active: theme === option.value }]"
@click="setTheme(option.value)"
>
<span class="icon">{{ option.icon }}</span>
<span class="label">{{ option.label }}</span>
</button>
</div>
</template>
<script setup lang="ts">
import { useTheme } from '@/composables/useTheme';
const { theme, setTheme } = useTheme();
const options = [
{ value: 'light', label: '亮色', icon: '☀️' },
{ value: 'dark', label: '暗色', icon: '🌙' },
{ value: 'system', label: '跟随系统', icon: '💻' }
];
</script>
<style scoped>
.theme-switcher {
display: flex;
gap: 8px;
}
.theme-btn {
padding: 8px 16px;
border: 1px solid var(--color-border);
border-radius: 8px;
background: var(--color-bg);
color: var(--color-text);
cursor: pointer;
transition: all 0.2s;
}
.theme-btn:hover {
border-color: var(--color-primary);
}
.theme-btn.active {
background: var(--color-primary);
color: #fff;
border-color: var(--color-primary);
}
</style>React 实现
ThemeProvider
tsx
// contexts/ThemeContext.tsx
import React, { createContext, useContext, useState, useEffect } from 'react';
type Theme = 'light' | 'dark' | 'system';
interface ThemeContextType {
theme: Theme;
isDark: boolean;
setTheme: (theme: Theme) => void;
toggleTheme: () => void;
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setThemeState] = useState<Theme>('system');
const [isDark, setIsDark] = useState(false);
const getAppliedTheme = (currentTheme: Theme): 'light' | 'dark' => {
if (currentTheme === 'system') {
return window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light';
}
return currentTheme;
};
const applyTheme = (theme: Theme) => {
const appliedTheme = getAppliedTheme(theme);
setIsDark(appliedTheme === 'dark');
document.documentElement.setAttribute('data-theme', appliedTheme);
};
const setTheme = (newTheme: Theme) => {
setThemeState(newTheme);
localStorage.setItem('theme', newTheme);
applyTheme(newTheme);
};
const toggleTheme = () => {
const current = getAppliedTheme(theme);
setTheme(current === 'light' ? 'dark' : 'light');
};
useEffect(() => {
const saved = localStorage.getItem('theme') as Theme | null;
const initial = saved || 'system';
setThemeState(initial);
applyTheme(initial);
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handler = () => {
if (theme === 'system') {
applyTheme('system');
}
};
mediaQuery.addEventListener('change', handler);
return () => mediaQuery.removeEventListener('change', handler);
}, [theme]);
return (
<ThemeContext.Provider value={{ theme, isDark, setTheme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const context = useContext(ThemeContext);
if (!context) {
throw new Error('useTheme must be used within ThemeProvider');
}
return context;
}使用示例
tsx
// App.tsx
import { ThemeProvider } from './contexts/ThemeContext';
import ThemeSwitcher from './components/ThemeSwitcher';
function App() {
return (
<ThemeProvider>
<div className="app">
<header>
<ThemeSwitcher />
</header>
<main>{/* ... */}</main>
</div>
</ThemeProvider>
);
}
// components/ThemeSwitcher.tsx
import { useTheme } from '../contexts/ThemeContext';
function ThemeSwitcher() {
const { theme, isDark, toggleTheme, setTheme } = useTheme();
return (
<button onClick={toggleTheme}>
{isDark ? '🌙' : '☀️'}
</button>
);
}多主题色方案
品牌色定制
css
/* 预设主题色 */
:root {
--color-primary: #1890ff;
}
[data-color="blue"] {
--color-primary: #1890ff;
}
[data-color="green"] {
--color-primary: #52c41a;
}
[data-color="purple"] {
--color-primary: #722ed1;
}
[data-color="red"] {
--color-primary: #f5222d;
}
/* 自动生成派生色 */
:root {
--color-primary: #1890ff;
--color-primary-light: color-mix(in srgb, var(--color-primary), white 20%);
--color-primary-dark: color-mix(in srgb, var(--color-primary), black 20%);
--color-primary-bg: color-mix(in srgb, var(--color-primary), transparent 90%);
}动态主题色
typescript
// 设置自定义主题色
function setCustomColor(color: string) {
const root = document.documentElement;
root.style.setProperty('--color-primary', color);
// 生成派生色
root.style.setProperty('--color-primary-light', lighten(color, 20));
root.style.setProperty('--color-primary-dark', darken(color, 20));
localStorage.setItem('custom-color', color);
}
// 颜色处理函数
function lighten(color: string, percent: number): string {
const hex = color.replace('#', '');
const r = parseInt(hex.substr(0, 2), 16);
const g = parseInt(hex.substr(2, 2), 16);
const b = parseInt(hex.substr(4, 2), 16);
const newR = Math.min(255, r + (255 - r) * (percent / 100));
const newG = Math.min(255, g + (255 - g) * (percent / 100));
const newB = Math.min(255, b + (255 - b) * (percent / 100));
return `#${Math.round(newR).toString(16).padStart(2, '0')}${Math.round(newG).toString(16).padStart(2, '0')}${Math.round(newB).toString(16).padStart(2, '0')}`;
}
function darken(color: string, percent: number): string {
const hex = color.replace('#', '');
const r = parseInt(hex.substr(0, 2), 16);
const g = parseInt(hex.substr(2, 2), 16);
const b = parseInt(hex.substr(4, 2), 16);
const newR = Math.max(0, r * (1 - percent / 100));
const newG = Math.max(0, g * (1 - percent / 100));
const newB = Math.max(0, b * (1 - percent / 100));
return `#${Math.round(newR).toString(16).padStart(2, '0')}${Math.round(newG).toString(16).padStart(2, '0')}${Math.round(newB).toString(16).padStart(2, '0')}`;
}过渡动画
平滑过渡
css
/* 全局过渡 */
* {
transition:
background-color 0.3s ease,
color 0.3s ease,
border-color 0.3s ease,
box-shadow 0.3s ease;
}
/* 或者只对特定属性 */
body,
.card,
.button {
transition: background-color 0.3s, color 0.3s;
}避免首次加载闪烁
html
<!-- 在 head 中尽早执行 -->
<script>
(function() {
const theme = localStorage.getItem('theme');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const appliedTheme = theme || (prefersDark ? 'dark' : 'light');
document.documentElement.setAttribute('data-theme', appliedTheme);
})();
</script>
<!-- 防止过渡动画在初始化时触发 -->
<style>
html.no-transition,
html.no-transition * {
transition: none !important;
}
</style>
<script>
document.documentElement.classList.add('no-transition');
window.addEventListener('load', () => {
document.documentElement.classList.remove('no-transition');
});
</script>后台管理布局
内容区域占满剩余高度
css
/* 方案 1:Flexbox */
.layout {
display: flex;
flex-direction: column;
height: 100vh;
}
.header {
height: 64px;
flex-shrink: 0;
}
.main {
flex: 1;
overflow: auto; /* 内容超出时滚动 */
}
/* 方案 2:Grid */
.layout {
display: grid;
grid-template-rows: 64px 1fr;
height: 100vh;
}
.main {
overflow: auto;
}
/* 方案 3:calc */
.main {
height: calc(100vh - 64px);
overflow: auto;
}
/* 方案 4:绝对定位 */
.layout {
position: relative;
height: 100vh;
}
.header {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 64px;
}
.main {
position: absolute;
top: 64px;
bottom: 0;
left: 0;
right: 0;
overflow: auto;
}左右布局
css
.layout {
display: flex;
height: 100vh;
}
.sidebar {
width: 200px;
flex-shrink: 0;
overflow-y: auto;
}
.content {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.content-header {
height: 64px;
flex-shrink: 0;
}
.content-main {
flex: 1;
overflow: auto;
}收起/展开过渡
高度过渡
css
/* 问题:height: auto 无法过渡 */
.collapse {
height: 0;
overflow: hidden;
transition: height 0.3s ease;
}
.collapse.open {
height: auto; /* ❌ 无法过渡 */
}
/* 方案 1:使用 max-height */
.collapse {
max-height: 0;
overflow: hidden;
transition: max-height 0.3s ease;
}
.collapse.open {
max-height: 500px; /* 设置足够大的值 */
}
/* 方案 2:使用 grid */
.collapse {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 0.3s ease;
}
.collapse > .inner {
overflow: hidden;
}
.collapse.open {
grid-template-rows: 1fr;
}Vue 过渡组件
vue
<template>
<button @click="isOpen = !isOpen">切换</button>
<CollapseTransition>
<div v-show="isOpen" class="content">
内容区域
</div>
</CollapseTransition>
</template>
<script setup>
import { ref } from 'vue';
const isOpen = ref(false);
</script>
<!-- CollapseTransition.vue -->
<template>
<Transition
@before-enter="beforeEnter"
@enter="enter"
@after-enter="afterEnter"
@before-leave="beforeLeave"
@leave="leave"
@after-leave="afterLeave"
>
<slot />
</Transition>
</template>
<script setup>
function beforeEnter(el) {
el.style.height = '0';
el.style.overflow = 'hidden';
}
function enter(el) {
el.style.transition = 'height 0.3s ease';
el.style.height = `${el.scrollHeight}px`;
}
function afterEnter(el) {
el.style.height = '';
el.style.overflow = '';
el.style.transition = '';
}
function beforeLeave(el) {
el.style.height = `${el.scrollHeight}px`;
el.style.overflow = 'hidden';
}
function leave(el) {
el.style.transition = 'height 0.3s ease';
el.style.height = '0';
}
function afterLeave(el) {
el.style.height = '';
el.style.overflow = '';
el.style.transition = '';
}
</script>React 版本
tsx
import { useRef, useState, useEffect, CSSProperties } from 'react';
interface CollapseProps {
isOpen: boolean;
children: React.ReactNode;
}
function Collapse({ isOpen, children }: CollapseProps) {
const contentRef = useRef<HTMLDivElement>(null);
const [height, setHeight] = useState<number | undefined>(isOpen ? undefined : 0);
useEffect(() => {
if (!contentRef.current) return;
if (isOpen) {
const contentHeight = contentRef.current.scrollHeight;
setHeight(contentHeight);
// 过渡结束后移除固定高度
const timer = setTimeout(() => {
setHeight(undefined);
}, 300);
return () => clearTimeout(timer);
} else {
// 先设置当前高度,然后设为 0
const contentHeight = contentRef.current.scrollHeight;
setHeight(contentHeight);
requestAnimationFrame(() => {
setHeight(0);
});
}
}, [isOpen]);
const style: CSSProperties = {
height: height,
overflow: 'hidden',
transition: 'height 0.3s ease'
};
return (
<div style={style}>
<div ref={contentRef}>{children}</div>
</div>
);
}
// 使用
function App() {
const [isOpen, setIsOpen] = useState(false);
return (
<>
<button onClick={() => setIsOpen(!isOpen)}>切换</button>
<Collapse isOpen={isOpen}>
<div className="content">
内容区域
</div>
</Collapse>
</>
);
}常见面试题
1. 如何实现夜间模式?
答案要点:
- 使用 CSS 变量定义颜色
- 通过
data-theme属性切换变量值 - 使用
prefers-color-scheme媒体查询跟随系统 - localStorage 保存用户偏好
- 监听系统主题变化事件
2. 如何避免主题切换时的闪烁?
javascript
// 在 <head> 中尽早执行同步脚本设置主题
// 添加 no-transition 类避免过渡动画
// 页面加载完成后移除 no-transition3. height: auto 无法过渡怎么办?
css
/* 方案 1:使用 max-height */
.collapse {
max-height: 0;
transition: max-height 0.3s;
}
.collapse.open {
max-height: 500px;
}
/* 方案 2:使用 grid */
.collapse {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 0.3s;
}
.collapse.open {
grid-template-rows: 1fr;
}
/* 方案 3:JS 动态设置 scrollHeight */4. 后台布局如何让内容区占满剩余高度?
css
/* Flexbox 方案 */
.layout {
display: flex;
flex-direction: column;
height: 100vh;
}
.header { height: 64px; flex-shrink: 0; }
.main { flex: 1; overflow: auto; }
/* Grid 方案 */
.layout {
display: grid;
grid-template-rows: 64px 1fr;
height: 100vh;
}