React+TS前台项目实战(二十一)-- Search业务组件封装实现全局搜索

文章目录


前言

今天,我们来封装一个业务灵巧的组件,它集成了全局搜索和展示搜索结果的功能。通过配置文件,我们可以为不同的模块定制展示和跳转逻辑,集中管理不同模块,当要加一个模块时,只需要通过配置即可,从而减少重复的代码,并方便地进行维护和扩展。同时,我们将使用React Query来实现搜索功能,并模拟请求成功、请求失败和中断请求的处理方式。


一、Search组件封装

1. 效果展示

(1)输入内容,当停止输入后,请求接口数据
注:如请求数据时添加加载状态,请求结束后取消加载状态

(2)点击清除按钮,清除输入框数据,并中止当前请求,重置react-query请求参数

(3)请求失败,展示失败界面

(4)是否显示搜索按钮

(5)移动端效果

2. 功能分析

(1)搜索功能灵活性: 使用防抖搜索,useMemo,以及react-query自带监听输入状态,只在输入框停止输入 后,才会触发接口请求 ,避免在用户仍在输入时进行不必要的API调用

(2)请求库选择: 使用Tanstack React Query中的useQuery钩子来管理加载状态并获取搜索结果

(3)导航到搜索结果: 点击搜索结果项或在搜索结果显示后按下回车键时,会跳转到对应的页面

(4)清除搜索: 点击清空按钮,会清空输入框的内容,并取消接口请求重置请求参数隐藏搜索结果 列表

(5)搜索结果展示: 一旦获取到搜索结果,该组件使用SearchResults组件 渲染搜索结果。它还显示搜索结果的加载状态

(6)搜索按钮: 如果hasButton属性为true,还将渲染一个搜索按钮,当点击时触发搜索

(7)使用国际化语言,可全局切换使用;使用联合类型声明使用,不同模块,添加配置即可

(8)使用useCallback,useMemo,useEffect, memo,lodash.debounce等对组件进行性能优化

(9)提供一些回调事件,供外部调用

3. 代码+详细注释

引入之前文章封装的 输入框组件,可自行查看,以及下面封装的结果展示组件

