在这里插入图片描述

案例开源地址:https://atomgit.com/nutpi/rn_openharmony_lol

筛选功能适合按条件缩小范围,但如果用户已经知道想找哪个英雄,直接输入名字搜索会更快。英雄搜索页提供一个输入框,用户输入关键词后实时显示匹配的英雄。

这篇文章我们来实现英雄搜索功能,重点是搜索框组件的封装、防抖优化、以及模糊匹配的实现。

搜索的用户体验

好的搜索体验应该是这样的:

  1. 用户输入时实时显示结果,不需要点"搜索"按钮
  2. 输入太快时不要每个字符都触发搜索,会卡顿
  3. 支持模糊匹配,输入"盖"能找到"盖伦",输入"德玛"也能找到(因为称号是"德玛西亚之力")
  4. 没有结果时给出友好提示

带着这些目标,我们来实现搜索功能。

页面结构

import React, {useState, useMemo} from 'react';
import {View, StyleSheet} from 'react-native';
import {colors} from '../../styles/colors';
import {useApp} from '../../context/AppContext';
import {useNavigation} from '../../context/NavigationContext';
import {championApi} from '../../api';
import {ChampionGrid} from '../../components/champion';
import {SearchBar} from '../../components/common';
import {useDebounce} from '../../hooks';
import type {Champion} from '../../models/Champion';

这个页面用到了几个关键的模块:

  • SearchBar:搜索框组件,我们自己封装的
  • useDebounce:防抖 Hook,用于优化搜索性能
  • ChampionGrid:英雄网格组件,复用之前实现的
export function ChampionSearchPage() {
  const {state} = useApp();
  const {navigate} = useNavigation();
  const [searchText, setSearchText] = useState('');

  const debouncedSearch = useDebounce(searchText, 300);

searchText 是用户输入的原始文本,每次按键都会更新。debouncedSearch 是防抖后的文本,用户停止输入 300ms 后才会更新。

为什么需要防抖?假设用户输入"盖伦"两个字,会触发两次 onChange:输入"盖"触发一次,输入"伦"触发一次。如果每次都执行搜索,会有两次搜索操作。用户输入快的话,可能输入"德玛西亚"会触发五六次搜索,造成不必要的性能开销。

防抖的作用是:用户停止输入一段时间后才执行搜索。300ms 是一个经验值,既不会让用户感觉到延迟,又能有效减少搜索次数。

搜索逻辑

  // 搜索英雄
  const searchResults = useMemo(() => {
    if (!debouncedSearch.trim()) {
      return [];
    }
    return championApi.searchChampions(state.champions, debouncedSearch);
  }, [state.champions, debouncedSearch]);

搜索逻辑用 useMemo 包裹,只有当 debouncedSearch 变化时才重新计算。

如果搜索词为空(或只有空格),直接返回空数组。这样用户刚进入页面时不会显示所有英雄,而是显示空状态,提示用户输入关键词。

championApi.searchChampions 是我们封装的搜索函数:

// api/champion.ts
export function searchChampions(champions: Champion[], keyword: string): Champion[] {
  const lowerKeyword = keyword.toLowerCase();
  return champions.filter(c => 
    c.name.toLowerCase().includes(lowerKeyword) ||
    c.title.toLowerCase().includes(lowerKeyword) ||
    c.id.toLowerCase().includes(lowerKeyword)
  );
}

搜索会匹配三个字段:name(中文名)、title(称号)、id(英文名)。全部转成小写后比较,实现大小写不敏感的搜索。

比如搜索"garen"能找到盖伦(id 是 Garen),搜索"德玛"能找到盖伦(称号是"德玛西亚之力")。

页面渲染

  const handleChampionPress = (champion: Champion) => {
    navigate('ChampionDetail', {championId: champion.id});
  };

  const SearchHeader = (
    <View style={styles.searchContainer}>
      <SearchBar
        value={searchText}
        onChangeText={setSearchText}
        placeholder="输入英雄名称、称号搜索..."
      />
    </View>
  );

  return (
    <View style={styles.container}>
      <ChampionGrid
        champions={searchResults}
        version={state.version}
        onChampionPress={handleChampionPress}
        ListHeaderComponent={SearchHeader}
      />
    </View>
  );
}

搜索框放在 ListHeaderComponent 中,会随着列表一起滚动。如果搜索结果很多,用户滚动到下面后搜索框会滚出屏幕。

如果你希望搜索框固定在顶部,可以把它放在 ChampionGrid 外面。两种方案各有优劣,取决于产品需求。

搜索框组件的实现

SearchBar 是一个通用的搜索框组件,可以在多个页面复用。

import React from 'react';
import {View, TextInput, TouchableOpacity, Text, StyleSheet} from 'react-native';
import {colors} from '../../styles/colors';

interface SearchBarProps {
  value: string;
  onChangeText: (text: string) => void;
  placeholder?: string;
  onClear?: () => void;
}

Props 设计遵循受控组件的模式:value 和 onChangeText 由父组件控制。这样父组件可以完全掌控输入框的状态,便于实现防抖等逻辑。

