我给网站加了个全站搜索:3 个组件、4 个 API、5 个快捷键

文章多了找不到?工具混在一起不好找?全站搜索是个人网站的标配 —— 自己做一个比想象的简单。

为什么自己做

13 篇文章 + 9 个工具,手动找东西开始变慢。搜过的方案:

方案 问题
Algolia / MeiliSearch 免费额度小,超出要钱
PostgreSQL 全文检索 小项目过度设计,要装插件
Django + SQLite LIKE 够用、零依赖、2 分钟搞定

作为数通工程师,我习惯:最简方案优先。SQLite 的 LIKE '%keyword%' 完全够个人博客用。

整体架构

┌─────────────────────────────────────────────────┐
│            导航栏 🔍 按钮 / `/` 快捷键          │
└────────────────────┬────────────────────────────┘
                     │
                     ▼
┌─────────────────────────────────────────────────┐
│          搜索面板(fixed 顶部下拉)             │
│  ┌───────────────────────────────────────┐     │
│  │ [搜索框] 200ms 防抖 → API 请求      │     │
│  └───────────────────────────────────────┘     │
│  ┌───────────────────────────────────────┐     │
│  │ 实时结果(带高亮)                   │     │
│  │ • 工具 + 文章                        │     │
│  └───────────────────────────────────────┘     │
└─────────────────────────────────────────────────┘

用户可:
- 点击结果 → 跳转
- ↑↓ Enter → 键盘选中
- Esc → 关闭面板
- Enter 无选中 → 进入完整搜索结果页 `/search/?q=...`

1. Django 后端:3 个视图

def search(request):
    query = request.GET.get('q', '').strip()
    articles = []
    matched_tools = []

    if query:
        # 文章:标题、摘要、Markdown 内容、标签、分类
        article_qs = Article.objects.filter(status='published')\
            .select_related('category').prefetch_related('tags')

        # 多关键词 AND 搜索(空格分隔)
        keywords = [k for k in re.split(r'\s+', query) if k]
        for kw in keywords:
            article_qs = article_qs.filter(
                Q(title__icontains=kw) |
                Q(excerpt__icontains=kw) |
                Q(markdown_content__icontains=kw) |
                Q(tags__name__icontains=kw) |
                Q(category__name__icontains=kw)
            )

        articles = article_qs.distinct()\
            .order_by('-is_top', '-published_at')[:30]

        # 工具搜索(内存里匹配)
        all_tools = get_all_tools_full()
        matched_tools = [
            t for t in all_tools
            if any(kw.lower() in t['name'].lower() or
                   kw.lower() in t['desc'].lower()
                   for kw in keywords)
        ]

视图 2 & 3:JSON API(导航栏实时下拉用)

def api_search(request):
    """文章 API:返回前 5 条"""
    query = request.GET.get('q', '').strip()
    if not query or len(query) < 2:
        return JsonResponse({'results': [], 'count': 0})

    article_qs = Article.objects.filter(status='published')\
        .select_related('category')

    keywords = [k for k in re.split(r'\s+', query) if k]
    for kw in keywords:
        article_qs = article_qs.filter(
            Q(title__icontains=kw) | Q(excerpt__icontains=kw)
        )

    # ⚠️ 注意:先 filter 后 slice(不能反过来!)
    articles = article_qs.distinct()[:5]

    results = [{
        'title': a.title,
        'url': a.get_absolute_url(),
        'excerpt': a.excerpt[:80] if a.excerpt else '',
        'category': a.category.name if a.category else '',
        'type': 'article',
    } for a in articles]

    return JsonResponse({'results': results, 'count': len(results)})


def api_search_tools(request):
    """工具 API:实时返回"""
    query = request.GET.get('q', '').strip().lower()
    if not query:
        return JsonResponse({'results': []})

    all_tools = get_all_tools_full()
    matched = [{
        'name': t['name'],
        'slug': t['slug'],
        'desc': t['desc'],
        'icon': t['icon'],
        'category': t['category'],
    } for t in all_tools
        if query in t['name'].lower() or
           query in t['desc'].lower() or
           query in t['slug']]

    return JsonResponse({'results': matched})

🐛 我踩过的坑

调试时第一版报:TypeError: Cannot filter a query once a slice has been taken

# ❌ 错误写法
article_qs = Article.objects.filter(...).select_related(...)[:5]
article_qs = article_qs.filter(...)  # ← 这里报错!

# ✅ 正确写法
article_qs = Article.objects.filter(...).select_related(...)
article_qs = article_qs.filter(...)
articles = article_qs.distinct()[:5]  # 在最后 slice

Django ORM 不允许 slice 后再 filter —— 必须先 filter,再 slice

2. 前端:实时下拉 + 键盘导航

search.js 核心逻辑

