﻿---
title: "Hexo 多主题博客设计：CSS变量 + :where 选择器实现主题切换"
date: 2025-11-18
tags:
  - Hexo
  - Web
  - Theme
  - Catppuccin
  - Shiki
  - Ricing
  - Blog
  - CSS
  - JavaScript
---

<script data-swup-reload-script type="module" src="/js/components/tab.js"></script>

本文介绍如何利用 CSS 变量与 `:where` 选择器实现博客多主题管理，包括 Shiki 代码高亮主题切换与页面闪烁（FOUC）的解决方案

<!--more-->

## Preview

<video autoplay loop muted playsinline>
  <source src="https://assets.vluv.space/blog_themes.webm" type="video/webm">
</video>

## Setup Multi-Themes with CSS Variables

实现多主题的核心在于**解耦**颜色值与 CSS 规则：

- 在 HTML 根元素上设置 `{css} data-theme` 属性标识当前主题（如 `nord`、`tokyo_night`）；切换主题时，动态修改该属性值
- 针对不同 `{css} data-theme` 值，定义同名的 CSS 变量：
  - `{css} :where([data-theme=nord]) { --red: #d20f39; }`
  - `{css} :where([data-theme=tokyo_night]) { --red: #f7768e; }`
- 组件的颜色样式，均通过 `var(--color-var)` 引用对应变量

### Define Color Variables

针对不同主题定义专属变量，可使用 `:where` 选择器[^1]；同时建议设置 `{css} color-scheme`[^2] 属性，告知浏览器当前主题是亮色还是暗色，这会影响滚动条和表单控件的系统默认样式

```css default.css
:where([data-theme="nord"]) {
	color-scheme: light;
	--red: #d20f39;
}

:where([data-theme="tokyo_night"]) {
	color-scheme: dark;
	--red: #f7768e;
}
```

应用如上CSS规则后，页面元素即可根据当前主题动态应用对应颜色值。

例如对于如下页面，`{css} h1` 元素会应用 `nord` 主题下的 `--red` 变量值，即 `#d20f39`。当用户切换至 `tokyo_night` 主题后，`{css} h1` 元素则会应用新的 `--red` 变量值，即 `#f7768e`

```html index.html
<!-- apply nord theme -->
<html data-theme="nord">
  <head>
    <link rel="stylesheet" href="color.css" />
  </head>
  <body>
    <h1 style="color: var(--red)">Hello, World!</h1>
  </body>
</html>
```

### Follow System Theme

对于"跟随系统主题"选项的实现，有两种实现路径：

<x-tabs>

<x-tab title="CSS" active>

CSS 方案的思路是：先定义 `{css} data-theme="system"` 情况下的颜色变量，这里默认采用浅色主题。在该规则后面，通过 `{css} @media (prefers-color-scheme: dark)` 媒体查询检测系统的暗色偏好，如果系统偏好为暗色，则再次针对 `{css} :where([data-theme="system"])` 重新定义暗色主题的变量值，覆盖之前的浅色值。当用户选择"跟随系统"时，只需将 `{css} data-theme` 设置为 `"system"`，浏览器会根据系统偏好自动应用对应的颜色变量。该方案的局限是有冗余变量定义，维护成本较高

 ```css color.css
/* ... light ... */
:where([data-theme="system"]) {
	color-scheme: light dark;
	--rosewater: #dc8a78;
}

/* ... night ... */
@media (prefers-color-scheme: dark) {
	:where([data-theme="system"]) {
		--rosewater: #f5e0dc;
	}
}
```

</x-tab>

<x-tab title="JavaScript">

使用 JavaScript 可根据系统偏好动态设置 `{css} data-theme` 属性为具体主题名。`resolveTheme` 函数会检查当前主题值，如果是 `"system"`，则通过 `window.matchMedia("(prefers-color-scheme: dark)")` 检测系统是否处于暗色模式，并返回对应的主题名称（如 `"mocha"` 或 `"nord"`）；如果是其他固定主题，则直接返回该主题名称。`applyTheme` 函数负责将解析后的主题应用到 HTML 元素上。

