深色模式开发的最佳实践

深色模式开发的最佳实践

自动深色模式

使用 CSS 媒体查询

相信大家在查阅深色模式实现时,都遇到过这种写法:

css
@media (prefers-color-scheme: dark) {
    body {
        background-color: black;
        color: white;
    }
}

渐进增强而非并列查询

有些同学会说,诶,我有个好主意,我了解 prefers-color-scheme 的值还有 lightno-preference,我把基础样式并列地写进这三种媒体查询里。

这对吗?Chrome 76 起才支持 prefers-color-scheme 媒体特性,放在某些兼容性差的浏览器里,这些规则都不会生效。

所以最好采用「渐进增强」的策略,即先写基础样式,再写深色模式样式,这样可以保证低版本内核的浏览器正常显示。

css
body {
    background-color: white;
    color: black;
}

@media (prefers-color-scheme: dark) {
    body {
        background-color: black;
        color: white;
    }
}

使用 CSS 变量

又有同学要头疼了,按钮、弱文本也要适应深色模式,那我岂不得维护许多深色模式下的 CSS 声明块?

这个时候,CSS 变量 就派上用场了。我们可以维护一个普通模式和深色模式的「调色盘」,其他声明块中需要使用颜色,只需要通过 var(--my-color-1) 的形式引用即可。

https://cooo.site/asset/index.csscss
:root {
    --bg1: #f7f9ff;
    --bg2: #fff;
    --text1: #334;
    --text2: #556;
    --text3: #aab;
    --line: rgba(127, 127, 191, .2);
    --ac1: #37f;
    --ac2: rgba(0, 127, 255, .5);
    --ac3: rgba(0, 127, 255, .2);
}

@media (prefers-color-scheme: dark) {
    :root {
        --bg1: #111;
        --bg2: #222;
        --text1: #eee;
        --text2: #ccc;
        --text3: #777;
        --ac1: #fc2;
        --ac2: rgba(255, 191, 0, .5);
        --ac3: rgba(255, 191, 0, .2);
    }
}

另外,要注意各个颜色模式下的对比度,在确保文字清晰的同时,不要使用过高的对比度(纯黑、纯白),让用户的体验更舒适。收起 「A屏黑」 的想法,纯黑色省电的代价是黑暗环境中高对比度对用户视觉的刺激。

手动深色模式

又有同学在想了:我们做出的自动深色模式只是设备支持下的自动切换小彩蛋,如何显式展示技术力,比如放个深色模式按钮呢?

假设我们已经通过 localStorage 存储了用户主题并在加载时给 <html> 添加了 data-theme 属性,看起来完美实现了颜色模式切换,其实还会遇到几个降低用户体验的问题。

浏览器生成的自动深色模式

你的浅色模式在设备的浅色模式下表现很好,但是当设备在深色模式时,你的浅色模式可能表现得一团糊涂。

怎会如此!?Chrome 的贴心设计优化了不支持深色模式的网页显示,但成为了开发者在深色模式下展示浅色网页的心智负担。

我们需要通过 color-scheme 告诉浏览器网页支持深色和浅色模式,不要让浏览器扳倒我们自己的适配。两种方法二选一即可:

  • 在 HTML 头部通过元数据声明 <meta name="color-scheme" content="light dark">
  • 在 CSS 中声明 :root { color-scheme: light dark; }

记得在手动深色模式(类名而非媒体查询的实现)下设置 [data-theme="dark"] { color-scheme: dark; }。不然想一想 color-scheme: light dark 会怎么表现?滚动条、按钮等元素在 用户代理样式表 给出的是浅色的样式!这和手动深色模式的预期不一致。

页面初载时的闪烁

你的深色模式在设备深色模式下表现很好,但是当设备在浅色模式下,你的深色模式在初载时可能闪白,这对于用户眼睛的伤害是巨大的(点名批评 GitHub Docs)。

我曾使用 Pinia 管理用户主题,但 Pinia 的挂载需要时间,产生了页面闪白问题。我留意到一些网站是没有的,比如 VitePress,我研究了其代码实现(vitepress/src/node/config.ts#L263)。它在 <head> 中添加了同步的 <script>,通过 IIFE 立即执行函数,在 DOM 构建完成前就完成了主题切换,避免了页面闪白。

html
<head>
    <script>
        if (localStorage.getItem('theme') === 'dark')
            document.documentElement.setAttribute('data-theme', 'dark')
    </script>
</head>

一致性和 CSS 代码冗余

代码冗余会轻松地创建不一致,而不一致带来潜在的破坏力是惊人的:

css
:root {
    --c-bg: white;
    --c-text: black;
}

@media (prefers-color-scheme: dark) {
    /* 原本是 :root[data-theme="auto"]
     * 但下一个选择器应该有相同或更高的优先级 */
    :root {
        --c-bg: #111;
        --c-text: #eee;
    }
}

[data-theme="dark"] {
    --c-bg: black;
    --c-text: white;
    color-scheme: dark;
}

这样,你的深色模式定义了两次,你需要小心地维护这些代码。也许可以用 @mixin 来减少冗余?但终归不是完美的解决方案。

这时,抛弃掉 [data-theme="auto"] 就显得有用了——通过在 JS 中提前让「自动」的状态「塌缩」为确定的「浅色」或「深色」,CSS 中只需维护两种模式下的对应样式即可。

不要忘记监听 prefers-color-scheme 的变化:

ts
const colorSchemeQuery = window.matchMedia('(prefers-color-scheme: dark)')

function setTheme(theme?: 'light' | 'dark' | 'auto') {
    if (theme)
        localStorage.setItem('theme', theme)

    const targetTheme = theme || localStorage.getItem('theme') || 'auto'
    const isDark = targetTheme === 'dark' || (targetTheme === 'auto' && colorSchemeQuery.matches)

    document.documentElement.setAttribute('data-theme', isDark ? 'dark' : 'light')
    // 如果使用类名实现,则
    // document.body.classList.toggle('dark', isDark ? 'dark' : 'light')
}

colorSchemeQuery.addEventListener('change', () => setTheme());

setTheme()

这里的代码只是给出实现思路,具体实现一个完美的深色模式还要多费些心思。什么,竟然有人操作 document.getElementsByTagName('html')[0].className?回家吧孩子。

Take care of users, and code well. ☝️

前端字体二三事
寻不回手工油糕

评论区

评论加载中...