给一个已经在运行、有大量既有页面的 Django 站点加暗色模式,最大的挑战往往不是「怎么做出暗色」,而是「怎么保证明亮态一个像素都不变」。本文给出一套零回归策略、CSS 变量架构,以及浏览器验证阶段常见的三个缺陷及其修复。
需求:三态主题
需求很明确,明亮 / 黑暗 / 跟随系统三态可切换:
- 明亮:强制浅色。
- 黑暗:强制深色。
- 跟随系统:读操作系统的
prefers-color-scheme,并且系统主题变化时实时跟随,不用刷新页面。
选择要持久化到 localStorage,下次访问自动恢复。
站点现状
假设站点用 Tailwind CSS(CDN 版)+ Alpine.js。既有的颜色体系是 base.html 里定义的 7 组语义颜色 token(静态 hex 值),没有任何暗色逻辑,darkMode 也没配。更麻烦的是模板里颜色用法很杂:有语义 token、有原生 Tailwind 色类(bg-white、text-gray-700)、还有硬编码 hex。这决定了不能靠单一手段搞定,得用混合策略。
策略一:Tailwind class 模式 + <html> 开关
Tailwind 配 darkMode: 'class',通过在 <html> 上加/去 .dark 类来切换。所有 dark: 变体都以祖先是否有 .dark 为条件:
// base.html 的 Tailwind 配置
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
colors: {
// 关键:token 写成 rgb(var(--x-rgb) / <alpha-value>) 保留透明度能力
'page-bg': 'rgb(var(--color-bg-rgb) / <alpha-value>)',
'text-primary': 'rgb(var(--color-text-primary-rgb) / <alpha-value>)',
// ... 其余 5 组
}
}
}
}
策略二:CSS 变量作为单一真相源
核心思路是让 main.css 里的 CSS 变量成为唯一的颜色真相源,明亮和暗色只是同一批变量的两套取值。为了让 Tailwind 的 <alpha-value> 透明度机制仍然可用,变量存的是 RGB 三元组(而不是完整颜色值),token 再拼成 rgb(var(--color-*-rgb) / <alpha-value>):
/* main.css */
:root {
--color-bg-rgb: 255 255 255;
--color-text-primary-rgb: 17 24 39;
--color-accent-rgb: 180 40 40;
/* ... 共 7 组 */
}
html.dark {
--color-bg-rgb: 17 20 24;
--color-text-primary-rgb: 229 231 235;
--color-accent-rgb: 220 120 100;
/* ... 对应 7 组暗色取值 */
}
这样一来,语义 token 会随 .dark 自动翻转——凡是用了 text-text-primary、bg-page-bg 这类 token 的地方,一行代码都不用改就自动适配暗色。
策略三:FOUC 预防
用 CDN 版 Tailwind 有个经典问题:如果等 Tailwind/Alpine 加载完再决定主题,页面会先闪一下白再变黑(FOUC,Flash of Unstyled Content)。解法是在 Tailwind CDN <script> 之前注入一段同步执行的 pre-paint 脚本,在首次绘制前就把 .dark 类打到 <html> 上:
<!-- 必须放在 Tailwind CDN 之前,同步执行 -->
<script>
(function () {
try {
var stored = localStorage.getItem('theme');
var sysDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
var dark = stored === 'dark' || (!stored || stored === 'system') && sysDark;
if (dark) document.documentElement.classList.add('dark');
} catch (e) {}
})();
</script>
策略四:Alpine store 管理三态
主题状态用 Alpine.store('theme') 集中管理,负责三态切换、localStorage 持久化,以及监听 matchMedia 实现「跟随系统」时的实时切换:
Alpine.store('theme', {
mode: 'system', // 'light' | 'dark' | 'system'
init() {
this.mode = localStorage.getItem('theme') || 'system';
this.apply();
// 系统主题变化时,若处于 system 模式则实时跟随
window.matchMedia('(prefers-color-scheme: dark)')
.addEventListener('change', () => { if (this.mode === 'system') this.apply(); });
},
set(m) { this.mode = m; localStorage.setItem('theme', m); this.apply(); },
apply() {
const dark = this.mode === 'dark' ||
(this.mode === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
document.documentElement.classList.toggle('dark', dark);
}
});
策略五:混合适配
因为站点颜色用法混杂,采用两条腿走路:
- 语义 token:靠 CSS 变量自动翻转,零改动。
- 原生 Tailwind 色孤岛:逐元素补
dark:变体。这里有一条铁律——零回归:只追加dark:变体,绝不新增/修改明亮态的类。典型映射:
bg-white → 追加 dark:bg-gray-800
text-gray-700 → 追加 dark:text-gray-200
border-gray-200 → 追加 dark:border-gray-700
这里容易踩一个「好心办坏事」的坑:给输入框加暗色时,顺手补一个 bg-white 想让明亮态「更明确」。但原文件的输入框根本没有显式背景类(靠浏览器默认白底),加 bg-white 就凭空多了一个原本不存在的明亮态类——违反了零回归原则。正确做法是只加 dark:bg-gray-800,它会覆盖浏览器默认白底,明亮态保持原样不动。
零回归的可验证判据:把改后文件里所有
dark:变体剥离掉,剩下的内容应与原文件diff完全一致。这是保证「明亮态一个像素没变」的硬指标。
水墨风格页:「夜宣纸·淡墨夜读」暗色重设计
有些工具页会采用水墨风格设计,它用了一套独立的 --ink-* CSS 变量,同时控制文字颜色和背景渐变/形状。这页不能简单套用「翻转变量」——简单翻转会破坏视觉意图:
.ink-icon用--ink-deep作渐变停止点,翻成浅色后变成「亮光环 + 暗字看不见」,而不是想要的「暗石 + 浅字」。.ink-enso(圆形印章)用深色 conic 渐变,在暗背景上直接消失。
这类页面的暗色需要专门重设计(以「夜宣纸·淡墨夜读」为题的一种思路):给这些类加自定义 html.dark 覆盖,用新值重建视觉意图,而不是机械翻转——Ensō 改成月白银钩、图标改成墨石质感、朱砂红作点睛。
通用教训:设计敏感的页面(艺术风格、渐变/形状/mask 多的),别指望简单翻转变量。要针对受影响的类加自定义暗色覆盖,重建原本的视觉意图。
三个典型缺陷与修复
结构性改完后,真实浏览器 + getComputedStyle + WCAG 对比度取证的验证阶段,往往会揪出三个典型缺陷。它们都很典型:光看代码看不出来,必须渲染后量。
缺陷一:属性选择器特异性——暗底暗字不可见
现象:水墨风格页的主题切换 pill,选中态在暗色下「暗底暗字」几乎看不见。
根因:选中 pill 的样式用了属性选择器 .ink-pill[aria-pressed="true"],而 html.dark 的覆盖规则漏掉了 color 属性。属性选择器带有一定的特异性,暗色下背景翻了但文字颜色没翻,两者都偏暗。实测对比度只有 cr 1.15,完全不可读。
修复:补全暗色覆盖,把 background / color / border-color 一起显式声明:
html.dark .ink-pill[aria-pressed="true"] {
background: /* 暗色选中背景 */;
color: /* 暗色选中文字,与背景拉开对比 */;
border-color: /* ... */;
}
修复后对比度升到 cr 13.86,清晰可读。
缺陷二:实心 bg-accent 按钮白字,暗色对比崩坏
现象:实心强调色按钮(bg-accent + text-white)在暗色下对比度崩了,实测 cr 2.57,不达 WCAG。
根因:暗色下 --color-accent-rgb 被调亮(为了在暗背景上更醒目),但按钮文字写死 text-white。亮 accent 底 + 白字,对比自然不够。
修复:把 text-white 换成 text-page-bg(即 rgb(var(--color-bg-rgb)))。妙处在于:
- 明亮态:
--color-bg-rgb=255 255 255,text-page-bg逐字节等于原来的白色,零回归。 - 暗色态:
--color-bg-rgb是深色,深字配亮 accent 底,对比度升到 cr 6.83,达标。
一个改动同时满足两态,且明亮态纹丝不动。
缺陷三:@media print 被 Tailwind 类压制
现象:打印时页面没有回到白底黑字,暗色被打印了出来。
根因:原来在 @media print 里想覆盖颜色,但媒体查询本身不增加选择器特异性。body 上的 Tailwind 工具类(如 bg-...)特异性压过了 @media print 里的普通规则,导致打印覆盖失效。
修复:不在 body 层面较劲,而是回到 CSS 变量这个真相源——在 @media print 里对 :root, html.dark 两级把 7 组 RGB 三元组统一覆盖回明亮值:
@media print {
:root, html.dark {
--color-bg-rgb: 255 255 255;
--color-text-primary-rgb: 17 24 39;
/* ... 7 组全部覆盖为明亮值 */
}
}
因为所有 token 都从这批变量派生,覆盖变量就等于把整页颜色拉回明亮,绕开了特异性战争。修复后打印为纯白底黑字。
验证方法论
三个缺陷都印证了同一套方法论:
- 必须真实浏览器验证:
getComputedStyle读实际生效的颜色,而不是看源码猜。CSS 特异性、媒体查询、级联顺序,只有渲染后才知道真相(这和「命令行返回 200 不等于浏览器可用」是同一个道理——验证手段必须匹配故障发生的层面)。 - WCAG 对比度取证:用对比度公式量化 cr 值,达标线明确(正文 4.5、大字 3.0),不靠肉眼「感觉还行」。三个缺陷分别对应 cr 1.15→13.86、2.57→6.83、打印白/黑。
- 明亮态零回归用 diff 保证:剥离
dark:变体后与原文件逐字节 diff 一致,这是「没弄坏明亮态」的客观证据,而不是主观判断。
小结
给既有站点加暗色模式,工程上的关键是:用 CSS 变量做单一真相源(语义 token 自动翻转)、混合策略(token 翻转 + 原生色孤岛补 dark:)、pre-paint 脚本防 FOUC、Alpine store 管三态 + 实时跟随,最后靠真实浏览器 + 对比度取证 + diff 零回归三件套验收。设计敏感页单独重做,别指望一刀切。
把这一类问题汇总成一份「问题 → 根因 → 解法 → 可复用经验」的清单,是把经验固化成清单的好办法。
评论 (0)
暂无评论,来说点什么吧。
登录 后可以发表评论