§ 背景
博客此前仅使用 Algolia 作为搜索后端,通过 hexo-algoliasearch 插件在构建时将索引推送到 Algolia 云端,前端使用 InstantSearch.js 组件渲染搜索结果。Algolia 虽然搜索体验出色(毫秒级响应、模糊匹配、Typo Tolerance),但存在以下问题:
- 外部依赖:依赖第三方 SaaS 服务,每月有免费额度限制(通过
pnpm run algolia-usage查询用量) - 网络延迟:国内访问 Algolia 服务器有时不稳定
- 离线不可用:无法在本地开发环境或无网络时使用
为解决这些问题,决定新增纯前端的本地搜索功能,与 Algolia 形成互补——用户可一键在两种模式间切换。
§ 修改的文件与目录
§ 一、博客仓库(llxlr/blog)
| # | 文件/目录 | 改动内容 |
|---|---|---|
| 1 | _config.yml | 新增 local_search 配置段,包含 enable、path、top_n_per_article、unescape、preload、per_page 六个参数 |
| 2 | themes\shoka\scripts\generaters\search-data.js | 构建脚本自动将主题的 search-data.js 注册为 Hexo generator,无需手动配置 |
配置说明:
_config.yml中local_search的enable: true时,scripts/generaters/script.js条件注入本地搜索 JS,scripts/generaters/search-data.js条件生成search.json。两者通过同一开关联动,无需分别控制。
§ 二、主题仓库(llxlr/hexo-theme-shoka,子模块)
tree . | ||
themes/shoka/ | ||
├── _config.yml ─ [无变更] 本地搜索配置在博客 _config.yml 中 | ||
├── languages/ | ||
│ ├── en.yml ─ 新增 search.mode 多语言键 | ||
│ ├── ja.yml ─ 同上 | ||
│ ├── zh-CN.yml ─ 同上 | ||
│ ├── zh-HK.yml ─ 同上 | ||
│ └── zh-TW.yml ─ 同上 | ||
├── layout/ | ||
│ └── _partials/ | ||
│ └── layout.njk ─ LOCAL.search 注入搜索模式标签 | ||
├── scripts/generaters/ | ||
│ ├── script.js ─ 条件注入 local-search.js + CONFIG.localSearch 全局对象 | ||
│ └── search-data.js ─ [新建] 生成 search.json 的 Hexo generator | ||
└── source/ | ||
├── css/_common/components/third-party/ | ||
│ └── search.styl ─ 搜索模式标签样式 + 本地搜索结果样式 + 关键词高亮 + spinner 动画 | ||
└── js/_app/ | ||
├── local-search.js ─ [新建] LocalSearch 类(核心搜索引擎,435行) | ||
├── page.js ─ 重构搜索入口:algoliaSearch() → searchController(),支持双模式 | ||
└── pjax.js ─ 函数名更新:algoliaSearch(pjax) → searchController(pjax) |
§ 架构设计
Error: Evaluation failed: DOMException: Failed to execute 'removeChild' on 'Node': The node to be removed is not a child of this node.
at __puppeteer_evaluation_script__:2:37§ 实现细节
§ 1. 搜索数据生成(search-data.js)
在 Hexo generate 阶段,注册一个自定义 generator 生成 /search.json:
// 核心逻辑 | |
const posts = locals.posts.sort("-date").filter((p) => p.published !== false); | |
const data = posts.map((post) => ({ | |
title: post.title, | |
url: "/" + post.path, | |
content: post.content | |
.replace(/<[^>]+>/g, "") // HTML → 纯文本 | |
.replace(/&/g, "&") // 反转义 HTML 实体 | |
// ... 其他实体 | |
.replace(/\s+/g, " ") // 压缩空白 | |
.trim(), | |
categories: post.categories.data.map((c) => c.name), | |
})); |
与现有的 hexo-generator-searchdb 插件不同,这个自定义 generator 更轻量——直接输出文章路径(而非编码后的 URI),前端无需额外解码;categories 以数组形式保留结构,便于渲染面包屑导航。
§ 2. 前端搜索引擎(local-search.js)
LocalSearch 类的核心算法参考了 hexo-generator-searchdb 的前端实现,但针对 shoka 主题做了适配和优化。
搜索流程:
用户输入 → 分词 (按空格/连字符) → 关键词数组
↓
getIndexByWord() → 在 title + content 中定位每个关键词的位置
↓
getResultItems() → 对每篇文章计算匹配度
↓
mergeIntoSlice() → 以匹配位置为中心截取上下文片段(前20字符 + 后100字符)
↓
排序 (includedCount ↓ → hitCount ↓ → id ↓)
↓
highlightKeyword() → 用 <mark class="search-keyword"> 包裹关键词
↓
分页渲染 (per_page 控制每页条数)
关键设计:
top_n_per_article:限制每篇文章最多展示的匹配片段数。设为 1 时每篇文章只显示最相关的一个片段,避免结果列表冗长unescape:开启时对搜索词进行 HTML 反转义,允许搜索如&等特殊字符- 预加载(
preload: true):页面加载时即 fetchsearch.json,用户打开搜索框时无需等待 - 延迟加载:默认不预取,仅在用户首次打开搜索框时异步 fetch,避免影响首屏加载速度
关键词高亮(URL 参数传递):
搜索结果页面 URL 携带 ?highlight=关键词 参数。文章页面加载时,highlightSearchWords() 使用 TreeWalker 遍历正文文本节点,对匹配的关键词包裹 <mark class="search-keyword">,同时跳过 <button>、<select>、<textarea> 和 .mermaid 图表等不应被高亮的元素。
§ 3. 搜索模式切换(page.js 重构)
将原有的 algoliaSearch() 重构为 searchController(),统一管理两种搜索模式:
- 构建阶段:根据
CONFIG.search(Algolia)和CONFIG.localSearch(本地)存在性决定可用模式 - DOM 结构:在搜索弹窗中添加
.search-mode-tabs标签栏(仅当两种模式都可用时显示),Algolia 和本地结果分别放在#search-algolia和#search-local两个独立容器中 - 模式切换:
switchMode()调用activate()/deactivate()控制本地搜索的生命周期;切换到 Algolia 前会清空其查询状态,避免上一次搜索的旧结果残留 - 打开搜索弹窗:焦点自动落在
.search-input,两种模式共用同一个输入框
§ 4. 样式(search.styl)
新增样式涵盖:
- 模式切换标签(
.search-mode-tabs):胶囊式按钮,active 状态填充主题色,hover 时边框变色,带过渡动画 - 搜索结果(
.local-search-hit-item):标题、分类面包屑、匹配片段的分层展示;分类面包屑用i-angle-right图标分隔 - 关键词高亮(
mark.search-keyword):主题色背景 + 白色文字 + 圆角 - Spinner(
.search-spinner):纯 CSS 旋转动画,在数据加载和搜索执行时显示 - 分页:复用 Algolia 分页的
.pagination样式,#local-search-pagination与#search-pagination共用同一套 CSS
§ 5. 多语言支持
在 5 个语言文件中新增 search.mode 键:
# zh-CN.yml | |
search: | |
mode: | |
algolia: Algolia | |
local: 本地 | |
# en.yml | |
search: | |
mode: | |
algolia: Algolia | |
local: Local |
layout.njk 将这些标签注入 LOCAL.search.mode,由前端 searchController() 读取并渲染模式切换标签。
§ 关键设计决策
双模式而非替代:本地搜索不是 Algolia 的替代品,而是互补选项。Algolia 提供模糊匹配、Typo Tolerance 等高级特性;本地搜索提供离线可用、零延迟的体验。用户可根据场景自由切换
自定义 generator 而非第三方插件:不依赖
hexo-generator-searchdb,而是在主题内实现search-data.js。这样做的好处:(1)输出格式与 shoka 主题的page.js完全耦合,无需插件间的格式适配;(2)categories 以数组形式保留,避免前端再做字符串解析;(3)避免额外的 npm 依赖懒加载优先:默认
preload: false,仅在用户首次打开搜索弹窗时才 fetchsearch.json。这保证了首屏加载不受搜索数据影响(search.json通常有数百 KB 到数 MB)Pjax 兼容:
- 搜索实例在
siteInit()时创建一次,Pjax 导航时不重复初始化 - Pjax 成功后自动执行
deactivate(),关闭搜索弹窗 - 搜索结果中的链接经由 Pjax 处理(
pjax.refresh(resultsListEl)),保持无刷新导航体验 - 监听
pjax:success事件,自动对新页面执行 URL 关键词高亮
- 搜索实例在
搜索词高亮的两种场景:
- 搜索弹窗内:
highlightKeyword()在渲染结果 HTML 时包裹<mark>标签 - 文章页面内:
highlightSearchWords()通过 DOM TreeWalker 在正文中定位并包裹,且排除交互元素和图表
- 搜索弹窗内:
§ 相关提交
| 仓库 | Commit | 日期 | 说明 |
|---|---|---|---|
| 主题 | c270bc3 | 2026-07-10 | ✨ 添加本地搜索功能 |
| 博客 | 00b99dd | 2026-07-11 | ✨ 更新配置文件,新增 Meting API |
§ 使用方法
在博客 _config.yml 中配置:
local_search: | |
enable: true # 启用本地搜索 | |
path: /search.json # 搜索数据文件路径 | |
top_n_per_article: 1 # 每篇文章最多展示的匹配片段数 | |
unescape: false # 是否对搜索词进行 HTML 反转义 | |
preload: false # 是否在页面加载时预取搜索数据 | |
per_page: 10 # 搜索结果每页条数 |
配置完成后执行 pnpm run build,构建过程会自动:
- 生成
public/search.json - 注入
local-search.js到全局脚本 - 生成
CONFIG.localSearch全局配置对象
前端会自动检测 CONFIG.search(Algolia)和 CONFIG.localSearch 的可用性,并在搜索弹窗中渲染对应的模式切换标签。