主题切换与暗黑模式 (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');
  }
});

面试避坑与发散

一段可以直接用于面试的回答

主题切换真正困难的地方并不是修改一个 data-theme 属性,而是同时处理好用户偏好、系统偏好、首屏时序和运行时同步

我会把主题偏好设计成 light | dark | system 三态,并将“用户选择的模式”和“页面最终生效的主题”分开:用户选择 system 时,通过 matchMedia('(prefers-color-scheme: dark)') 计算出实际的 lightdark;用户明确选择浅色或深色后,系统变化不应覆盖用户选择。初始化脚本放在 <head> 中,在首次绘制前读取缓存并设置 <html data-theme>,避免页面先亮后暗。页面运行期间监听媒体查询变化,并在组件卸载时移除监听。CSS 层使用语义化 Token,JS 只负责切换状态,不直接修改每个组件的颜色。

完整链路可以概括为:

localStorage 中的用户模式
        ↓(没有记录则视为 system)
matchMedia 解析系统偏好

得到实际主题 light / dark

首次绘制前写入 html[data-theme]

CSS 变量重新映射,组件统一换肤

1. 为什么不能只保存 lightdark

如果第一次访问时把系统解析结果 dark 直接保存进 localStorage,这个值就从“系统当时是深色”错误地变成了“用户永久选择深色”。以后系统切回浅色,网站仍然保持深色,所谓“跟随系统”就失效了。

因此需要区分两个概念:

概念可选值含义
用户模式 modelight / dark / system用户真正作出的选择,需要持久化
实际主题 resolvedThemelight / dark当前用于渲染页面的结果,不必持久化
function resolveTheme(mode, mediaQuery) {
  if (mode === 'system') {
    return mediaQuery.matches ? 'dark' : 'light';
  }
  return mode;
}

不要用“删除缓存代表跟随系统”作为长期数据模型。它虽然能工作,但无法区分“用户明确选择 system”和“缓存被清理、读取失败或旧版本没有该字段”,后续做设置同步、埋点或服务端存储时语义会变得含糊。更稳妥的做法是显式保存 system,并对非法值回退到 system

2. 如何避免首屏闪烁(FOUC)?

如果等 React/Vue 应用挂载后才在 useEffectonMounted 中读取主题,浏览器可能已经按默认浅色完成第一次绘制,随后才切成深色,于是用户会看到一次明显的白光闪烁。链路如下:

HTML 下载 → 默认浅色 CSS 生效 → 首次绘制 → 框架启动 → Effect 执行 → 切换深色
                                      ↑ 用户已经看到错误主题

解决方法是在 <head> 中放置一段同步、无依赖、尽可能短的内联脚本,让它在 body 渲染前确定主题:

<script>
  (() => {
    const STORAGE_KEY = 'theme-mode';
    const validModes = ['light', 'dark', 'system'];

    try {
      const storedMode = localStorage.getItem(STORAGE_KEY);
      const mode = validModes.includes(storedMode) ? storedMode : 'system';
      const systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
      const resolvedTheme = mode === 'system'
        ? (systemDark ? 'dark' : 'light')
        : mode;

      document.documentElement.dataset.theme = resolvedTheme;
      document.documentElement.style.colorScheme = resolvedTheme;
    } catch {
      // localStorage 在隐私模式、安全策略或存储异常时可能不可用。
      const systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
      document.documentElement.dataset.theme = systemDark ? 'dark' : 'light';
    }
  })();
</script>

这里的关键不是“用了内联 JS”,而是脚本执行发生在首次绘制之前color-scheme 还会提示浏览器同步调整表单控件、滚动条等原生 UI。生产环境如果启用了严格 CSP,内联脚本需要使用 nonce 或脚本哈希,不能简单加入 unsafe-inline

服务端渲染时还可以通过 Cookie 让服务端提前输出 data-theme。但系统偏好通常只有浏览器知道,服务端无法可靠获得,所以 system 模式仍需要首屏脚本兜底。Cookie 中的显式主题可能过期或与本地设置不一致,需要事先定义清晰的优先级。

3. 运行时如何正确监听系统变化?

只在初始化时调用一次 matchMedia().matches,只能读取当前快照。操作系统在日落后自动切换主题时,页面不会更新。正确做法是复用同一个 MediaQueryList 并监听 change

