主题切换与暗黑模式 (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。全部改为语义化甚至带有色阶的变量引用:
2. 页面与组件层怎么写?
所有组件的 CSS 对这背后到底是黑暗还是白昼一无所知。它们只管用变量:
3. JS 如何一键切换大局?
此时 JS 的主场来临了,只需要做一件如同指点江山般极其微小但影响深远的事:向 <html> 标签写属性!
浏览器内部监测到这一行 HTML Attr 变化,会由于极度高效的 CSS 流式设计,只触发重绘(重新上色),完全无请求、0 阻塞页面,暗黑模式犹如熄灯一般顺滑降临。
更高级的能力:跟随计算机系统主题
现代操作系统都有深浅色系统。如果有用户从来不主动去按你网页里的切换按钮,但他傍晚 6 点 Mac 系统自动变成深色模式后,你的页面突然变成电灯泡就非常刺眼了。
我们需要利用 CSS 和 JS 探头来监测系统环境。
CSS 媒体查询层面覆盖(极快防闪烁)
JS 原生对象实时监听
面试避坑与发散
一段可以直接用于面试的回答
主题切换真正困难的地方并不是修改一个 data-theme 属性,而是同时处理好用户偏好、系统偏好、首屏时序和运行时同步。
我会把主题偏好设计成 light | dark | system 三态,并将“用户选择的模式”和“页面最终生效的主题”分开:用户选择 system 时,通过 matchMedia('(prefers-color-scheme: dark)') 计算出实际的 light 或 dark;用户明确选择浅色或深色后,系统变化不应覆盖用户选择。初始化脚本放在 <head> 中,在首次绘制前读取缓存并设置 <html data-theme>,避免页面先亮后暗。页面运行期间监听媒体查询变化,并在组件卸载时移除监听。CSS 层使用语义化 Token,JS 只负责切换状态,不直接修改每个组件的颜色。
完整链路可以概括为:
1. 为什么不能只保存 light 和 dark?
如果第一次访问时把系统解析结果 dark 直接保存进 localStorage,这个值就从“系统当时是深色”错误地变成了“用户永久选择深色”。以后系统切回浅色,网站仍然保持深色,所谓“跟随系统”就失效了。
因此需要区分两个概念:
不要用“删除缓存代表跟随系统”作为长期数据模型。它虽然能工作,但无法区分“用户明确选择 system”和“缓存被清理、读取失败或旧版本没有该字段”,后续做设置同步、埋点或服务端存储时语义会变得含糊。更稳妥的做法是显式保存 system,并对非法值回退到 system。
2. 如何避免首屏闪烁(FOUC)?
如果等 React/Vue 应用挂载后才在 useEffect、onMounted 中读取主题,浏览器可能已经按默认浅色完成第一次绘制,随后才切成深色,于是用户会看到一次明显的白光闪烁。链路如下:
解决方法是在 <head> 中放置一段同步、无依赖、尽可能短的内联脚本,让它在 body 渲染前确定主题:
这里的关键不是“用了内联 JS”,而是脚本执行发生在首次绘制之前。color-scheme 还会提示浏览器同步调整表单控件、滚动条等原生 UI。生产环境如果启用了严格 CSP,内联脚本需要使用 nonce 或脚本哈希,不能简单加入 unsafe-inline。
服务端渲染时还可以通过 Cookie 让服务端提前输出 data-theme。但系统偏好通常只有浏览器知道,服务端无法可靠获得,所以 system 模式仍需要首屏脚本兜底。Cookie 中的显式主题可能过期或与本地设置不一致,需要事先定义清晰的优先级。
3. 运行时如何正确监听系统变化?
只在初始化时调用一次 matchMedia().matches,只能读取当前快照。操作系统在日落后自动切换主题时,页面不会更新。正确做法是复用同一个 MediaQueryList 并监听 change:
老版本 Safari 曾使用 addListener/removeListener。如果业务确实需要兼容这些版本,可以做能力检测,而不是同时注册两套监听:
4. React/Vue 中最容易踩什么坑?
水合不一致
SSR 输出的是浅色按钮图标,而客户端首次渲染直接读到了深色,就可能出现 hydration mismatch。不要在服务端渲染期间直接访问 window、localStorage,也不要让服务端和客户端首次生成不同的 DOM 结构。
常见处理方式是:
- 主题属性由
<head>初始化脚本提前写到<html>; - 首次渲染让主题按钮使用与主题无关的占位结构,挂载后再展示准确图标;
- 或通过 Cookie 把显式选择同步给服务端,使两端初始状态一致。
Effect 重复注册
如果 Effect 没有清理函数,或者依赖项每次渲染都变化,就会重复注册监听器,一次系统切换触发多次回调。监听函数需要保持稳定,并在卸载时解绑。
开发环境的 React Strict Mode 可能故意执行一次“注册 → 清理 → 再注册”来检查副作用。只要清理逻辑正确,最终不会留下两个监听器;不要把开发环境的双执行误认为浏览器事件触发了两次。
5. 多标签页为什么会主题不一致?
用户在标签页 A 切换主题后,标签页 B 已加载到内存中的状态不会自动改变。可以监听 storage 事件同步:
需要注意:storage 事件通常在同源的其他页面触发,不会在执行 localStorage.setItem 的当前页面触发。因此当前页面要在保存后主动调用 applyTheme,其他页面由 storage 事件更新。
6. 切换主题是否真的“只触发重绘”?
不能绝对地说只触发重绘。CSS 变量变化后,浏览器会重新计算受影响元素的样式:
如果变量只用于 color、background-color,通常不会改变几何尺寸,主要成本是样式计算和绘制;如果变量还用于 font-size、border-width、spacing、图片尺寸等属性,就可能触发布局。因此面试中更准确的表达是:CSS 变量避免了重新下载和替换整份样式表,但切换成本取决于变量影响了哪些 CSS 属性和多少元素。
也不要给全局元素写 transition: all。它会让大量属性参与动画,可能导致卡顿和意外的布局过渡。只过渡必要的颜色属性,并尊重用户的“减少动态效果”设置:
首屏初始化时也不应播放主题过渡,否则页面可能从默认颜色渐变到目标颜色。可以在应用初始化完成后再为根节点添加允许过渡的类。
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. 三种实现方案对比
动态 CSS 并非任何情况下都“绝对错误”。当不同主题实际上是两套独立品牌、布局与资源体系时,按需加载主题资源可能更合理;但对于仅颜色、阴影等 Token 不同的深浅色模式,CSS 变量通常更简单高效。
11. 模拟面试官递进追问
追问 1:prefers-color-scheme 返回的是什么?
它是 CSS 媒体特性,JS 可通过 matchMedia 得到 MediaQueryList;matches 表示当前是否匹配,change 事件表示匹配结果发生变化。
为什么问:确认候选人不只会背 CSS 写法。回答重点:当前快照与后续变化是两件事。
追问 2:为什么主题状态要设计成三态?
因为 system 是一种用户偏好,而 light/dark 是解析后的渲染结果。只存两态会丢失“继续跟随系统”的意图。
为什么问:考察状态建模。回答重点:区分 mode 与 resolvedTheme。
追问 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、三态状态模型、首次绘制时序、运行时同步、渐进增强。
一句话本质:主题系统不是“换一组颜色”,而是把用户偏好和系统环境解析为统一的渲染状态,并保证这个状态在首屏及整个应用生命周期中始终一致。

