[Medium] 🎨 多主題切換實作
📋 面試情境題
Q: 當一個頁面要做 2 種不同風格(例如亮色/暗色主題),怎麼安排 CSS?
這是一個考察 CSS 架構設計和實務經驗的問題,涉及:
- CSS 架構設計
- 主題切換策略
- 現代化工具應用(Tailwind CSS、CSS Variables)
- 效能與維護性考量
解決方案總覽
| 方案 | 適 用場景 | 優點 | 缺點 | 推薦度 |
|---|---|---|---|---|
| CSS Variables | 現代瀏覽器專案 | 動態切換、效能好 | IE 不支援 | 5/5 強烈推薦 |
| Quasar + Pinia + SCSS | Vue 3 + Quasar 專案 | 完整生態、狀態管理、易維護 | 需 Quasar Framework | 5/5 強烈推薦 |
| Tailwind CSS | 快速開發、設計系統 | 開發快速、一致性高 | 學習曲線、HTML 冗長 | 5/5 強烈推薦 |
| CSS Class 切換 | 需相容舊瀏覽器 | 相容性好 | CSS 體積較大 | 4/5 推薦 |
| CSS Modules | React/Vue 元件化專案 | 作用域隔離 | 需打包工具 | 4/5 推薦 |
| Styled Components | React 專案 | CSS-in-JS、動態樣式 | Runtime 開銷 | 4/5 推薦 |
| SASS/LESS 變數 | 需編譯時決定主題 | 功能強大 | 無法動態切換 | 3/5 可考慮 |
| 獨立 CSS 檔案 | 主題差異大、完全獨立 | 清晰分離 | 載入開銷、重複程式碼 | 2/5 不推薦 |
方案 1:CSS Variables
核心概念
使用 CSS 自訂屬性(CSS Custom Properties),透過切換根元素的 class 來改變變數值。
實作方式
1. 定義主題變數
/* styles/themes.css */
/* 亮色主題(預設) */
:root {
--color-primary: #3b82f6;
--color-secondary: #8b5cf6;
--color-background: #ffffff;
--color-text: #1f2937;
--color-border: #e5e7eb;
--color-card: #f9fafb;
--shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
/* 暗色主題 */
[data-theme='dark'] {
--color-primary: #60a5fa;
--color-secondary: #a78bfa;
--color-background: #1f2937;
--color-text: #f9fafb;
--color-border: #374151;
--color-card: #111827;
--shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
}
/* 如果有第三種主題(例如護眼模式) */
[data-theme='sepia'] {
--color-primary: #92400e;
--color-secondary: #78350f;
--color-background: #fef3c7;
--color-text: #451a03;
--color-border: #fde68a;
--color-card: #fef9e7;
--shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
}
2. 使用變數
/* components/Button.css */
.button {
background-color: var(--color-primary);
color: var(--color-text);
border: 1px solid var(--color-border);
box-shadow: var(--shadow);
transition: all 0.3s ease;
}
.card {
background-color: var(--color-card);
color: var(--color-text);
border: 1px solid var(--color-border);
}
body {
background-color: var(--color-background);
color: var(--color-text);
}
3. JavaScript 切換主題
// utils/theme.js
// 取得當前主題
function getCurrentTheme() {
return localStorage.getItem('theme') || 'light';
}
// 設定主題
function setTheme(theme) {
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem('theme', theme);
}
// 切換主題
function toggleTheme() {
const currentTheme = getCurrentTheme();
const newTheme = currentTheme === 'light' ? 'dark' : 'light';
setTheme(newTheme);
}
// 初始化(從 localStorage 讀取使用者偏好)
function initTheme() {
const savedTheme = getCurrentTheme();
setTheme(savedTheme);
// 監聽系統主題變化
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
mediaQuery.addEventListener('change', (e) => {
if (!localStorage.getItem('theme')) {
// 如果使用者沒有設定偏好,跟隨系統
setTheme(e.matches ? 'dark' : 'light');
}
});
}
// 頁面載入時初始化
initTheme();
4. Vue 3 整合範例
<template>
<div>
<button @click="toggleTheme" class="theme-toggle">
<span v-if="currentTheme === 'light'">🌙 暗色模式</span>
<span v-else>☀️ 亮色模式</span>
</button>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
const currentTheme = ref('light');
function toggleTheme() {
const newTheme = currentTheme.value === 'light' ? 'dark' : 'light';
currentTheme.value = newTheme;
document.documentElement.setAttribute('data-theme', newTheme);
localStorage.setItem('theme', newTheme);
}
onMounted(() => {
const savedTheme = localStorage.getItem('theme') || 'light';
currentTheme.value = savedTheme;
document.documentElement.setAttribute('data-theme', savedTheme);
});
</script>
優點
- ✅ 動態切換:無需重新載入 CSS 檔案
- ✅ 效能好:瀏覽器原生支援,只改變變數值
- ✅ 易維護:主題集中管理,修改方便
- ✅ 可擴展:輕鬆添加第三、第四種主題
缺點
- ❌ IE 不支援:需要 polyfill 或降級方案
- ❌ 預處理器整合:與 SASS/LESS 變數混用需注意
方案 2:Tailwind CSS
核心概念
使用 Tailwind CSS 的 dark: 變體和自訂主題配置,搭配 class 切換實現主題。
實作方式
1. 配置 Tailwind
// tailwind.config.js
module.exports = {
darkMode: 'class', // 使用 class 策略(而非 media query)
theme: {
extend: {
colors: {
// 自訂顏色(可定義多組主題色)
primary: {
light: '#3b82f6',
dark: '#60a5fa',
},
background: {
light: '#ffffff',
dark: '#1f2937',
},
text: {
light: '#1f2937',
dark: '#f9fafb',
},
},
},
},
plugins: [],
};
2. 使用 Tailwind 的主題類別
<template>
<!-- 方式 1:使用 dark: 變體 -->
<div class="bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100">
<h1 class="text-blue-600 dark:text-blue-400">標題</h1>
<button
class="bg-blue-500 hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-700 text-white px-4 py-2 rounded"
>
按鈕
</button>
<div
class="bg-gray-100 dark:bg-gray-700 p-4 rounded-lg shadow-md dark:shadow-lg"
>
<p class="text-gray-700 dark:text-gray-300">內容文字</p>
</div>
</div>
<!-- 主題切換按鈕 -->
<button @click="toggleTheme" class="fixed top-4 right-4">
<svg v-if="isDark" class="w-6 h-6">
<!-- 太陽圖示 -->
</svg>
<svg v-else class="w-6 h-6">
<!-- 月亮圖示 -->
</svg>
</button>
</template>
<script setup>
import { ref, onMounted } from 'vue';
const isDark = ref(false);
function toggleTheme() {
isDark.value = !isDark.value;
updateTheme();
}
function updateTheme() {
if (isDark.value) {
document.documentElement.classList.add('dark');
localStorage.setItem('theme', 'dark');
} else {
document.documentElement.classList.remove('dark');
localStorage.setItem('theme', 'light');
}
}
onMounted(() => {
// 讀取儲存的主題偏好
const savedTheme = localStorage.getItem('theme');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
isDark.value = savedTheme === 'dark' || (!savedTheme && prefersDark);
updateTheme();
});
</script>
3. 進階:自訂多主題(超過 2 種)
// tailwind.config.js
module.exports = {
darkMode: 'class',
theme: {
extend: {
colors: {
theme: {
bg: 'var(--theme-bg)',
text: 'var(--theme-text)',
primary: 'var(--theme-primary)',
},
},
},
},
};
/* styles/themes.css */
:root {
--theme-bg: #ffffff;
--theme-text: #000000;
--theme-primary: #3b82f6;
}
[data-theme='dark'] {
--theme-bg: #1f2937;
--theme-text: #f9fafb;
--theme-primary: #60a5fa;
}
[data-theme='sepia'] {
--theme-bg: #fef3c7;
--theme-text: #451a03;
--theme-primary: #92400e;
}
<template>
<!-- 使用自訂的主題變數 -->
<div class="bg-theme-bg text-theme-text">
<button class="bg-theme-primary">按鈕</button>
</div>
<!-- 主題選擇器 -->
<select @change="setTheme($event.target.value)">
<option value="light">亮色</option>
<option value="dark">暗色</option>
<option value="sepia">護眼</option>
</select>
</template>
<script setup>
function setTheme(theme) {
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem('theme', theme);
}
</script>
Tailwind 的優勢
- ✅ 快速開發:utility-first,不需寫 CSS
- ✅ 一致性:設計系統內建,保持風格統一
- ✅ tree-shaking:自動移除未使用的樣式
- ✅ RWD 友好:
sm:,md:,lg:響應式變體 - ✅ 主題變體:
dark:,hover:,focus:等豐富的變體
缺點
- ❌ HTML 冗長:class 很多,可能影響可讀性
- ❌ 學習曲線:需要熟悉 utility class 命名
- ❌ 客製化:深度客製需要了解配置
方案 3:Quasar + Pinia + SCSS(近期經驗)
💼 實際專案經驗:這是我在實際專案中使用的方案,整合了 Quasar Framework、Pinia 狀態管理和 SCSS 變數系統。
核心概念
採用多層架構設計:
- Quasar Dark Mode API - 框架層級的主題支援
- Pinia Store - 集中管理主題狀態
- SessionStorage - 持久化使用者偏好
- SCSS Variables + Mixin - 主題變數與樣式管理
架構流程
使用者點擊切換按鈕
↓
Quasar $q.dark.toggle()
↓
Pinia Store 更新狀態
↓
同步至 SessionStorage
↓
Body class 切換 (.body--light / .body--dark)
↓
CSS 變數更新
↓
UI 自動更新
實作方式
1. Pinia Store(狀態管理)
// src/stores/darkModeStore.ts
import { defineStore } from 'pinia';
import { useSessionStorage } from '@vueuse/core';
export const useDarkModeStore = defineStore('darkMode', () => {
// 使用 SessionStorage 持久化狀態
const isDarkMode = useSessionStorage<boolean>('isDarkMode', false);
// 更新 Dark Mode 狀態
const updateIsDarkMode = (status: boolean) => {
isDarkMode.value = status;
};
return {
isDarkMode,
updateIsDarkMode,
};
});
2. Quasar 配置
// quasar.config.js
module.exports = configure(function (/* ctx */) {
return {
framework: {
config: {
dark: 'true', // 啟用 Dark Mode 支援
},
plugins: ['Notify', 'Loading', 'Dialog'],
},
};
});
3. SCSS 主題變數系統
// assets/css/_variable.scss
// 定義 Light 和 Dark 兩種主題的變數映射
$themes: (
light: (
--bg-main: #ffffff,
--bg-side: #f0f1f4,
--text-primary: #000000,
--text-secondary: #666666,
--primary-color: #2d7eff,
--border-color: #e5ebf2,
),
dark: (
--bg-main: #081f2d,
--bg-side: #0d2533,
--text-primary: #ffffff,
--text-secondary: #b0b0b0,
--primary-color: #2d7eff,
--border-color: #14384d,
),
);
// Mixin: 根據主題套用對應的 CSS 變數
@mixin theme-vars($theme) {
@each $key, $value in map-get($themes, $theme) {
#{$key}: #{$value};
}
}
// Mixin: Light Mode 專用樣式
@mixin light {
.body--light & {
@content;
}
}
// Mixin: Dark Mode 專用樣式
@mixin dark {
.body--dark & {
@content;
}
}
4. 全域應用主題
// src/css/app.scss
@import 'assets/css/_variable.scss';
// 預設套用 Light Theme
:root {
@include theme-vars('light');
}
// Dark Mode 套用 Dark Theme
.body--dark {
@include theme-vars('dark');
}
5. 元件中使用
方式 A:使用 CSS 變數(推薦)
<template>
<div class="my-card">
<h2 class="title">標題</h2>
<p class="content">內容文字</p>
</div>
</template>
<style scoped lang="scss">
.my-card {
background: var(--bg-main);
color: var(--text-primary);
border: 1px solid var(--border-color);
padding: 1rem;
}
.title {
color: var(--primary-color);
font-size: 1.5rem;
}
.content {
color: var(--text-secondary);
}
</style>
方式 B:使用 SCSS Mixin(進階)
<template>
<button class="custom-btn">按鈕</button>
</template>
<style scoped lang="scss">
@import 'assets/css/_variable.scss';
.custom-btn {
padding: 0.5rem 1rem;
border-radius: 0.5rem;
transition: all 0.3s ease;
@include light {
background: #2d7eff;
color: #ffffff;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
&:hover {
background: #1a5fd9;
}
}
@include dark {
background: #1677ff;
color: #ffffff;
box-shadow: 0 2px 4px rgba(255, 255, 255, 0.1);
&:hover {
background: #0d5acc;
}
}
}
</style>
6. 切換功能
<template>
<button @click="toggleDarkMode" class="theme-toggle">
<q-icon :name="isDarkMode ? 'light_mode' : 'dark_mode'" />
{{ isDarkMode ? '切換至淺色' : '切換至深色' }}
</button>
</template>
<script setup lang="ts">
import { useQuasar } from 'quasar';
import { onMounted } from 'vue';
import { useDarkModeStore } from 'stores/darkModeStore';
const $q = useQuasar();
const { isDarkMode, updateIsDarkMode } = useDarkModeStore();
// 切換主題
const toggleDarkMode = () => {
$q.dark.toggle(); // Quasar 切換
updateIsDarkMode($q.dark.isActive); // 同步至 Store
};
// 頁面載入時恢復使用者偏好
onMounted(() => {
if (isDarkMode.value) {
$q.dark.set(true);
}
});
</script>
<style scoped lang="scss">
.theme-toggle {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: var(--primary-color);
color: var(--text-primary);
border: none;
border-radius: 0.5rem;
cursor: pointer;
transition: opacity 0.3s ease;
&:hover {
opacity: 0.8;
}
}
</style>
優點
- ✅ 完整生態系統:Quasar + Pinia + VueUse 一站式解決方案
- ✅ 狀態管理:Pinia 集中管理,易於測試和維護
- ✅ 持久化:SessionStorage 自動保存,刷新不丟失
- ✅ 類型安全:TypeScript 支援,減少錯誤
- ✅ 開發體驗:SCSS Mixin 簡化樣式開發
- ✅ 效能優良:CSS Variables 動態更新,無需重載