约 6,900 字
23 分钟
React Native 搜索联想与精准匹配实现
-
-
无标签
摘要
完整实现搜索联想(Autocomplete)与精准匹配逻辑,含 Trie 前缀树、Levenshtein 编辑距离、拼音匹配、防抖缓存等
React Native 实现搜索联想与精准匹配
概述
搜索功能是几乎所有内容类应用的核心入口,其中搜索联想(Autocomplete / Suggest)和精准匹配(Exact Match)直接影响用户搜索体验。一个好的搜索系统应该:
输入时实时显示联想建议
优先展示精准匹配结果
支持拼音、模糊匹配容错
缓存热门搜索,减少重复请求
本指南从零构建完整的搜索联想 + 精准匹配系统。
一、核心概念
搜索联想流程
纯文本
用户输入 "深"
│
▼
联想层 ──→ 本地缓存索引 ──→ ["深圳", "深度", "深度学习"]
│ ──→ ["深蓝", "深入浅出"]
│
▼
精准匹配 ──→ 完全匹配 "深" ──→ 标题或标签含"深"的结果
│
▼
模糊匹配 ──→ 拼音匹配 ──→ "shen" → "深"
──→ 前缀匹配 ──→ "深度" → "深度学习"
──→ 编辑距离 ──→ "深成" → "深圳" (levenshtein ≤ 2)匹配优先级
优先级 | 匹配类型 | 示例(输入"深度学习") |
|---|---|---|
⭐ 最高 | 精准匹配 | "深度学习" |
⭐⭐⭐ | 前缀匹配 | "深度" |
⭐⭐⭐⭐ | 关键字匹配 | "学习" / "深度" 出现在任意位置 |
⭐⭐⭐⭐⭐ | 拼音匹配 | "shen du xue xi" |
⭐⭐⭐⭐⭐⭐ | 模糊匹配(编辑距离) | "深成学习" |
二、搜索索引构建
本地搜索索引
TypeScript
// services/search-index.ts
export interface SearchableItem {
id: string | number;
title: string;
subtitle?: string;
tags?: string[];
keywords?: string[];
category?: string;
popularity?: number; // 热度权重(用于排序)
createdAt?: string;
}
// 拼音映射(简化版)
const PINYIN_MAP: Record<string, string[]> = {
'深': ['shen', 'sen'],
'度': ['du', 'duo'],
'学': ['xue'],
'习': ['xi'],
'人': ['ren'],
'工': ['gong'],
'智': ['zhi'],
'能': ['neng'],
'大': ['da', 'dai'],
'数': ['shu', 'shuo'],
'据': ['ju'],
'云': ['yun'],
'计': ['ji'],
'算': ['suan'],
'机': ['ji'],
'器': ['qi'],
};
// 搜索索引节点
interface SearchIndexNode {
children: Map<string, SearchIndexNode>;
items: Set<string | number>; // 在此前缀下的 item id
isEnd: boolean;
}
export class SearchIndex {
private trie: SearchIndexNode = {
children: new Map(),
items: new Set(),
isEnd: false,
};
private items: Map<string | number, SearchableItem> = new Map();
private pinyinIndex: Map<string, Set<string | number>> = new Map();
// 构建索引
buildIndex(items: SearchableItem[]): void {
this.trie = { children: new Map(), items: new Set(), isEnd: false };
this.items.clear();
this.pinyinIndex.clear();
for (const item of items) {
this.items.set(item.id, item);
this.addToIndex(item);
}
}
// 添加单个项目到索引
private addToIndex(item: SearchableItem): void {
const text = `${item.title} ${item.subtitle || ''} ${(item.tags || []).join(' ')} ${(item.keywords || []).join(' ')}`;
const normalized = this.normalize(text);
const words = normalized.split(/\s+/).filter(Boolean);
// 1. 添加到 Trie(前缀树)
for (const word of words) {
this.addToTrie(word, item.id);
}
// 2. 添加拼音索引
for (const keyword of item.keywords || []) {
const pinyins = this.getPinyin(keyword);
for (const py of pinyins) {
if (!this.pinyinIndex.has(py)) {
this.pinyinIndex.set(py, new Set());
}
this.pinyinIndex.get(py)!.add(item.id);
}
}
// 3. 为每个字添加拼音前缀
for (const char of item.title) {
const pinyins = this.getPinyin(char);
for (const py of pinyins) {
if (!this.pinyinIndex.has(py)) {
this.pinyinIndex.set(py, new Set());
}
this.pinyinIndex.get(py)!.add(item.id);
}
}
}
// 添加到前缀树
private addToTrie(word: string, itemId: string | number): void {
let node = this.trie;
for (const char of word) {
if (!node.children.has(char)) {
node.children.set(char, {
children: new Map(),
items: new Set(),
isEnd: false,
});
}
node = node.children.get(char)!;
node.items.add(itemId);
}
node.isEnd = true;
}
// 搜索联想建议(前缀匹配)
suggest(prefix: string, limit: number = 10): SearchableItem[] {
const normalized = this.normalize(prefix);
if (!normalized) return [];
const results = new Map<string | number, SearchableItem>();
// 1. Trie 前缀匹配
const trieItems = this.searchTrie(normalized);
for (const id of trieItems) {
const item = this.items.get(id);
if (item && !results.has(id)) {
results.set(id, item);
}
}
// 2. 拼音前缀匹配
const pinyinItems = this.searchPinyin(normalized);
for (const id of pinyinItems) {
const item = this.items.get(id);
if (item && !results.has(id)) {
results.set(id, {
...item,
// 标记为拼音匹配,降低权重
});
}
}
// 3. 排序:精准匹配 > 前缀匹配 > 拼音匹配 > 热度
return Array.from(results.values())
.sort((a, b) => this.getMatchScore(normalized, a, b))
.slice(0, limit);
}
// Trie 搜索
private searchTrie(prefix: string): Set<string | number> {
let node = this.trie;
for (const char of prefix) {
if (!node.children.has(char)) {
return new Set(); // 无匹配
}
node = node.children.get(char)!;
}
return node.items; // 返回此前缀下的所有 item
}
// 拼音搜索
private searchPinyin(prefix: string): Set<string | number> {
const results = new Set<string | number>();
for (const [py, ids] of this.pinyinIndex) {
if (py.startsWith(prefix)) {
for (const id of ids) {
results.add(id);
}
}
}
return results;
}
// 排序打分
private getMatchScore(
query: string,
a: SearchableItem,
b: SearchableItem
): number {
let scoreA = 0;
let scoreB = 0;
const qLower = query.toLowerCase();
// 精准匹配加分(标题完全等于搜索词)
if (a.title.toLowerCase() === qLower) scoreA -= 1000;
if (b.title.toLowerCase() === qLower) scoreB -= 1000;
// 开头匹配加分
if (a.title.toLowerCase().startsWith(qLower)) scoreA -= 500;
if (b.title.toLowerCase().startsWith(qLower)) scoreB -= 500;
// 包含匹配加分
if (a.title.toLowerCase().includes(qLower)) scoreA -= 100;
if (b.title.toLowerCase().includes(qLower)) scoreB -= 100;
// 关键字匹配加分
if (a.keywords?.some(k => k.toLowerCase().includes(qLower))) scoreA -= 50;
if (b.keywords?.some(k => k.toLowerCase().includes(qLower))) scoreB -= 50;
// 热度排序(优先显示热门结果)
scoreA += 1000 - (a.popularity || 0);
scoreB += 1000 - (b.popularity || 0);
// 时间排序(新的优先)
if (a.createdAt && b.createdAt) {
scoreA += new Date(a.createdAt).getTime() > new Date(b.createdAt).getTime() ? -1 : 1;
}
return scoreA - scoreB;
}
// 精准匹配搜索(完全匹配)
exactMatch(query: string): SearchableItem[] {
const normalized = this.normalize(query).toLowerCase();
const results: SearchableItem[] = [];
for (const [, item] of this.items) {
const title = item.title.toLowerCase();
const tags = (item.tags || []).map(t => t.toLowerCase());
const keywords = (item.keywords || []).map(k => k.toLowerCase());
const isExact =
title === normalized ||
tags.some(t => t === normalized) ||
keywords.some(k => k === normalized);
if (isExact) {
results.push(item);
}
}
return results.sort((a, b) => (b.popularity || 0) - (a.popularity || 0));
}
// 全文本搜索
search(query: string, limit: number = 20): SearchableItem[] {
const normalized = this.normalize(query);
if (!normalized) return [];
// 1. 精准匹配
const exact = this.exactMatch(query);
// 2. 联想匹配
const suggested = this.suggest(query, limit);
// 3. 合并去重,精准匹配优先
const seen = new Set<string | number>();
const merged: SearchableItem[] = [];
const addIfNotSeen = (item: SearchableItem) => {
if (!seen.has(item.id)) {
seen.add(item.id);
merged.push(item);
}
};
// 精准匹配排在最前面
exact.forEach(addIfNotSeen);
// 然后是联想结果
suggested.forEach(addIfNotSeen);
return merged.slice(0, limit);
}
// 文本标准化(去标点、转小写)
private normalize(text: string): string {
return text
.replace(/[^\w\u4e00-\u9fa5\s]/g, '') // 去标点
.replace(/\s+/g, ' ')
.trim()
.toLowerCase();
}
// 获取汉字拼音(简化)
private getPinyin(char: string): string[] {
if (PINYIN_MAP[char]) {
return PINYIN_MAP[char];
}
// 非中文:返回自身
if (/[\w]/.test(char)) {
return [char.toLowerCase()];
}
return [];
}
}
export const searchIndex = new SearchIndex();三、搜索联想 Hook
TypeScript
// hooks/useSearchSuggest.ts
import { useState, useEffect, useCallback, useRef } from 'react';
import { searchIndex, SearchableItem } from '../services/search-index';
interface UseSearchSuggestOptions {
debounceMs?: number; // 防抖时间
maxSuggestions?: number; // 最多联想数
minQueryLength?: number; // 最小触发长度
cacheResults?: boolean; // 缓存结果
}
export function useSearchSuggest(
options: UseSearchSuggestOptions = {}
) {
const {
debounceMs = 300, // 300ms 防抖
maxSuggestions = 10,
minQueryLength = 1,
cacheResults = true,
} = options;
const [query, setQuery] = useState('');
const [suggestions, setSuggestions] = useState<SearchableItem[]>([]);
const [exactResults, setExactResults] = useState<SearchableItem[]>([]);
const [isSearching, setIsSearching] = useState(false);
const [hasSearched, setHasSearched] = useState(false);
const debounceTimer = useRef<ReturnType<typeof setTimeout>>();
const cacheRef = useRef<Map<string, { suggestions: SearchableItem[]; exact: SearchableItem[] }>>(new Map());
// 搜索核心逻辑
const performSearch = useCallback(
(searchQuery: string) => {
if (!searchQuery || searchQuery.length < minQueryLength) {
setSuggestions([]);
setExactResults([]);
setHasSearched(false);
return;
}
setIsSearching(true);
// 检查缓存
if (cacheResults && cacheRef.current.has(searchQuery)) {
const cached = cacheRef.current.get(searchQuery)!;
setSuggestions(cached.suggestions);
setExactResults(cached.exact);
setIsSearching(false);
setHasSearched(true);
return;
}
// 执行搜索
const exact = searchIndex.exactMatch(searchQuery);
const suggest = searchIndex.suggest(searchQuery, maxSuggestions);
// 缓存结果
if (cacheResults) {
cacheRef.current.set(searchQuery, {
suggestions: suggest,
exact,
});
// 限制缓存大小
if (cacheRef.current.size > 100) {
const firstKey = cacheRef.current.keys().next().value;
if (firstKey) cacheRef.current.delete(firstKey);
}
}
setSuggestions(suggest);
setExactResults(exact);
setIsSearching(false);
setHasSearched(true);
},
[minQueryLength, maxSuggestions, cacheResults]
);
// 输入变化时触发搜索(带防抖)
const handleInputChange = useCallback(
(text: string) => {
setQuery(text);
if (debounceTimer.current) {
clearTimeout(debounceTimer.current);
}
if (text.length < minQueryLength) {
setSuggestions([]);
setExactResults([]);
setHasSearched(false);
return;
}
debounceTimer.current = setTimeout(() => {
performSearch(text);
}, debounceMs);
},
[debounceMs, minQueryLength, performSearch]
);
// 清除搜索
const clearSearch = useCallback(() => {
setQuery('');
setSuggestions([]);
setExactResults([]);
setHasSearched(false);
setIsSearching(false);
if (debounceTimer.current) {
clearTimeout(debounceTimer.current);
}
}, []);
// 清理防抖
useEffect(() => {
return () => {
if (debounceTimer.current) {
clearTimeout(debounceTimer.current);
}
};
}, []);
return {
query,
suggestions,
exactResults,
isSearching,
hasSearched,
handleInputChange,
setQuery,
clearSearch,
};
}四、搜索联想 UI 组件
TSX
// components/SearchBar.tsx
import React, { useState, useCallback, useRef, useEffect } from 'react';
import {
View,
Text,
TextInput,
FlatList,
TouchableOpacity,
StyleSheet,
Animated,
Keyboard,
} from 'react-native';
import { useSearchSuggest } from '../hooks/useSearchSuggest';
import { SearchableItem } from '../services/search-index';
interface SearchBarProps {
onSearch: (query: string) => void;
onSelectItem: (item: SearchableItem) => void;
placeholder?: string;
}
export function SearchBar({
onSearch,
onSelectItem,
placeholder = '搜索...',
}: SearchBarProps) {
const {
query,
suggestions,
exactResults,
isSearching,
handleInputChange,
clearSearch,
} = useSearchSuggest({ debounceMs: 200 });
const [isFocused, setIsFocused] = useState(false);
const inputRef = useRef<TextInput>(null);
const dropdownAnim = useRef(new Animated.Value(0)).current;
// 展开/收起联想面板动画
useEffect(() => {
Animated.timing(dropdownAnim, {
toValue: isFocused && query.length > 0 ? 1 : 0,
duration: 200,
useNativeDriver: false,
}).start();
}, [isFocused, query, dropdownAnim]);
// 提交搜索
const handleSubmit = useCallback(() => {
Keyboard.dismiss();
if (query.trim()) {
onSearch(query.trim());
}
}, [query, onSearch]);
// 高亮搜索关键字
const highlightText = (text: string, keyword: string) => {
if (!keyword) return <Text>{text}</Text>;
const parts = text.split(new RegExp(`(${escapeRegExp(keyword)})`, 'gi'));
return (
<Text>
{parts.map((part, i) =>
part.toLowerCase() === keyword.toLowerCase() ? (
<Text key={i} style={styles.highlight}>
{part}
</Text>
) : (
<Text key={i}>{part}</Text>
)
)}
</Text>
);
};
// 联想结果项
const renderSuggestionItem = ({ item }: { item: SearchableItem }) => (
<TouchableOpacity
style={styles.suggestionItem}
onPress={() => {
onSelectItem(item);
handleInputChange(item.title);
Keyboard.dismiss();
}}
>
<View style={styles.suggestionContent}>
{/* 匹配图标 */}
{item.title.toLowerCase() === query.toLowerCase() ? (
<Text style={styles.matchIcon}>🎯</Text>
) : (
<Text style={styles.suggestIcon}>🔍</Text>
)}
<View style={styles.suggestionText}>
{/* 标题(带高亮) */}
<Text style={styles.suggestionTitle} numberOfLines={1}>
{highlightText(item.title, query)}
</Text>
{/* 副标题 */}
{item.subtitle && (
<Text style={styles.suggestionSubtitle} numberOfLines={1}>
{item.subtitle}
</Text>
)}
{/* 分类 / 标签 */}
<View style={styles.tagRow}>
{item.category && (
<Text style={styles.categoryTag}>{item.category}</Text>
)}
{(item.tags || [])
.filter(t => t.toLowerCase().includes(query.toLowerCase()))
.slice(0, 2)
.map((tag) => (
<Text key={tag} style={styles.matchTag}>
{highlightText(tag, query)}
</Text>
))}
</View>
</View>
{/* 热度 */}
<Text style={styles.popularity}>
{item.popularity ? `🔥 ${item.popularity}` : ''}
</Text>
</View>
</TouchableOpacity>
);
// 联想面板最大高度
const dropdownHeight = dropdownAnim.interpolate({
inputRange: [0, 1],
outputRange: [0, 400],
});
const showDropdown = isFocused && query.length > 0;
return (
<View style={styles.container}>
{/* 搜索输入框 */}
<View style={styles.searchBarContainer}>
<View style={styles.searchIcon}>
<Text>🔍</Text>
</View>
<TextInput
ref={inputRef}
style={styles.input}
value={query}
onChangeText={handleInputChange}
placeholder={placeholder}
placeholderTextColor="#9CA3AF"
returnKeyType="search"
onSubmitEditing={handleSubmit}
onFocus={() => setIsFocused(true)}
onBlur={() => {
// 延迟关闭,允许点击联想项
setTimeout(() => setIsFocused(false), 200);
}}
autoCorrect={false}
autoCapitalize="none"
/>
{/* 清除按钮 */}
{query.length > 0 && (
<TouchableOpacity
style={styles.clearBtn}
onPress={clearSearch}
>
<Text style={styles.clearText}>✕</Text>
</TouchableOpacity>
)}
</View>
{/* 联想下拉面板 */}
{showDropdown && (
<Animated.View
style={[
styles.dropdown,
{ maxHeight: dropdownHeight, opacity: dropdownAnim },
]}
>
{/* 精准匹配结果 */}
{exactResults.length > 0 && (
<View style={styles.section}>
<Text style={styles.sectionTitle}>精准匹配</Text>
{exactResults.map((item) => (
<TouchableOpacity
key={item.id}
style={styles.exactMatchItem}
onPress={() => {
onSelectItem(item);
handleInputChange(item.title);
Keyboard.dismiss();
}}
>
<Text style={styles.exactIcon}>🎯</Text>
<View style={styles.exactContent}>
<Text style={styles.exactTitle}>{item.title}</Text>
{item.subtitle && (
<Text style={styles.exactSub}>{item.subtitle}</Text>
)}
</View>
</TouchableOpacity>
))}
</View>
)}
{/* 联想建议 */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>
{isSearching ? '搜索中...' : '联想建议'}
</Text>
{suggestions.length > 0 ? (
<FlatList
data={suggestions.filter(
(s) => !exactResults.find((e) => e.id === s.id)
)}
keyExtractor={(item) => String(item.id)}
renderItem={renderSuggestionItem}
keyboardShouldPersistTaps="always"
showsVerticalScrollIndicator={false}
/>
) : (
!isSearching && (
<Text style={styles.noResult}>无匹配结果</Text>
)
)}
</View>
</Animated.View>
)}
</View>
);
}五、精准匹配逻辑模块
精准匹配是搜索质量的关键。以下是对比标准:
TypeScript
// services/exact-matcher.ts
export interface MatchResult {
matched: boolean;
matchType: MatchType;
score: number;
positions?: number[]; // 匹配位置
}
export enum MatchType {
EXACT = 'exact', // 完全匹配
PREFIX = 'prefix', // 前缀匹配
FUZZY = 'fuzzy', // 模糊匹配(编辑距离)
PINYIN = 'pinyin', // 拼音匹配
SYNONYM = 'synonym', // 同义词匹配
NONE = 'none', // 不匹配
}
// 编辑距离(Levenshtein Distance)
function levenshteinDistance(a: string, b: string): number {
const m = a.length;
const n = b.length;
const dp: number[][] = [];
for (let i = 0; i <= m; i++) {
dp[i] = [i];
}
for (let j = 0; j <= n; j++) {
dp[0][j] = j;
}
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
if (a[i - 1] === b[j - 1]) {
dp[i][j] = dp[i - 1][j - 1];
} else {
dp[i][j] = Math.min(
dp[i - 1][j - 1] + 1, // 替换
dp[i - 1][j] + 1, // 删除
dp[i][j - 1] + 1 // 插入
);
}
}
}
return dp[m][n];
}
export class ExactMatcher {
// 主匹配函数
static match(query: string, target: string): MatchResult {
const q = query.toLowerCase().trim();
const t = target.toLowerCase().trim();
if (!q || !t) {
return { matched: false, matchType: MatchType.NONE, score: Infinity };
}
// 1️⃣ 精准匹配(最高优先级)
if (q === t) {
return {
matched: true,
matchType: MatchType.EXACT,
score: 0,
positions: [0, q.length],
};
}
// 2️⃣ 前缀匹配
if (t.startsWith(q)) {
return {
matched: true,
matchType: MatchType.PREFIX,
score: q.length / t.length,
positions: [0, q.length],
};
}
// 3️⃣ 包含匹配(子串匹配)
if (t.includes(q)) {
const pos = t.indexOf(q);
return {
matched: true,
matchType: MatchType.FUZZY,
score: 1 + (pos / t.length),
positions: [pos, pos + q.length],
};
}
// 4️⃣ 分词匹配(按空格或中文分词)
const queryWords = q.split(/[\s,,、]+/).filter(Boolean);
const targetWords = t.split(/[\s,,、]+/).filter(Boolean);
if (queryWords.length > 1) {
let matchedWords = 0;
for (const qw of queryWords) {
if (targetWords.some(tw => tw.includes(qw) || qw.includes(tw))) {
matchedWords++;
}
}
if (matchedWords === queryWords.length) {
return {
matched: true,
matchType: MatchType.FUZZY,
score: 2,
};
}
}
// 5️⃣ 单个字匹配(中文常见,如输入"深"匹配"深度学习")
const singleChars = queryWords.flatMap(w => w.split(''));
const targetChars = new Set(t.split(''));
let charMatchCount = 0;
for (const char of singleChars) {
if (targetChars.has(char)) {
charMatchCount++;
}
}
if (charMatchCount >= Math.min(singleChars.length, 2)) {
return {
matched: true,
matchType: MatchType.FUZZY,
score: 3 + (singleChars.length - charMatchCount),
};
}
// 6️⃣ 拼音匹配(输入拼音匹配中文)
// 简化的拼音匹配
const pinyinRegex = /^[a-z\s]+$/;
if (pinyinRegex.test(q)) {
if (this.matchPinyin(q, t).matched) {
return this.matchPinyin(q, t);
}
}
return { matched: false, matchType: MatchType.NONE, score: Infinity };
}
// 拼音匹配
private static matchPinyin(pinyin: string, target: string): MatchResult {
// 将中文标题转换为拼音进行匹配
// 这里需要引入拼音库,简化逻辑
const lowerTarget = target.toLowerCase();
const pinyinPrefix = {
'shen': '深',
'du': '度',
'xue': '学',
'xi': '习',
};
// 输入的首拼音匹配命中
for (const [py, ch] of Object.entries(pinyinPrefix)) {
if (py.startsWith(pinyin[0]) && lowerTarget.includes(ch)) {
return {
matched: true,
matchType: MatchType.PINYIN,
score: 4,
};
}
}
return { matched: false, matchType: MatchType.NONE, score: Infinity };
}
// 批量精准匹配
static batchMatch(
query: string,
targets: string[]
): Array<{ target: string; result: MatchResult }> {
return targets
.map((target) => ({
target,
result: this.match(query, target),
}))
.filter(({ result }) => result.matched)
.sort((a, b) => a.result.score - b.result.score);
}
// 获取匹配类型标签
static getMatchTypeLabel(type: MatchType): string {
const labels: Record<MatchType, string> = {
[MatchType.EXACT]: '精准匹配',
[MatchType.PREFIX]: '前缀匹配',
[MatchType.FUZZY]: '相似匹配',
[MatchType.PINYIN]: '拼音匹配',
[MatchType.SYNONYM]: '同义匹配',
[MatchType.NONE]: '不匹配',
};
return labels[type];
}
}六、搜索历史与热门搜索
TypeScript
// services/search-history.ts
import AsyncStorage from '@react-native-async-storage/async-storage';
const HISTORY_KEY = '@search_history';
const MAX_HISTORY = 20;
export class SearchHistory {
private static history: string[] = [];
// 加载搜索历史
static async load(): Promise<string[]> {
try {
const data = await AsyncStorage.getItem(HISTORY_KEY);
this.history = data ? JSON.parse(data) : [];
} catch {
this.history = [];
}
return this.history;
}
// 添加搜索记录
static async add(query: string): Promise<void> {
query = query.trim();
if (!query) return;
// 去重(最近一次放到最前面)
this.history = this.history.filter(h => h !== query);
this.history.unshift(query);
// 限制数量
if (this.history.length > MAX_HISTORY) {
this.history = this.history.slice(0, MAX_HISTORY);
}
await AsyncStorage.setItem(HISTORY_KEY, JSON.stringify(this.history));
}
// 获取搜索历史
static get(): string[] {
return this.history;
}
// 删除单条
static async remove(query: string): Promise<void> {
this.history = this.history.filter(h => h !== query);
await AsyncStorage.setItem(HISTORY_KEY, JSON.stringify(this.history));
}
// 清空历史
static async clear(): Promise<void> {
this.history = [];
await AsyncStorage.setItem(HISTORY_KEY, '[]');
}
}七、热门搜索 Hook
TypeScript
// hooks/useHotSearch.ts
import { useState, useEffect, useCallback } from 'react';
import AsyncStorage from '@react-native-async-storage/async-storage';
const HOT_KEY = '@hot_searches';
const HOT_COUNT_KEY = '@hot_search_counts';
interface HotSearchItem {
keyword: string;
count: number;
}
export function useHotSearch() {
const [hotSearches, setHotSearches] = useState<HotSearchItem[]>([]);
const [searchHistory, setSearchHistory] = useState<string[]>([]);
// 加载热门搜索和搜索历史
useEffect(() => {
loadHotSearches();
loadHistory();
}, []);
const loadHotSearches = async () => {
try {
const data = await AsyncStorage.getItem(HOT_KEY);
if (data) {
setHotSearches(JSON.parse(data));
}
} catch {}
};
const loadHistory = async () => {
const history = await SearchHistory.load();
setSearchHistory(history);
};
// 记录搜索
const recordSearch = useCallback(async (query: string) => {
await SearchHistory.add(query);
setSearchHistory(SearchHistory.get());
// 更新热门搜索计数
try {
const data = await AsyncStorage.getItem(HOT_COUNT_KEY);
const counts: Record<string, number> = data ? JSON.parse(data) : {};
counts[query] = (counts[query] || 0) + 1;
await AsyncStorage.setItem(HOT_COUNT_KEY, JSON.stringify(counts));
// 重新排序热门搜索
const sorted = Object.entries(counts)
.map(([keyword, count]) => ({ keyword, count }))
.sort((a, b) => b.count - a.count)
.slice(0, 10);
setHotSearches(sorted);
await AsyncStorage.setItem(HOT_KEY, JSON.stringify(sorted));
} catch {}
}, []);
const clearHistory = useCallback(async () => {
await SearchHistory.clear();
setSearchHistory([]);
}, []);
return {
hotSearches,
searchHistory,
recordSearch,
clearHistory,
loadHistory,
refresh: loadHotSearches,
};
}八、搜索页面完整示例
TSX
// screens/SearchScreen.tsx
import React, { useEffect, useState, useCallback } from 'react';
import {
View,
Text,
FlatList,
TouchableOpacity,
StyleSheet,
SafeAreaView,
} from 'react-native';
import { SearchBar } from '../components/SearchBar';
import { useHotSearch } from '../hooks/useHotSearch';
import { SearchableItem } from '../services/search-index';
import { searchIndex } from '../services/search-index';
import { ExactMatcher, MatchType } from '../services/exact-matcher';
// 模拟数据
const DEMO_ITEMS: SearchableItem[] = [
{ id: 1, title: '深度学习入门', tags: ['AI', 'ML'], keywords: ['deep learning', 'ai'], popularity: 95, category: '教程' },
{ id: 2, title: '深度神经网络', tags: ['AI', 'NN'], keywords: ['deep neural network'], popularity: 88, category: '论文' },
{ id: 3, title: 'React Native 开发实战', tags: ['前端', '移动端'], keywords: ['rn'], popularity: 92, category: '教程' },
{ id: 4, title: '数据分析基础', tags: ['数据'], keywords: ['data analysis'], popularity: 85, category: '课程' },
{ id: 5, title: '人工智能导论', tags: ['AI'], keywords: ['ai intro'], popularity: 80, category: '课程' },
{ id: 6, title: '云原生架构设计', tags: ['云原生', '架构'], keywords: ['cloud native'], popularity: 78, category: '文章' },
{ id: 7, title: 'Flutter vs React Native', tags: ['前端', '对比'], keywords: ['flutter', 'rn'], popularity: 90, category: '文章' },
{ id: 8, title: '深圳互联网公司一览', tags: ['公司', '深圳'], keywords: ['shenzhen'], popularity: 75, category: '资讯' },
{ id: 9, title: '深度强化学习算法', tags: ['AI', 'RL'], keywords: ['deep reinforcement learning'], popularity: 82, category: '论文' },
{ id: 10, title: '搜索引擎原理', tags: ['搜索', '算法'], keywords: ['search engine'], popularity: 78, category: '教程' },
];
export function SearchScreen() {
const { hotSearches, searchHistory, recordSearch, clearHistory, loadHistory } = useHotSearch();
const [searchResults, setSearchResults] = useState<SearchableItem[]>([]);
const [showResults, setShowResults] = useState(false);
const [lastQuery, setLastQuery] = useState('');
// 初始化索引
useEffect(() => {
searchIndex.buildIndex(DEMO_ITEMS);
loadHistory();
}, []);
// 搜索提交
const handleSearch = useCallback(async (query: string) => {
await recordSearch(query);
setLastQuery(query);
// 执行全文本搜索
const results = searchIndex.search(query);
setSearchResults(results);
setShowResults(true);
}, [recordSearch]);
// 选择联想项
const handleSelectItem = useCallback((item: SearchableItem) => {
recordSearch(item.title);
setLastQuery(item.title);
const results = searchIndex.search(item.title);
setSearchResults(results);
setShowResults(true);
}, [recordSearch]);
// 点击热门搜索
const handleHotSearchPress = useCallback((keyword: string) => {
handleSearch(keyword);
}, [handleSearch]);
// 渲染匹配标签
const renderMatchBadge = (title: string) => {
const matchResult = ExactMatcher.match(lastQuery, title);
if (matchResult.matched) {
return (
<Text style={styles.matchBadge}>
{ExactMatcher.getMatchTypeLabel(matchResult.matchType)}
</Text>
);
}
return null;
};
return (
<SafeAreaView style={styles.container}>
{/* 搜索栏 */}
<SearchBar
onSearch={handleSearch}
onSelectItem={handleSelectItem}
placeholder="搜索视频、文章、教程..."
/>
{/* 搜索结果 */}
{showResults && (
<View style={styles.resultsSection}>
<Text style={styles.resultsTitle}>
搜索结果 ({searchResults.length})
</Text>
<FlatList
data={searchResults}
keyExtractor={(item) => String(item.id)}
renderItem={({ item }) => (
<TouchableOpacity style={styles.resultItem}>
<View style={styles.resultContent}>
<Text style={styles.resultTitle}>{item.title}</Text>
<View style={styles.resultMeta}>
{item.category && (
<Text style={styles.resultCategory}>{item.category}</Text>
)}
{renderMatchBadge(item.title)}
</View>
</View>
<Text style={styles.resultArrow}>›</Text>
</TouchableOpacity>
)}
ListEmptyComponent={
<Text style={styles.emptyResult}>
未找到「{lastQuery}」相关结果
</Text>
}
/>
</View>
)}
{/* 默认页:搜索历史 + 热门搜索 */}
{!showResults && (
<View style={styles.defaultSection}>
{/* 搜索历史 */}
{searchHistory.length > 0 && (
<>
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}>搜索历史</Text>
<TouchableOpacity onPress={clearHistory}>
<Text style={styles.clearText}>清空</Text>
</TouchableOpacity>
</View>
<View style={styles.historyTags}>
{searchHistory.slice(0, 8).map((item) => (
<TouchableOpacity
key={item}
style={styles.historyTag}
onPress={() => handleHotSearchPress(item)}
>
<Text style={styles.historyText}>🕐 {item}</Text>
</TouchableOpacity>
))}
</View>
</>
)}
{/* 热门搜索 */}
{hotSearches.length > 0 && (
<>
<Text style={styles.sectionTitle}>🔥 热门搜索</Text>
<View style={styles.hotList}>
{hotSearches.slice(0, 10).map((item, index) => (
<TouchableOpacity
key={item.keyword}
style={styles.hotItem}
onPress={() => handleHotSearchPress(item.keyword)}
>
<Text
style={[
styles.hotRank,
index < 3 && styles.hotRankTop,
]}
>
{index + 1}
</Text>
<Text style={styles.hotKeyword}>{item.keyword}</Text>
<Text style={styles.hotCount}>{item.count}次</Text>
</TouchableOpacity>
))}
</View>
</>
)}
</View>
)}
</SafeAreaView>
);
}九、性能优化策略
1. 防抖(Debounce)
TypeScript
// 通用防抖 Hook
function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debouncedValue;
}
// 用法
const debouncedQuery = useDebounce(query, 300);
useEffect(() => {
if (debouncedQuery) {
searchIndex.suggest(debouncedQuery, 10);
}
}, [debouncedQuery]);2. Trie 前缀树索引(O(n) → O(m))
TypeScript
// Trie 将搜索从遍历所有项目优化为仅遍历前缀节点
// 未使用 Trie: 遍历 n 个项目 × 比较字符串 = O(n × k)
// 使用 Trie 前缀索引: 遍历前缀节点 m = O(m),其中 m << n3. 缓存策略
TypeScript
// LRU 缓存
class LRUCache<K, V> {
private cache: Map<K, V>;
private maxSize: number;
constructor(maxSize: number = 100) {
this.cache = new Map();
this.maxSize = maxSize;
}
get(key: K): V | undefined {
if (!this.cache.has(key)) return undefined;
// 更新到最近使用
const value = this.cache.get(key)!;
this.cache.delete(key);
this.cache.set(key, value);
return value;
}
set(key: K, value: V): void {
if (this.cache.has(key)) {
this.cache.delete(key);
} else if (this.cache.size >= this.maxSize) {
// 删除最久未使用的
const firstKey = this.cache.keys().next().value;
this.cache.delete(firstKey);
}
this.cache.set(key, value);
}
}4. 虚拟列表
对于大量搜索结果,使用 FlatList 的虚拟化特性:
TSX
<FlatList
data={searchResults}
initialNumToRender={10}
maxToRenderPerBatch={15}
windowSize={5}
removeClippedSubviews={true}
renderItem={renderItem}
/>十、样式定义
TypeScript
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#F8FAFC',
},
searchBarContainer: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#FFFFFF',
margin: 16,
paddingHorizontal: 12,
borderRadius: 12,
borderWidth: 1,
borderColor: '#E5E7EB',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.05,
shadowRadius: 4,
elevation: 2,
},
searchIcon: {
marginRight: 8,
},
input: {
flex: 1,
height: 44,
fontSize: 16,
color: '#1F2937',
},
clearBtn: {
padding: 8,
},
clearText: {
fontSize: 16,
color: '#9CA3AF',
},
dropdown: {
marginHorizontal: 16,
backgroundColor: '#FFFFFF',
borderRadius: 12,
borderWidth: 1,
borderColor: '#E5E7EB',
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.1,
shadowRadius: 8,
elevation: 4,
overflow: 'hidden',
},
section: {
paddingVertical: 8,
},
sectionTitle: {
fontSize: 12,
color: '#9CA3AF',
paddingHorizontal: 16,
marginBottom: 4,
fontWeight: '600',
},
suggestionItem: {
paddingVertical: 10,
paddingHorizontal: 16,
},
suggestionContent: {
flexDirection: 'row',
alignItems: 'center',
},
suggestIcon: {
marginRight: 12,
fontSize: 14,
},
matchIcon: {
marginRight: 12,
fontSize: 16,
},
suggestionText: {
flex: 1,
},
suggestionTitle: {
fontSize: 15,
color: '#1F2937',
fontWeight: '500',
},
suggestionSubtitle: {
fontSize: 12,
color: '#6B7280',
marginTop: 2,
},
tagRow: {
flexDirection: 'row',
marginTop: 4,
gap: 4,
},
matchTag: {
fontSize: 11,
color: '#3B82F6',
backgroundColor: '#EFF6FF',
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: 4,
overflow: 'hidden',
},
categoryTag: {
fontSize: 11,
color: '#10B981',
backgroundColor: '#ECFDF5',
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: 4,
overflow: 'hidden',
},
popularity: {
fontSize: 12,
color: '#9CA3AF',
marginLeft: 8,
},
highlight: {
color: '#3B82F6',
fontWeight: '700',
},
noResult: {
textAlign: 'center',
color: '#9CA3AF',
paddingVertical: 20,
fontSize: 14,
},
exactMatchItem: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 10,
paddingHorizontal: 16,
backgroundColor: '#F0F9FF',
},
exactIcon: {
marginRight: 12,
fontSize: 16,
},
exactContent: {
flex: 1,
},
exactTitle: {
fontSize: 15,
color: '#1F2937',
fontWeight: '600',
},
exactSub: {
fontSize: 12,
color: '#6B7280',
marginTop: 2,
},
matchBadge: {
fontSize: 11,
color: '#F59E0B',
backgroundColor: '#FFFBEB',
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: 4,
overflow: 'hidden',
marginLeft: 8,
},
// ... 更多样式
});十一、最佳实践总结
匹配规则优先级
纯文本
输入 → ① 精准匹配 → 结果标题完全等于搜索词
"深度学习" → ② 前缀匹配 → "深度学习入门" 等以"深度学习"开头的
→ ③ 包含匹配 → 包含"深度学习"的
→ ④ 分词匹配 → 词条中匹配所有词
→ ⑤ 拼音匹配 → shen du → 深度学习
→ ⑥ 单字匹配 → "深" "度" 分别出现性能指标
项目 | 目标 |
|---|---|
输入延迟 | < 200ms(含防抖) |
搜索响应 | < 100ms(本地索引) |
索引构建 | < 500ms(万级数据) |
内存占用 | < 10MB(10 万条索引) |
用户体验要点
即时反馈:输入即联想,不需点击搜索按钮
滑动清除:搜索历史逐条可删
键盘适配:联想面板遮挡键盘时自动收起
空状态友好:无搜索结果时展示建议/引导
二级筛选:搜索结果支持分类/标签过滤
技术栈
模块 | 方案 |
|---|---|
索引结构 | Trie 前缀树 |
模糊匹配 | Levenshtein 编辑距离 |
拼音引擎 | 内置拼音映射表(可引入 pinyin-pro) |
持久化 | AsyncStorage |
搜索防抖 | 自定义 debounce Hook |
缓存 | LRU Map + AsyncStorage |
高亮 | RegExp 分段渲染 |
本文档由 MClaw 生成并发布。
END
相关文章
暂无相关文章