export function SearchBar({
  value,
  onChangeText,
  placeholder = '搜索...',
  onClear,
}: SearchBarProps) {
  const handleClear = () => {
    onChangeText('');
    onClear?.();
  };

  return (
    <View style={styles.container}>
      <Text style={styles.icon}>🔍</Text>
      <TextInput
        style={styles.input}
        value={value}
        onChangeText={onChangeText}
        placeholder={placeholder}
        placeholderTextColor={colors.textMuted}
        returnKeyType="search"
      />
      {value.length > 0 && (
        <TouchableOpacity onPress={handleClear} style={styles.clearButton}>
          <Text style={styles.clearText}>✕</Text>
        </TouchableOpacity>
      )}
    </View>
  );
}

搜索框由三部分组成:左边的搜索图标、中间的输入框、右边的清除按钮。

清除按钮只在有输入内容时显示。点击后清空输入框,并调用 onClear 回调(如果有的话)。onClear?.() 是可选链调用,如果 onClear 是 undefined 就不会执行。

returnKeyType="search" 让键盘的回车键显示为"搜索",虽然我们没有处理回车事件(因为是实时搜索),但这个视觉提示能让用户知道这是一个搜索框。

搜索框样式

const styles = StyleSheet.create({
  container: {
    flexDirection: 'row',
    alignItems: 'center',
    backgroundColor: colors.backgroundCard,
    borderRadius: 8,
    paddingHorizontal: 12,
    height: 44,
    borderWidth: 1,
    borderColor: colors.border,
  },
  icon: {
    fontSize: 16,
    marginRight: 8,
  },
  input: {
    flex: 1,
    fontSize: 16,
    color: colors.textPrimary,
    padding: 0,
  },
  clearButton: {
    padding: 4,
  },
  clearText: {
    fontSize: 14,
    color: colors.textMuted,
  },
});

高度固定为 44px,这是 iOS 推荐的最小可点击区域高度。圆角 8px 让搜索框看起来比较柔和。

输入框的 padding: 0 是为了去掉 TextInput 的默认内边距,让文字和图标对齐。

防抖 Hook 的实现

useDebounce 是一个通用的防抖 Hook,可以用于任何需要防抖的场景。

import {useState, useEffect} from 'react';

export function useDebounce<T>(value: T, delay: number = 300): T {
  const [debouncedValue, setDebouncedValue] = useState<T>(value);

  useEffect(() => {
    const timer = setTimeout(() => {
      setDebouncedValue(value);
    }, delay);

    return () => {
      clearTimeout(timer);
    };
  }, [value, delay]);

  return debouncedValue;
}

这个 Hook 的原理是:

  1. 当 value 变化时,设置一个定时器,delay 毫秒后更新 debouncedValue
  2. 如果在定时器触发前 value 又变化了,清除旧的定时器,设置新的定时器
  3. 只有当 value 稳定 delay 毫秒后,debouncedValue 才会更新

useEffect 的返回函数是清理函数,会在下一次 effect 执行前调用。这里用它来清除旧的定时器,避免内存泄漏。

泛型 <T> 让这个 Hook 可以用于任何类型的值,不仅仅是字符串。比如你也可以用它来防抖一个对象或数组。

搜索优化建议

当前实现已经能满足基本需求,但还有一些可以优化的地方。

搜索历史:记住用户最近搜索过的关键词,下次打开搜索页时显示出来。可以用 AsyncStorage 存储:

// 保存搜索历史
const saveHistory = async (keyword: string) => {
  const history = await AsyncStorage.getItem('searchHistory');
  const list = history ? JSON.parse(history) : [];
  const newList = [keyword, ...list.filter(k => k !== keyword)].slice(0, 10);
  await AsyncStorage.setItem('searchHistory', JSON.stringify(newList));
};

热门搜索:在搜索框下方显示热门英雄,方便用户快速选择。可以根据英雄的热度数据(如果有的话)或者固定显示几个常用英雄。

拼音搜索:支持用拼音搜索中文名,比如输入"gailun"能找到"盖伦"。这需要引入拼音转换库,会增加包体积。

高亮匹配:在搜索结果中高亮显示匹配的关键词,让用户知道为什么这个英雄被搜出来。实现起来需要把名称拆分成匹配部分和非匹配部分,分别渲染。

页面样式

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: colors.background,
  },
  searchContainer: {
    marginBottom: 16,
  },
});

页面样式很简单,因为主要的 UI 都在 SearchBar 和 ChampionGrid 组件中。searchContainer 的 marginBottom 让搜索框和下面的结果列表之间有一点间距。

空状态处理

当前实现中,如果搜索没有结果,ChampionGrid 会显示一个空列表。可以加一个更友好的空状态提示:

// 在 ChampionGrid 组件中
ListEmptyComponent={
  searchText ? (
    <View style={styles.emptyState}>
      <Text style={styles.emptyText}>没有找到"{searchText}"相关的英雄</Text>
      <Text style={styles.emptyHint}>试试其他关键词?</Text>
    </View>
  ) : (
    <View style={styles.emptyState}>
      <Text style={styles.emptyText}>输入关键词开始搜索</Text>
    </View>
  )
}

区分两种空状态:没有输入时提示"输入关键词开始搜索",有输入但没结果时提示"没有找到相关英雄"。

小结

英雄搜索页涉及到几个实用的技术点:

  1. 受控组件:搜索框的值由父组件控制,便于实现防抖等逻辑
  2. 防抖优化:用 useDebounce Hook 减少不必要的搜索操作
  3. 模糊匹配:搜索多个字段,提高搜索的命中率
  4. 组件复用:SearchBar 和 ChampionGrid 都是可复用的组件

下一篇我们来实现装备大全页面,展示游戏中所有装备的列表。


欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