这个系列写到第八篇了。前几篇我们把 Hugo 的骨架搭好、主题调好、文章写顺、部署到位,接下来就是博客体验里最容易被忽略却很重要的一个模块:搜索功能。我这个私人博客跑在 Ubuntu 服务器上,文章一多,光靠归档页和标签已经很难定位旧文,所以第七篇结束后就立刻着手加了站内搜索。这篇就把思路、实现、踩坑全部整理出来,给同样在用 Hugo 建站的朋友一条可以照着走的路。
1. 先想清楚:静态博客的搜索应该怎么做
1.1 为什么静态站点搜索是个“老大难”
先把痛点说透。WordPress 这类动态博客搜文章,本质上就是一条 SQL 查询,后端在数据库里LIKE '%关键词%'一下,结果直接渲染成页面;就算数据量上去了,也可以上 Elasticsearch 这类专门的检索引擎。但这些能力都有一个前提:有一台能跑代码的服务器。
Hugo 不一样。hugo命令执行完,产出的就是一整个 public 目录,里面全是静态的 HTML、CSS、JS 和图片。你把它扔到任意一台 Web 服务器、对象存储桶或者 CDN 后面,它都不会有任何动态计算能力。这个特性带来了无与伦比的部署自由度,却也把“搜索”变成了需要额外设计的问题:没有数据库、没有后端接口,搜索逻辑只能放在两个地方——构建期或浏览器端。
很多第一次给 Hugo 加搜索的朋友,第一反应是去接 Google 站内搜索或者第三方搜索服务。这条路不是不行,但对私人博客来说有点重:你需要注册服务、把文章索引推到别人服务器上,还得忍受搜索结果样式和自家博客不一致的割裂感。所以我更推荐第二种思路:直接在静态站点内部把搜索闭环做掉。
1.2 主流方案横向对比
先看一圈市面上常用的静态博客搜索方案,心里有张地图再选路,不容易跑偏。我把它们按实现原理分成四类:第三方托管、前端预建索引、构建期索引、服务端自建。
| 方案 | 实现原理 | 中文支持 | 上手成本 | 适合场景 |
|---|---|---|---|---|
| Algolia / DocSearch | 索引推到第三方平台,前端调 API | 好 | 中 | 流量较大的站点,可接受外部依赖 |
| lunr.js / Elasticlunr | 前端加载索引 JSON,浏览器内存中建倒排索引 | 一般,需要分词插件 | 中 | 英文内容为主、文档类站点 |
| Fuse.js | 前端加载索引 JSON,运行时做模糊匹配 | 基础可用,逐字匹配 | 低 | 中小型博客、内容量中等的站点 |
| Pagefind | 构建后自动分析静态 HTML 生成索引,配合 UI 组件 | 好 | 低 | 静态站通用,效果和成本最均衡 |
| Stork | Rust 构建期生成索引文件,前端做检索 | 需要额外处理分词 | 中 | 对性能有较高要求的场景 |
1.3 我选择的组合及理由
我最终选择的组合是:Hugo 生成 JSON 索引 + Fuse.js 前端模糊匹配。理由说直白一点:
第一,不引入外部依赖。博客托管在服务器上,我不希望用户搜索一下还得等第三方接口响应,也不希望 Algolia 这种免费额度哪天超了导致搜索直接挂掉。
第二,代码完全可控。索引模板是我写的、前端脚本也是我写的,整个链路透明,出问题能自己排查,样式能跟博客完全统一。
第三,对中文基本够用。Fuse.js 不是为中文设计的分词器,但博客这种量级的内容,用户输入的关键词大多是标题或正文的连续片段,逐字模糊匹配在大多数情况下都能命中,配合合适的阈值完全够用。
当然,如果你的文章已经超过两三百篇,或者对中文搜索准确性有更高要求,我建议直接看第 4.3 节的 Pagefind 替代方案,那是另一条更省心的路。
2. Hugo 端造数据:把文章导出成 JSON 索引
2.1 版本确认与环境准备
开始之前先确认 Hugo 版本。在 Ubuntu 上直接执行hugo version,重点看是不是 Extended 版本,因为部分主题依赖 SCSS 编译能力。我的是 Hugo 0.111.3 extended,后面的配置都基于这个版本,老版本语法上可能有些出入。
这里插一句 Ubuntu 上的小经验:不要一上来就sudo apt install hugo,Ubuntu 官方源里的 Hugo 版本往往偏老,Snap 版本又可能存在文件权限限制问题。我建议去 Hugo 的 GitHub Releases 页面下载对应架构的.deb包安装,或者用brew/ 直接解压二进制到/usr/local/bin,这样能保证版本足够新。装完之后用hugo version确认。
2.2 配置自定义输出格式
Hugo 默认的输出格式是 HTML,偶尔加个 RSS。要输出 JSON 索引文件,需要先在站点配置里声明一个自定义输出格式。我的站点配置文件是hugo.toml,在文件里加上这段:
[outputFormats.SearchIndex] mediaType = "application/json" baseName = "search" isPlainText = true notAlternative = true [outputs] home = ["HTML", "SearchIndex"]逐项解释一下含义。baseName = "search"决定最终生成的文件名是search.json,不是其他名字;mediaType = "application/json"告诉 Hugo 这是 JSON 类型;isPlainText = true很关键,如果少了这一项,Hugo 会把 JSON 当 HTML 一样套上主题的模板结构,生成一个带<html>标签的怪东西;notAlternative = true则是禁止这个格式被替代到其他页面输出。
最后那个[outputs]意思是:只在站点首页(home 页面)输出这个格式。因为首页模板会遍历所有文章,正好适合生成全站索引。如果你的配置里之前已经写过了home = ["HTML", "RSS"],记得合并,别把 RSS 覆盖丢了。
2.3 编写 search 索引模板
配置文件声明好之后,创建对应模板文件。Hugo 模板查找顺序这里容易踩坑,我放在layouts/_default/list.searchindex.json,前面 output format 名字是SearchIndex,对应模板文件名就是list.searchindex.json。如果某些主题结构特殊没生效,也可以再放一份到layouts/index.searchindex.json。
文件内容不长,但每一行都有讲究:
{{- $pages := .Site.RegularPages -}} [ {{- range $i, $p := $pages -}} {{- if $i }},{{ end -}} { "title": {{ $p.Title | jsonify }}, "url": {{ $p.Permalink | jsonify }}, "tags": {{ $p.Params.tags | jsonify }}, "date": {{ $p.Date.Format "2006-01-02" | jsonify }}, "summary": {{ $p.Summary | jsonify }}, "content": {{ $p.Plain | truncate 5000 | jsonify }} } {{- end -}} ]为什么用$pages := .Site.RegularPages?因为 RegularPages 只包含真正的文章内容页,像“关于我”“搜索页”这类独立页面不会混进来,避免用户搜到一堆导航页。
字段里必须强调的是content。这里用的是$p.Plain,它已经把文章正文里的所有 HTML 标签剥掉了,剩下纯文本,不会把段落标签、代码高亮标签的源码也索引进去。后面再挂一个truncate 5000,限制单篇文章最多进索引 5000 个字符。这个裁剪非常必要,不然全文索引的体积会变得很大,前端加载和搜索都会变慢。中文内容 5000 字符基本能覆盖文章主体,够用。
另外两个容易被忽略的点:$p.Params.tags如果文章没有 tags,会输出null,这没问题,Fuse.js 能处理;date字段加上以后,搜索结果可以按时间排序,体验会好很多。最后整个结构用jsonify处理,中文、引号、反斜杠这些特殊字符都会生成合法的 JSON 转义,不会出现因为文章里有双引号就导致索引文件语法错误的情况。
2.4 构建索引并验证
配置和模板都完成后,执行构建命令:
hugo --gc --cleanDestinationDir--gc清理构建缓存,--cleanDestinationDir清理 public 目录里的旧文件,这两个参数后面会经常用到,先养成习惯。构建完成后检查索引文件:
jq '. | length' public/search.json head -c 500 public/search.json第一条命令统计索引里有多少篇文章,第二条看文件开头格式是否正确。如果服务器上没装 jq,用cat public/search.json直接看也行,只要确认开头是数组、字段完整即可。开发阶段更省事的验证方式是直接跑hugo server,然后浏览器访问http://localhost:1313/search.json,看到的应该是一个标准的 JSON 数组,每个元素包含 title、url、tags、date、summary、content 六个字段。
3. 前端实现:搜索框、匹配、结果展示
3.1 创建搜索页面
索引数据有了,接下来做用户看得见的搜索页。我的做法是新建一个内容页content/search.md,front matter 指定 layout 为 search:
--- title: "站内搜索" layout: "search" ---然后创建模板文件layouts/_default/search.html。因为博客主题里有 baseof 主模板,这里只需要填充main区块,页面结构如下:
{{ define "main" }} <main class="search-page"> <h1>{{ .Title }}</h1> <input type="search" id="search-input" placeholder="输入关键词,例如:Hugo、部署、评论..." autocomplete="off" /> <div id="search-meta"></div> <ul id="search-results"></ul> </main> {{ end }}搜索结果列表用一个无序列表容器,具体结果由 JavaScript 动态填充。下一步把 Fuse.js 引进来。
3.2 引入 Fuse.js
Fuse.js 是一个轻量的前端模糊搜索库,零依赖,单文件压缩后大概 10KB 左右,对博客来说非常合适。Ubuntu 服务器上如果没有外网下载条件,直接在能联网的机器上把fuse.min.js下载好,然后丢到 Hugo 站点的static/js/目录下。这样每次构建都会原样拷贝到 public/js 下,本地引用,不依赖任何 CDN。
在 search.html 模板底部引入:
<script src="{{ "js/fuse.min.js" | relURL }}"></script>这里用relURL而不是硬编码/js/fuse.min.js,是为了照顾站点部署在子路径的情况。比如你的博客挂在https://example.com/blog/下面,/js/fuse.min.js会请求到根域名,直接 404,用relURL会根据baseURL自动拼出正确路径。
3.3 搜索逻辑与交互代码
搜索脚本部分,我给出一份可以直接抄作业的完整代码:
(function () { const input = document.getElementById("search-input"); const meta = document.getElementById("search-meta"); const results = document.getElementById("search-results"); if (!input) return; let fuse = null; let timer = null; fetch("{{ "search.json" | relURL }}") .then((res) => res.json()) .then((data) => { fuse = new Fuse(data, { keys: [ { name: "title", weight: 0.5 }, { name: "tags", weight: 0.3 }, { name: "summary", weight: 0.15 }, { name: "content", weight: 0.05 } ], includeScore: true, includeMatches: true, ignoreLocation: true, threshold: 0.4, minMatchCharLength: 1 }); }); function escapeHtml(s) { const div = document.createElement("div"); div.textContent = s; return div.innerHTML; } function highlightTitle(text, ranges) { if (!ranges || ranges.length === 0) return escapeHtml(text); let html = ""; let last = 0; ranges.forEach(([start, end]) => { html += escapeHtml(text.slice(last, start)); html += "<mark>" + escapeHtml(text.slice(start, end + 1)) + "</mark>"; last = end + 1; }); html += escapeHtml(text.slice(last)); return html; } function render() { const q = input.value.trim(); if (!q) { meta.textContent = ""; results.innerHTML = ""; return; } if (!fuse) return; const matches = fuse.search(q); meta.textContent = "找到 " + matches.length + " 条结果"; if (matches.length === 0) { results.innerHTML = "<li class='empty'>没有找到相关文章,换个关键词再试试。</li>"; return; } results.innerHTML = matches.slice(0, 20).map(({ item, matches }) => { const titleMatch = matches.find((m) => m.key === "title"); const titleHtml = highlightTitle(item.title, titleMatch ? titleMatch.indices : null); const snippet = item.summary ? escapeHtml(item.summary.slice(0, 120)) : ""; return ( "<li>" + "<a href=\"" + item.url + "\">" + titleHtml + "</a>" + "<div class=\"snippet\">" + snippet + "</div>" + "</li>" ); }).join(""); } input.addEventListener("input", function () { clearTimeout(timer); timer = setTimeout(render, 180); }); })();这段代码有几个细节值得展开说。includeMatches: true会返回每个匹配结果的字符位置,我拿它给标题里的关键词加上 mark 高亮。高亮函数里先做了escapeHtml再做拼接,这样可以避免文章内容里含 HTML 标签时被浏览器解析成 DOM,防 XSS 这个习惯一定要有。
threshold: 0.4是模糊度阈值,0 表示必须精确匹配,1 表示什么都匹配。中文场景下我实测 0.3 偏严、搜错一个字就找不到,0.5 又太松、经常出现无关结果,0.4 是个比较均衡的取值。ignoreLocation: true表示忽略“匹配位置距离”,对长文搜索很关键,不然正文深处的匹配容易被距离惩罚掉。
minMatchCharLength: 1允许单字搜索,中文两个字的关键词如果设成 2,用户只输一个词就什么也搜不到;但如果你感觉单字导致结果太杂,可以改成 2,性能也会更好。防抖 180ms 是为了避免每敲一个字母就全量扫一遍 JSON,输入停顿一下才开始搜,体验上几乎无感但性能差别很大。
3.4 样式与体验细节
搜索结果页的样式不用复杂,保持干净就好。我这里给一组比较通用的 CSS,搭配大多数简洁主题都不违和:
.search-page { max-width: 720px; margin: 0 auto; padding: 2rem 1rem; } #search-input { width: 100%; padding: 0.75rem 1rem; font-size: 1.05rem; border: 1px solid #ddd; border-radius: 6px; } #search-results { list-style: none; margin-top: 1.5rem; padding: 0; } #search-results li { padding: 0.6rem 0; border-bottom: 1px dashed #eee; } #search-results .snippet { margin-top: 0.2rem; font-size: 0.9rem; color: #888; } #search-results mark { background: #fff3bf; padding: 0 2px; border-radius: 2px; }几个体验层面的细节我提一下:搜索结果限制前 20 条,防止结果列表无限长;摘要截取 120 字符,够用户判断是不是想找的文章;无结果时给出引导文案,比白屏友好得多。搜索页建立以后,记得在导航栏或页脚加一个入口链接,不然用户根本不知道有这个功能。
4. 构建部署、问题排查与进阶优化
4.1 Ubuntu 上的一次完整发布流程
前端代码写好后,本地预览没问题,就该发布到服务器了。我 Ubuntu 服务器上走的是一套很朴素的流程:本地构建、rsync 同步、差异化备份。
hugo --gc --cleanDestinationDir rsync -avz --delete public/ user@your-server:/var/www/blog/构建命令前面讲过。rsync 的--delete参数很重要,它会删除服务器上 public 目录里这次构建没有的文件,确保旧版的 search.json 不会被残留下来。如果你用的是 Git 托管流程(比如 GitHub Pages、Cloudflare Pages),就省略 rsync,直接把 public 目录推上去即可,原理一样。
这里特别提醒一句:search.json 是构建产物,不是源文件。很多人改完文章、本地hugo构建后,顺手把整个 public 用 FTP 传上去,结果发现搜索结果是旧的。原因往往是只上传了部分文件,或者服务器上旧的 search.json 没有清理。只要每次构建都加上--cleanDestinationDir、部署时确保索引文件被覆盖,就不会有这个困扰。还有,如果你开了 CDN 缓存,记得给search.json设置较短缓存时间,否则文章更新了,用户搜到的还是缓存的旧索引。
4.2 我踩过的坑:中文搜索、转义、索引不更新
这个问题必须是全文的重点,因为我在把搜索功能从“能用”调到“好用”的过程中,几乎把所有坑都踩了一遍。整理成速查表,你们遇到问题直接对号入座:
| 现象 | 原因 | 解决办法 |
|---|---|---|
| 部署后搜不到内容,本地却正常 | 代码里硬编码了/search.json,站点部署在子路径 | 换成 `{{ "search.json" |
| 中文关键词搜不到,英文正常 | minMatchCharLength设置过高,或threshold太严 | 设为 1,threshold 放宽到 0.4~0.5 |
索引里中文变成\uXXXX乱码 | 这不是乱码,是合法的 JSON 转义 | 前端用res.json()解析,不要手动处理字符串 |
| search.json 体积巨大,加载慢 | content 字段没裁剪,全文都进索引 | 加truncate 5000 |
| 搜索结果里出现代码片段、标题标签 | 索引用了.Content导致 HTML 标签混入 | 改为.Plain |
| 改完文章重新构建,搜索还是旧内容 | 增量构建残留,或 CDN 缓存 | --cleanDestinationDir,清理 CDN 缓存 |
| 有引号或反斜杠的文章导致搜索页空白 | 手动拼接 JSON 时没做转义 | 字段统一经过jsonify |
其中第二个坑最有代表性,我第一次部署完就翻车了。当时搜索“部署”这个词没有任何结果,但搜“Hugo”能搜出来,排查了很久发现是minMatchCharLength默认值是 2,两个汉字作为整体被拆散了,Fuse.js 认为匹配字数不够就直接忽略。中文和英文在字符粒度上差异很大,英文单词天然有空格分词,中文是连续字符串,所以这个参数必须显式设置为 1。
还有一个小细节:有些朋友喜欢在 content 字段放全文,觉得搜索结果更全。但全文索引的代价不仅仅是体积大——Fuse.js 是循环所有记录做匹配,文章越多、内容越长,搜索延迟越明显,而且代码块里的变量名很容易让用户搜出大量无关结果。我的建议是 content 只保留文章正文的前几千字符,配合 title 和 summary 的高权重,日常搜索足够用。
4.3 进阶方向:从 Fuse.js 换到 Pagefind、加入分类过滤
如果你文章量已经很大,或者对搜索准确率有了更高要求,就轮到 Pagefind 登场了。Pagefind 由 CloudCannon 团队开发,专为静态网站设计,工作原理是构建后直接分析 public 目录里的 HTML 文件,自动生成一套索引和前端检索库。我本地实测效果是:中文搜索准确率明显优于 Fuse.js 的逐字匹配,而且不需要手写数据模板,UI 组件开箱即用。
接入方式也很简单,Ubuntu 上先确认 Node 环境存在,然后每次 Hugo 构建完之后执行:
hugo --gc --cleanDestinationDir npx pagefind --site publicPagefind 会在 public 目录里生成pagefind/文件夹,包含索引数据和 UI 资源。然后在需要展示搜索的地方引入:
<link href="{{ "pagefind/pagefind-ui.css" | relURL }}" rel="stylesheet" /> <script src="{{ "pagefind/pagefind-ui.js" | relURL }}"></script> <div id="search"></div> <script> window.addEventListener("DOMContentLoaded", function () { new PagefindUI({ element: "#search", showSubResults: true }); }); </script>这里要注意构建顺序:必须先hugo生成 HTML,再npx pagefind分析 HTML 生成索引,两个命令缺一不可。我用这套方案给一个朋友的三百多篇中文博客做过迁移,从 Fuse.js 换过来之后,搜索速度反而更快了,因为 Pagefind 会把索引按词条拆分存储,加载策略更优。
当然,Fuse.js 方案也有自己不可替代的场景:它零 Node 依赖、纯静态文件即可运行,适合不想在构建流程里再插一层 Node 命令的极简环境。我现在这个博客保持在 Fuse.js,不是因为 Pagefind 不好,而是对我来说文章的体量还远没到需要升级的程度,维护成本低才是私人博客的第一诉求。
另一个值得做的增强是在搜索页加入标签筛选。索引里已经有 tags 字段了,前端渲染结果时把文章标签展示出来,点击某个标签可以直接调用fuse.search(tag),基本不用改架构就能实现归类搜索。我现在就是这么用的,搜索结果里标签高亮显示,视觉上很清楚。
回到最初那句判断:静态博客加搜索,难不在技术,而在想清楚自己的数据量、文章语言和维护习惯。真正落地之后你会发现,几十行模板加几十行 JavaScript,就能给博客加上一个比很多动态站还好用的搜索能力。这个功能做完,我的私人博客也终于闭环了——写作、发布、被找到,剩下的事就是好好写文章。