my.woshicai.tech
· 前端开发

在 Astro 项目中实现全局搜索功能:从 Pagefind 到 MiniSearch 的演进

详细记录在个人博客中实现全局搜索功能的技术选型、踩坑过程和最终实现方案,包括中文分词支持、导航栏实时搜索等优化技巧

Astro MiniSearch TypeScript 前端搜索

在 Astro 项目中实现全局搜索功能:从 Pagefind 到 MiniSearch 的演进

最近为个人博客添加了搜索功能,这个过程经历了从 Pagefind 到 MiniSearch 的技术选型转变,也踩了不少坑。本文记录完整实现过程,包括中文分词、全局搜索栏等细节优化。

需求分析

在开始实现前,先明确搜索功能的需求:

  1. 全文搜索:支持标题、描述、正文内容的搜索
  2. 中文支持:必须支持中文分词(中英文混合)
  3. 实时响应:输入时即时显示结果,无需跳转页面
  4. 轻量级:不依赖外部服务,减少维护成本
  5. 隐私友好:搜索数据不上传到服务器

技术选型

方案一:Pagefind

最初考虑使用 Pagefind,这是一个流行的静态网站搜索方案。

优点

  • 专为静态网站设计
  • 支持多语言
  • 无需后端

缺点

  • 需要预先生成索引文件
  • 依赖静态 HTML 文件(与 Astro SSR 不兼容)
  • 配置复杂

踩坑记录