const STORAGE_KEY = 'theme-mode';
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');

function getMode() {
  const value = localStorage.getItem(STORAGE_KEY);
  return ['light', 'dark', 'system'].includes(value) ? value : 'system';
}

function applyTheme(mode = getMode()) {
  const theme = mode === 'system'
    ? (mediaQuery.matches ? 'dark' : 'light')
    : mode;

  document.documentElement.dataset.theme = theme;
  document.documentElement.style.colorScheme = theme;
}

function handleSystemThemeChange() {
  // 只有 system 模式才允许系统变化接管页面主题。
  if (getMode() === 'system') applyTheme('system');
}

mediaQuery.addEventListener('change', handleSystemThemeChange);

// SPA 的组件或模块销毁时执行,避免热更新或重复挂载造成多次监听。
function cleanup() {
  mediaQuery.removeEventListener('change', handleSystemThemeChange);
}

老版本 Safari 曾使用 addListener/removeListener。如果业务确实需要兼容这些版本,可以做能力检测,而不是同时注册两套监听:

if (mediaQuery.addEventListener) {
  mediaQuery.addEventListener('change', handleSystemThemeChange);
} else {
  mediaQuery.addListener(handleSystemThemeChange);
}

4. React/Vue 中最容易踩什么坑?

水合不一致

SSR 输出的是浅色按钮图标,而客户端首次渲染直接读到了深色,就可能出现 hydration mismatch。不要在服务端渲染期间直接访问 windowlocalStorage,也不要让服务端和客户端首次生成不同的 DOM 结构。

常见处理方式是:

  • 主题属性由 <head> 初始化脚本提前写到 <html>
  • 首次渲染让主题按钮使用与主题无关的占位结构,挂载后再展示准确图标;
  • 或通过 Cookie 把显式选择同步给服务端,使两端初始状态一致。

Effect 重复注册

如果 Effect 没有清理函数,或者依赖项每次渲染都变化,就会重复注册监听器,一次系统切换触发多次回调。监听函数需要保持稳定,并在卸载时解绑。

useEffect(() => {
  const media = window.matchMedia('(prefers-color-scheme: dark)');
  const handleChange = () => {
    if (getMode() === 'system') applyTheme('system');
  };

  media.addEventListener('change', handleChange);
  return () => media.removeEventListener('change', handleChange);
}, []);

开发环境的 React Strict Mode 可能故意执行一次“注册 → 清理 → 再注册”来检查副作用。只要清理逻辑正确,最终不会留下两个监听器;不要把开发环境的双执行误认为浏览器事件触发了两次。

5. 多标签页为什么会主题不一致?

用户在标签页 A 切换主题后,标签页 B 已加载到内存中的状态不会自动改变。可以监听 storage 事件同步:

window.addEventListener('storage', (event) => {
  if (event.key === 'theme-mode') {
    applyTheme(getMode());
  }
});

需要注意:storage 事件通常在同源的其他页面触发,不会在执行 localStorage.setItem 的当前页面触发。因此当前页面要在保存后主动调用 applyTheme,其他页面由 storage 事件更新。

6. 切换主题是否真的“只触发重绘”?

不能绝对地说只触发重绘。CSS 变量变化后,浏览器会重新计算受影响元素的样式:

修改 data-theme → 样式重新计算 → 必要时布局 → 绘制 → 合成

如果变量只用于 colorbackground-color,通常不会改变几何尺寸,主要成本是样式计算和绘制;如果变量还用于 font-sizeborder-widthspacing、图片尺寸等属性,就可能触发布局。因此面试中更准确的表达是:CSS 变量避免了重新下载和替换整份样式表,但切换成本取决于变量影响了哪些 CSS 属性和多少元素。

也不要给全局元素写 transition: all。它会让大量属性参与动画,可能导致卡顿和意外的布局过渡。只过渡必要的颜色属性,并尊重用户的“减少动态效果”设置:

.card {
  transition: background-color 0.2s ease, color 0.2s ease;
}

@media (prefers-reduced-motion: reduce) {
  .card {
    transition: none;
  }
}

