主题切换与暗黑模式 (Dark Mode) 架构设计
场景题:
“你正在重构一个成熟的 C 端产品,产品经理要求加入 跟随系统深色模式/自主切换浅色深色主题(Dark Mode) 的功能。
同时,要求这个切换必须在瞬间完成无显著卡顿,并且不能依赖加载多份完全不一样的巨大的 CSS 捆绑文件。
请从底层方案设计上说明你会如何实现?”
历史方案(了解以用来鄙视链打压)
早期前端更换主题的暴力美学是:切主题 = 换 CSS 文件。
点击【暗黑模式】按钮后,前端在 DOM 树里凭空创建一条 <link href="/dark-theme.css"> 标签,撤除了浅色的 link。
这会导致浏览器触发巨大的、全盘的回流与重绘,甚至在 CSS 还没通过网络下载下来的零点几秒内,页面会变成令人瞎眼的残破无样式布局状态(FOUC 现象)。它已被时代抛弃。
现代企业级基建:CSS 变量 (Custom Properties)
目前全网所有优秀的开源 UI 组件库(Ant Design v5,Element Plus,Radix 等),以及所有成熟的商业软件底层全部依托于一个浏览器原生特性:CSS 自定义变量(又名 CSS Variables)。
1. 制定词典(Token)
不要在业务组件的样式里写死 color: #333。全部改为语义化甚至带有色阶的变量引用:
/* 全局基础定义:挂载在 :root 顶级节点 */
:root {
/* 调色板 Palette */
--color-blue-500: #1890ff;
--color-gray-100: #f5f5f5;
--color-gray-900: #111111;
/* 语义化映射 Design Tokens (核心) */
--bg-primary: #ffffff;
--text-primary: var(--color-gray-900);
--border-color: #d9d9d9;
}
/* 核心杀手锏:打上某一个特殊 class 时,暴力覆盖所有的变量映射 */
html[data-theme='dark'] {
--bg-primary: var(--color-gray-900);
--text-primary: var(--color-gray-100);
--border-color: #333333;
}
2. 页面与组件层怎么写?
所有组件的 CSS 对这背后到底是黑暗还是白昼一无所知。它们只管用变量:
.card-container {
background-color: var(--bg-primary);
color: var(--text-primary);
border: 1px solid var(--border-color);
transition: background-color 0.3s ease, color 0.3s ease; /* 开启过渡效果非常惊艳 */
}
3. JS 如何一键切换大局?
此时 JS 的主场来临了,只需要做一件如同指点江山般极其微小但影响深远的事:向 <html> 标签写属性!
function toggleTheme() {
const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
if (isDark) {
document.documentElement.setAttribute('data-theme', 'light');
localStorage.setItem('theme', 'light'); // 记忆用户的选择
} else {
document.documentElement.setAttribute('data-theme', 'dark');
localStorage.setItem('theme', 'dark');
}
}
浏览器内部监测到这一行 HTML Attr 变化,会由于极度高效的 CSS 流式设计,只触发重绘(重新上色),完全无请求、0 阻塞页面,暗黑模式犹如熄灯一般顺滑降临。
更高级的能力:跟随计算机系统主题
现代操作系统都有深浅色系统。如果有用户从来不主动去按你网页里的切换按钮,但他傍晚 6 点 Mac 系统自动变成深色模式后,你的页面突然变成电灯泡就非常刺眼了。
我们需要利用 CSS 和 JS 探头来监测系统环境。
CSS 媒体查询层面覆盖(极快防闪烁)
/* 当用户操作系统处于深色模式,但在网页 localStorage 还没有配置记录时应用此兜底 */
@media (prefers-color-scheme: dark) {
:root:not([data-theme='light']) {
--bg-primary: #111;
...
}
}
JS 原生对象实时监听
// 查询系统到底是否被设置成了深色
const sysThemeRes = window.matchMedia('(prefers-color-scheme: dark)');
// 监听由于太阳落山或者用户在系统设置里拨动按钮引发的环境突变
sysThemeRes.addEventListener('change', (e) => {
const isNowDark = e.matches;
if (!localStorage.getItem('theme')) {
document.documentElement.setAttribute('data-theme', isNowDark ? 'dark' : 'light');
}
});
面试避坑与发散
Q:在 SPA 单页应用挂载时,如何避免刷新一瞬间白屏(白光闪烁 / Flash Of White)现象?
A:这是一个极度展现前端老油条经验的点。如果你的初始化代码写在了 useEffect 或者是打包好的巨大 main.js 里,等各种网络延迟这坨 JS 执行时,此时页面 HTML 早已经画完(并且是以亮色画完的),然后你的 JS 读取缓存把它强行变成暗色,整个一秒内就会发生“先闪白再突然变黑”的诡异体验。
终极解法:在打包入口 index.html 的 <head> 标签里,用内联标签写入一段没有任何三方依赖的极简原生 JS (Inline Script)。
这段 JS 会在浏览器接触到页面 body 形成前优先解析,立刻通过读取 localStorage 或者检查 matchMedia 决定往 <html> 上提前写入 data-theme="dark" 属性。真正做到“从页面诞生的那一刻起,就是黑夜。”