主题切换与暗黑模式 (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" 属性。真正做到“从页面诞生的那一刻起,就是黑夜。”