首屏初始化时也不应播放主题过渡,否则页面可能从默认颜色渐变到目标颜色。可以在应用初始化完成后再为根节点添加允许过渡的类。

7. 图片、图表和第三方组件怎么办?

CSS Token 只能自动影响使用这些变量的样式,无法凭空修改位图、Canvas 或 iframe:

  • 普通图片可用 <picture media="(prefers-color-scheme: dark)"> 提供不同资源;若允许用户覆盖系统主题,则应由业务状态选择图片,而不能只依赖媒体查询;
  • SVG 优先使用 currentColor 或 CSS 变量;
  • Canvas/ECharts 等命令式绘制内容需要监听最终主题并主动重新绘制;
  • iframe 内容受同源策略约束,跨域页面不能直接改样式,只能通过对方提供的参数或 postMessage 协议协作;
  • 第三方组件库如果有独立 ThemeProvider,需要与根节点的主题状态保持单一数据源,避免两个主题系统互相覆盖。

8. 可访问性不能只看“颜色变黑”

暗黑模式不等于简单地把 RGB 取反。设计时还需要检查:

  • 正文、弱文本、边框和交互状态的对比度;
  • hover / focus / active / disabled 是否仍能区分;
  • 主题按钮是否有可读的 aria-label,并能用键盘操作;
  • 不能只用颜色传达成功、警告和错误状态;
  • 原生控件是否通过 color-scheme 与页面一致。

主题选择器最好明确提供“浅色、深色、跟随系统”三个选项。只有一个太阳/月亮按钮时,用户通常无法知道怎样恢复“跟随系统”。

9. 异常、降级和安全边界

  • localStorage 可能因浏览器策略、隐私模式、iframe 沙箱或容量问题抛出异常,读取和写入都应降级;
  • 缓存值可能被旧版本或用户手工改成非法字符串,必须校验;
  • JavaScript 被禁用时,应让 @media (prefers-color-scheme: dark) 提供基础降级;
  • 内联初始化脚本受 CSP 管理,优先使用 nonce/hash,不要为了主题功能放宽整个站点的脚本策略;
  • 如果主题保存在账号服务端,需要定义“未登录本地偏好、登录后云端偏好、系统偏好”之间的合并规则,避免登录瞬间反复切换;
  • 主题不是敏感信息,通常无需加密,但不应借主题接口写入未经校验的任意 HTML 属性或 CSS 文本。

10. 三种实现方案对比

方案优点缺点适用场景
prefers-color-scheme无 JS、天然跟随系统、降级简单用户不能独立选择主题只要求跟随系统的简单站点
CSS 变量 + data-theme切换快、Token 统一、容易扩展三态需要处理初始化时序和持久化大多数 SPA、SSR 应用
动态替换整份 CSS主题隔离直观多一次资源请求,容易 FOUC,维护重复样式历史系统或主题结构完全不同的场景

动态 CSS 并非任何情况下都“绝对错误”。当不同主题实际上是两套独立品牌、布局与资源体系时,按需加载主题资源可能更合理;但对于仅颜色、阴影等 Token 不同的深浅色模式,CSS 变量通常更简单高效。

11. 模拟面试官递进追问

追问 1:prefers-color-scheme 返回的是什么?

它是 CSS 媒体特性,JS 可通过 matchMedia 得到 MediaQueryListmatches 表示当前是否匹配,change 事件表示匹配结果发生变化。

为什么问:确认候选人不只会背 CSS 写法。回答重点:当前快照与后续变化是两件事。

追问 2:为什么主题状态要设计成三态?

因为 system 是一种用户偏好,而 light/dark 是解析后的渲染结果。只存两态会丢失“继续跟随系统”的意图。

为什么问:考察状态建模。回答重点:区分 moderesolvedTheme

追问 3:为什么放在 useEffect 中会闪屏?

Effect 在浏览器完成提交、通常也完成首次绘制后才执行;默认主题已经被用户看到,Effect 再改主题就产生错误主题闪烁。

为什么问:考察浏览器渲染和框架生命周期。回答重点:问题本质是首次绘制时序。

追问 4:为什么不用 useLayoutEffect 彻底解决?

它比普通 Effect 更早,但仍依赖应用 JS 下载、解析、执行和水合,会阻塞绘制,也不能覆盖脚本加载之前的等待时间。<head> 中的精简初始化脚本更靠前。