# Pagefind 要求构建后的 dist 目录包含 HTML 文件
# 但 Astro SSR 模式下 dist 只有服务端代码
Running Pagefind v1.4.0 (Extended)
Found 0 files matching **/*.{html}
Indexed 0 pages
Error: Pagefind was not able to build an index

由于我的项目使用 Astro + Cloudflare Pages SSR 模式,Pagefind 无法正常工作,只能放弃。

方案二:MiniSearch

最终选择 MiniSearch,一个轻量级的客户端全文搜索引擎。

优点

  • 纯客户端运行,无需后端
  • 支持自定义分词
  • 体积小(~10KB gzip)
  • API 简单易用
  • 支持 SSR 架构

缺点

  • 需要自己处理数据索引
  • 构建时需要生成搜索数据

实现过程

1. 准备搜索数据

在构建时收集可搜索的内容:

---
import { getCollection } from 'astro:content';

// 获取博客文章
const allPosts = await getCollection('blog');
const publicPosts = allPosts.filter(post => 
  post.data.visibility !== 'private' && !post.data.draft
);

// 生成搜索数据
const searchableData = [
  ...publicPosts.map(post => ({
    id: `blog-${post.slug}`,
    title: post.data.title,
    description: post.data.description,
    content: post.body?.slice(0, 500) || '',
    url: `/blog/${post.slug}`,
    category: '博客',
    tags: post.data.tags,
    date: post.data.publishDate.toISOString().split('T')[0],
  })),
  // 工具页面
  {
    id: 'tool-json',
    title: 'JSON 格式化',
    description: '格式化、验证和美化 JSON 数据',
    url: '/tools/json-formatter',
    category: '工具',
    tags: ['json', '格式化'],
  },
];
---

2. 中文分词支持

MiniSearch 默认按空格分词,这对中文不友好。需要自定义分词器:

function tokenize(text: string): string[] {
  if (!text) return [];
  const tokens = [];
  // 匹配英文单词/数字,或单个中文字符
  const regex = /[a-zA-Z0-9]+|[\u4e00-\u9fa5]/g;
  let match;
  while ((match = regex.exec(text)) !== null) {
    tokens.push(match[0].toLowerCase());
  }
  return tokens;
}

// 示例:
// "JSON 格式化工具" → ["json", "格", "式", "化", "工", "具"]

配置 MiniSearch 使用自定义分词器:

import MiniSearch from 'minisearch';

const miniSearch = new MiniSearch({
  fields: ['title', 'description', 'content', 'tags'],
  storeFields: ['title', 'description', 'url', 'category', 'tags', 'date'],
  tokenize,           // 索引时分词
  processTerm: tokenize,  // 搜索时分词
  searchOptions: {
    boost: { 
      title: 2, 
      description: 1.5, 
      tags: 1.5 
    },
    fuzzy: 0.2,      // 模糊匹配
    prefix: true,    // 前缀匹配
  },
});

miniSearch.addAll(searchableData);

3. 全局搜索栏组件

创建 GlobalSearch.astro 组件:

<div class="relative hidden md:block" id="global-search-container">
  <!-- 搜索输入框 -->
  <div class="relative">
    <input
      type="text"
      id="global-search-input"
      placeholder="搜索... (Ctrl+K)"
      class="w-64 px-4 py-2 pl-10 text-sm bg-gray-100 rounded-lg 
             focus:w-80 transition-all"
    />
    <kbd class="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-gray-400">
      Ctrl K
    </kbd>
  </div>

  <!-- 下拉结果 -->
  <div id="search-dropdown" class="absolute top-full mt-2 bg-white rounded-lg 
                                  shadow-xl border hidden z-50">
    <div id="search-results-list" class="max-h-96 overflow-y-auto">
      <!-- 结果插入这里 -->
    </div>
  </div>
</div>

4. 搜索交互逻辑

实现实时搜索和键盘快捷键:

const input = document.getElementById('global-search-input');
const dropdown = document.getElementById('search-dropdown');
let debounceTimer;

// 输入防抖
input?.addEventListener('input', (e) => {
  const query = e.target.value;
  clearTimeout(debounceTimer);
  
  debounceTimer = setTimeout(() => {
    if (query.trim()) {
      const results = miniSearch.search(query).slice(0, 5);
      showResults(results);
      dropdown.classList.remove('hidden');
    }
  }, 150);
});

// 快捷键 Ctrl+K
document.addEventListener('keydown', (e) => {
  if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
    e.preventDefault();
    input?.focus();
  }
  
  // Escape 关闭
  if (e.key === 'Escape') {
    dropdown.classList.add('hidden');
    input?.blur();
  }
});

// 点击外部关闭
document.addEventListener('click', (e) => {
  if (!container.contains(e.target)) {
    dropdown.classList.add('hidden');
  }
});

5. 移动端适配

为移动端设计全屏搜索模态框:

<!-- 移动端搜索按钮 -->
<button id="mobile-search-btn" class="md:hidden">
  <svg><!-- 搜索图标 --></svg>
</button>

<!-- 移动端搜索模态框 -->
<div id="mobile-search-modal" class="fixed inset-0 bg-black/50 z-50 hidden md:hidden">
  <div class="absolute top-0 left-0 right-0 bg-white p-4">
    <input id="mobile-search-input" placeholder="搜索文章和工具..." />
    <div id="mobile-search-results"></div>
  </div>
</div>

性能优化

1. 数据体积控制

  • 只索引公开文章(私密文章排除)
  • 正文内容只取前 500 字符
  • 结果限制最多显示 5 条(桌面)/ 10 条(移动端)

2. 防抖处理

使用 150ms 防抖,避免频繁搜索:

let debounceTimer;
input.addEventListener('input', (e) => {
  clearTimeout(debounceTimer);
  debounceTimer = setTimeout(() => search(e.target.value), 150);
});

3. 懒加载初始化

MiniSearch 在组件挂载时才初始化:

document.addEventListener('DOMContentLoaded', () => {
  initSearch();  // 延迟初始化
});

踩坑记录

1. JSON 数据传递

将搜索数据从 Astro 传递到客户端 JavaScript 时遇到 JSON 转义问题:

<!-- 错误方式:set:html 在 script 标签上不工作 -->
<script id="search-data" set:html={JSON.stringify(data)} />

<!-- 正确方式:使用 define:vars -->
<script is:inline define:vars={{ searchableData }}>
  window.__SEARCH_DATA__ = searchableData;
</script>

2. 中文单字匹配

用户可能输入单个中文字搜索,需要确保每个字都被索引:

// "格式化" 需要能被 "格" 匹配
// 分词结果:["格", "式", "化"]
// 而不是:["格式化"]

3. 搜索结果排序

调整权重使标题匹配优先于正文:

searchOptions: {
  boost: { title: 2, description: 1.5, content: 1 }
}

最终效果

实现的功能包括:

  • ✅ 全局导航栏搜索框
  • ✅ 实时下拉搜索结果
  • ✅ 中英文混合分词
  • ✅ 快捷键支持(Ctrl+K)
  • ✅ 移动端全屏搜索
  • ✅ 分类标签显示(博客/工具)
  • ✅ 结果数量统计

总结

MiniSearch 是一个非常适合静态/SSR 网站的搜索方案。相比 Pagefind:

特性PagefindMiniSearch
SSR 支持
中文分词内置自定义
体积~50KB~10KB
配置复杂度
构建依赖需要生成索引文件构建时嵌入数据

对于 Astro + Cloudflare Pages SSR 项目,MiniSearch 是更好的选择。

参考链接


博客更新: 搜索功能已上线,欢迎体验 my.woshicai.tech