Hugo 静态博客站内搜索:JSON 索引与 Fuse.js 实战方案

发布时间:2026/10/5 17:09:04
Hugo 静态博客站内搜索:JSON 索引与 Fuse.js 实战方案 这个系列写到第八篇了。前几篇我们把 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 组件好低静态站通用效果和成本最均衡StorkRust 构建期生成索引文件前端做检索需要额外处理分词中对性能有较高要求的场景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 hugoUbuntu 官方源里的 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.mdfront matter 指定 layout 为 search--- title: 站内搜索 layout: search ---然后创建模板文件layouts/_default/search.html。因为博客主题里有 baseof 主模板这里只需要填充main区块页面结构如下{{ define main }} main classsearch-page h1{{ .Title }}/h1 input typesearch idsearch-input placeholder输入关键词例如Hugo、部署、评论... autocompleteoff / div idsearch-meta/div ul idsearch-results/ul /main {{ end }}搜索结果列表用一个无序列表容器具体结果由 JavaScript 动态填充。下一步把 Fuse.js 引进来。3.2 引入 Fuse.jsFuse.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 classempty没有找到相关文章换个关键词再试试。/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/ useryour-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太严设为 1threshold 放宽到 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 }} relstylesheet / script src{{ pagefind/pagefind-ui.js | relURL }}/script div idsearch/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就能给博客加上一个比很多动态站还好用的搜索能力。这个功能做完我的私人博客也终于闭环了——写作、发布、被找到剩下的事就是好好写文章。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询