为hexo-theme-cactus添加深色模式

前言

最近给博客添加了 Giscus 评论系统并从 Utterances 做了迁移。看到 Giscus 官网 上支持白天、夜间模式,在晚上浏览这个网站,系统自动调整到深色模式,整个网站和浏览器黑色主题融为一体,观感非常好。回到自己的站点,浏览器顶部是深色,网页确实非常刺眼的白色,非常不搭。正好趁着最近有空维护,顺带优化一下。

方案

自定义深色模式 CSS 样式

周六上午花了两个多小时的时间把深色模式的 css 样式给写好了,准备加模式切换功能的时候才发现,原生主题的四种主题样式中就已经包含了深色模式。遂放弃了自定义深色模式样式,转用原生的深色主题。

样式切换

初始进入站点时,根据用户系统偏好设置自动加载浅色或深色模式。在导航中增加一个按钮 ☀️ | 🌙 用来手动切换主题,新增一个 JavaScript 文件用于给按钮新增监听事件。手动切换之后,浏览器本地缓存设置,用于后续页面加载时加载对应的样式。

不过由于原主题浅色 / 深色模式样式是写在不同的文件中的,而 Hexo 默认只会编译其中一个样式。因此要想实现切换,就必须将所有主题样式都编译出来,再动态的通过 JavaScript 改变。
这里采用的方案是新增几个和主题色对应的 styl 文件,分别 import 对应的主题样式,然后在 theme/_config.yml 文件中加入配置使几个样式文件同时编译:

1
2
3
4
5
6
stylus:
include:
- 'source/css/theme-classic.styl'
- 'source/css/theme-dark.styl'
- 'source/css/theme-light.styl'
- 'source/css/theme-wihte.styl'

样式文件如 theme-dark.styl 文件则在原样式名前增加一个前缀 theme–dark 以避免同时编译多个样式文件导致属性名称冲突报错。

1
2
3
4
5
6
7
8
9
10
11
12
13
.theme--dark
@import "_variables"
@import "_colors/dark"
@import "_util"
@import "_mixins"
@import "_extend"
@import "_fonts"

global-reset()

*, *:before, *:after
box-sizing: border-box
// 省略原文件后续内容

这样编译完之后,在 public/css 目录下就同时有了几套主题样式,别忘记在 head 相关的 ejs 文件中全部引入。

核心功能放在 switch-theme.js 文件中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
const themes = [
{ name: 'dark', className: 'theme--dark' },
{ name: 'white', className: 'theme--white' }
];

document.addEventListener('DOMContentLoaded', () => {
const toggleButton = document.getElementById('btn-theme-switch');
if (!toggleButton) return;

// 检查 localStorage 中的用户偏好
const userPref = localStorage.getItem('color-scheme');
const systemPref = window.matchMedia('(prefers-color-scheme: dark)').matches;

let isDark = false;

if (userPref === 'dark') {
isDark = true;
} else if (userPref === 'white') {
isDark = false;
} else {
// 未设置时,跟随系统
isDark = systemPref;
}

const nextColorScheme = isDark ? 'dark' : 'white'; // 修复单引号错误
const nextTheme = themes.find(t => t.name === nextColorScheme);

setTheme(null, nextTheme);
syncGiscusTheme(nextTheme); // 初始应该同步当前主题

// 切换按钮图标和行为
toggleButton.textContent = isDark ? '☀️' : '🌙';

toggleButton.addEventListener('click', () => {
// 点击时应该基于当前isDark状态获取主题,而不是直接读localStorage
const currentColorScheme = isDark ? 'dark' : 'white'; // 切换前的主题
const currentTheme = themes.find(t => t.name === currentColorScheme);
const nextColorScheme = isDark ? 'white' : 'dark'; // 切换后的主题
const nextTheme = themes.find(t => t.name === nextColorScheme);

isDark = !isDark;
toggleButton.textContent = isDark ? '☀️' : '🌙';

setTheme(currentTheme, nextTheme);
syncGiscusTheme(nextTheme);

localStorage.setItem('color-scheme', nextTheme.name);
});

window.addEventListener('message', (event) => {
if (event.origin !== 'https://giscus.app') return;
const colorScheme = localStorage.getItem('color-scheme');
const theme = themes.find(t => t.name === colorScheme);
syncGiscusTheme(theme);
});

const html = document.documentElement;
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.attributeName === 'class') {
const isDark = html.classList.contains('theme--dark'); // 用主题类名判断更准确
syncGiscusTheme(isDark ? themes[0] : themes[1]);
}
});
});
observer.observe(html, { attributes: true });


// 设置主题色
function setTheme(currentTheme, newTheme) {
if (!newTheme) return; // 增加当前主题的校验

if (currentTheme) {
document.documentElement.classList.remove(currentTheme.className);
}
document.documentElement.classList.add(newTheme.className);
localStorage.setItem('color-scheme', newTheme.name);
}