// 防抖:输入停止 200ms 后才发请求
let debounceTimer;
input.addEventListener('input', () => {
    clearTimeout(debounceTimer);
    debounceTimer = setTimeout(doSearch, 200);
});

function doSearch() {
    const q = input.value.trim();
    if (q.length < 2) {
        resultsBox.classList.remove('active');
        return;
    }

    // 并行请求文章 + 工具
    Promise.all([
        fetch(`/api/search/?q=${encodeURIComponent(q)}`).then(r => r.json()),
        fetch(`/api/search/tools/?q=${encodeURIComponent(q)}`).then(r => r.json()),
    ]).then(([articles, tools]) => {
        currentResults = [];

        // 工具在前(短小,结果明确)
        tools.results.forEach(t => {
            currentResults.push({
                type: 'tool',
                title: t.name,
                meta: t.desc,
                icon: t.icon,
                url: `/tools/${t.slug}/`,
            });
        });

        // 文章
        articles.results.forEach(a => {
            currentResults.push({
                type: 'article',
                title: a.title,
                meta: a.category || '文章',
                icon: 'bi-newspaper',
                url: a.url,
            });
        });

        renderResults();
    });
}

键盘导航

input.addEventListener('keydown', (e) => {
    if (e.key === 'Escape') closePanel();
    else if (e.key === 'ArrowDown') selectNext();
    else if (e.key === 'ArrowUp') selectPrev();
    else if (e.key === 'Enter') {
        e.preventDefault();
        openSelected();
    }
});

// 全局快捷键:按 / 聚焦搜索框
document.addEventListener('keydown', (e) => {
    if (e.key === '/' && !['INPUT', 'TEXTAREA'].includes(e.target.tagName)) {
        e.preventDefault();
        openPanel();
    }
});

关键词高亮

function highlight(text, q) {
    if (!q) return text;
    const re = new RegExp(`(${q.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi');
    return text.replace(re, '<mark>$1</mark>');
}

replace 的参数需要转义正则特殊字符 —— 否则用户搜 (192.168) 会爆炸。

3. UI 设计:CSS 动画

搜索面板用 transform: translateY(-100%) 隐藏,.opentranslateY(0)

.search-panel {
    position: fixed;
    top: 0;
    left: 0;
    right: 0;
    background: var(--bg-card);
    box-shadow: var(--shadow-hover);
    transform: translateY(-100%);
    transition: transform 0.3s ease;
}

.search-panel.open {
    transform: translateY(0);
}

display:none 流畅 —— GPU 加速,60 fps 不掉帧。

4. 完整快捷键清单

快捷键 作用 范围
/ 打开搜索 全局(输入框内除外)
Esc 关闭搜索 全局
上一项 搜索面板内
下一项 搜索面板内
Enter 打开选中项 / 跳转结果页 搜索面板内

5 个快捷键 —— 数通工程师标配 ⌨️

5. 踩坑经验总结

坑 1:Django ORM slice + filter

见上文。

坑 2:搜索框 autofocus 在 firefox 不灵

<!-- ❌ Firefox 第一次访问不聚焦 -->
<input autofocus>

<!-- ✅ 兼容写法 -->
<input autofocus>
<!-- JS 加 setTimeout 兜底 -->
input.focus();
input.select();

坑 3:截图脚本截图渲染前 CSS 未生效

# ❌ 设置完主题立刻截图(CSS 还没应用)
set_theme(driver, 'dark')
driver.save_screenshot(...)

# ✅ 刷新页面让 JS 应用主题
driver.refresh()
time.sleep(2)
driver.save_screenshot(...)

坑 4:搜索性能

LIKE '%keyword%' 不走索引,全表扫描。

对个人博客(< 100 篇文章)完全够用

如果以后文章破千: 1. 加 SQLite FTS5 全文索引(10 分钟搞定) 2. 或迁 PostgreSQL + GIN 索引

现在不需要

6. 测试效果

查询 命中 性能
VLAN 1 篇文章 + 0 工具 < 50ms
MAC 2 篇文章 + 2 工具 < 50ms
OSPF 配置 AND 搜索,2 篇文章 < 80ms
xxxnotexist 空结果 + 提示 < 30ms

13 篇 + 9 个工具的规模,单次查询 < 100ms,完全够用。

在线使用

👉 试试全站搜索

/ 或点导航栏 🔍 图标即可。


📚 后续优化方向

如果以后想做更"专业"的搜索:

  • 🔍 SQLite FTS5 全文索引(替代 LIKE)
  • 🌟 结果评分(标题命中权重 > 摘要 > 内容)
  • 📝 搜索历史(localStorage)
  • 🤖 AI 语义搜索(embedding + 向量数据库)
  • 🔗 相关文章推荐(点击搜索结果后侧栏显示)

但对 13 篇文章 + 9 个工具,当前方案已足够


📱 喜欢这类技术拆解?扫码右侧关注「网英的日常」,第一时间收到新功能上线通知!