为博客添加站内搜索和暗黑模式
立泉博客自 2016 年创建一直使用 Materialize 作为 UI 框架,实现几乎所有 Material Design 组件的它在三年前发布 v1.0 正式版后不再更新,而 Material Design 本身仍在进化,从过度强调层级阴影变得趋向扁平,所以切换到一个更有活力的现代框架势在必行。
我不是 Web 开发者,对 HTML、CSS、JavaScript 和 Node.js 仅浅尝可用,逐个解决遇到的问题,最终实现用 Google 的 Material Design Components 完成重构。在继承原有设计的同时削减阴影、统一配色,使简洁淡雅风格更加协调,虽然还未达到设想中的完美状态,但以我当前审美来看十分是可以给八分的。而且因为这次重构我开始接触到 Web 端主流技术栈,之前计划的站内搜索和暗黑模式自然水到渠成。
站内搜索
Jekyll 原生支持给文章添加 Tag 标签,可用 Liquid 语言获取页面的所有 Tag 和标记该 Tag 的文章列表。比如这篇标记的 CS、Google 和 DarkMode,点击顶部 Chip 会弹出包含相关文章的 Dialog。只是 Tag 毕竟有限,不可能把文章的每个词都设为 Tag,所以站内搜索是一个非常诱人的功能点。写作日积月累,我有时候想知道自己在多少文章中提及过某些特定内容。
2024 年 06 月 12 日更新:站内搜索已由 Pagefind 实现,参见博文《基于 Pagefind 实现静态博客站内搜索》。
实现搜索无非两种方案,使用搜索引擎提供的站内搜索或自建搜索服务。后者功能灵活不依赖搜索引擎缓慢的索引更新,优势很大,但有太多前后端问题需要解决。所以最终选定 Google 站内搜索,一是因为博客早已被索引可直接使用,其次也是一个接触当前“最好搜索引擎”的契机。
用过 Google 应该知道,能像这样在搜索时限定网站:
此外它提供面向开发者的 Programmable Search 服务,允许用户创建自定义范围的 Google 搜索,支持自定义部分 UI 和把搜索框、结果嵌入网页。我试着嵌入但发现组件风格和博客相去甚远,而且存在搜索结果不定引起的元素尺寸变化问题,所以虽然我并不擅长 Web 技术栈,踌躇后还是决定手动调用搜索 API 搭建站内搜索。
配合 Material Design 的最终成果还不错,调用 API 会产生每千次 5 美元的费用,这个数字对于访问量很小的站点与免费无异。
用 JS 请求数据填充页面要注意参数中的鉴权 API KEY,它来自 Google Cloud Platform 里一个启用 Custom Search API 的 Project,搜索服务的账单即是与它绑定。
此 KEY 默认权限极大,可以控制整个 Project ,非常危险。当我无意中把包含它的 JS 文件 Push 到 GitHub 上时立刻收到 Google 发来的安全警告邮件:
We have detected a publicly accessible Google API key associated with the following Google Cloud Platform project: Project JetSnail (id: jetsnail-****) with API key ****
We believe that you or your organization may have inadvertently published the affected API key in public sources or on public websites (for example, credentials mistakenly uploaded to a service such as GitHub.)
一定要按说明在 GCP 上限定其只用于搜索。
2022 年 12 月 25 日更新:注意搜索 API 可能被滥用引起的高额账单风险。
暗黑模式
我作为 Android 开发者可以轻松实现 Android 软件的暗黑模式,但在不熟悉的 Web 平台则花一段时间才理清楚。
系统/浏览器级别的暗黑模式有一个标准 CSS 触发选择器:prefers-color-scheme: dark
。用户开启暗黑模式时浏览器会加载此代码块中定义的暗色主题去覆盖原有 CSS 属性,但这种方式只能响应全局主题切换,不能实现仅针对当前页面的暗色主题,不够灵活。
/* 默认主题样式 */
.title {
color: black;
}
/* 暗黑模式下的主题样式 */
@media (prefers-color-scheme: dark) {
.title {
color: white;
}
}
另一种方式是创建表示暗色主题的dark
类,在其中定义组件新样式或全局变量新值,这样切换暗黑模式只需 JS 监听触发事件给<body>
添加dark
类即可。同理它适用于切换其它不同配色的主题,如red
和blue
。
/* 默认主题样式 */
body {
/* 表示字体颜色的全局变量 */
--text-color: black;
}
.title {
/* 应用 --text-color 变量对应的字体颜色 */
color: var(--text-color);
}
/* 在 dark 类中定义该变量的新值,如果 dark 类被添加到 <body> 中,则变量新值生效,页面随之变化 */
.dark {
--text-color: white;
}
/* 也可直接定义叠加 dark 类的新样式 */
.dark .title {
color: white;
}
监听主题切换按钮的示例:
const THEME_LIGHT = "light"
const THEME_DARK = "dark"
const KEY_THEME = "theme"
// 读取保存的用户主题设置
const savedTheme = localStorage.getItem(KEY_THEME)
console.log("saved theme = " + savedTheme)
const bodyE = document.body
// 根据用户设置显示对应主题
if (savedTheme == THEME_DARK) {
bodyE.classList.add("dark")
showThemeDarkIcon(true)
} else {
showThemeDarkIcon(false)
}
const btnTheme = document.querySelector("#topbar_btn_theme")
// 监听主题切换按钮点击事件,决定是否给 body 添加或删除 dark 类
btnTheme.addEventListener("click", () => {
if (bodyE.classList.contains("dark")) {
bodyE.classList.remove("dark")
showThemeDarkIcon(false)
localStorage.setItem(KEY_THEME, THEME_LIGHT)
} else {
bodyE.classList.add("dark")
showThemeDarkIcon(true)
localStorage.setItem(KEY_THEME, THEME_DARK)
}
})
// 根据当前主题配色显示主题按钮的图标
function showThemeDarkIcon(dark: boolean) {
const btnTheme = document.getElementById("topbar_btn_theme")
if (btnTheme == null) return
if (dark) {
btnTheme.innerHTML = "light_mode"
} else {
btnTheme.innerHTML = "dark_mode"
}
}
基本实现如此,只是我在把它与 SASS 和 Material Design Components 组合的过程中遇到很多问题,一些甚至困扰几天都百思不解,有太多陌生细节要逐个试错。过程曲折但都能一一解决,所以当我看到最终成品的时候心里十分欣慰。
新问题
启用暗黑模式使用几天发现页面跳转有时出现“闪烁”,从一个页面进入另一个页面,浏览器会先加载默认的亮色主题再由 JS 驱动切换到暗色主题,两个暗色之间的短暂亮色会“闪一下”。这个闪屏在本地测试中不出现,而在网络环境不佳的情况下大概率出现。
原因是主题代码放在统一的外部 JS 文件中,为避免 DOM 树构建好之前执行 JS 可能出现的异常,其初始化时机被延迟到DOMContentLoaded
之后。如果网络不佳,浏览器会先用默认样式渲染页面,等待 DOM 构建完成再触发 JS 加载暗色主题,就是这个时间差造成“闪烁”。
那么只要消除时间差即可,把这部分代码嵌入 HTML 的<body>
节点之下,使它在节点加载时立刻执行主题切换(添加dark
类),之后浏览器渲染页面直接应用暗色主题,不会再闪烁。
<html>
<head></head>
<body>
<!-- 切换主题的 Button -->
<button id="topbar_btn_theme">Change theme</button>
<!-- 切换主题的 JavaScript -->
<script>
function checkTheme() {
...
}
// 在 body 中立即执行主题切换
checkTheme()
</script>
<body>
</html>