为博客添加站内搜索和暗黑模式

2024年06月12日更新:站内搜索已由Pagefind实现,参见博文《基于Pagefind实现静态博客站内搜索》

博客自2016年创建一直使用Materialize作为UI框架,它实现了Material Design的几乎所有组件,但在三年前发布v1.0正式版后就不再更新。而Material Design本身却在进化,从过度强调层级阴影变得趋向扁平,所以切换到一个更有活力的现代框架势在必行。

我不是Web开发者,对HTMLCSSJavaScriptNode.js仅浅尝可用,花不少时间解决问题,终于用GoogleMaterial Design Components完成重构。在继承原有设计的同时削减阴影、统一配色,使简洁淡雅风格更加协调,虽然还未达到设想中的完美状态,但以我当前审美来看,十分是可以给八分的。而且因为这次重构,我开始接触到Web端的开发思路和主流技术栈,之前计划的站内搜索暗黑模式顺便水到渠成。

站内搜索

Jekyll原生支持给文章添加Tag标签,可以用Liquid语言获取页面的所有Tag和标记相同Tag的文章列表。比如这篇标记的CSGoogleDarkModeTag,点击顶部Chip会弹出包含相关文章列表的Dialog。只是Tag毕竟有限,不可能把文章里每个词都设置为Tag,所以站内搜索是一个非常诱人的功能点。写作日积月累,我有时候也想知道自己在多少文章中提及过某些特定内容🤔。

实现搜索无非两种方案,使用搜索引擎提供的站内搜索或完全自建搜索服务。后者更灵活且不依赖搜索引擎缓慢的索引更新,优势很大,但有太多前后端问题需要解决。所以最终选定Google的站内搜索,一是因为博客早就被索引,可以直接使用,二来也是一个接触当前“最好搜索引擎”的契机。

用过Google应该知道,可以像这样在搜索时限定网站:

google site

此外它提供面向开发者的Programmable Search服务,允许用户创建自定义范围的Google搜索,可以自定义部分UI,也支持把搜索框和搜索结果嵌入网页中。我试着嵌入但发现组件风格和博客相去甚远,而且存在搜索结果不定引起的元素尺寸变化问题,所以虽然我并不擅长Web技术栈,踌躇后还是决定手动调用搜索API来搭建站内搜索。

google programmable search

配合Material Design的最终成果还不错,调用API会产生每千次5美元的费用,这个数字对于访问量很小的站点其实与“免费”无异。

google

JS请求数据填充页面要注意参数中的鉴权API KEY,它来自Google Cloud Platform里一个启用Custom Search APIProject,搜索服务的账单就是与它绑定。

KEY默认权限极大,可以控制整个Project,非常危险。当我无意中把包含它的JS文件PushGitHub上时立刻收到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"
    }
}

基本实现如此,只是我在把它与SASSMaterial 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>
arrow_upward