2026/10/14 6:49:22

Hugo博客搜索样式设计:从JSON索引到Fuse.js高亮美化

Hugo博客搜索样式设计:从JSON索引到Fuse.js高亮美化 这一篇我们继续折腾 Hugo 博客。今天这第 113 篇聊的是搜索功能里的重头戏搜索样式。很多朋友把 Hugo 站点部署出来后第一件事就是加搜索但往往能搜起来样子却丑得没法见人。搜索框歪歪扭扭、结果列表挤成一团、高亮颜色刺眼、暗色模式下一片惨白。我这套是在 Ubuntu 22.04 上跑的 Hugo 0.121.2从生成 JSON 索引、写页面结构到打磨 CSS前前后后调了好几版总算是把“能用”和“好看”凑齐了。这一篇先讲清楚“搜索样式一”要做什么、怎么做整套基础代码我会直接贴出来你照着抄就能用。如果你是第一次接触 Hugo 的搜索功能这篇文章同样适合你我会把前置原理也交代明白。1. 搜索样式的前置考虑1.1 为什么先聊样式而不是功能Hugo 本身不带动态搜索常见的做法有两种一种是用第三方平台比如 Algolia把索引推到远端再调用它的 JS 接口另一种是纯前端搜索把文章内容生成一个 JSON 索引文件用户输入关键词后在前端用 Fuse.js 这类库做模糊匹配。前者对私人博客来说太笨重又要注册服务、又要维护 API Key一旦网络波动搜索框就转圈。所以我的博客用的是后者先生成索引再在浏览器里本地搜索。既然方案定了功能好不好用一半取决于匹配逻辑另一半就看前端怎么把结果呈现出来。这正好是“搜索样式”的范畴。样式不是锦上添花它直接影响你会不会用这个搜索功能。我见过不少人写完搜索以后自己都不爱用因为结果列表一出来长标题折行、日期和摘要挤在一起根本扫不出重点。搜索的本质是信息筛选样式的作用是让筛选结果一眼就能被读懂。所以在动手写 CSS 之前先想清楚用户会在哪里触发搜索、搜索框长什么样、结果怎么排列这些比“能不能搜到”更值得花时间。1.2 基础方案Hugo 输出 JSON Fuse.js既然要做纯前端搜索就需要给 Hugo 配置一个额外的输出格式。Hugo 支持自定义 output format我把搜索结果页的索引放到站点根目录命名为 index.json。这样每次构建站点的时候Hugo 会把所有文章的标题、摘要、正文、链接、日期都扫出来写进一个 JSON 文件里。前端再通过 fetch 去拉这个文件用关键词匹配。选 Fuse.js 是因为它轻量、无依赖、对中文基本友好。默认的精确匹配在中文语境下容易出问题但只要把threshold调到合适的值也能有不俗的搜索表现。这个库只有一个核心功能给定一个列表给定一个关键词返回一个按相似度排序的结果数组。我们只需要把结果渲染到页面上样式就可以完全自己控制。这也是为什么我敢在这一篇里大谈样式因为数据源和匹配引擎都是透明的UI 这一层可以放心大胆地改。1.3 搜索页面结构规划与信息架构在动笔画样式之前先划分搜索页的信息模块。一个完整的搜索界面我认为至少要有五个部分搜索入口通常放在导航栏一个小放大镜图标或“搜索”按钮。输入框用户输入关键词的地方要有清晰的当前状态反馈。初始提示当页面刚打开、还没输入时显示一些热门标签或“输入关键词开始搜索”的引导文案。结果列表匹配后的条目集合每条包含标题、摘要、日期和标签。空状态当搜索不到任何内容时显示“没有找到相关内容”并给出重新输入的建议。这五个部分各自承担不同的任务样式上要有明显的层级差异但又不能各自为政。我的设计原则是输入框是整个页面的视觉锚点结果列表是信息的主干初始提示和空状态是辅助信息必须弱化存在感。所以样式上会控制对比度颜色上做得淡一些字体小一些避免抢走主视觉。2. 搜索框的视觉设计入口要顺眼2.1 导航栏搜索按钮与跳转逻辑搜索入口我放在导航栏最右侧。私人博客的导航栏本来就没几个链接放一个放大镜图标不会显得拥挤。而且搜索是一个跨越所有文章的行为它在导航栏的位置应该稳定、固定。我使用的是 SVG 内联图标没有用 iconfont。原因很简单内联 SVG 不用担心字体加载失败而且可以通过 CSS 直接控制stroke颜色来适配暗色模式。按钮跳转逻辑我采用的是跳转到独立搜索页/search/而不是在当前页弹出遮罩。两种方式都有人用我选跳转是出于两个考虑一是搜索页的样式空间大可以有独立的头部、表单区和结果区二是 URL 会变成/search/?q关键词这样浏览器历史记录能保留搜索状态刷新页面也不会丢关键词。你在做样式的时候也可以把搜索按钮做成一个普通的a标签点击后跳到搜索页页面加载后再从 URL 参数里读取关键词填充到输入框。这样比弹窗更容易控制样式细节也更容易做响应式布局。2.2 搜索页顶部搜索框的尺寸、圆角与阴影搜索页里最关键的就是那个输入框。我把它垂直居中放在页面顶部偏下的位置宽度最大 720px左右留白自适应。高度我设为 48px这个尺寸在桌面端看起来不笨重在手机端也够手指点按。考虑到中文输入法的候选框会挡住输入内容输入框的高度不宜小于 44px。圆角我用了 8px不算特别圆但比直角柔和。这里有一个经验圆角大小要和页面整体风格统一。如果你的博客正文用的是偏硬朗的设计圆角用 4px 就好如果整体偏柔和可以用 12px。我整套博客用了 8px因为它和卡片、按钮的圆角一致不会显得突兀。阴影上我没有用夸张的外发光而是用一层非常淡的默认阴影box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06)。聚焦状态时阴影稍微加深同时加 2px 的边框颜色变化从灰色变成主题色。这样做的好处是让用户能明确感知输入框已经被激活但又不至于像霓虹灯一样晃眼。2.3 输入状态、聚焦状态与光标样式输入框的默认状态、悬停状态、聚焦状态这三者必须要有肉眼可辨的差别。默认状态我用浅灰色边框悬停时边框变深一点聚焦时边框变主题色并配合阴影增强。这里有个容易忽略的细节transition不要加在box-shadow上否则会感觉拖沓。我只对border-color和box-shadow做了 0.2s 的过渡这个时长刚好既不生硬也不磨叽。光标颜色也要设置。默认情况下输入框光标是黑色的但在暗色模式下可能看不清。我单独设置了caret-color: var(--primary-color)让光标变成主题色。这样用户定位关键词时视觉引导会更清晰。另一个小技巧是::placeholder的样式一定要设置不要让浏览器用默认灰色。我在淡色模式下用rgba(0,0,0,0.35)暗色模式下用rgba(255,255,255,0.4)看起来会更贴合整体色调。3. 搜索结果列表的样式打磨3.1 结果条目的层级标题、摘要、元信息搜索结果的每条内容我把它看作一个独立的卡片。卡片之间用分隔线隔开不搞复杂的边框和背景因为搜索结果本身信息密度高再加边框会让页面显得很重。每条卡片内部从上到下依次是标题、摘要、元信息。标题我用 18px加粗颜色要明显区别于正文。摘要用 14px行高 1.7颜色稍微淡一些。元信息包括日期、标签、阅读时间等用 12px 的次要色与摘要再有明显区分。这样用户扫一眼就能先看到标题然后从摘要里确认是不是自己要找的东西最后再看元信息判断文章的新旧和分类。间距控制上标题和摘要之间我用 6px摘要和元信息之间用 10px卡片上下间距我给了 20px。如果你觉得分隔线太“硬”可以把分隔线换成淡一点的背景色块或者干脆只用间距划分。我的经验是搜索结果列表不需要卡片式设计那样会割裂阅读的连续性分隔线加留白反而是最舒服的。3.2 搜索关键词高亮不要刺眼关键词高亮是搜索样式里最容易做丑的地方。有人把高亮背景设成亮黄色字号不变结果看上去像用荧光笔抹了一遍。我的选择是保留关键词原有的字号和粗细只把文字颜色改成主题色同时加一丁点背景色。具体用了一个mark标签包裹匹配到的文本然后设background-color: rgba(0, 120, 255, 0.12)文字颜色继承当前配色。暗色模式下这个透明度正好不刺眼。还有一种做法是把整段摘要在搜索时重新截取只显示包含关键词的那一部分。我建议在生成索引的时候保留更长的摘要由前端在渲染时截取关键词前后各 40 个字作为上下文。前后补上的内容我用…连接。这样用户能直接看到命中位置而不是每次都从摘要开头读起。样式上mark 标签前后也可以略微加点内边距让高亮背景有点呼吸感但不要多2px 足够。3.3 空结果、加载中、初始提示三种状态搜索页在没有输入时、加载中、没有结果时都要有对应的提示。这三种状态我分别用三个容器通过 JavaScript 控制显示隐藏。初始提示我放了一行文字“输入关键词搜索全部文章”再显式列出几个热门的标签作为快速入口。这些标签的样式做成胶囊按钮点击后自动填入输入框并触发搜索。加载中状态只在 fetch JSON 时出现一般本地文件很快基本一闪而过但还是给了“加载索引中…”的提示。空结果状态最需要设计我除了给出“没有找到相关文章”的文案还会建议用户“试着换个更短或更宽泛的词”。空状态里我会插一张矢量插画不是必须的但能缓和找不到内容的挫败感。这一部分在视觉上要求整体居中、留白大文字颜色淡不要渲染得太重。4. 完整编码实现从 HTML 到 CSS4.1 生成搜索索引的模板配置先解决数据源。在 Hugo 项目的config.toml里追加输出格式配置[outputFormats] [outputFormats.json] mediaType application/json baseName index isPlainText true [outputs] home [HTML, RSS, JSON]然后在layouts/_default/index.json.json中用 Hugo 模板语法把所有内容遍历出来{{- $pages : .Site.RegularPages -}} {{- $data : slice -}} {{- range $pages -}} {{- $item : dict title .Title permalink .Permalink date (.Date.Format 2006-01-02) tags .Params.tags summary (plainify .Summary) content (plainify .Content) -}} {{- $data $data | append $item -}} {{- end -}} {{- $data | jsonify -}}注意几点模板里不要使用.Summary原样输出因为里面可能残留 HTML 标签。我用了plainify函数去掉格式。如果你想让搜索更精准可以把正文也放进去但这样 JSON 体积会变大。对私人博客来说把.Summary放进去就够了正文搜索会带来很多噪音。生成之后打开/index.json看一眼确认内容正确。4.2 搜索页面的 HTML 结构在content/search.md创建一个搜索页让它使用独立模板。我在layouts/_default/search.html中写页面结构只保留搜索界面这样样式不会和博客正文混在一起。核心结构如下div classsearch-container form idsearch-form action/search/ methodget rolesearch input typesearch idsearch-input nameq placeholder输入关键词搜索全部文章 autocompleteoff value{{ if isset .Params q }}{{ .Params.q }}{{ end }} / button typesubmit classsearch-submit搜索/button /form div idsearch-initial classsearch-status p输入关键词搜索全部文章/p div classsearch-tags {{ range site.Taxonomies.tags }} a classsearch-tag href#>:root { --primary: #0a7de1; --primary-light: rgba(10, 125, 225, 0.12); --text-main: #24292f; --text-secondary: #57606a; --border-color: #d0d7de; --bg-search: #ffffff; --shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.06); --shadow-focus: 0 2px 12px rgba(10, 125, 225, 0.15); --radius: 8px; --input-height: 48px; }搜索页的整体样式.search-container { max-width: 720px; margin: 60px auto 80px; padding: 0 16px; } #search-form { display: flex; gap: 12px; margin-bottom: 32px; } #search-input { flex: 1; height: var(--input-height); padding: 0 16px; font-size: 16px; color: var(--text-main); background: var(--bg-search); border: 1px solid var(--border-color); border-radius: var(--radius); box-shadow: var(--shadow-sm); transition: border-color 0.2s, box-shadow 0.2s; } #search-input:focus { outline: none; border-color: var(--primary); box-shadow: var(--shadow-focus); } .search-submit { height: var(--input-height); padding: 0 24px; font-size: 15px; color: #fff; background: var(--primary); border: none; border-radius: var(--radius); cursor: pointer; transition: opacity 0.2s; }搜索框的按钮宽度我保留文字“搜索”没有做图标。移动端空间有限按钮可以压缩成图标。响应式部分放在后面单独说。这段 CSS 的关键在于聚焦状态输入框聚焦时边框变成主题色阴影也带一点蓝色。要注意outline: none虽然去掉了默认聚焦环但我们也提供了替代的视觉反馈不要为了好看而让键盘用户找不到焦点。结果列表的样式.search-results { border-top: 1px solid var(--border-color); } .search-result-item { padding: 20px 0; border-bottom: 1px solid var(--border-color); } .search-result-title { font-size: 18px; line-height: 1.5; color: var(--text-main); text-decoration: none; } .search-result-title:hover { color: var(--primary); } .search-result-summary { margin: 6px 0 10px; font-size: 14px; line-height: 1.7; color: var(--text-secondary); } .search-result-meta { font-size: 12px; color: var(--text-secondary); display: flex; gap: 12px; flex-wrap: wrap; } mark { background: var(--primary-light); color: inherit; padding: 1px 2px; border-radius: 2px; }元信息我用了 flex 布局日期、标签之间用gap分隔这样不用额外加逗号。如果你有阅读时间或分类也可以加进去。mark的背景色用半透明主题色不影响暗色模式。空状态和标签按钮样式.search-status { padding: 48px 0; text-align: center; color: var(--text-secondary); } .search-tags { margin-top: 16px; display: flex; justify-content: center; flex-wrap: wrap; gap: 8px; } .search-tag { display: inline-block; padding: 6px 14px; font-size: 13px; color: var(--text-secondary); border: 1px solid var(--border-color); border-radius: 999px; text-decoration: none; transition: all 0.2s; } .search-tag:hover { color: var(--primary); border-color: var(--primary); }标签按钮用全圆角看起来像胶囊点击后会触发搜索。样式上 hover 时变色即可不需要做复杂动画。4.4 暗色模式适配与响应式规则因为博客可能启用暗色模式我直接把暗色变量写进media (prefers-color-scheme: dark)如果你用的是手动切换可以把变量挂在html[data-themedark]上。两种方式我只推荐手动切换因为可以避免系统主题变化导致页面突然跳变。这里提供一个基于属性的暗色版本html[data-themedark] { --primary: #58a6ff; --primary-light: rgba(88, 166, 255, 0.15); --text-main: #e6edf3; --text-secondary: #8b949e; --border-color: #30363d; --bg-search: #0d1117; --shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.3); --shadow-focus: 0 2px 12px rgba(88, 166, 255, 0.2); }在暗色模式下输入框背景变成了深灰色边框也更深。这里有个细节暗色模式下输入框的背景色不要和页面背景完全一样否则会“陷”进页面里。我保留了一点色差让搜索框有立体感。响应式很简单窄屏幕下主要调整搜索表单和结果间距media (max-width: 600px) { .search-container { margin-top: 32px; } #search-form { flex-direction: column; } .search-submit { width: 100%; } .search-result-title { font-size: 16px; } }移动端把搜索按钮变成整行更方便点击。结果标题字号略缩避免折行。如果你有足够空间也可以把按钮和输入框排在同一行做成圆角容器内嵌按钮的那种样式不过那需要更多额外包装层一篇文章讲不清。5. 常见问题与排查技巧实录5.1 为什么搜索框输入后没有反应我在 Ubuntu 上调试时最容易出现的问题是fetch(/index.json)路径写错。如果你把博客部署在子目录比如https://example.com/blog/那么/index.json会请求根域名下的文件而不是博客目录里的。解决办法是用相对路径或者借助 Hugo 的relURL模板函数把路径输出到页面里。在搜索页面里可以这样input typehidden idsearch-index-url value{{ index.json | relURL }}然后 JS 读取这个值再请求。这样无论部署在哪路径都不容易出错。另一个原因是浏览器缓存了旧的 JSON 文件。Hugo 构建后文件名不会变浏览器很容易缓存住旧版本导致搜不到刚发的文章。我在 fetch 时给 URL 加了一个版本参数用构建时间戳index.json?v1710000000强制刷新。你可以用 Hugo 模板字符串拼接或者简单在部署脚本里替换。5.2 搜索中文分词不准怎么办Fuse.js 默认使用字符级别匹配对于中文一个词会被拆成单个字。比如搜“博客”可能匹配出“博”或“客”单独出现的文章结果列表顺序会乱。一个简单办法是把threshold调高到 0.4 左右distance调到 200会让“模糊”变得更宽松但噪声也会增多。更实用的做法是让 Fuse.js 按连续字符匹配一个短语同时把ignoreLocation设为true避免位置影响打分。我的方案是牺牲一点精度换取召回率。用户搜一个词大概率是想找到和这个词相关的文章而不是非要精确匹配标题。再把结果按日期降序排列这样最新文章永远排前面即便匹配分不高也能保证大多时候用户想找的内容在前面。你可以在 JS 渲染前对结果做一次二次排序把标题命中的结果排到前面摘要命中的排后面。5.3 样式不生效或错乱的排查思路搜索页样式不生效十有八九是 CSS 缓存。因为 Hugo 生成的静态文件带哈希,但 CSS 文件路径变化不够平凡浏览器会缓存旧的。我在 Chrome 开发者工具里强制禁用缓存后仍出问题检查发现是搜索页模板没有引入我们自定义的 CSS 文件。Hugo 里每个模板都有block和define如果你在baseof.html里没有预留head的扩展块单独页面的样式就不会加载。解决方法是把搜索专用的 CSS 单独抽成search.css并在搜索页面的模板里通过resources.Get和minify引入而不是全部塞进全局主样式表。错乱的问题多半是样式表继承关系混乱。搜索页用到了mark标签而博客正文中也有mark全局样式很容易冲突。我给搜索页的mark加了一个父级限制写成.search-results mark避免影响正文。5.4 性能优化让搜索和渲染都保持流畅我的博客文章数量目前几百篇生成的 JSON 索引大约 300KB。这个大小在本地加载是可以接受的但如果你的文章很多索引体积变大搜索时每次都要遍历整个数组会有点慢。优化方案有两个一是把索引按年份拆分但那样逻辑会复杂二是用 Pagefind 这类预生成索引的工具替代 Fuse.js。不过如果只做一个私人博客Fuse.js 足够用。渲染方面建议不要用 jQuery 或大型框架直接原生 DOM 操作就够了。结果条目多时用DocumentFragment一次性拼接后再插入页面能减少重绘次数。我在搜索框输入时用了 200ms 的debounce避免每敲一个字就触发一次搜索。搜索是同步遍历 JS 数组性能完全够用只要别在input事件里做太重的操作。写在最后的小经验这套搜索样式我迭代过三版。第一版直接用浏览器的默认样式基本没法看第二版只改了颜色结果和博客割裂第三版才开始从变量、布局、状态统一设计。现在每次部署完我自己都会先搜一个冷门词看看空状态是否得体再搜一个热门词看看结果列表能不能一眼找到目标。搜索样式这件事没有标准答案但从“能用”到“好用”之间差的不是技术而是细节。这一篇是“搜索样式一”后续我还会继续做搜索结果详情、键盘导航、中文拼音匹配这些增强功能。你在自己博客上改的时候如果遇到什么特别隐蔽的坑欢迎留个整理思路互相借鉴。