c 复制代码
// @/components/Search/index.tsx
import { FC, useCallback, useMemo, memo, useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import debounce from "lodash.debounce";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { SearchContainer, SearchButton } from "./styled";
import Input from "@/components/Input";
import { querySearchInfo } from "@/api/search";
import { useIsMobile } from "@/hooks";
import { SearchResults } from "./searchResults";
import { getURLBySearchResult } from "./utils";

// 组件的属性类型
type Props = {
  defaultValue?: string;
  hasButton?: boolean;
  onClear?: () => void;
};
// 搜索框组件
const Search: FC<Props> = memo(({ defaultValue, hasButton, onClear: handleClear }) => {
  const queryClient = useQueryClient();
  const navigate = useNavigate();
  const { t } = useTranslation();
  const isMobile = useIsMobile();
  const [keyword, _setKeyword] = useState(defaultValue || "");
  const searchValue = keyword.trim();
  // 获取搜索结果数据
  const fetchData = async (searchValue: string) => {
    const { data } = await querySearchInfo({
      p: searchValue,
    });
    return {
      data,
      total: data.length,
    };
  };
  // 使用useQuery实现搜索
  const {
    refetch: refetchSearch,
    data: _searchResults,
    isFetching,
  } = useQuery(["moduleSearch", searchValue], () => fetchData(searchValue), {
    enabled: false,
  });
  // 从查询结果中获取搜索结果数据
  const searchResultData = _searchResults?.data;
  // 使用useMemo函数创建一个防抖函数debouncedSearch,用于实现搜索请求功能
  const debouncedSearch = useMemo(() => {
    return debounce(refetchSearch, 1500, { trailing: true }); // 在搜索值变化后1.5秒后触发refetchSearch函数
  }, [refetchSearch]); // 当refetchSearch函数发生变化时,重新创建防抖函数debouncedSearch

  // 监听搜索值变化,当有搜索值时,调用debouncedSearch函数进行搜索
  useEffect(() => {
    if (!searchValue) return;
    debouncedSearch();
  }, [searchValue]);

  // 重置搜索
  const resetSearch = useCallback(() => {
    debouncedSearch.cancel(); // 取消搜索轮询
    queryClient.resetQueries(["moduleSearch", searchValue]); // 重置查询缓存
  }, [debouncedSearch, queryClient, searchValue]);

  // 清空搜索
  const onClear = useCallback(() => {
    resetSearch(); // 调用重置方法
    handleClear?.(); // 调用清空回调方法
  }, [resetSearch, handleClear]);

  // 设置搜索内容,如果值为空,则调用清空方法
  const setKeyword = (value: string) => {
    if (value === "") onClear();
    _setKeyword(value);
  };
  // 搜索按钮点击事件
  const handleSearch = () => {
    // 如果没有搜索内容,或者搜索无数据则直接返回
    if (!searchValue || !searchResultData) return;
    // 根据搜索结果数据的第一个元素获取搜索结果对应的URL
    const url = getURLBySearchResult(searchResultData[0]);
    // 跳转到对应的URL,如果获取不到URL,则跳转到失败的搜索页面
    navigate(url ?? `/search/fail?q=${searchValue}`);
  };
  return (
    <SearchContainer>
      {/* 搜索框 */}
      <Input loading={isFetching} value={keyword} hasPrefix placeholder={t("navbar.search_placeholder")} autoFocus={!isMobile} onChange={(event) => setKeyword(event.target.value)} onEnter={handleSearch} onClear={onClear} />
      {/* 搜索按钮,hasButton为true时显示 */}
      {hasButton && <SearchButton onClick={handleSearch}>{t("search.search")}</SearchButton>}
      {/* 搜索结果列表组件展示 */}
      {(isFetching || searchResultData && <SearchResults keyword={keyword} results={searchResultData ?? []} loading={isFetching} />}
    </SearchContainer>
  );
});

export default Search;
------------------------------------------------------------------------------
// @/components/Search/styled.tsx
import styled from "styled-components";
import variables from "@/styles/variables.module.scss";
export const SearchContainer = styled.div`
  position: relative;
  margin: 0 auto;
  width: 100%;
  padding-right: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  background: white;
  border: 0 solid white;
  border-radius: 4px;
`;
export const SearchButton = styled.div`
  flex-shrink: 0;
  width: 72px;
  height: calc(100% - 4px);
  margin: 2px 2px 2px 8px;
  border-radius: 0 4px 4px 0;
  background-color: #121212;
  text-align: center;
  line-height: 34px;
  color: #fff;
  letter-spacing: 0.2px;
  font-size: 14px;
  cursor: pointer;

  @media (max-width: ${variables.mobileBreakPoint}) {
    display: none;
  }
`;

4. 使用方式

c 复制代码
// 引入组件
import Search from '@/components/Search'
// 使用
{/* 带搜索按钮 */}
<Search hasButton />
{/* 不带搜索按钮 */}
<Search />

二、搜索结果展示组件封装

注:这个组件在上面Search组件中引用,单独列出来讲讲。运用关注点分离的策略,将页面分割成多个片段,易维护,容易定位代码位置。

1. 功能分析

(1)组件接受搜索内容,是否显示loading加载,以及搜索列表这三个参数

(2)根据搜索结果列表,按模块类型分类数据,这里举例2种类型(如Transaction 和 Block)

(3)对搜索的模块类型列表,添加点击事件,当点击某个模块时,展示该模块的数据

(4)不同模块类型的列表,展示不同效果(例如类型是 Transaction,显示交易信息,包括交易名称和所在区块的编号;类型是 Block,则显示区块信息,包括区块编号)

(5)通过useEffect监听数据变化,发生变化时,重置激活的模块类型分类,默认不选中任何模块类型

(6)封装不同模块匹配对应的地址,名字的方法,统一管理

(7)采用联合等进行类型声明的定义

2. 代码+详细注释

c 复制代码
// @/components/Search/SearchResults/index.tsx
import { useTranslation } from "react-i18next";
import classNames from "classnames";
import { FC, useEffect, useState } from "react";
import { SearchResultsContainer, CategoryFilterList, SearchResultList, SearchResultListItem } from "./styled";
import { useIsMobile } from "@/hooks";
import Loading from "@/components/Loading";
import { SearchResultType, SearchResult } from "@/models/Search";
// 引入不同模块匹配对应的地址,名字方法
import { getURLBySearchResult, getNameBySearchResult } from "../utils";

// 组件的类型定义
type Props = {
  keyword?: string; // 搜索内容
  loading?: boolean; // 是否显示 loading 状态
  results: SearchResult[]; // 搜索结果列表
};

// 列表数据每一项Item的渲染
const SearchResultItem: FC<{ keyword?: string; item: SearchResult }> = ({ item, keyword = "" }) => {
  const { t } = useTranslation(); // 使用国际化
  const to = getURLBySearchResult(item); // 根据搜索结果项获取对应的 URL
  const displayName = getNameBySearchResult(item); // 根据搜索结果项获取显示名称
  // 如果搜索结果项类型是 Transaction,则显示交易信息
  if (item.type === SearchResultType.Transaction) {
    return (
      <SearchResultListItem to={to}>
        <div className={classNames("content")}>
          {/* 显示交易名称 */}
          <div className={classNames("secondary-text")} title={displayName}>
            {displayName}
          </div>
          {/* 显示交易所在区块的编号 */}
          <div className={classNames("sub-title", "monospace")}>
            {t("search.block")} # {item.attributes.blockNumber}
          </div>
        </div>
      </SearchResultListItem>
    );
  }
  // 否则,类型是Block, 显示区块信息
  return (
    <SearchResultListItem to={to}>
      <div className={classNames("content")} title={displayName}>
        {displayName}
      </div>
    </SearchResultListItem>
  );
};

// 搜索结果列表
export const SearchResults: FC<Props> = ({ keyword = "", results, loading }) => {
  const isMobile = useIsMobile(); // 判断是否是移动端
  const { t } = useTranslation(); // 使用国际化
  // 设置激活的模块类型分类
  const [activatedCategory, setActivatedCategory] = useState<SearchResultType | undefined>(undefined);
  // 当搜索结果列表发生变化时,重置激活的分类
  useEffect(() => {
    setActivatedCategory(undefined);
  }, [results]);

  // 根据搜索结果列表,按模块类型分类数据
  const categories = results.reduce((acc, result) => {
    if (!acc[result.type]) {
      acc[result.type] = [];
    }
    acc[result.type].push(result);
    return acc;
  }, {} as Record<SearchResultType, SearchResult[]>);

  // 按模块类型分类的列表
  const SearchResultBlock = (() => {
    return (
      <SearchResultList>
        {Object.entries(categories)
          .filter(([type]) => (activatedCategory === undefined ? true : activatedCategory === type))
          .map(([type, items]) => (
            <div key={type} className={classNames("search-result-item")}>
              <div className={classNames("title")}>{t(`search.${type}`)}</div>
              <div className={classNames("list")}>
                {items.map((item) => (
                  <SearchResultItem keyword={keyword} key={item.id} item={item} />
                ))}
              </div>
            </div>
          ))}
      </SearchResultList>
    );
  })();

  // 如果搜索结果列表为空,则显示空数据提示;否则显示搜索结果列表
  return (
    <SearchResultsContainer>
      {!loading && Object.keys(categories).length > 0 && (
        <CategoryFilterList>
          {(Object.keys(categories) as SearchResultType[]).map((category) => (
            <div key={category} className={classNames("categoryTagItem", { active: activatedCategory === category })} onClick={() => setActivatedCategory((pre) => (pre === category ? undefined : category))}>
              {t(`search.${category}`)} {`(${categories[category].length})`}
            </div>
          ))}
        </CategoryFilterList>
      )}
      {loading ? <Loading size={isMobile ? "small" : undefined} /> : results.length === 0 ? <div className={classNames("empty")}>{t("search.no_search_result")}</div> : SearchResultBlock}
    </SearchResultsContainer>
  );
};

------------------------------------------------------------------------------
// @/components/Search/SearchResults/styled.tsx
import styled from "styled-components";
import Link from "@/components/Link";
export const SearchResultsContainer = styled.div`
  display: flex;
  flex-direction: column;
  gap: 12px;
  width: 100%;
  max-height: 292px;
  overflow-y: auto;
  background: #fff;
  color: #000;
  border-radius: 4px;
  box-shadow: 0 4px 4px 0 #1010100d;
  position: absolute;
  z-index: 2;
  top: calc(100% + 8px);
  left: 0;
  .empty {
    padding: 28px 0;
    text-align: center;
    font-size: 16px;
    color: #333;
  }
`;
export const CategoryFilterList = styled.div`
  display: flex;
  flex-wrap: wrap;
  padding: 12px 12px 0;
  gap: 4px;
  .categoryTagItem {
    border: 1px solid #e5e5e5;
    border-radius: 24px;
    padding: 4px 12px;
    cursor: pointer;
    transition: all 0.3s;

    &.active {
      border-color: var(--cd-primary-color);
      color: var(--cd-primary-color);
    }
  }
`;
export const SearchResultList = styled.div`
  .search-result-item {
    .title {
      color: #666;
      font-size: 0.65rem;
      letter-spacing: 0.5px;
      font-weight: 700;
      padding: 12px 12px 6px;
      background-color: #f5f5f5;
      text-align: left;
    }
    .list {
      padding: 6px 8px;
    }
  }
`;
export const SearchResultListItem = styled(Link)`
  display: block;
  width: 100%;
  padding: 4px 0;
  cursor: pointer;
  border-bottom: solid 1px #e5e5e5;
  .content {
    display: flex;
    align-items: center;
    justify-content: space-between;
    width: 100%;
    padding: 4px;
    border-radius: 4px;
    text-align: left;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
    color: var(--cd-primary-color);
  }
  .secondary-text {
    flex: 1;
    width: 0;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
  }

  .sub-title {
    font-size: 14px;
    color: #666;
    overflow: hidden;
    margin: 0 4px;
  }
  &:last-child {
    border-bottom: none;
  }
  &:hover,
  &:focus-within {
    .content {
      background: #f5f5f5;
    }
  }
`;

三、引用到文件,自行取用

(1)获取不同模块地址,展示名称的方法

c 复制代码
// @/components/Search/utils
import { SearchResultType, SearchResult } from "@/models/Search";
// 根据搜索结果项类型,返回对应的 URL 链接
export const getURLBySearchResult = (item: SearchResult) => {
  const { type, attributes } = item;
  switch (type) {
    case SearchResultType.Block:
      // 如果搜索结果项类型是 Block,则返回对应的区块详情页面链接
      return `/block/${attributes.blockHash}`;
    case SearchResultType.Transaction:
      // 如果搜索结果项类型是 Transaction,则返回对应的交易详情页面链接
      return `/transaction/${attributes.transactionHash}`;
    default:
      // 如果搜索结果项类型不是 Block 或者 Transaction,则返回空字符串
      return "";
  }
};
// 根据搜索结果项类型,返回不同显示名称
export const getNameBySearchResult = (item: SearchResult) => {
  const { type, attributes } = item;
  switch (type) {
    case SearchResultType.Block:
      return attributes?.number?.toString(); // 返回高度
    case SearchResultType.Transaction:
      return attributes?.transactionHash?.toString(); // 返回交易哈希
    default:
      return ""; // 返回空字符串
  }
};

(2)用到的类型声明

c 复制代码
// @/models/Search/index.ts
import { Response } from '@/request/types'
import { Block } from '@/models/Block'
import { Transaction } from '@/models/Transaction'
export enum SearchResultType {
  Block = 'block',
  Transaction = 'ckb_transaction',
}
export type SearchResult =
  | Response.Wrapper<Block, SearchResultType.Block>
  | Response.Wrapper<Transaction, SearchResultType.Transaction>
-------------------------------------------------------------------------------------------------------
// @/models/Block/index.ts
export interface Block {
  blockHash: string
  number: number
  transactionsCount: number
  proposalsCount: number
  unclesCount: number
  uncleBlockHashes: string[]
  reward: string
  rewardStatus: 'pending' | 'issued'
  totalTransactionFee: string
  receivedTxFee: string
  receivedTxFeeStatus: 'pending' | 'calculated'
  totalCellCapacity: string
  minerHash: string
  minerMessage: string
  timestamp: number
  difficulty: string
  epoch: number
  length: string
  startNumber: number
  version: number
  nonce: string
  transactionsRoot: string
  blockIndexInEpoch: string
  minerReward: string
  liveCellChanges: string
  size: number
  largestBlockInEpoch: number
  largestBlock: number
  cycles: number | null
  maxCyclesInEpoch: number | null
  maxCycles: number | null
}
-------------------------------------------------------------------------------------------------------
// @/models/Transaction/index.ts
export interface Transaction {
  isBtcTimeLock: boolean
  isRgbTransaction: boolean
  rgbTxid: string | null
  transactionHash: string
  // FIXME: this type declaration should be fixed by adding a transformation between internal state and response of API
  blockNumber: number | string
  blockTimestamp: number | string
  transactionFee: string
  income: string
  isCellbase: boolean
  targetBlockNumber: number
  version: number
  displayInputs: any
  displayOutputs: any
  liveCellChanges: string
  capacityInvolved: string
  rgbTransferStep: string | null
  txStatus: string
  detailedMessage: string
  bytes: number
  largestTxInEpoch: number
  largestTx: number
  cycles: number | null
  maxCyclesInEpoch: number | null
  maxCycles: number | null
  createTimestamp?: number
}

总结

下一篇讲【全局常用Echarts组件封装】。关注本栏目,将实时更新。

相关推荐
程序猿阿伟10 分钟前
《Flutter社交应用暗黑奥秘:模式适配与色彩的艺术》
前端·flutter
rafael(一只小鱼)14 分钟前
黑马点评实战笔记
前端·firefox
weifont14 分钟前
React中的useSyncExternalStore使用
前端·javascript·react.js
初遇你时动了情19 分钟前
js fetch流式请求 AI动态生成文本,实现逐字生成渲染效果
前端·javascript·react.js
影子信息33 分钟前
css 点击后改变样式
前端·css
几何心凉1 小时前
如何使用 React Hooks 替代类组件的生命周期方法?
前端·javascript·react.js
小堃学编程1 小时前
前端学习(1)—— 使用HTML编写一个简单的个人简历展示页面
前端·javascript·html
hnlucky2 小时前
通俗易懂版知识点:Keepalived + LVS + Web + NFS 高可用集群到底是干什么的?
linux·前端·学习·github·web·可用性测试·lvs
懒羊羊我小弟2 小时前
使用 ECharts GL 实现交互式 3D 饼图:技术解析与实践
前端·vue.js·3d·前端框架·echarts
前端小巷子2 小时前
CSS3 遮罩
前端·css·面试·css3