静态站点搜索方案设计:从零实现全站搜索
静态站点的搜索困境
静态站点没有后端,传统搜索需要数据库 + 服务端索引。但我们的目标是不依赖任何后端服务就能实现可靠的全站搜索——对于个人博客、文档站、作品集这类只有几十到几百篇内容的站点,这完全可行。
完整实现方案
第一步:生成搜索索引
// generate-search-index.js
const fs = require('fs');
const path = require('path');
const dirs = ['projects', 'posts', 'knowledge'];
const index = [];
function stripHtml(html) {
return html
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
.replace(/<[^>]+>/g, ' ')
.replace(/&[a-z]+;/gi, '')
.replace(/\s+/g, ' ')
.trim();
}
for (const dir of dirs) {
const dirPath = path.join(__dirname, dir);
const files = fs.readdirSync(dirPath).filter(f => f.endsWith('.html'));
for (const file of files) {
const raw = fs.readFileSync(path.join(dirPath, file), 'utf8');
const text = stripHtml(raw);
// 提取标题
const titleMatch = raw.match(/<title>([^<]+)<\/title>/)
|| raw.match(/<h1[^>]*>([^<]+)<\/h1>/);
const title = titleMatch ? titleMatch[1].trim() : file.replace('.html', '');
// 提取标签
const tags = [];
const tagMatch = raw.match(/tag-list["']>\s*<span>([^<]+)/g);
if (tagMatch) {
tagMatch.forEach(t => {
const m = t.match(/>([^<]+)</);
if (m) tags.push(m[1].trim());
});
}
index.push({
title,
url: `${dir}/${file}`,
tags,
snippet: text.slice(0, 300),
body: text.slice(0, 4000) // 全文索引,限制大小
});
}
}
fs.writeFileSync('search-index.json', JSON.stringify(index, null, 2));
console.log(`✅ 生成 search-index.json (${index.length} 条索引)`);
第二步:集成 Fuse.js
// 搜索核心逻辑
let fuse = null;
let searchIndex = null;
async function initSearch() {
try {
const resp = await fetch('/search-index.json');
searchIndex = await resp.json();
fuse = new Fuse(searchIndex, {
keys: [
{ name: 'title', weight: 0.6 },
{ name: 'tags', weight: 0.2 },
{ name: 'body', weight: 0.2 }
],
threshold: 0.4,
distance: 100,
includeScore: true,
minMatchCharLength: 2,
ignoreLocation: true
});
} catch (err) {
console.warn('搜索索引加载失败,使用降级方案');
// 降级:用 allPosts 数据
searchIndex = window.allPosts || [];
fuse = new Fuse(searchIndex, {
keys: ['title', 'description'],
threshold: 0.3
});
}
}
function search(query) {
if (!fuse || !query.trim()) return [];
const results = fuse.search(query.trim());
return results.slice(0, 10); // 最多返回 10 条
}
第三步:关键词高亮
function highlightText(text, query) {
if (!query) return text;
const words = query.split(/\s+/).filter(w => w.length > 0);
let result = text;
for (const word of words) {
const regex = new RegExp(`(${escapeRegex(word)})`, 'gi');
result = result.replace(regex, '<mark>$1</mark>');
}
return result;
}
function escapeRegex(str) {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\
静态站点搜索方案设计:从零实现全站搜索
静态站点的搜索困境
静态站点没有后端,传统搜索需要数据库 + 服务端索引。但我们的目标是不依赖任何后端服务就能实现可靠的全站搜索——对于个人博客、文档站、作品集这类只有几十到几百篇内容的站点,这完全可行。
完整实现方案
第一步:生成搜索索引
// generate-search-index.js
const fs = require('fs');
const path = require('path');
const dirs = ['projects', 'posts', 'knowledge'];
const index = [];
function stripHtml(html) {
return html
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
.replace(/<[^>]+>/g, ' ')
.replace(/&[a-z]+;/gi, '')
.replace(/\s+/g, ' ')
.trim();
}
for (const dir of dirs) {
const dirPath = path.join(__dirname, dir);
const files = fs.readdirSync(dirPath).filter(f => f.endsWith('.html'));
for (const file of files) {
const raw = fs.readFileSync(path.join(dirPath, file), 'utf8');
const text = stripHtml(raw);
// 提取标题
const titleMatch = raw.match(/<title>([^<]+)<\/title>/)
|| raw.match(/<h1[^>]*>([^<]+)<\/h1>/);
const title = titleMatch ? titleMatch[1].trim() : file.replace('.html', '');
// 提取标签
const tags = [];
const tagMatch = raw.match(/tag-list["']>\s*<span>([^<]+)/g);
if (tagMatch) {
tagMatch.forEach(t => {
const m = t.match(/>([^<]+)</);
if (m) tags.push(m[1].trim());
});
}
index.push({
title,
url: `${dir}/${file}`,
tags,
snippet: text.slice(0, 300),
body: text.slice(0, 4000) // 全文索引,限制大小
});
}
}
fs.writeFileSync('search-index.json', JSON.stringify(index, null, 2));
console.log(`✅ 生成 search-index.json (${index.length} 条索引)`);
第二步:集成 Fuse.js
// 搜索核心逻辑
let fuse = null;
let searchIndex = null;
async function initSearch() {
try {
const resp = await fetch('/search-index.json');
searchIndex = await resp.json();
fuse = new Fuse(searchIndex, {
keys: [
{ name: 'title', weight: 0.6 },
{ name: 'tags', weight: 0.2 },
{ name: 'body', weight: 0.2 }
],
threshold: 0.4,
distance: 100,
includeScore: true,
minMatchCharLength: 2,
ignoreLocation: true
});
} catch (err) {
console.warn('搜索索引加载失败,使用降级方案');
// 降级:用 allPosts 数据
searchIndex = window.allPosts || [];
fuse = new Fuse(searchIndex, {
keys: ['title', 'description'],
threshold: 0.3
});
}
}
function search(query) {
if (!fuse || !query.trim()) return [];
const results = fuse.search(query.trim());
return results.slice(0, 10); // 最多返回 10 条
}
第三步:关键词高亮
function highlightText(text, query) {
if (!query) return text;
const words = query.split(/\s+/).filter(w => w.length > 0);
let result = text;
for (const word of words) {
const regex = new RegExp(`(${escapeRegex(word)})`, 'gi');
result = result.replace(regex, '<mark>$1</mark>');
}
return result;
}
function escapeRegex(str) {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\
静态站点搜索方案设计:从零实现全站搜索
静态站点的搜索困境
静态站点没有后端,传统搜索需要数据库 + 服务端索引。但我们的目标是不依赖任何后端服务就能实现可靠的全站搜索——对于个人博客、文档站、作品集这类只有几十到几百篇内容的站点,这完全可行。
完整实现方案
第一步:生成搜索索引
// generate-search-index.js
const fs = require('fs');
const path = require('path');
const dirs = ['projects', 'posts', 'knowledge'];
const index = [];
function stripHtml(html) {
return html
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
.replace(/<[^>]+>/g, ' ')
.replace(/&[a-z]+;/gi, '')
.replace(/\s+/g, ' ')
.trim();
}
for (const dir of dirs) {
const dirPath = path.join(__dirname, dir);
const files = fs.readdirSync(dirPath).filter(f => f.endsWith('.html'));
for (const file of files) {
const raw = fs.readFileSync(path.join(dirPath, file), 'utf8');
const text = stripHtml(raw);
// 提取标题
const titleMatch = raw.match(/<title>([^<]+)<\/title>/)
|| raw.match(/<h1[^>]*>([^<]+)<\/h1>/);
const title = titleMatch ? titleMatch[1].trim() : file.replace('.html', '');
// 提取标签
const tags = [];
const tagMatch = raw.match(/tag-list["']>\s*<span>([^<]+)/g);
if (tagMatch) {
tagMatch.forEach(t => {
const m = t.match(/>([^<]+)</);
if (m) tags.push(m[1].trim());
});
}
index.push({
title,
url: `${dir}/${file}`,
tags,
snippet: text.slice(0, 300),
body: text.slice(0, 4000) // 全文索引,限制大小
});
}
}
fs.writeFileSync('search-index.json', JSON.stringify(index, null, 2));
console.log(`✅ 生成 search-index.json (${index.length} 条索引)`);
第二步:集成 Fuse.js
// 搜索核心逻辑
let fuse = null;
let searchIndex = null;
async function initSearch() {
try {
const resp = await fetch('/search-index.json');
searchIndex = await resp.json();
fuse = new Fuse(searchIndex, {
keys: [
{ name: 'title', weight: 0.6 },
{ name: 'tags', weight: 0.2 },
{ name: 'body', weight: 0.2 }
],
threshold: 0.4,
distance: 100,
includeScore: true,
minMatchCharLength: 2,
ignoreLocation: true
});
} catch (err) {
console.warn('搜索索引加载失败,使用降级方案');
// 降级:用 allPosts 数据
searchIndex = window.allPosts || [];
fuse = new Fuse(searchIndex, {
keys: ['title', 'description'],
threshold: 0.3
});
}
}
function search(query) {
if (!fuse || !query.trim()) return [];
const results = fuse.search(query.trim());
return results.slice(0, 10); // 最多返回 10 条
}
第三步:关键词高亮
function highlightText(text, query) {
if (!query) return text;
const words = query.split(/\s+/).filter(w => w.length > 0);
let result = text;
for (const word of words) {
const regex = new RegExp(`(${escapeRegex(word)})`, 'gi');
result = result.replace(regex, '<mark>$1</mark>');
}
return result;
}
function escapeRegex(str) {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\
静态站点搜索方案设计:从零实现全站搜索
静态站点的搜索困境
静态站点没有后端,传统搜索需要数据库 + 服务端索引。但我们的目标是不依赖任何后端服务就能实现可靠的全站搜索——对于个人博客、文档站、作品集这类只有几十到几百篇内容的站点,这完全可行。
完整实现方案
第一步:生成搜索索引
// generate-search-index.js
const fs = require('fs');
const path = require('path');
const dirs = ['projects', 'posts', 'knowledge'];
const index = [];
function stripHtml(html) {
return html
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
.replace(/<[^>]+>/g, ' ')
.replace(/&[a-z]+;/gi, '')
.replace(/\s+/g, ' ')
.trim();
}
for (const dir of dirs) {
const dirPath = path.join(__dirname, dir);
const files = fs.readdirSync(dirPath).filter(f => f.endsWith('.html'));
for (const file of files) {
const raw = fs.readFileSync(path.join(dirPath, file), 'utf8');
const text = stripHtml(raw);
// 提取标题
const titleMatch = raw.match(/<title>([^<]+)<\/title>/)
|| raw.match(/<h1[^>]*>([^<]+)<\/h1>/);
const title = titleMatch ? titleMatch[1].trim() : file.replace('.html', '');
// 提取标签
const tags = [];
const tagMatch = raw.match(/tag-list["']>\s*<span>([^<]+)/g);
if (tagMatch) {
tagMatch.forEach(t => {
const m = t.match(/>([^<]+)</);
if (m) tags.push(m[1].trim());
});
}
index.push({
title,
url: `${dir}/${file}`,
tags,
snippet: text.slice(0, 300),
body: text.slice(0, 4000) // 全文索引,限制大小
});
}
}
fs.writeFileSync('search-index.json', JSON.stringify(index, null, 2));
console.log(`✅ 生成 search-index.json (${index.length} 条索引)`);
第二步:集成 Fuse.js
// 搜索核心逻辑
let fuse = null;
let searchIndex = null;
async function initSearch() {
try {
const resp = await fetch('/search-index.json');
searchIndex = await resp.json();
fuse = new Fuse(searchIndex, {
keys: [
{ name: 'title', weight: 0.6 },
{ name: 'tags', weight: 0.2 },
{ name: 'body', weight: 0.2 }
],
threshold: 0.4,
distance: 100,
includeScore: true,
minMatchCharLength: 2,
ignoreLocation: true
});
} catch (err) {
console.warn('搜索索引加载失败,使用降级方案');
// 降级:用 allPosts 数据
searchIndex = window.allPosts || [];
fuse = new Fuse(searchIndex, {
keys: ['title', 'description'],
threshold: 0.3
});
}
}
function search(query) {
if (!fuse || !query.trim()) return [];
const results = fuse.search(query.trim());
return results.slice(0, 10); // 最多返回 10 条
}
第三步:关键词高亮
function highlightText(text, query) {
if (!query) return text;
const words = query.split(/\s+/).filter(w => w.length > 0);
let result = text;
for (const word of words) {
const regex = new RegExp(`(${escapeRegex(word)})`, 'gi');
result = result.replace(regex, '<mark>$1</mark>');
}
return result;
}
function escapeRegex(str) {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
第四步:UI 实现
<!-- 搜索栏:圆形胶囊样式,聚焦光晕 -->
<div class="search-container">
<input type="text" id="searchInput"
placeholder="搜索笔记..."
autocomplete="off">
<div id="searchResults" class="search-results"></div>
</div>
<style>
.search-container {
max-width: 480px;
margin: 0 auto;
position: relative;
}
.search-container input {
width: 100%;
padding: 12px 20px;
border-radius: 24px;
border: 2px solid #e0e0e0;
font-size: 16px;
transition: border-color 0.3s, box-shadow 0.3s;
outline: none;
}
.search-container input:focus {
border-color: #7c3aed;
box-shadow: 0 0 0 4px rgba(124, 58, 237, 0.15);
}
</style>
更新工作流
每次修改或新增内容后,需要重新生成索引并部署:
# 日常更新流程
1. 编辑 / 创建笔记 → knowledge/xxx.html
2. node generate-search-index.js # 重新生成索引
3. npm run build # 构建验证
4. git add -A && git commit -m "..."
5. git push # Vercel 自动部署
性能考虑
- 30 篇笔记的索引约 50-200KB,首次加载几乎无感知
- 100+ 篇时可考虑 gzip 压缩(Vercel 自动启用)
- Fuse.js 的搜索是同步的,1000 条数据内 < 10ms
- 搜索索引可以和站点一起 CDN 缓存
amp;');
}
第四步:UI 实现
<!-- 搜索栏:圆形胶囊样式,聚焦光晕 -->
<div class="search-container">
<input type="text" id="searchInput"
placeholder="搜索笔记..."
autocomplete="off">
<div id="searchResults" class="search-results"></div>
</div>
<style>
.search-container {
max-width: 480px;
margin: 0 auto;
position: relative;
}
.search-container input {
width: 100%;
padding: 12px 20px;
border-radius: 24px;
border: 2px solid #e0e0e0;
font-size: 16px;
transition: border-color 0.3s, box-shadow 0.3s;
outline: none;
}
.search-container input:focus {
border-color: #7c3aed;
box-shadow: 0 0 0 4px rgba(124, 58, 237, 0.15);
}
</style>
更新工作流
每次修改或新增内容后,需要重新生成索引并部署:
# 日常更新流程
1. 编辑 / 创建笔记 → knowledge/xxx.html
2. node generate-search-index.js # 重新生成索引
3. npm run build # 构建验证
4. git add -A && git commit -m "..."
5. git push # Vercel 自动部署
性能考虑
- 30 篇笔记的索引约 50-200KB,首次加载几乎无感知
- 100+ 篇时可考虑 gzip 压缩(Vercel 自动启用)
- Fuse.js 的搜索是同步的,1000 条数据内 < 10ms
- 搜索索引可以和站点一起 CDN 缓存