为什么问:避免只会套框架 API。回答重点:框架生命周期无法早于框架脚本自身加载。

追问 5:SSR 为什么容易水合不一致?

服务端通常不知道浏览器的系统主题。如果服务端按浅色输出、客户端首次 render 按深色生成不同 DOM,就与服务端标记不一致。应尽量只提前修改根属性,或用 Cookie 统一显式偏好,并让首次组件结构稳定。

为什么问:考察服务端与浏览器环境边界。回答重点:系统媒体查询是客户端信息。

追问 6:CSS 变量变化一定不触发布局吗?

不一定。变量只是值的传递机制;是否布局取决于它最终用于颜色属性还是尺寸、字体、间距等几何属性。

为什么问:识别“切主题只重绘”这种过度简化。回答重点:变量用途决定渲染阶段。

追问 7:如何同步多个标签页?

监听 storage 事件更新其他同源页面;当前页面不会依赖该事件,而是在写入后直接更新自身。

为什么问:考察浏览器存储的事件语义。回答重点:事件在其他文档触发。

追问 8:如何处理不支持 CSS Token 的第三方图表?

让最终主题成为单一数据源,在主题变化时调用图表库的主题更新或重绘接口;iframe 则需要参数或消息协议配合。

为什么问:考察真实项目落地。回答重点:声明式 CSS 与命令式绘制的边界。

追问 9:如何验证主题功能没有问题?

至少覆盖首次无缓存、有三种缓存值、非法缓存、系统运行时切换、用户显式选择后系统再切换、多标签页、刷新、SSR 水合、禁用存储、减少动态效果等场景;再用性能面板检查大页面切换时的样式计算、布局与绘制成本。

为什么问:考察测试和工程闭环。回答重点:测试的是状态组合与时序,而不仅是按钮能不能点。

追问 10:如果用户登录后云端主题与本地主题冲突怎么办?

需要由产品定义优先级,例如“本次设备上的最新显式选择优先,并同步到云端”,同时记录更新时间避免旧值覆盖新值。加载云端值期间应避免反复切换。

为什么问:考察分布式状态和产品意识。回答重点:不存在天然正确的优先级,但必须一致且可解释。

12. 常见错误回答

错误一:“监听 prefers-color-scheme,变化后加一个 dark class 就行。”

问题在于只描述 API,没有说明用户手动选择是否应覆盖系统、如何持久化以及如何避免首屏闪烁。改进时要补全“模式 → 解析 → 应用 → 同步”的链路。

错误二:“主题切换只会触发重绘,所以性能一定很好。”

CSS 变量会引发样式重新计算,变量若影响几何属性还会布局。更准确的回答应说明性能取决于受影响元素数量和最终属性。

错误三:“把逻辑写进 useEffect 就不会闪。”

Effect 执行得太晚。首屏问题必须在首次绘制前解决,框架挂载后的逻辑主要负责交互与后续同步。

错误四:“缓存里没有主题就存一个系统当前主题。”

这会把系统当前值固化成用户选择,破坏后续跟随。应保存 system,动态计算实际主题。

错误五:“深色模式就是把颜色反转。”

真实主题还涉及语义 Token、对比度、交互状态、图片、阴影、原生控件与第三方绘制内容,简单反转无法保证可读性和品牌效果。

13. 最后记忆:如何按面试时间组织答案?

1 分钟版本:讲清 CSS 变量、data-theme、三态模型、matchMedia 监听和 <head> 防闪烁。

3 分钟版本:在 1 分钟版本上补充 mode/resolvedTheme 的区别、SSR 水合、监听清理、storage 跨标签页同步,以及 CSS 变量并非必然只触发重绘。

10 分钟版本:继续展开 CSP、Cookie 与服务端首屏、异常降级、图片/Canvas/iframe、可访问性、减少动态效果、账号云同步冲突及测试矩阵。

这个问题最核心的关键词是:语义化 Token、三态状态模型、首次绘制时序、运行时同步、渐进增强

一句话本质:主题系统不是“换一组颜色”,而是把用户偏好和系统环境解析为统一的渲染状态,并保证这个状态在首屏及整个应用生命周期中始终一致。