// 设置评论区主题色
function syncGiscusTheme(theme) {
if (!theme) return;
const giscusFrame = document.querySelector('iframe.giscus-frame');
if (!giscusFrame) {
if (window.giscusRetryCount === undefined) window.giscusRetryCount = 0;
if (window.giscusRetryCount < 10) {
window.giscusRetryCount++;
setTimeout(() => syncGiscusTheme(theme), 100); // 这里传theme对象
}
return;
}

if (giscusFrame.contentWindow) {
// 从 iframe 的 src 中提取正确的 origin(避免跨域错误)
const frameOrigin = new URL(giscusFrame.src).origin;

giscusFrame.contentWindow.postMessage({
giscus: {
setConfig: {
defaultCommentOrder: "newest",
theme: theme.name === 'white' ? 'light' : 'dark' // 使用主题对象的name属性
}
}
}, frameOrigin);
} else {
// 延迟重试
setTimeout(() => syncGiscusTheme(themeName), 100);
}
}

// 避免页面加载时的白屏问题
document.body.style.visibility = 'visible';
});

Giscus 深色模式切换

前面引入的 Giscus 使用 data-theme 预先定义好了主题样式。要手动切换,需要通过监听按钮事件,向 iframe 传递消息来实现。逻辑如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// 设置评论区主题色
function syncGiscusTheme(theme) {
if (!theme) return;
const giscusFrame = document.querySelector('iframe.giscus-frame');
if (!giscusFrame) {
if (window.giscusRetryCount === undefined) window.giscusRetryCount = 0;
if (window.giscusRetryCount < 10) {
window.giscusRetryCount++;
setTimeout(() => syncGiscusTheme(theme), 100); // 这里传theme对象
}
return;
}

if (giscusFrame.contentWindow) {
// 从 iframe 的 src 中提取正确的 origin(避免跨域错误)
const frameOrigin = new URL(giscusFrame.src).origin;

giscusFrame.contentWindow.postMessage({
giscus: {
setConfig: {
defaultCommentOrder: "newest",
theme: theme.name === 'white' ? 'light' : 'dark' // 使用主题对象的name属性
}
}
}, frameOrigin);
} else {
// 延迟重试
setTimeout(() => syncGiscusTheme(themeName), 100);
}
}

踩坑记录

加载新页面时白屏问题

测试的时候发现在深色模式下进入其他页面时,在页面加载的时候会出现白屏,页面加载完成后才会变成指定的颜色模式。

参考 日夜间模式切换功能 提出的优化方案,在 head 部分增加一个 style,给 body 设置一个不可见属性,页面加载完成后去掉这个不可见属性,顺利的解决了白屏问题。
head.ejs

1
2
3
<style>
body { visibility: hidden; }
</style>

theme-switch.js

1
2
3
4
document.addEventListener('DOMContentLoaded', () => {
// 避免页面加载时的白屏问题
document.body.style.visibility = 'visible';
});

Giscus 样式切换不生效

本站点的颜色切换 OK,就是 Giscus 评论区一直不能动态切换,控制台报错 https://giscus.app/white 返回 404。
在参考 Hogo 中 Giscus 配置 时,发现该站点可以正常切换。原想发个评论请教一下怎么做的,随便打开控制台切换了两下,看到网络请求地址是 https://giscus.app/theme/dark.csshttps://giscus.app/theme/light.css 。回到我的站点确认后发现我调用的地址是 https://giscus.app/theme/white.css ,是我的浅色主题名称和 Giscus 浅色主题名称不一致导致请求 404。做了名称转换就没问题了。

本地保存的颜色模式未清除

本地保存的颜色模式没有自动清理机制,导致在深色模式访问过站点,第二天白天访问时仍然读取到上一次的深色模式标识,没有自动切换主题颜色。

这个问题很好解决,每次加载页面保存颜色模式到本地时,另外保存一个有效期:30分钟。每次进入站点时自动检查该变量是够过期,如果过期则删除,走跟随系统主题颜色的逻辑即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 初始化处理:检查是否有过期的color-theme并清除
function initColorThemeStorage() {
const storedTheme = localStorage.getItem('color-scheme');
const expiryTime = localStorage.getItem('color-scheme-expiry');

// 如果存在存储且已过期,清除它
if (storedTheme && expiryTime && Date.now() > Number(expiryTime)) {
localStorage.removeItem('color-scheme');
localStorage.removeItem('color-scheme-expiry');
}
}

// 保存color-theme时同时设置30分钟过期时间
function saveColorTheme(theme) {
const expiry = Date.now() + 30 * 60 * 1000; // 30分钟后过期
localStorage.setItem('color-scheme', theme);
localStorage.setItem('color-scheme-expiry', expiry.toString());
}

自定义 Giscus 样式

研究了许久未能实现,暂时放弃。

其他调整

将首页的 description 换成了一些自己喜欢的句子,并可以在 source/_data/sentence.json 中定义,每次进入页面随机展示其中一句。