自定义搜索

搜索是文档网站的关键功能。Rspress 提供灵活的搜索系统,可以完全自定义以满足您的需求。本章探讨如何实现和自定义搜索功能。

搜索概述

搜索选项

Rspress 支持多种搜索解决方案:

  • 内置搜索: 简单的客户端搜索
  • 本地搜索: 基于索引的全文搜索
  • Algolia DocSearch: 强大的云搜索
  • 自定义搜索: 实现您自己的搜索

选择搜索方案

内置搜索:

  • ✅ 无需配置
  • ✅ 免费
  • ❌ 功能有限
  • ❌ 大型站点性能较差

本地搜索:

  • ✅ 快速和强大
  • ✅ 离线工作
  • ✅ 完全控制
  • ❌ 需要构建索引

Algolia DocSearch:

  • ✅ 功能丰富
  • ✅ 优秀性能
  • ✅ 分析功能
  • ❌ 需要申请
  • ❌ 第三方依赖

内置搜索

基本配置

启用默认搜索:

// rspress.config.ts
import { defineConfig } from 'rspress/config';

export default defineConfig({
  themeConfig: {
    search: {
      // 启用搜索
      enabled: true,
      // 搜索占位符
      placeholder: '搜索文档...',
    },
  },
});

自定义搜索

自定义搜索行为:

export default defineConfig({
  themeConfig: {
    search: {
      enabled: true,
      // 搜索字段
      searchFields: ['title', 'content', 'headers'],
      // 最大结果数
      maxResults: 10,
      // 最小搜索长度
      minSearchLength: 2,
      // 搜索快捷键
      hotkey: {
        key: 'k',
        meta: true, // Cmd/Ctrl + K
      },
    },
  },
});

本地搜索

安装

安装本地搜索插件:

npm install @rspress/plugin-search

配置

配置本地搜索:

import { defineConfig } from 'rspress/config';
import pluginSearch from '@rspress/plugin-search';

export default defineConfig({
  plugins: [
    pluginSearch({
      // 搜索模式
      mode: 'local',

      // 索引选项
      indexOptions: {
        // 要索引的字段
        fields: ['title', 'content', 'headers'],

        // 权重
        fieldWeights: {
          title: 10,
          headers: 5,
          content: 1,
        },

        // 最小词长度
        minWordLength: 2,

        // 停止词
        stopWords: ['the', 'a', 'an', 'and', 'or', 'but'],
      },

      // 搜索选项
      searchOptions: {
        // 最大结果数
        maxResults: 20,

        // 模糊匹配
        fuzzy: 0.2,

        // 前缀搜索
        prefix: true,

        // 高亮匹配
        highlight: true,
      },

      // UI 选项
      uiOptions: {
        placeholder: '搜索文档...',
        emptyText: '未找到结果',
        hotkey: 'k',
      },
    }),
  ],
});

高级索引

自定义索引构建:

pluginSearch({
  mode: 'local',

  // 自定义索引函数
  indexing: async (pages) => {
    const index = [];

    for (const page of pages) {
      // 提取和处理内容
      const sections = extractSections(page.content);

      for (const section of sections) {
        index.push({
          id: `${page.id}-${section.id}`,
          title: page.title,
          header: section.header,
          content: section.content,
          url: `${page.url}#${section.anchor}`,
          weight: calculateWeight(section),
        });
      }
    }

    return index;
  },
});

