指南 / Web

我只是想加个深色模式,最后网站有了三个深色模式。

深色模式看起来只是一个布尔值,直到系统主题、用户选择、初次渲染和浏览器 UI 全部加入。可靠的主题逻辑往往需要三个状态,而不是两个。

发布 · 更新 · 8 MIN

主题只有两个。

亮色。

深色。

一个 Boolean 就能解决:

const dark = true;

完工。

然后有人问:“能不能跟随系统?”

于是现在有三个:

system
light
dark

这个 Boolean 的职业生涯只有四分钟。

最简单的深色模式其实不需要 JavaScript

@media (prefers-color-scheme: dark) {
  body {
    background: #111;
    color: #eee;
  }
}

浏览器会读取系统的颜色偏好。手机深色,网页深色;系统亮色,网页亮色。

直到你加上“手动切换主题”。

用户选择应该压过系统选择

设备处于深色,但用户偏偏想让这个网站保持亮色。刷新后也应该继续亮色。因此你需要记住网站自己的选择,比如:

localStorage.setItem("theme", "light");

逻辑变成:

  1. 用户有没有显式选择主题?有,就用它。
  2. 没有,就读取系统。

所以数据模型最好从一开始就是 system / light / dark,而不是硬塞进 true / false。

然后你获得了“闪白一下”

主题切换完成了,刷新页面却先亮白半秒,再变成深色。

这不是灵异,是加载顺序:

HTML 开始渲染
↓
默认亮色先出现
↓
JavaScript 加载
↓
读取 localStorage
↓
切到深色

浏览器完全按你的代码工作,只是用户的眼睛也看到了这个时间线。

解决思路是尽量在页面第一次绘制前就确定主题,用一小段早期脚本读取保存值和 prefers-color-scheme,给 html 设置主题属性。

CSS 变量比复制整套样式靠谱

:root {
  --bg: white;
  --text: #111;
  --surface: #f4f4f4;
}

html[data-theme="dark"] {
  --bg: #111;
  --text: #eee;
  --surface: #1c1c1c;
}

组件只需要使用 var(--surface),不用知道深色模式内部怎么实现。以后加主题也不会到处找硬编码颜色。

网页深了,浏览器栏还亮着

移动浏览器的地址栏、状态区域和 PWA 外壳也可能受 theme-color 影响。如果网页切成深色,而浏览器顶部仍然白得发光,整体就像额头贴了一盏手电筒。

动态主题最好同步更新对应的 meta name="theme-color"。

原生表单控件也要通知

color-scheme 可以让浏览器知道页面支持哪些主题,帮助输入框、滚动条等原生元素匹配。否则你精心做好的深色界面里,可能孤零零站着一个雪白的系统控件提醒你:“漏了我。”

System 不是“第一次加载时读一下系统”

如果用户选择跟随系统,晚上操作系统从亮色自动切深色,网页也应该跟着改变。可以监听:

matchMedia("(prefers-color-scheme: dark)")

只有当前模式是 system 时,才响应变化。否则用户明明选了 light,你不能在晚上擅自替他改回 dark。

深色模式不是“背景黑一点”

还要检查文字对比度、次要文字、禁用状态、边框、链接、焦点框、错误提示和图标。低对比灰字在设计稿里可能很“高级”,到了普通屏幕和户外环境里就变成寻找彩蛋。

深色模式最重要的性能指标之一:凌晨两点刷新页面时,不要给用户一个 200 毫秒的纯白闪光弹。

最后

深色模式一开始只是换颜色。

然后变成状态管理。

再变成本地持久化。

再处理首屏闪烁、系统变化、浏览器 UI 和可访问性。

一枚月亮图标,职业发展相当丰富。

最干净的模型通常仍然是三个状态:

system
light
dark

尊重用户显式选择,让 system 真正跟随系统,并尽早应用首屏主题。

这样深色模式才不只是“网页变黑”,而是真的像一个完整功能。