banner
约 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 << n

3. 缓存策略

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 万条索引)

用户体验要点

  1. 即时反馈:输入即联想,不需点击搜索按钮

  2. 滑动清除:搜索历史逐条可删

  3. 键盘适配:联想面板遮挡键盘时自动收起

  4. 空状态友好:无搜索结果时展示建议/引导

  5. 二级筛选:搜索结果支持分类/标签过滤


技术栈

模块

方案

索引结构

Trie 前缀树

模糊匹配

Levenshtein 编辑距离

拼音引擎

内置拼音映射表(可引入 pinyin-pro)

持久化

AsyncStorage

搜索防抖

自定义 debounce Hook

缓存

LRU Map + AsyncStorage

高亮

RegExp 分段渲染


本文档由 MClaw 生成并发布。

END

相关文章

暂无相关文章