function extractSections(content: string) {
  // 按标题拆分内容
  const sections = [];
  const lines = content.split('\n');
  let currentSection = null;

  for (const line of lines) {
    if (line.startsWith('#')) {
      if (currentSection) {
        sections.push(currentSection);
      }
      currentSection = {
        id: generateId(),
        header: line.replace(/^#+\s*/, ''),
        content: '',
        anchor: generateAnchor(line),
      };
    } else if (currentSection) {
      currentSection.content += line + '\n';
    }
  }

  if (currentSection) {
    sections.push(currentSection);
  }

  return sections;
}

function calculateWeight(section) {
  // 根据内容计算权重
  const headerLevel = section.header.match(/^#+/)?.[0].length || 1;
  const contentLength = section.content.length;

  return (7 - headerLevel) * 2 + Math.min(contentLength / 100, 5);
}

Algolia DocSearch

申请 DocSearch

  1. 访问 DocSearch 申请页面
  2. 提交您的文档站点
  3. 等待批准
  4. 接收 API 密钥

配置

配置 Algolia DocSearch:

import { defineConfig } from 'rspress/config';

export default defineConfig({
  themeConfig: {
    search: {
      provider: 'algolia',
      algolia: {
        // Algolia 配置
        appId: 'YOUR_APP_ID',
        apiKey: 'YOUR_SEARCH_API_KEY',
        indexName: 'YOUR_INDEX_NAME',

        // 搜索参数
        searchParameters: {
          facetFilters: ['language:zh-CN'],
          hitsPerPage: 10,
        },

        // UI 选项
        placeholder: '搜索文档...',
        translations: {
          button: {
            buttonText: '搜索',
            buttonAriaLabel: '搜索文档',
          },
          modal: {
            searchBox: {
              resetButtonTitle: '清除查询',
              resetButtonAriaLabel: '清除查询',
              cancelButtonText: '取消',
              cancelButtonAriaLabel: '取消',
            },
            footer: {
              selectText: '选择',
              navigateText: '导航',
              closeText: '关闭',
            },
          },
        },
      },
    },
  },
});

自定义爬虫

创建自定义 Algolia 爬虫配置:

{
  "index_name": "my_docs",
  "start_urls": ["https://example.com/docs/"],
  "sitemap_urls": ["https://example.com/sitemap.xml"],
  "selectors": {
    "lvl0": {
      "selector": ".sidebar .active",
      "global": true,
      "default_value": "Documentation"
    },
    "lvl1": "article h1",
    "lvl2": "article h2",
    "lvl3": "article h3",
    "lvl4": "article h4",
    "lvl5": "article h5",
    "text": "article p, article li"
  },
  "selectors_exclude": [
    ".table-of-contents",
    ".hash-link"
  ],
  "custom_settings": {
    "attributesForFaceting": ["language", "version"],
    "attributesToRetrieve": [
      "hierarchy",
      "content",
      "anchor",
      "url"
    ],
    "attributesToHighlight": [
      "hierarchy",
      "content"
    ],
    "attributesToSnippet": ["content:20"],
    "camelCaseAttributes": ["hierarchy", "content"],
    "searchableAttributes": [
      "unordered(hierarchy.lvl0)",
      "unordered(hierarchy.lvl1)",
      "unordered(hierarchy.lvl2)",
      "unordered(hierarchy.lvl3)",
      "unordered(hierarchy.lvl4)",
      "unordered(hierarchy.lvl5)",
      "content"
    ],
    "distinct": true,
    "attributeForDistinct": "url",
    "customRanking": [
      "desc(weight.pageRank)",
      "desc(weight.level)",
      "asc(weight.position)"
    ],
    "ranking": [
      "words",
      "filters",
      "typo",
      "attribute",
      "proximity",
      "exact",
      "custom"
    ],
    "highlightPreTag": "<mark>",
    "highlightPostTag": "</mark>",
    "minWordSizefor1Typo": 3,
    "minWordSizefor2Typos": 7,
    "allowTyposOnNumericTokens": false,
    "minProximity": 1,
    "ignorePlurals": true,
    "advancedSyntax": true,
    "removeStopWords": true,
    "queryLanguages": ["zh-cn"]
  }
}

自定义搜索组件

创建搜索组件

实现自定义搜索 UI:

// components/CustomSearch.tsx
import React, { useState, useEffect } from 'react';
import { useSearch } from 'rspress/runtime';

export function CustomSearch() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);
  const [isOpen, setIsOpen] = useState(false);
  const { search } = useSearch();

  useEffect(() => {
    if (query.length < 2) {
      setResults([]);
      return;
    }

    // 执行搜索
    const searchResults = search(query);
    setResults(searchResults);
  }, [query, search]);

  return (
    <div className="custom-search">
      <div className="search-input-wrapper">
        <input
          type="text"
          value={query}
          onChange={(e) => setQuery(e.target.value)}
          onFocus={() => setIsOpen(true)}
          placeholder="搜索文档..."
          className="search-input"
        />
        <kbd className="search-hotkey">⌘K</kbd>
      </div>

      {isOpen && results.length > 0 && (
        <div className="search-results">
          {results.map((result) => (
            <a
              key={result.id}
              href={result.url}
              className="search-result-item"
              onClick={() => setIsOpen(false)}
            >
              <div className="result-title">{result.title}</div>
              <div className="result-excerpt">{result.excerpt}</div>
              <div className="result-path">{result.path}</div>
            </a>
          ))}
        </div>
      )}
    </div>
  );
}

样式化搜索组件:

/* styles/custom-search.css */
.custom-search {
  position: relative;
}

.search-input-wrapper {
  position: relative;
  display: flex;
  align-items: center;
}

.search-input {
  width: 100%;
  padding: 0.5rem 2.5rem 0.5rem 1rem;
  border: 1px solid var(--rp-c-border);
  border-radius: 8px;
  background: var(--rp-c-bg);
  color: var(--rp-c-text-1);
  font-size: 0.875rem;
  transition: all 0.3s;
}