```js theme-selector.js
const colorSchemeMediaQuery = window.matchMedia("(prefers-color-scheme: dark)");

function resolveTheme(theme) {
	return theme === "system"
		? colorSchemeMediaQuery.matches
			? "mocha"
			: "nord"
		: theme;
}

function applyTheme(theme) {
	const html = document.documentElement;
	const resolvedTheme = resolveTheme(theme);
	html.setAttribute("data-theme", resolvedTheme);
	html.classList.remove("night", "light");
	html.classList.add(THEME_MAP[resolvedTheme]);
}
```

</x-tab>

</x-tabs>

#### Theme Persistence and Responsiveness

为了让主题切换体验更加完善，需要解决两个问题：

**主题持久化（Persistence）**：用户选择的主题应该在刷新页面或关闭浏览器后依然生效。通过 `localStorage` 将用户的选择保存在本地，下次访问时自动恢复。

```js theme-selector.js
// [!code word:persist]
const colorSchemeMediaQuery = window.matchMedia("(prefers-color-scheme: dark)");

// 从 localStorage 获取用户选择的主题
function getThemePreference() {
	// [!code ++]
	const stored = localStorage.getItem(STORAGE_KEY); // [!code ++]
	return stored && stored in THEME_MAP ? stored : DEFAULT_THEME; // [!code ++]
} // [!code ++]

// 当用户选择主题时，调用该方法，且传入persist=True
function applyTheme(theme, persist) {
	const html = document.documentElement;
	const resolvedTheme = resolveTheme(theme);
	html.setAttribute("data-theme", resolvedTheme);
	html.classList.remove("night", "light");
	html.classList.add(THEME_MAP[resolvedTheme]);
	if (persist) {
		// [!code ++]
		localStorage.setItem(STORAGE_KEY, theme); // [!code ++]
	} // [!code ++]
}
```

**主题响应性（Responsiveness）**：当用户使用"跟随系统"选项时，如果系统偏好发生变化（如从浅色模式切换到深色模式），网页应该立即响应并更新主题。

```js
colorSchemeMediaQuery.addEventListener("change", () => {
	if (getThemePreference() === "system") {
		applyTheme("system");
	}
});
```

### Transparent Colors

> [!example]-
>
> 一些组件可能需要使用半透明颜色。例如这段文字，使用了 Callout 组件。针对这种需求，一种简单但不优雅的方案是：
>
> ```css
> :where([data-theme="nord"]) {
> 	--red: #d20f39;
> 	--red-10: rgba(210, 15, 57, 0.1); /* 10% 透明度 */
> 	--red-50: rgba(210, 15, 57, 0.5); /* 50% 透明度 */
> 	--green: #40a02b;
> 	--green-10: rgba(64, 160, 43, 0.1);
> 	--green-50: rgba(64, 160, 43, 0.5);
> 	/* 其他颜色 */
> }
> ```

如需广泛使用透明颜色，变量数量会成倍增加；该方法颇费人力，维护成本也不小。更推荐使用 CSS 的 relative color syntax[^3] 来实现透明颜色的定义：

```css
color-function(from origin-color channel1 channel2 channel3)
color-function(from origin-color channel1 channel2 channel3 / alpha)

/* color space included for color() functions */
color(from origin-color colorspace channel1 channel2 channel3)
color(from origin-color colorspace channel1 channel2 channel3 / alpha)
```

对于上例中的红色，便可直接通过如下方式复用 `--red` 变量，定义半透明色：

```css
/* 50% 透明度的红色背景 */
background-color: hsl(from var(--red) h s l / 0.5);
```

浏览器兼容性[^4]一般，不过对于个人博客来说，影响不大。

## Avoid FOUC

一个避免页面初始渲染时出现 Flash[^5]的方案是：在 `{html} <head>` 插入 IIFE（Immediately Invoked Function Expression）脚本，保证在页面渲染前完成 `{css} data-theme` 属性的设置

```html
<html lang="zh-CN" data-theme="mocha" class="night">
    <head>
        <script>
        (function() {
          var THEME_MAP = {
            mocha: "night", macchiato: "night",
            nord: "light", nord_night: "night",
            tokyo_night: "night", latte: "light"
          };
          var stored = localStorage.getItem("themePreference");
          var theme = stored ? stored : "system";
          var html = document.documentElement;
          var resolvedTheme = theme === "system"
            ? window.matchMedia("(prefers-color-scheme: dark)").matches ? "mocha" : "nord"
            : theme;
          html.setAttribute("data-theme", resolvedTheme);
          html.classList.add(THEME_MAP[resolvedTheme]);
        })();
        </script>
    </head>
</html>
```

