added 搜索功能

This commit is contained in:
Vaica committed 2026-01-28 23:20:39 +08:00
1 parent 53afae972b
commit 4baf51f4b1
7 files changed
+803 -2

No files matched your search

@@ -0,0 +1,124 @@
---
title: "Hugo静态博客如何实现搜索功能"
date: 2026-01-28
description: "文章慢慢多了起来,有时候自己想找以前写的内容都得翻半天。于是抽空给博客加了个简单的搜索功能,没用什么高大上的第三方服务,纯前端实现,简单好用。"
tags: ["Hugo", "Search", "PJAX", "折腾"]
categories: ["建站"]
status: public
allowComment: true
---
最近发现博客文章数量慢慢上来了,有时候想找自己以前写的某个知识点,翻半天都翻不到,确实有点不方便。
其实早就想加个搜索功能了,但看了一圈,像 Algolia 这种第三方服务虽然强大,但配置起来感觉有点繁琐,而且对于咱们这种访问量不大的静态博客来说,是不是有点“杀鸡用牛刀”了?
想了想,还是决定用最简单的方式来实现:直接生成一个 JSON 索引文件,用 JS 在前端搜一下。虽然比较基础,但胜在轻量,而且完全可控。
折腾了几个小时,终于搞定了,顺便把交互做得稍微优化了一下,支持了 PJAX 和暗黑模式。这里简单记录一下实现过程,希望能给同样在折腾 Hugo 的朋友一点参考。
## 效果展示
为了不破坏页面的简洁感,我把搜索入口藏在了两个地方:
1. **右下角悬浮工具栏**:点那个放大镜图标,会弹出一个搜索框。
2. **首页“随笔”标题**:这个算是个小彩蛋吧。在首页找到“随笔”这两个字,鼠标放上去会有“点击搜索”的提示,点一下就能直接在原地搜索,不用跳转。
## 实现思路
原理其实非常简单,主要就三步:
1. 让 Hugo 构建时多生成一个 `index.json` 文件,里面包含所有文章的标题、链接和摘要。
2. 前端 JS 通过 `fetch` 请求这个 JSON 文件。
3. 根据用户输入的关键词,在 JSON 数据里进行匹配,然后把结果渲染出来。
## 代码实现
### 1. 配置 Hugo 输出 JSON
首先在 `hugo.toml` (或者 `config.toml`) 里配置一下输出格式,告诉 Hugo 首页除了 HTML 还要输出 JSON。
```toml
[outputs]
home = ["HTML", "RSS", "JSON"]
```
### 2. 创建 JSON 模板
在主题的 `layouts/index.json` 创建一个模板文件,定义 JSON 的数据结构。这里我只取了标题、日期、链接和摘要,尽量让文件小一点。
```go
[
{{- range $index, $e := where .Site.RegularPages "Type" "post" -}}
{{- if $index -}}, {{- end -}}
{
"title": {{ .Title | jsonify }},
"date": {{ .Date.Format "2006-01-02" | jsonify }},
"permalink": {{ .Permalink | jsonify }},
"summary": {{ .Summary | plainify | jsonify }}
}
{{- end -}}
]
```
这样每次 `hugo` 构建的时候,网站根目录就会生成一个 `index.json`。
### 3. 前端 JS 逻辑
逻辑主要在 `main.js` 里。为了提升体验,我做了一些小优化:
* **懒加载**:不会一打开网页就下载 JSON,而是等你点击搜索按钮时才去加载,帮大家省点流量。
* **防抖处理**:输入时不会每敲一个字都去搜索,而是稍微停顿一下再触发,避免频繁计算。
* **PJAX 适配**:因为博客用了 PJAX 做无刷新跳转,一开始遇到个坑,点击搜索结果页面会白屏或者刷新。后来发现需要在渲染结果后,手动调用一下 `window.pjax.refresh()`,让 PJAX 重新接管这些新生成的链接。
```javascript
// 简单的搜索逻辑
const results = window.searchIndex.filter(item => {
// 简单的关键词匹配
return item.title.includes(query) || item.summary.includes(query);
});
displayResults(results);
```
### 4. 那个“随笔”变搜索框的交互
这个交互其实主要是 CSS 在控制。
默认是一个普通的 `<h2>` 标题,点击后通过 JS 隐藏标题,显示输入框。为了过渡自然一点,加了一些简单的 CSS 动画。
```css
/* 默认隐藏提示 */
.search-hint {
opacity: 0;
transition: all 0.3s ease;
}
/* 鼠标放上去显示 */
.section-header.normal-mode:hover .search-hint {
opacity: 1;
transform: translateX(0);
}
```
为了照顾移动端用户,我在 CSS 里加了媒体查询,手机上直接显示“点击搜索”的提示,不然手机上没有 hover 状态,可能会不知道这里可以点。
## 遇到的坑
折腾过程中最大的坑还是 **PJAX**。
一开始写好的时候,搜索功能本身没问题,但是点搜索结果跳转的时候,要么页面刷新,要么音乐播放器断了。查了半天文档,才发现是因为动态插入的 HTML(搜索结果列表),PJAX 默认是监听不到的。
解决办法就是在插入 HTML 后,手动通知一下 PJAX:
```javascript
if (window.pjax) window.pjax.refresh(searchResults);
```
另外,为了体验好一点,我还加了个逻辑:点击搜索结果后,自动关闭搜索框,不然跳转完搜索框还挡在那里,确实有点傻。
## 总结
虽然只是个小功能,但前前后后也调了好久,特别是细节上的打磨。不过看到最后丝滑的搜索体验,感觉还是挺值得的。
如果你也在用 Hugo,不想接复杂的第三方搜索,不妨试试这个方案,轻量、简单,完全够用了。
代码就不全贴了,核心逻辑都在上面,有兴趣的朋友可以自己折腾一下。如果有更好的实现方式,也欢迎留言交流哈!
+1 -1
View File
@@ -69,7 +69,7 @@ hasCJKLanguage = true
# 输出格式配置 (Outputs)
[outputs]
home = ["HTML", "RSS"]
home = ["HTML", "RSS", "JSON"]
# RSS 媒体类型定义 (RSS Media Type)
[mediaTypes."application/rss+xml"]
+387
View File
@@ -3269,6 +3269,393 @@ html[data-theme="light"] .atk-loading-spinner svg circle {
.artalk.hidden { display: none; }
/* Search Modal Styles */
.search-modal {
display: none; /* Hidden by default */
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
overflow: auto;
background-color: rgba(0, 0, 0, 0.4); /* Black w/ opacity */
backdrop-filter: blur(5px);
-webkit-backdrop-filter: blur(5px);
opacity: 0;
transition: opacity 0.3s ease;
}
.search-modal.show {
display: flex;
opacity: 1;
align-items: center;
justify-content: center;
}
.search-modal-content {
background-color: #fefefe;
margin: auto;
padding: 20px;
border: 1px solid #888;
width: 80%;
max-width: 600px;
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0,0,0,0.1);
transform: translateY(-20px);
transition: transform 0.3s ease;
}
.search-modal.show .search-modal-content {
transform: translateY(0);
}
.search-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
border-bottom: 1px solid #eee;
padding-bottom: 10px;
}
.search-header h3 {
margin: 0;
font-size: 1.2rem;
color: #333;
}
.close-search {
color: #aaa;
font-size: 28px;
font-weight: bold;
cursor: pointer;
line-height: 1;
}
.close-search:hover,
.close-search:focus {
color: black;
text-decoration: none;
cursor: pointer;
}
.search-input-wrapper {
display: flex;
margin-bottom: 20px;
border: 1px solid #ddd;
border-radius: 8px;
padding: 5px 10px;
background: #f9f9f9;
}
.search-input-wrapper input {
flex: 1;
border: none;
background: transparent;
padding: 10px;
font-size: 16px;
outline: none;
}
.search-input-wrapper button {
background: none;
border: none;
cursor: pointer;
color: #666;
padding: 0 10px;
}
.search-results {
max-height: 400px;
overflow-y: auto;
}
.search-result-item {
padding: 15px;
border-bottom: 1px solid #f0f0f0;
transition: background-color 0.2s;
}
.search-result-item:last-child {
border-bottom: none;
}
.search-result-item:hover {
background-color: #f5f5f5;
}
.search-result-title {
font-size: 16px;
font-weight: 600;
margin-bottom: 5px;
display: block;
color: #333;
text-decoration: none;
}
.search-result-title:hover {
color: var(--text-highlight-color);
}
.search-result-date {
font-size: 12px;
color: #999;
margin-bottom: 5px;
display: block;
}
.search-result-summary {
font-size: 14px;
color: #666;
line-height: 1.5;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.search-tip {
text-align: center;
color: #999;
padding: 20px;
}
/* Dark Mode Adaptation */
[data-theme="dark"] .search-modal-content {
background-color: #2d2d2d;
border-color: #444;
color: #e0e0e0;
}
[data-theme="dark"] .search-header {
border-bottom-color: #444;
}
[data-theme="dark"] .search-header h3 {
color: #e0e0e0;
}
[data-theme="dark"] .close-search {
color: #888;
}
[data-theme="dark"] .close-search:hover {
color: #fff;
}
[data-theme="dark"] .search-input-wrapper {
background: #333;
border-color: #555;
}
[data-theme="dark"] .search-input-wrapper input {
color: #e0e0e0;
}
[data-theme="dark"] .search-input-wrapper button {
color: #aaa;
}
[data-theme="dark"] .search-result-item {
border-bottom-color: #444;
}
[data-theme="dark"] .search-result-item:hover {
background-color: #383838;
}
[data-theme="dark"] .search-result-title {
color: #e0e0e0;
}
[data-theme="dark"] .search-result-summary {
color: #aaa;
}
/* Inline Search Styles */
.searchable-header-container {
position: relative;
margin-top: 15px;
height: 13px;
display: flex;
align-items: center;
}
.section-header.normal-mode {
cursor: pointer;
transition: opacity 0.3s ease;
margin: 0;
user-select: none;
line-height: 1;
}
.section-header.normal-mode:hover {
opacity: 0.7;
}
.search-hint {
font-size: 12px;
font-weight: normal;
color: #999;
margin-left: 10px;
opacity: 0; /* Hidden by default */
transform: translateX(-5px);
transition: all 0.3s ease;
display: inline-block;
vertical-align: middle;
}
.section-header.normal-mode:hover .search-hint {
opacity: 1;
transform: translateX(0);
}
/* Mobile: always show hint to ensure discoverability */
@media (max-width: 768px) {
.search-hint {
opacity: 0.6;
transform: translateX(0);
}
}
.search-mode {
width: 100%;
animation: fadeIn 0.3s ease;
}
.search-input-group {
display: flex;
align-items: center;
background: transparent;
border-bottom: 1px solid #e5e7eb;
padding: 0 5px;
width: 100%;
max-width: 300px;
transition: all 0.3s ease;
}
.search-input-group:focus-within {
background: transparent;
border-bottom-color: var(--text-highlight-color);
box-shadow: none;
}
.search-input-group input {
border: none;
background: transparent;
flex: 1;
padding: 5px 10px;
font-size: 14px;
outline: none;
color: #333;
}
.search-input-group .search-icon {
color: #999;
font-size: 16px;
}
.search-input-group .close-icon {
color: #999;
cursor: pointer;
font-size: 16px;
padding: 5px;
}
.search-input-group .close-icon:hover {
color: #333;
}
.search-dropdown {
position: absolute;
top: 100%;
left: 0;
width: 100%;
max-width: 300px;
background: #fff;
border-radius: 4px;
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
z-index: 100;
max-height: 300px;
overflow-y: auto;
display: none;
margin-top: 5px;
border: 1px solid #eee;
}
.search-dropdown .search-result-item {
padding: 10px 15px;
font-size: 14px;
display: block; /* Ensure it behaves like a block */
text-decoration: none; /* Remove underline */
color: inherit; /* Inherit text color */
cursor: pointer;
}
.search-dropdown .search-result-item:hover {
background-color: #f5f5f5;
text-decoration: none;
}
.search-dropdown .search-result-title {
font-size: 15px;
font-weight: 600;
display: block; /* Ensure title is block to break line */
color: #333;
}
.search-dropdown .search-result-summary {
font-size: 12px;
-webkit-line-clamp: 1;
}
/* Dark Mode for Inline Search */
[data-theme="dark"] .search-input-group {
background: transparent;
border-bottom-color: #444;
}
[data-theme="dark"] .search-input-group:focus-within {
background: transparent;
border-bottom-color: var(--text-highlight-color);
}
[data-theme="dark"] .search-input-group input {
color: #e0e0e0;
}
[data-theme="dark"] .search-input-group .search-icon,
[data-theme="dark"] .search-input-group .close-icon {
color: #888;
}
[data-theme="dark"] .search-input-group .close-icon:hover {
color: #fff;
}
[data-theme="dark"] .search-dropdown {
background: #2d2d2d;
border-color: #444;
}
[data-theme="dark"] .search-dropdown .search-result-item {
color: #e0e0e0;
}
[data-theme="dark"] .search-dropdown .search-result-item:hover {
background-color: #383838;
}
[data-theme="dark"] .search-dropdown .search-result-title {
color: #e0e0e0;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateX(-10px); }
to { opacity: 1; transform: translateX(0); }
}
+241
View File
@@ -461,3 +461,244 @@ function scrollToCommentsAndQuote(text) {
}, 500);
}
}
/* Search Functionality */
function initSearchFunctionality() {
const searchBtn = document.getElementById('search-btn');
const searchModal = document.getElementById('search-modal');
const closeSearch = document.querySelector('.close-search');
const searchInput = document.getElementById('search-input');
const searchResults = document.getElementById('search-results');
const searchSubmit = document.getElementById('search-submit');
// Global variables (window scope) to persist across re-inits if needed,
// but better to keep them scoped if we re-fetch.
// For simplicity, we keep index loading check.
if (!window.searchIndex) window.searchIndex = [];
if (!window.isIndexLoaded) window.isIndexLoaded = false;
if (!searchBtn || !searchModal) return;
// Open Modal
// Use named function to avoid duplicate listeners if we didn't clean up (though init is usually called once per page load/pjax)
function openModal() {
searchModal.classList.add('show');
if (!window.isIndexLoaded) {
loadSearchIndex();
}
setTimeout(() => searchInput.focus(), 100);
}
searchBtn.removeEventListener('click', openModal); // Prevent duplicates
searchBtn.addEventListener('click', openModal);
// Close Modal
function closeModal() {
searchModal.classList.remove('show');
}
if (closeSearch) {
closeSearch.onclick = closeModal;
}
// Close when clicking outside
window.onclick = function(e) {
if (e.target === searchModal) {
closeModal();
}
};
// Close on Escape key
document.onkeydown = function(e) {
if (e.key === 'Escape' && searchModal.classList.contains('show')) {
closeModal();
}
};
// Load Index
function loadSearchIndex() {
fetch('/index.json')
.then(response => response.json())
.then(data => {
window.searchIndex = data;
window.isIndexLoaded = true;
})
.catch(error => {
console.error('Error loading search index:', error);
if(searchResults) searchResults.innerHTML = '<p class="search-tip">搜索索引加载失败</p>';
});
}
// Perform Search
function performSearch() {
if(!searchInput) return;
const query = searchInput.value.trim().toLowerCase();
if (query === '') {
searchResults.innerHTML = '<p class="search-tip">输入关键词并回车进行搜索</p>';
return;
}
if (!window.isIndexLoaded) {
searchResults.innerHTML = '<p class="search-tip">正在加载索引...</p>';
// Retry after a short delay if index is loading
setTimeout(performSearch, 500);
return;
}
const results = window.searchIndex.filter(item => {
const title = item.title ? item.title.toLowerCase() : '';
const summary = item.summary ? item.summary.toLowerCase() : '';
return title.includes(query) || summary.includes(query);
});
displayResults(results);
}
// Display Results
function displayResults(results) {
if (results.length === 0) {
searchResults.innerHTML = '<p class="search-tip">未找到相关文章</p>';
return;
}
let html = '';
results.forEach(item => {
html += `
<a href="${item.permalink}" class="search-result-item" style="text-decoration:none; display:block; color:inherit;">
<span class="search-result-title" style="display:block;font-weight:600;margin-bottom:5px;">${item.title}</span>
<span class="search-result-date" style="font-size:12px;color:#999;display:block;margin-bottom:5px;">${item.date}</span>
<div class="search-result-summary">${item.summary}</div>
</a>
`;
});
searchResults.innerHTML = html;
if (window.pjax) window.pjax.refresh(searchResults);
// Add click listener to close modal on result click
const resultLinks = searchResults.querySelectorAll('.search-result-item');
resultLinks.forEach(link => {
link.addEventListener('click', function() {
closeModal();
});
});
}
// Event Listeners for Search
if (searchSubmit) {
searchSubmit.onclick = performSearch;
}
if (searchInput) {
searchInput.onkeypress = function(e) {
if (e.key === 'Enter') {
performSearch();
}
};
// Optional: Live search with debounce
let debounceTimer;
searchInput.oninput = function() {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(performSearch, 300);
};
}
/* Inline Search Logic */
const essayHeader = document.getElementById('essay-header');
const searchContainer = document.getElementById('search-container');
const inlineSearchInput = document.getElementById('inline-search-input');
const inlineSearchResults = document.getElementById('inline-search-results');
if (essayHeader && searchContainer && inlineSearchInput) {
window.toggleSearchMode = function(show) {
if (show) {
essayHeader.style.display = 'none';
searchContainer.style.display = 'block';
inlineSearchInput.focus();
if (!window.isIndexLoaded) {
loadSearchIndex();
}
} else {
essayHeader.style.display = 'block';
searchContainer.style.display = 'none';
inlineSearchInput.value = '';
inlineSearchResults.style.display = 'none';
}
};
// Close when clicking outside - delegated to document body to handle pjax updates
document.body.onclick = function(e) {
const isClickInside = essayHeader.contains(e.target) ||
searchContainer.contains(e.target) ||
inlineSearchResults.contains(e.target);
if (!isClickInside && searchContainer.style.display === 'block') {
window.toggleSearchMode(false);
}
// Also handle modal outside click here if needed, but window.onclick handles that
if (e.target === searchModal) {
closeModal();
}
};
// Inline Search Function
function performInlineSearch() {
const query = inlineSearchInput.value.trim().toLowerCase();
if (query === '') {
inlineSearchResults.style.display = 'none';
return;
}
if (!window.isIndexLoaded) {
// Wait if loading
setTimeout(performInlineSearch, 500);
return;
}
const results = window.searchIndex.filter(item => {
const title = item.title ? item.title.toLowerCase() : '';
const summary = item.summary ? item.summary.toLowerCase() : '';
return title.includes(query) || summary.includes(query);
});
displayInlineResults(results);
}
function displayInlineResults(results) {
if (results.length === 0) {
inlineSearchResults.innerHTML = '<div class="search-result-item" style="text-align:center;color:#999;">未找到相关文章</div>';
inlineSearchResults.style.display = 'block';
return;
}
let html = '';
results.slice(0, 5).forEach(item => { // Limit to 5 inline results
html += `
<a href="${item.permalink}" class="search-result-item">
<span class="search-result-title">${item.title}</span>
<div class="search-result-summary">${item.summary}</div>
</a>
`;
});
inlineSearchResults.innerHTML = html;
inlineSearchResults.style.display = 'block';
if (window.pjax) window.pjax.refresh(inlineSearchResults);
}
let inlineDebounce;
inlineSearchInput.oninput = function() {
clearTimeout(inlineDebounce);
inlineDebounce = setTimeout(performInlineSearch, 300);
};
// Handle Escape key
inlineSearchInput.onkeydown = function(e) {
if (e.key === 'Escape') {
window.toggleSearchMode(false);
}
};
}
}
document.addEventListener('DOMContentLoaded', initSearchFunctionality);
// Re-init on Pjax complete
document.addEventListener('pjax:complete', initSearchFunctionality);
+16 -1
View File
@@ -2,7 +2,22 @@
<div id="pjax-container">
<article>
<div class="recent-posts section">
<h2 class="section-header" uk-scrollspy="cls: uk-animation-slide-left-small; delay: 200">随笔<i class="ri-quill-pen-line"></i></h2>
<!-- Searchable Header Container -->
<div class="searchable-header-container">
<h2 class="section-header normal-mode" id="essay-header" onclick="toggleSearchMode(true)" uk-scrollspy="cls: uk-animation-slide-left-small; delay: 200">
随笔<i class="ri-quill-pen-line"></i>
<span class="search-hint">点击随笔,开始搜索</span>
</h2>
<div class="search-mode" id="search-container" style="display: none;">
<div class="search-input-group">
<i class="ri-search-line search-icon"></i>
<input type="text" id="inline-search-input" placeholder="搜索文章..." autocomplete="off">
<i class="ri-close-line close-icon" onclick="toggleSearchMode(false)"></i>
</div>
</div>
<div id="inline-search-results" class="search-dropdown"></div>
</div>
<div class="posts" uk-scrollspy="target: > .post; cls: uk-animation-slide-bottom-small; delay: 150">
<!-- 使用 sort 对文章按 Weight 排序 -->
{{ $posts := sort (where (where .Site.RegularPages "Type" "post") "Params.status" "ne" "hidden")
+11
View File
@@ -0,0 +1,11 @@
[
{{- range $index, $e := where .Site.RegularPages "Type" "post" -}}
{{- if $index -}}, {{- end -}}
{
"title": {{ .Title | jsonify }},
"date": {{ .Date.Format "2006-01-02" | jsonify }},
"permalink": {{ .Permalink | jsonify }},
"summary": {{ .Summary | plainify | jsonify }}
}
{{- end -}}
]
+23
View File
@@ -14,6 +14,9 @@
</div>
</div>
<div class="floating-tools">
<a href="javascript:;" id="search-btn" title="搜索文章">
<i class="ri-search-line"></i>
</a>
<a href="javascript:;" id="theme-toggle" title="切换主题">
<i class="ri-palette-line"></i>
</a>
@@ -24,6 +27,26 @@
<i class="ri-arrow-up-line"></i>
</a>
</div>
<!-- Search Modal -->
<div id="search-modal" class="search-modal">
<div class="search-modal-content">
<div class="search-header">
<h3>搜索文章</h3>
<span class="close-search">&times;</span>
</div>
<div class="search-body">
<div class="search-input-wrapper">
<input type="text" id="search-input" placeholder="输入关键词搜索..." autocomplete="off">
<button id="search-submit"><i class="ri-search-line"></i></button>
</div>
<div id="search-results" class="search-results">
<!-- Search results will appear here -->
<p class="search-tip">输入关键词并回车进行搜索</p>
</div>
</div>
</div>
</div>
{{ $iconfont := resources.Get "js/iconfont.js" }}
{{ $lightgallery := resources.Get "js/lightgallery.umd.min.js" }}
{{ $uikit := resources.Get "js/uikit.min.js" }}