Hi,我是前端人类学 !
在网页设计中,暗黑模式早已从"酷炫的彩蛋"变成了"用户刚需"。无论是为了夜间护眼、节省 OLED 屏幕电量,还是单纯追求视觉沉浸感,提供暗黑模式切换功能都已成为现代 Web 应用的标准实践。
本文将带你从零构建一套生产环境可用 的暗黑模式切换方案,核心思路是:CSS 变量统一管理主题色彩,JavaScript 控制切换逻辑,localStorage 持久化用户偏好。
文章目录
-
- 一、整体架构思路
- [二、CSS 变量定义与主题切换](#二、CSS 变量定义与主题切换)
- [三、JavaScript 切换逻辑(含本地存储)](#三、JavaScript 切换逻辑(含本地存储))
- 四、防止闪白(FOUC)的关键策略
- [五、UI 组件和交互细节](#五、UI 组件和交互细节)
- 六、进阶增强功能
- 七、常见问题与踩坑指南
- [八、完整代码示例(HTML 模板)](#八、完整代码示例(HTML 模板))
一、整体架构思路
我们追求的不仅仅是"能切换",而是:
- 流畅无闪烁:页面加载时立即呈现正确主题
- 持久记忆:用户刷新或下次访问时,自动记住上次的选择
- 系统感知:尊重操作系统级别的主题偏好(可选增强)
- 易于维护:主题颜色集中管理,新增颜色或调整主题无需改多处代码
整个方案由三块协作完成:
- CSS 变量:定义两套色彩体系,通过根类名切换
- JavaScript 控制逻辑:检测系统主题、切换类名、读写本地存储
- 本地存储:保存用户显式选择,覆盖系统默认
二、CSS 变量定义与主题切换
首先在 :root 中定义亮色模式的 CSS 变量,然后给 [data-theme="dark"] 定义暗色模式的变量值。
css
/* 亮色主题(默认) */
:root {
--bg-primary: #ffffff;
--bg-secondary: #f3f4f6;
--bg-card: #ffffff;
--text-primary: #111827;
--text-secondary: #4b5563;
--border-color: #e5e7eb;
--shadow-color: rgba(0, 0, 0, 0.1);
--accent: #3b82f6;
--accent-hover: #2563eb;
}
/* 暗色主题 */
[data-theme="dark"] {
--bg-primary: #111827;
--bg-secondary: #1f2937;
--bg-card: #1f2937;
--text-primary: #f9fafb;
--text-secondary: #9ca3af;
--border-color: #374151;
--shadow-color: rgba(0, 0, 0, 0.3);
--accent: #60a5fa;
--accent-hover: #3b82f6;
}
为什么用 data-theme 而不是 .dark 类?
使用 data-* 属性在语义上更清晰,且可以方便扩展多主题(如高对比度、护眼模式等)。当然,你也可以用类名 .dark,原理相同。
在实际样式代码中,所有颜色值都必须引用 CSS 变量,而不是写死十六进制值:
css
body {
background-color: var(--bg-primary);
color: var(--text-primary);
transition: background-color 0.3s ease, color 0.3s ease;
}
.card {
background-color: var(--bg-card);
border: 1px solid var(--border-color);
box-shadow: 0 4px 6px var(--shadow-color);
}
.button-primary {
background-color: var(--accent);
color: #fff;
}
加上 transition 可以让主题切换时有平滑过渡效果,提升体验。
三、JavaScript 切换逻辑(含本地存储)
- 读取本地存储中的用户偏好
- 根据偏好或系统主题设置正确的
data-theme - 提供切换函数,并同步更新本地存储
javascript
const THEME_KEY = 'theme-preference';
// 获取当前有效的主题
function getPreferredTheme() {
const stored = localStorage.getItem(THEME_KEY);
if (stored === 'dark' || stored === 'light') {
return stored;
}
// 若无存储,则跟随系统
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
// 应用主题(设置 data-theme 属性)
function applyTheme(theme) {
document.documentElement.setAttribute('data-theme', theme);
// 可选:更新 meta 标签,控制浏览器 UI 样式
const meta = document.querySelector('meta[name="theme-color"]');
if (meta) {
meta.content = theme === 'dark' ? '#111827' : '#ffffff';
}
}
// 切换主题
function toggleTheme() {
const current = document.documentElement.getAttribute('data-theme');
const next = current === 'dark' ? 'light' : 'dark';
applyTheme(next);
localStorage.setItem(THEME_KEY, next);
}
// 初始化主题
function initTheme() {
const preferred = getPreferredTheme();
applyTheme(preferred);
}
// 监听系统主题变化(当用户未手动设置时)
function watchSystemTheme() {
const media = window.matchMedia('(prefers-color-scheme: dark)');
media.addEventListener('change', (e) => {
// 仅当 localStorage 中没有用户显式偏好时,才跟随系统
if (!localStorage.getItem(THEME_KEY)) {
const theme = e.matches ? 'dark' : 'light';
applyTheme(theme);
}
});
}
// 页面加载时执行
initTheme();
watchSystemTheme();
关于执行时机 :这段 JS 应该尽量早执行,最好放在 <head> 中(或使用 async/defer 并确保在 DOM 渲染前执行),以避免页面先显示白色再跳变到暗色的"闪烁"问题。
四、防止闪白(FOUC)的关键策略
即使代码逻辑正确,如果执行时机不对,用户仍可能看到一瞬间的白屏。解决方案:
方案一:内联关键脚本到 <head>
把上述初始化代码直接内联到 HTML 的 <head> 中,且放在任何样式表之前。这是最稳健的方式。
html
<!DOCTYPE html>
<html>
<head>
<script>
// 整个 initTheme 相关代码内联在此
(function() {
const stored = localStorage.getItem('theme-preference');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const theme = stored || (prefersDark ? 'dark' : 'light');
document.documentElement.setAttribute('data-theme', theme);
})();
</script>
<!-- 然后加载样式表 -->
<link rel="stylesheet" href="styles.css">
</head>
方案二:在 CSS 中使用 @media (prefers-color-scheme: dark) 配合默认样式
这种方法不需要 JS 干预,但缺点是用户切换偏好后无法持久化,且 CSS 中两套颜色维护起来较分散。不推荐作为主方案。
五、UI 组件和交互细节
切换按钮的 HTML 结构:
html
<button id="theme-toggle" aria-label="切换暗黑模式">
<span class="icon-sun">☀️</span>
<span class="icon-moon">🌙</span>
</button>
切换按钮的视觉反馈:
css
[data-theme="dark"] .icon-sun { display: inline; }
[data-theme="dark"] .icon-moon { display: none; }
[data-theme="light"] .icon-sun { display: none; }
[data-theme="light"] .icon-moon { display: inline; }
JS 绑定事件:
javascript
document.getElementById('theme-toggle').addEventListener('click', toggleTheme);
更优雅的做法是用 SVG 图标或字体图标,但原理相同。
六、进阶增强功能
1. 过渡动画优化
我们可以让主题切换时有"渐变"效果,但要注意大面积 transition 可能影响性能。推荐仅在背景色和文字色上做过渡,且持续时间控制在 200-300ms。
css
* {
transition: background-color 0.2s ease, color 0.2s ease, border-color 0.2s ease;
}
2. 多主题扩展
如果未来要增加"高对比度"或"蓝色滤镜"主题,只需增加新的 data-theme 值并定义相应变量即可,JS 逻辑几乎无需改动。
3. 结合框架(React/Vue)的封装
在 React 中,可以将主题状态放入 Context 或 Zustand 中;在 Vue 中可以使用 Pinia 或 provide/inject。但底层逻辑完全一致,只是将 document.documentElement 操作封装到副作用中。
4. 图片适配暗黑模式
对于图片,可以使用 picture 元素配合 prefers-color-scheme 媒体查询,或者用 CSS filter: brightness(0.8) 来降低亮图在暗色下的刺眼感。
七、常见问题与踩坑指南
Q1:本地存储中保存了 dark,但刷新后先闪白再变暗?
A:几乎可以肯定是 JS 执行太晚。解决方法:将主题初始化脚本内联到 <head> 最顶部,确保在渲染任何 DOM 之前设置好 data-theme。
Q2:系统主题是暗色,用户手动切到亮色,刷新后为什么又变回暗色?
A:检查 getPreferredTheme 逻辑------它应该优先返回 localStorage 的值,而不是系统值。上述代码已经处理了这一点。
Q3:切换时页面所有元素都"跳"一下,不够平滑?
A:检查是否有元素没有使用 CSS 变量而是硬编码颜色。此外,transition 应只作用于颜色相关属性,不要对 display、width 等做过渡。
Q4:Safari 下暗黑模式切换有延迟?
A:Safari 对 CSS 变量的支持良好,但 matchMedia 的 change 事件在某些旧版本中需要 polyfill。建议使用 addEventListener 方式,并做好降级。
八、完整代码示例(HTML 模板)
html
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- 主题初始化脚本(内联,优先执行) -->
<script>
(function initTheme() {
const key = 'theme-preference';
let theme = localStorage.getItem(key);
if (!theme) {
theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
document.documentElement.setAttribute('data-theme', theme);
// 同步 meta theme-color
const meta = document.querySelector('meta[name="theme-color"]');
if (meta) {
meta.content = theme === 'dark' ? '#111827' : '#ffffff';
}
})();
</script>
<link rel="stylesheet" href="styles.css">
<title>暗黑模式切换</title>
</head>
<body>
<header>
<h1>我的网站</h1>
<button id="theme-toggle">切换主题</button>
</header>
<main>
<!-- 页面内容 -->
</main>
<script>
// 切换逻辑(可单独抽离为 theme.js)
const toggleBtn = document.getElementById('theme-toggle');
toggleBtn.addEventListener('click', () => {
const current = document.documentElement.getAttribute('data-theme');
const next = current === 'dark' ? 'light' : 'dark';
document.documentElement.setAttribute('data-theme', next);
localStorage.setItem('theme-preference', next);
// 更新 meta
const meta = document.querySelector('meta[name="theme-color"]');
if (meta) {
meta.content = next === 'dark' ? '#111827' : '#ffffff';
}
});
</script>
</body>
</html>
这样做的好处在于:
- 干净分离:CSS 变量负责颜色,JS 负责状态,存储负责持久化
- 零依赖:不需要任何第三方库,原生实现,体积极小
- 可扩展:支持任意数量主题,且易于接入各类前端框架
- 用户体验优先:杜绝闪烁,尊重系统偏好,又能让用户自主选择
当你把这一切搭建好后,用户可能不会刻意注意"暗黑模式切换很流畅"------但这份"无感"正是对体验最好的褒奖。