## Special Components

### Code Highlighting

在代码高亮方面，本博客使用 Shiki 进行静态代码高亮渲染。Shiki 支持传入多种主题。例如：

```js
const themes = {
	light: "catppuccin-latte",
	dark: "catppuccin-mocha",
	tokyo: "tokyo-night",
};

const code_html = highlighter.codeToHtml(code, {
	lang: options.lang || "",
	themes: themes,
	transformers: enableTransformers ? SUPPORTED_TRANSFORMERS : [],
});

/* Generated Span Be Like  */
<span style="color:#1E66F5;--shiki-dark:#89B4FA;--shiki-tokyo:#7AA2F7">
	name
</span>;
```

不难想到，我们可以通过 `{css} data-theme` 属性，结合 CSS 变量，来实现多主题色彩支持：

```css shiki.css
code span {
	font-style: var(--shiki-light-font-style);
	font-weight: var(--shiki-light-font-weight);
}
:is([data-theme="tokyo_night"]) {
	code span {
		font-style: var(--shiki-tokyo-font-style) !important;
		font-weight: var(--shiki-tokyo-font-weight) !important;
		color: var(--shiki-tokyo) !important;
	}
}
```

> [!TIP]- Further Optmization
>
>如果要应用更多主题，那么前面生成的 span 标签就会非常臃肿，理想的方案是为每个颜色生成单独的 class。Shiki 提供了 [transformerstyletoclass](https://shiki.style/packages/transformers#transformerstyletoclass) 转换器，将生成如下 HTML
>
>```html
><pre
>  class="shiki shiki-themes vitesse-dark vitesse-light __shiki_9knfln"
>  tabindex="0"
>>
>    <code>
>        <span class="line">
>          <span class="__shiki_14cn0u">console</span>
>          <span class="__shiki_ps5uht">.</span>
>          <span class="__shiki_1zrdwt">log</span>
>          <span class="__shiki_ps5uht">(</span>
>          <span class="__shiki_236mh3">'</span>
>          <span class="__shiki_1g4r39">hello</span>
>          <span class="__shiki_236mh3">'</span>
>          <span class="__shiki_ps5uht">)</span>
>        </span>
>    </code>
></pre>
>```
>
>配合 `{js} transformerStyleToClass({ classPrefix: '__shiki_'}).getCSS()` API 获取相应的 CSS 文件；具体操作可以参考[[shiki_style_to_class|我的这篇文章]]
>
>```css auto_generated.css
>.__shiki_14cn0u {
>	--shiki-dark: #bd976a;
>	--shiki-light: #b07d48;
>}
>/* ... */
>.__shiki_9knfln {
>	--shiki-dark: #dbd7caee;
>	--shiki-light: #393a34;
>	--shiki-dark-bg: #121212;
>	--shiki-light-bg: #ffffff;
>}
>```

[^1]: The CSS `:where()` pseudo-class is used to apply the same style to all the elements inside the parentheses, at the same time. `:where()` always has 0 specificity. [CSS :where Pseudo-class](https://www.w3schools.com/cssref/sel_where.php)
[^2]: Common choices for operating system color schemes are "light" and "dark", or "day mode" and "night mode". When a user selects one of these color schemes, the operating system makes adjustments to the user interface. This includes [form controls](https://developer.mozilla.org/en-US/docs/Learn_web_development/Extensions/Forms), [scrollbars](https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Scrollbars_styling), and the used values of [CSS system colors](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/system-color). [color-scheme - CSS | MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/color-scheme)
[^3]: The [CSS colors module](https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Colors) defines **relative color syntax**, which allows a CSS color value to be defined relative to another color. This is a powerful feature that enables easy creation of complements to existing colors — such as lighter, darker, saturated, semi-transparent, or inverted variants — enabling more effective color palette creation. [Using relative colors - CSS | MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Colors/Using_relative_colors)
[^4]: [CSS Relative color syntax | Can I use... Support tables for HTML5, CSS3, etc](https://caniuse.com/css-relative-colors)
[^5]: A **flash of unstyled content** (FOUC, or flash of unstyled text) is an instance where a web page appears briefly with the browser's default styles prior to loading an external CSS stylesheet, due to the web browser engine rendering the page before all information is retrieved.