.search-input:focus {
  outline: none;
  border-color: var(--rp-c-brand);
  box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}

.search-hotkey {
  position: absolute;
  right: 0.75rem;
  padding: 0.25rem 0.5rem;
  border: 1px solid var(--rp-c-border);
  border-radius: 4px;
  font-size: 0.75rem;
  color: var(--rp-c-text-3);
  background: var(--rp-c-bg-soft);
}

.search-results {
  position: absolute;
  top: calc(100% + 0.5rem);
  left: 0;
  right: 0;
  max-height: 400px;
  overflow-y: auto;
  background: var(--rp-c-bg);
  border: 1px solid var(--rp-c-border);
  border-radius: 8px;
  box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
  z-index: 100;
}

.search-result-item {
  display: block;
  padding: 1rem;
  border-bottom: 1px solid var(--rp-c-divider);
  text-decoration: none;
  transition: background 0.2s;
}

.search-result-item:hover {
  background: var(--rp-c-bg-soft);
}

.search-result-item:last-child {
  border-bottom: none;
}

.result-title {
  font-weight: 600;
  color: var(--rp-c-text-1);
  margin-bottom: 0.25rem;
}

.result-excerpt {
  font-size: 0.875rem;
  color: var(--rp-c-text-2);
  margin-bottom: 0.25rem;
  line-height: 1.5;
}

.result-excerpt mark {
  background: rgba(102, 126, 234, 0.2);
  color: var(--rp-c-brand);
  padding: 0 2px;
  border-radius: 2px;
}

.result-path {
  font-size: 0.75rem;
  color: var(--rp-c-text-3);
}

搜索快捷键

实现键盘快捷键:

// hooks/useSearchHotkey.ts
import { useEffect } from 'react';

export function useSearchHotkey(callback: () => void) {
  useEffect(() => {
    const handleKeyDown = (event: KeyboardEvent) => {
      // Cmd/Ctrl + K
      if ((event.metaKey || event.ctrlKey) && event.key === 'k') {
        event.preventDefault();
        callback();
      }

      // /
      if (event.key === '/' && !isInputFocused()) {
        event.preventDefault();
        callback();
      }
    };

    window.addEventListener('keydown', handleKeyDown);
    return () => window.removeEventListener('keydown', handleKeyDown);
  }, [callback]);
}

function isInputFocused() {
  const activeElement = document.activeElement;
  return (
    activeElement?.tagName === 'INPUT' ||
    activeElement?.tagName === 'TEXTAREA' ||
    activeElement?.hasAttribute('contenteditable')
  );
}

使用:

export function SearchButton() {
  const [isOpen, setIsOpen] = useState(false);

  useSearchHotkey(() => setIsOpen(true));

  return (
    <button onClick={() => setIsOpen(true)}>
      搜索 <kbd>⌘K</kbd>
    </button>
  );
}

搜索优化

索引优化

优化搜索索引:

pluginSearch({
  mode: 'local',

  // 排除路径
  exclude: [
    '/admin/**',
    '/internal/**',
    '**/_*.md', // 排除私有文件
  ],

  // 包含路径
  include: [
    '/docs/**',
    '/guide/**',
    '/api/**',
  ],

  // 自定义分词
  tokenizer: (text) => {
    // 中文分词
    return text
      .split(/[\s\u3000]+/)
      .filter(word => word.length >= 2);
  },

  // 自定义评分
  scoring: {
    title: 10,
    heading: 5,
    content: 1,
    // 最新内容提升
    recency: (date) => {
      const days = (Date.now() - date.getTime()) / (1000 * 60 * 60 * 24);
      return Math.max(0, 1 - days / 365);
    },
  },
});

搜索性能

提高搜索性能:

// 使用 Web Worker 进行搜索
// workers/search.worker.ts
import { buildIndex, search } from './search-engine';

let searchIndex;

self.addEventListener('message', async (event) => {
  const { type, data } = event.data;

  switch (type) {
    case 'build-index':
      searchIndex = await buildIndex(data);
      self.postMessage({ type: 'index-ready' });
      break;

    case 'search':
      const results = search(searchIndex, data.query);
      self.postMessage({ type: 'results', results });
      break;
  }
});

在组件中使用:

// components/WorkerSearch.tsx
import { useEffect, useState } from 'react';

