自定义搜索
搜索是文档网站的关键功能。Rspress 提供灵活的搜索系统,可以完全自定义以满足您的需求。本章探讨如何实现和自定义搜索功能。
搜索概述
搜索选项
Rspress 支持多种搜索解决方案:
- 内置搜索: 简单的客户端搜索
- 本地搜索: 基于索引的全文搜索
- Algolia DocSearch: 强大的云搜索
- 自定义搜索: 实现您自己的搜索
选择搜索方案
内置搜索:
- ✅ 无需配置
- ✅ 免费
- ❌ 功能有限
- ❌ 大型站点性能较差
本地搜索:
- ✅ 快速和强大
- ✅ 离线工作
- ✅ 完全控制
- ❌ 需要构建索引
Algolia DocSearch:
- ✅ 功能丰富
- ✅ 优秀性能
- ✅ 分析功能
- ❌ 需要申请
- ❌ 第三方依赖
内置搜索
基本配置
启用默认搜索:
typescript
// rspress.config.ts
import { defineConfig } from 'rspress/config';
export default defineConfig({
themeConfig: {
search: {
// 启用搜索
enabled: true,
// 搜索占位符
placeholder: '搜索文档...',
},
},
});自定义搜索
自定义搜索行为:
typescript
export default defineConfig({
themeConfig: {
search: {
enabled: true,
// 搜索字段
searchFields: ['title', 'content', 'headers'],
// 最大结果数
maxResults: 10,
// 最小搜索长度
minSearchLength: 2,
// 搜索快捷键
hotkey: {
key: 'k',
meta: true, // Cmd/Ctrl + K
},
},
},
});本地搜索
安装
安装本地搜索插件:
bash
npm install @rspress/plugin-search配置
配置本地搜索:
typescript
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',
},
}),
],
});高级索引
自定义索引构建:
typescript
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
- 访问 DocSearch 申请页面
- 提交您的文档站点
- 等待批准
- 接收 API 密钥
配置
配置 Algolia DocSearch:
typescript
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 爬虫配置:
json
{
"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:
tsx
// 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>
);
}样式化搜索组件:
css
/* 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);
}搜索快捷键
实现键盘快捷键:
tsx
// 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')
);
}使用:
tsx
export function SearchButton() {
const [isOpen, setIsOpen] = useState(false);
useSearchHotkey(() => setIsOpen(true));
return (
<button onClick={() => setIsOpen(true)}>
搜索 <kbd>⌘K</kbd>
</button>
);
}搜索优化
索引优化
优化搜索索引:
typescript
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);
},
},
});搜索性能
提高搜索性能:
typescript
// 使用 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;
}
});在组件中使用:
tsx
// 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="搜索..."
/>
);
}缓存策略
实现搜索缓存:
typescript
// 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();使用:
typescript
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;
}搜索分析
跟踪搜索
跟踪搜索查询:
typescript
// 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(),
}),
});
}使用:
tsx
function SearchComponent() {
const handleSearch = async (query: string) => {
const results = await search(query);
trackSearch(query, results.length);
setResults(results);
};
// ...
}搜索洞察
收集搜索洞察:
typescript
// 跟踪流行查询
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
- 即时反馈
tsx
function SearchInput() {
const [isSearching, setIsSearching] = useState(false);
return (
<div>
<input {...props} />
{isSearching && <Spinner />}
</div>
);
}- 空状态
tsx
{results.length === 0 && query && (
<div className="no-results">
未找到 "{query}" 的结果
<button onClick={clearSearch}>清除</button>
</div>
)}- 键盘导航
tsx
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'ArrowDown') {
selectNext();
} else if (e.key === 'ArrowUp') {
selectPrevious();
} else if (e.key === 'Enter') {
openSelected();
}
};索引策略
- 增量索引: 仅更新已更改的页面
- 懒加载: 按需加载索引部分
- 压缩: 压缩索引数据
- 版本化: 为不同版本维护单独的索引
性能技巧
- 防抖: 延迟搜索执行
typescript
const debouncedSearch = debounce(search, 300);- 节流: 限制搜索频率
typescript
const throttledSearch = throttle(search, 500);- 虚拟滚动: 大型结果列表
- 代码分割: 懒加载搜索功能
故障排除
搜索不工作
问题: 搜索返回空结果
检查:
- 索引已构建
- 搜索配置正确
- 内容已索引
- 查询格式正确
慢搜索
问题: 搜索响应慢
优化:
- 使用 Web Worker
- 实现缓存
- 优化索引大小
- 减少搜索字段
内存问题
问题: 搜索索引太大
解决方案:
- 压缩索引
- 懒加载
- 排除不必要的内容
- 使用服务器端搜索
下一步
搜索提示
- 提供清晰的搜索占位符
- 显示最近的搜索
- 高亮匹配的文本
- 提供搜索建议
- 支持键盘导航
性能
- 监控搜索性能
- 实现搜索缓存
- 使用 Web Worker
- 优化索引大小
- 防抖用户输入