缘起
今天在玩我的个人博客的时候,遇到一个老生常谈的 UI 问题,怎么给它加一个"暗黑模式"?虽然这个需求极其普遍,但是做的过程中还是遇到了很多细节问题,这里记录一下。
所谓的暗黑模式,最早要解决的问题场景,应该是当用户在晚上环境光很暗的时候可以便捷的调整网站的配色,降低亮度和对比度,来缓解眼睛的不适。现在常见的做法是在网站上提供一个手动切换暗黑模式的按钮,允许用户手动切换,同时也会监听系统的首选项,如果在电脑系统或者浏览器层面设置了优先暗黑模式的话,要自动做模式切换。
解法思路
对于网站配色来讲,这里最核心的就是各个元素的背景色,和文字的前景色。一个网站上的元素可能会非常多,如果要对它们进行处理的话,首先要做的事情,就是将这些元素的颜色属性进行统一,避免元素内的硬编码。
这里我们使用 tailwindcss 来管理项目内涉及到的所有颜色,我采用的方式,是对颜色进行语义化命名,然后再通过CSS Variable
来管理正常模式和暗黑模式下颜色的不同变体。这样做有一个额外的好处,就是如果后面需要做暗黑模式之外的其他主题的时候,可以很轻易的做横向扩展。配置代码 demo 如下:
javascript
// tailwind.config.js
module.exports = {
// 通过在html元素上增加dark的类名来管理暗黑模式
darkMode: ["class"],
theme: {
extends: {
colors: {
primary: "rgb(var(--color-primary) / <alpha-value>)",
secondary: "rgb(var(--color-secondary) / <alpha-value>)",
neutral: {
light: "rgb(var(--color-neutral-l) / <alpha-value>)",
DEFAULT: "rgb(var(--color-neutral) / <alpha-value>)",
bold: "rgb(var(--color-neutral-b) / <alpha-value>)",
},
},
},
},
};
这里有一点细节,就是在颜色定义中注入<alpha-value>
的模板值,这样后续在元素中使用的时候,可以通过类似bg-primary/20
的方式来动态修改颜色的 alpha 通道。
之后就只需要在引入 tailwind 的 CSS 文件中定义对应 CSS 变量的值即可:
CSS
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--color-primary: 140 120 78;
--color-secondary: 255 254 253;
--color-neutral: 41 27 0;
}
.dark {
--color-primary: 255 254 253;
--color-secondary: 23 23 23;
--color-neutral: 229 229 229;
}
* {
/* 这里我直接给所有的元素都设置了默认的背景色和文字的前景色,方便默认样式的最大程度复用 */
@apply bg-secondary text-neutral;
}
}
当我们做完这一步之后,核心的工作就已经做的七七八八了,只要在某个触发条件下(比如用户点击了某个【切换暗黑模式】的按钮的时候)在 html 元素上增加或者去掉dark
这个类名就可以了。
富文本场景兜底
在个人博客的这个场景下,我选择的实现方式是通过 markdown 来写博客,之后丢到网站上通过 NextJs 的 SSG 能力渲染成一堆 html 页面,因为 markdown 到 html 的转换工作是三方库来做的,所以我们能拿到的其实是一个 html 的长字符串,对里面的具体元素的样式控制能力自然就会弱很多。特别是在使用 tailwindcss 的时候,如果我们什么都不做,它的 reset 功能会把这些富文本直接渲染成近乎无样式的文本。针对这个问题,tailwindcss 专门维护了一个官方插件@tailwindcss/typography
,它做的事情说白了就是在 reset rule 之外,针对富文本里面的内容新增了一组 CSS rule,让他们看起来是符合基本的设计感的(比如 headings 要比 paragraph 字号大一些,列表要有列表符等等)。
上面的这个方式可以省去我们大量的专门针对富文本内容的样式定制工作,而且这个插件提供了很精细的定制能力,可以很方便的将三方富文本定制成为我们想要的样子。它的使用方式如下:
jsx
// 富文本组件
<div
className="prose prose-custom prose-h1:bg-primary"
dangerouslySetInnerHTML={{ __html: contentHtml }}
/>
在我们富文本组件里,通过指定prose
这个类型来激活这个插件,tailwind 会为富文本里的比如<h1>
标签生成下面这样的 CSS Rule:
css
.prose :where(h1):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
color: var(--tw-prose-headings);
font-weight: 800;
font-size: 2.25em;
margin-top: 0;
margin-bottom: 0.8888889em;
line-height: 1.1111111;
}
从而可以让我们实现一键为三方富文本添加还 OK 的样式。注意上面的另外两个类名,prose-h1:bg-primary
这组类名给我们暴露了 tailwindcss 级别的精细定制能力,我们可以针对富文本里的特定标签或者特定类名,来添加 tailwind 的 utility 类(完整的类名列表可以参考它的 repo),而prose-custom
是给我们暴露了自定义样式集合的口子,可以把prose
理解为prose-default
。自定义样式的方式如下:
javascript
// tailwind.config.js
module.exports = {
theme: {
extends: {
typography: {
custom: {
// 这里可以来覆写插件预定义的一组CSS变量
"--tw-prose-headings": "#aabbcc";
}
}
}
},
plugins: [require('@tailwindcss/typography')]
}
这里的自定义是通过覆写 CSS Variable 的方式来修改各个元素的文字颜色和部分元素的背景色(完整的变量列表可以参考这里)。其余更细节的定制还是要使用上面 utility 的方式。
暗黑模式的持久化
上面的流程跑通之后,用户在打开网站之后,通过点击页面上的切换按钮,可以保证页面视觉风格的正常切换。但是如果用户切换到暗黑模式之后刷新页面,那么页面将回到普通模式进行渲染,我们需要把用户选择的结果持久化到本地,这样可以保证用户无论何时打开网页,得到的体验是一致的,而不会出现白天选了暗黑模式,晚上打开网页还要被先"晃瞎一次"。
要解决上面这个问题其实非常简单,用户在手动切换暗黑模式的时候,我们把用户的选择结果记录到 localStorage 里,然后在页面初始化的时候,把 localStorage 里的用户记录加载到内存并且更新页面状态即可。只是这里有一个小细节需要注意一下,页面首次加载去更新状态的时候,最好是在页面实际渲染之前,否则如果用户选择了暗黑模式,那么刷新页面的时候,可能会先闪一下默认的渲染样式,然后页面才会在我们的代码逻辑下变成暗黑模式,用户体验并不好。一个简单的状态加载逻辑可以是这样:
html
<html>
<head>
<script>
(function () {
if (
localStorage.getItem("theme") === "dark" ||
(localStorage.getItem("theme") == null &&
window.matchMedia("(prefers-color-scheme: dark)").matches)
) {
document.documentElement.classList.add("dark");
} else {
document.documentElement.classList.remove("dark");
}
})();
</script>
</head>
</html>
NextJs 带来的新问题
上面的逻辑在静态网站的技术框架(比如基于 Vite 或者 CRA 构建的 SPA 站点)下是没有任何问题的,但是如果我们的网站是使用比如 NextJs 这样的全栈框架通过 SSG 来生成的,那么就会有另外一些问题需要处理。
框架限制,<head>
标签改不了?
NextJs 在 13 引入 App Router 之后,有一个很大的变化是,我们没有办法直接去定义<head>
标签了,框架转而给我们提供了export const metadata
以及export function generateMetaData()
这样的 conventional API 来有限制 的定义部分<head>
标签的内容。虽然框架也提供了一个<Script strategy="beforeInteractive">
这样的自定义组件来让我们往<head>
里面注入 JS 代码,但是经测试之后,这个自定义标签注入的时机是在 render 之后,hydration 之前,所以并不能解决我们的问题。
最终我发现一个简单粗暴的解法是,直接在 root layout 里塞一个原生的 html <script>
标签。ugly, but working...
jsx
// /app/layout.tsx
export default function layout({ children }: { children: ReactNode }) {
return (
<html lang="en">
<body>
<script>{/*上面的初始化代码*/}</script>
{children}
</body>
</html>
);
}
Hydration Warning
如果上面的问题我们采用了直接写<script>
的方式的话,在开发过程中,马上就会遇到这个问题,在暗黑模式下,每次刷新,console 里都会报 Warning,类似Prop "className" did not match. Server: "", Client: "dark"
。这个结果其实是在我们预期之内的。当我们强行在 html 里面加入自定义的 JS 代码,在渲染完成之前就修改了 html 文档的内容,那么一定会出现,在服务端渲染的结果和在客户端渲染的结果不一致的问题。当然,在暗黑模式这个场景下,我们是知道,这种不一致,是不影响我们网站的业务逻辑的,所以这里我们可以简单的采用这样的方式处理<html suppressHydrationWarning>
。
SSR 下的完整解法
我们的博客网站,其实绝大多数情况下都是一个静态网站(即 SSG 模式渲染完的一堆静态 html/css/js),如果我们的网站是一个真实的 SSR Service,那么这个场景其实还有另外一种解决方式,就是通过服务端来管理用户的首选项。
从这个角度来看的话,其实用户对暗黑模式的选择,本质是一个用户 session 管理的问题。最简单的方式,就是在客户端本地用户选择暗黑模式的时候,把数据记到 cookie 中,然后在服务端渲染的时候,把 cookie 中的数据也取出来一起决定渲染的页面结果。