export function WorkerSearch() {
  const [worker, setWorker] = useState<Worker>();
  const [results, setResults] = useState([]);

  useEffect(() => {
    const searchWorker = new Worker('/workers/search.worker.js');

    searchWorker.onmessage = (event) => {
      if (event.data.type === 'results') {
        setResults(event.data.results);
      }
    };

    setWorker(searchWorker);

    return () => searchWorker.terminate();
  }, []);

  const handleSearch = (query: string) => {
    worker?.postMessage({
      type: 'search',
      data: { query },
    });
  };

  return (
    <input
      onChange={(e) => handleSearch(e.target.value)}
      placeholder="搜索..."
    />
  );
}

缓存策略

实现搜索缓存:

// utils/search-cache.ts
class SearchCache {
  private cache = new Map<string, any>();
  private maxSize = 100;

  get(query: string) {
    return this.cache.get(query);
  }

  set(query: string, results: any) {
    // LRU 缓存
    if (this.cache.size >= this.maxSize) {
      const firstKey = this.cache.keys().next().value;
      this.cache.delete(firstKey);
    }
    this.cache.set(query, results);
  }

  clear() {
    this.cache.clear();
  }
}

export const searchCache = new SearchCache();

使用:

async function performSearch(query: string) {
  // 检查缓存
  const cached = searchCache.get(query);
  if (cached) {
    return cached;
  }

  // 执行搜索
  const results = await search(query);

  // 缓存结果
  searchCache.set(query, results);

  return results;
}

搜索分析

跟踪搜索

跟踪搜索查询:

// utils/search-analytics.ts
export function trackSearch(query: string, resultsCount: number) {
  // Google Analytics
  if (window.gtag) {
    window.gtag('event', 'search', {
      search_term: query,
      results_count: resultsCount,
    });
  }

  // 自定义分析
  fetch('/api/analytics/search', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      query,
      resultsCount,
      timestamp: Date.now(),
    }),
  });
}

使用:

function SearchComponent() {
  const handleSearch = async (query: string) => {
    const results = await search(query);
    trackSearch(query, results.length);
    setResults(results);
  };

  // ...
}

搜索洞察

收集搜索洞察:

// 跟踪流行查询
export class SearchInsights {
  private queries = new Map<string, number>();

  trackQuery(query: string) {
    const count = this.queries.get(query) || 0;
    this.queries.set(query, count + 1);
  }

  getPopularQueries(limit = 10) {
    return Array.from(this.queries.entries())
      .sort((a, b) => b[1] - a[1])
      .slice(0, limit)
      .map(([query, count]) => ({ query, count }));
  }

  trackNoResults(query: string) {
    // 记录没有结果的查询
    console.log('No results for:', query);
  }
}

最佳实践

搜索 UX

  1. 即时反馈
function SearchInput() {
  const [isSearching, setIsSearching] = useState(false);

  return (
    <div>
      <input {...props} />
      {isSearching && <Spinner />}
    </div>
  );
}
  1. 空状态
{results.length === 0 && query && (
  <div className="no-results">
    未找到 "{query}" 的结果
    <button onClick={clearSearch}>清除</button>
  </div>
)}
  1. 键盘导航
const handleKeyDown = (e: KeyboardEvent) => {
  if (e.key === 'ArrowDown') {
    selectNext();
  } else if (e.key === 'ArrowUp') {
    selectPrevious();
  } else if (e.key === 'Enter') {
    openSelected();
  }
};

索引策略

  1. 增量索引: 仅更新已更改的页面
  2. 懒加载: 按需加载索引部分
  3. 压缩: 压缩索引数据
  4. 版本化: 为不同版本维护单独的索引

性能技巧

  1. 防抖: 延迟搜索执行
const debouncedSearch = debounce(search, 300);
  1. 节流: 限制搜索频率
const throttledSearch = throttle(search, 500);
  1. 虚拟滚动: 大型结果列表
  2. 代码分割: 懒加载搜索功能

故障排除

搜索不工作

问题: 搜索返回空结果

检查:

  1. 索引已构建
  2. 搜索配置正确
  3. 内容已索引
  4. 查询格式正确

慢搜索

问题: 搜索响应慢

优化:

  1. 使用 Web Worker
  2. 实现缓存
  3. 优化索引大小
  4. 减少搜索字段

内存问题

问题: 搜索索引太大

解决方案:

  1. 压缩索引
  2. 懒加载
  3. 排除不必要的内容
  4. 使用服务器端搜索

下一步


::: tip 搜索提示

  • 提供清晰的搜索占位符
  • 显示最近的搜索
  • 高亮匹配的文本
  • 提供搜索建议
  • 支持键盘导航 :::

::: warning 性能

  • 监控搜索性能
  • 实现搜索缓存
  • 使用 Web Worker
  • 优化索引大小
  • 防抖用户输入 :::

::: info 资源