Lumin Blog 全站搜索功能文档
📌 概述
Lumin Blog 的全站搜索功能采用纯前端实现方案,无需后端服务。Hugo 构建时生成 index.json 搜索索引,前端通过 fetch 加载索引后,在浏览器端完成搜索匹配、权重排序和结果渲染。搜索入口位于导航栏 Logo 右侧,点击或使用 Ctrl+K 快捷键即可打开搜索面板。
🎯 一、功能说明
| 属性 | 说明 |
|---|---|
| 搜索入口 | 导航栏 Logo 右侧搜索按钮(🔍 搜索 Ctrl+K) |
| 快捷键 | Ctrl+K 打开搜索 / ESC 关闭搜索 |
| 搜索方式 | 多关键词 AND 搜索,空格分隔关键词 |
| 排序算法 | 权重排序:标题(10) > 标签(6) > 分类(4) > 摘要(2) > 内容(1) |
| 结果数量 | 最多显示 12 条结果 |
| 关键词高亮 | 搜索结果中匹配的关键词以渐变背景高亮 |
| 索引加载 | 懒加载模式,首次打开搜索框时才加载索引 |
| 移动端适配 | 移动端隐藏文字和快捷键,仅显示搜索图标 |
搜索流程
1用户点击搜索按钮 / Ctrl+K
2 → 打开搜索遮罩层,自动聚焦输入框
3 → 首次打开时懒加载 index.json 索引
4 → 用户输入关键词(200ms 防抖)
5 → 拆分为多个关键词,AND 匹配
6 → 计算每条结果的权重分数
7 → 按分数降序排列,取前 12 条
8 → 渲染结果:标题高亮 + 摘要截取 + 分类 + 标签
🏗️ 二、代码结构
2.1 搜索入口按钮
定义在 layouts/partials/header.html 中,位于 Logo 右侧:
1{{ if .Site.Params.search.enable }}
2<button class="search-bar-toggle" id="search-toggle"
3 aria-label="搜索" title="搜索 (Ctrl+K)">
4 <svg width="16" height="16" viewBox="0 0 24 24" ...>
5 <circle cx="11" cy="11" r="8"></circle>
6 <line x1="21" y1="21" x2="16.65" y2="16.65"></line>
7 </svg>
8 <span class="search-bar-text">搜索</span>
9 <kbd class="search-bar-kbd">Ctrl+K</kbd>
10</button>
11{{ end }}
布局位置:header-brand → search-bar-toggle → header-nav → header-actions
搜索按钮紧跟 Logo,header-nav 的 margin-left: auto 将导航菜单推到右侧,形成 Logo + 搜索 | 导航 | 操作按钮 的三段式布局。
2.2 搜索遮罩层
定义在 layouts/partials/search-overlay.html 中:
1{{ if .Site.Params.search.enable }}
2<div class="search-overlay" id="search-overlay">
3 <div class="search-container">
4 <div class="search-header">
5 <svg class="search-icon" ...>...</svg>
6 <input type="text" class="search-input" id="search-input"
7 placeholder="{{ .Site.Params.search.placeholder | default '搜索文章...' }}"
8 autocomplete="off">
9 <kbd class="search-kbd">ESC</kbd>
10 <button class="search-close" id="search-close" ...>✕</button>
11 </div>
12 <div class="search-hint" id="search-hint">
13 <div class="search-hint-item"><kbd>↑↓</kbd> 导航</div>
14 <div class="search-hint-item"><kbd>↵</kbd> 打开</div>
15 <div class="search-hint-item"><kbd>ESC</kbd> 关闭</div>
16 </div>
17 <div class="search-results" id="search-results"></div>
18 </div>
19</div>
20{{ end }}
结构说明:
search-header:搜索图标 + 输入框 + ESC 提示 + 关闭按钮search-hint:快捷键引导提示,输入关键词后自动隐藏search-results:搜索结果容器,最大高度 420px 可滚动
2.3 搜索索引
定义在 layouts/_default/index.json 中,Hugo 构建时自动生成:
1[{{- $pages := .Site.RegularPages }}
2{{- $total := len $pages }}
3{{- range $index, $page := $pages }}
4{
5 "title": {{ $page.Title | jsonify }},
6 "permalink": {{ $page.Permalink | jsonify }},
7 "date": {{ $page.Date.Format "2006-01-02" | jsonify }},
8 "summary": {{ $page.Summary | plainify | truncate 200 | jsonify }},
9 "content": {{ $page.Plain | truncate 1200 | jsonify }},
10 "tags": {{ with $page.Params.tags }}{{ . | jsonify }}{{ else }}[]{{ end }},
11 "categories": {{ with $page.Params.categories }}{{ . | jsonify }}{{ else }}[]{{ end }},
12 "section": {{ $page.Section | jsonify }}
13}{{ if ne (add $index 1) $total }},{{ end }}
14{{- end }}
15]
索引字段说明:
| 字段 | 来源 | 截取 | 用途 |
|---|---|---|---|
title | .Title | 不截取 | 搜索匹配(权重 10)+ 结果标题 |
permalink | .Permalink | 不截取 | 结果链接跳转 |
date | .Date | 格式化 | 结果日期显示 |
summary | .Summary | 200 字 | 搜索匹配(权重 2)+ 摘要回退 |
content | .Plain | 1200 字 | 搜索匹配(权重 1)+ 摘要截取 |
tags | .Params.tags | 不截取 | 搜索匹配(权重 6)+ 结果标签展示 |
categories | .Params.categories | 不截取 | 搜索匹配(权重 4)+ 结果分类展示 |
section | .Section | 不截取 | 内容分区标识 |
2.4 JavaScript 实现
搜索逻辑集成在 assets/js/main.js 的 Search IIFE 模块中。
索引懒加载
1var indexLoaded = false;
2var indexLoading = false;
3
4function loadIndex() {
5 indexLoading = true;
6 fetch('/index.json').then(function(r) { return r.json(); }).then(function(d) {
7 indexData = d || [];
8 indexLoaded = true;
9 indexLoading = false;
10 }).catch(function(e) {
11 indexLoading = false;
12 if (results) {
13 results.innerHTML = '<div class="search-empty">⚠️ 搜索索引加载失败,请刷新页面重试</div>';
14 }
15 });
16}
17
18function open() {
19 // ...
20 if (!indexLoaded && !indexLoading) loadIndex();
21}
懒加载策略:不在页面加载时立即请求索引,而是在用户首次打开搜索框时才加载。indexLoading 标志防止重复请求。
多关键词 AND 匹配
1function matchItem(item, keywords) {
2 var t = (item.title || '').toLowerCase();
3 var s = (item.summary || '').toLowerCase();
4 var c = (item.content || '').toLowerCase();
5 var tags = (item.tags || []).join(' ').toLowerCase();
6 var cats = (item.categories || []).join(' ').toLowerCase();
7 var all = t + ' ' + tags + ' ' + cats + ' ' + s + ' ' + c;
8
9 for (var i = 0; i < keywords.length; i++) {
10 if (keywords[i] && all.indexOf(keywords[i]) === -1) return false;
11 }
12 return true;
13}
输入 “Hugo 搜索” 会被 split(/\s+/) 拆分为 ["hugo", "搜索"],两个关键词必须在文章的任意字段中都能匹配才算命中。
权重评分排序
1var WEIGHT = { title: 10, tags: 6, categories: 4, summary: 2, content: 1 };
2
3function scoreItem(item, keywords) {
4 var score = 0;
5 // ... 对每个关键词在每个字段中检查匹配
6 for (var i = 0; i < keywords.length; i++) {
7 var kw = keywords[i];
8 if (t.indexOf(kw) > -1) score += WEIGHT.title; // 标题匹配 +10
9 if (tags.indexOf(kw) > -1) score += WEIGHT.tags; // 标签匹配 +6
10 if (cats.indexOf(kw) > -1) score += WEIGHT.categories; // 分类匹配 +4
11 if (s.indexOf(kw) > -1) score += WEIGHT.summary; // 摘要匹配 +2
12 if (c.indexOf(kw) > -1) score += WEIGHT.content; // 内容匹配 +1
13 }
14 return score;
15}
评分示例:搜索 “Hugo”
| 文章 | 标题匹配 | 标签匹配 | 分类匹配 | 摘要匹配 | 内容匹配 | 总分 |
|---|---|---|---|---|---|---|
| Hugo 搭建指南 | +10 | +6 | +4 | +2 | +1 | 23 |
| 静态博客搜索优化 | 0 | +6 | 0 | +2 | +1 | 9 |
| 我的博客历程 | 0 | 0 | 0 | 0 | +1 | 1 |
结果按分数降序排列,标题包含关键词的文章排在最前面。
关键词高亮
1function highlightMulti(text, keywords) {
2 var escaped = keywords.map(function(k) {
3 return k.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
4 }).join('|');
5 var re = new RegExp('(' + escaped + ')', 'gi');
6 return text.replace(re, '<mark class="search-highlight">$1</mark>');
7}
多个关键词通过 | 组合成正则表达式,一次替换完成所有关键词的高亮,性能优于逐个关键词多次替换。
摘要截取
1function extractSnippetMulti(text, keywords, maxLen) {
2 var lower = text.toLowerCase();
3 var bestIdx = -1;
4 for (var i = 0; i < keywords.length; i++) {
5 var idx = lower.indexOf(keywords[i]);
6 if (idx > -1) { bestIdx = idx; break; }
7 }
8 if (bestIdx === -1) return text.substring(0, maxLen).trim() + '...';
9 var start = Math.max(0, bestIdx - Math.floor(maxLen / 3));
10 var end = Math.min(text.length, start + maxLen);
11 var snippet = text.substring(start, end).trim();
12 if (start > 0) snippet = '…' + snippet;
13 if (end < text.length) snippet = snippet + '…';
14 return snippet;
15}
截取策略:以第一个匹配关键词的位置为基准,向前取 maxLen/3,向后取剩余长度,确保关键词出现在摘要前部。
结果渲染
1results.innerHTML = matched.map(function(entry) {
2 var item = entry.item;
3 var titleHl = highlightMulti(item.title, keywords);
4 var snippet = highlightMulti(extractSnippetMulti(
5 item.content || item.summary || '', keywords, 80), keywords);
6 var dateStr = item.date || '';
7 var catHtml = item.categories.length
8 ? '<span class="search-result-cat">' + item.categories.join(' / ') + '</span>' : '';
9 var tagHtml = item.tags.slice(0, 3).map(function(tag) {
10 return '<span class="search-result-tag">#' + tag + '</span>';
11 }).join('');
12
13 return '<a href="' + item.permalink + '" class="search-result-item">' +
14 '<div class="search-result-header">' +
15 '<h4 class="search-result-title">' + titleHl + '</h4>' +
16 (dateStr ? '<span class="search-result-date">' + dateStr + '</span>' : '') +
17 '</div>' +
18 '<p class="search-result-snippet">' + snippet + '</p>' +
19 '<div class="search-result-meta">' + catHtml + tagHtml + '</div>' +
20 '</a>';
21}).join('');
每条结果包含:标题(高亮)+ 日期 + 摘要(高亮)+ 分类标签 + 文章标签(最多3个)。
🎨 三、样式设计
3.1 搜索按钮
1.search-bar-toggle {
2 display: inline-flex;
3 align-items: center;
4 gap: 8px;
5 padding: 6px 14px;
6 margin-left: 16px;
7 background: var(--bg-card);
8 border: 1px solid var(--border-color);
9 border-radius: 8px;
10 color: var(--text-secondary);
11 font-size: 0.84rem;
12 transition: all 0.2s ease;
13}
14
15.search-bar-toggle:hover {
16 border-color: rgba(59,130,246,0.5);
17 color: var(--text-primary);
18 background: var(--bg-secondary);
19 box-shadow: 0 2px 8px rgba(59,130,246,0.1);
20}
暗黑主题:
1[data-theme="dark"] .search-bar-toggle {
2 background: var(--bg-tertiary);
3 border-color: var(--border-color);
4 color: var(--text-secondary);
5}
6
7[data-theme="dark"] .search-bar-toggle:hover {
8 border-color: rgba(96,165,250,0.5);
9 color: var(--text-primary);
10 background: var(--bg-secondary);
11 box-shadow: 0 2px 8px rgba(96,165,250,0.08);
12}
3.2 搜索遮罩层
1.search-overlay {
2 display: none;
3 position: fixed;
4 inset: 0;
5 background: rgba(0, 0, 0, 0.5);
6 backdrop-filter: blur(4px);
7 z-index: 10000;
8 align-items: flex-start;
9 justify-content: center;
10 padding-top: calc(var(--header-offset) + 20px);
11}
12
13.search-overlay.active {
14 display: flex;
15}
遮罩层使用 backdrop-filter: blur(4px) 毛玻璃效果增强视觉层次。
3.3 关键词高亮
1.search-highlight {
2 background: linear-gradient(120deg, rgba(59,130,246,0.28), rgba(99,102,241,0.28));
3 color: var(--text-primary);
4 padding: 1px 4px;
5 border-radius: 3px;
6 font-weight: 600;
7}
8
9[data-theme="dark"] .search-highlight {
10 background: linear-gradient(120deg, rgba(96,165,250,0.38), rgba(167,139,250,0.38));
11}
3.4 结果标签
1.search-result-tag {
2 font-size: 0.7rem;
3 color: rgba(59,130,246,0.8);
4 padding: 1px 6px;
5 background: rgba(59,130,246,0.08);
6 border-radius: 4px;
7}
8
9[data-theme="dark"] .search-result-tag {
10 color: rgba(96,165,250,0.9);
11 background: rgba(96,165,250,0.1);
12}
3.5 移动端适配
1@media (max-width: 768px) {
2 .search-bar-toggle {
3 padding: 5px 10px;
4 margin-left: 8px;
5 }
6 .search-bar-text { display: none; }
7 .search-bar-kbd { display: none; }
8 .search-input { font-size: 16px; }
9 .search-kbd { display: none; }
10 .search-hint { display: none; }
11 .search-results { max-height: 55vh; }
12}
移动端关键处理:font-size: 16px 防止 iOS Safari 自动缩放页面。
⚙️ 四、配置方法
4.1 启用搜索
在 hugo.toml 中配置:
1[params.search]
2 enable = true
3 placeholder = "搜索文章..."
placeholder 控制搜索框的占位文字,不设置时默认为"搜索文章…"。
4.2 调整权重
在 main.js 的 Search 模块中修改:
1var WEIGHT = { title: 10, tags: 6, categories: 4, summary: 2, content: 1 };
| 字段 | 默认权重 | 说明 |
|---|---|---|
title | 10 | 标题匹配权重最高,确保标题含关键词的文章排前面 |
tags | 6 | 标签匹配权重次高,标签是人工标注的关键词 |
categories | 4 | 分类匹配 |
summary | 2 | 摘要匹配 |
content | 1 | 正文匹配权重最低,避免长文章仅因字数多而排名靠前 |
4.3 调整搜索结果数
1var matched = scored.slice(0, 12); // 修改 12 调整最大结果数
4.4 调整防抖时间
1timer = setTimeout(search, 200); // 修改 200 调整防抖毫秒数
4.5 调整索引内容长度
在 layouts/_default/index.json 中修改:
1"content": {{ $page.Plain | truncate 1200 | jsonify }},
truncate 1200 控制正文截取字数。增大可提高长文章的搜索覆盖率,但会增加索引文件体积。
🔍 五、搜索算法详解
5.1 与旧版算法的对比
| 特性 | 旧版 | 新版 |
|---|---|---|
| 搜索方式 | 完整字符串匹配 | 多关键词 AND 匹配 |
| 排序方式 | 原始顺序(无排序) | 权重评分降序 |
| 搜索范围 | 标题 + 内容 + 摘要 | 标题 + 标签 + 分类 + 摘要 + 内容 |
| 结果数 | 10 条 | 12 条 |
| 索引加载 | 页面加载时立即加载 | 首次打开搜索框时懒加载 |
| 加载失败 | 静默失败 | 显示错误提示 |
| 标签展示 | 仅分类 | 分类 + 标签(最多3个) |
5.2 多关键词搜索示例
输入 “Hugo 搜索”:
- 拆分为
["hugo", "搜索"] - 遍历索引,每篇文章必须同时包含 “hugo” 和 “搜索” 才算匹配
- 计算每篇匹配文章的权重分数
- 按分数降序排列
输入 “评论气泡”:
- 拆分为
["评论", "气泡"] - 文章必须同时包含 “评论” 和 “气泡”
- 标题含 “评论气泡” 的文章分数最高(标题匹配 +10 × 2 = 20)
🐛 六、常见问题排查
Q1: 搜索按钮不显示
- 检查
hugo.toml中[params.search] enable = true是否已设置 - 打开浏览器开发者工具,搜索
#search-toggle,确认 DOM 是否渲染 - 检查 CSS 中
.search-bar-toggle是否被其他样式覆盖
Q2: 搜索无结果
- 打开浏览器控制台,检查是否有
[Search] ✓ 索引已加载日志 - 如果没有 → 索引未加载,检查
/index.json是否可访问 - 在控制台执行
fetch('/index.json').then(r=>r.json()).then(d=>console.log(d.length))确认索引内容 - 尝试输入单个简单关键词(如文章标题中的词)
Q3: Ctrl+K 快捷键无效
- 检查是否有其他扩展或页面脚本占用了 Ctrl+K
- 在控制台执行
document.addEventListener('keydown', e => console.log(e.key, e.ctrlKey))确认按键事件 - macOS 上同时支持
Cmd+K(e.metaKey)
Q4: 移动端搜索框导致页面缩放
已通过 font-size: 16px 解决。iOS Safari 在 input 字号小于 16px 时会自动缩放页面。
Q5: 搜索索引文件过大
- 在
index.json模板中减小truncate值(如从 1200 改为 800) - 检查是否有大量短页面被索引(可通过
.Site.RegularPages过滤)
📊 七、性能指标
| 指标 | 数值 |
|---|---|
| 索引文件大小 | 约 50-200KB(取决于文章数量和内容长度) |
| 索引加载时机 | 首次打开搜索框时(懒加载) |
| 搜索防抖 | 200ms |
| 单次搜索耗时 | < 5ms(100 篇文章以内) |
| DOM 节点数 | 搜索按钮 + 遮罩层(初始隐藏) |
| 内存占用 | 索引数据 + 闭包变量,约 100-500KB |
📁 八、相关文件索引
| 文件 | 作用 |
|---|---|
layouts/partials/header.html | 搜索入口按钮(Logo 右侧) |
layouts/partials/search-overlay.html | 搜索遮罩层模板 |
layouts/_default/index.json | 搜索索引生成模板 |
assets/js/main.js Search 模块 | 搜索算法 + 显隐控制 + 事件绑定 |
assets/css/main.css | 搜索按钮 + 遮罩层 + 结果项样式 |
hugo.toml [params.search] | 搜索功能配置开关 |
留言评论
期待你的想法评论加载中