静态站点搜索方案设计:从零实现全站搜索

2026-06-11搜索前端Fuse.js静态站点

静态站点的搜索困境

静态站点没有后端,传统搜索需要数据库 + 服务端索引。但我们的目标是不依赖任何后端服务就能实现可靠的全站搜索——对于个人博客、文档站、作品集这类只有几十到几百篇内容的站点,这完全可行。

完整实现方案

第一步:生成搜索索引

// 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, '\\

静态站点搜索方案设计:从零实现全站搜索

2026-06-11搜索前端Fuse.js静态站点

静态站点的搜索困境

静态站点没有后端,传统搜索需要数据库 + 服务端索引。但我们的目标是不依赖任何后端服务就能实现可靠的全站搜索——对于个人博客、文档站、作品集这类只有几十到几百篇内容的站点,这完全可行。

完整实现方案

第一步:生成搜索索引

// 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, '\\

静态站点搜索方案设计:从零实现全站搜索

2026-06-11搜索前端Fuse.js静态站点

静态站点的搜索困境

静态站点没有后端,传统搜索需要数据库 + 服务端索引。但我们的目标是不依赖任何后端服务就能实现可靠的全站搜索——对于个人博客、文档站、作品集这类只有几十到几百篇内容的站点,这完全可行。

完整实现方案

第一步:生成搜索索引

// 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, '\\

静态站点搜索方案设计:从零实现全站搜索

2026-06-11搜索前端Fuse.js静态站点

静态站点的搜索困境

静态站点没有后端,传统搜索需要数据库 + 服务端索引。但我们的目标是不依赖任何后端服务就能实现可靠的全站搜索——对于个人博客、文档站、作品集这类只有几十到几百篇内容的站点,这完全可行。

完整实现方案

第一步:生成搜索索引

// 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 缓存
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 自动部署

性能考虑

未标记