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

devtools/2024/10/17 18:24:02/

文章目录

  • 前言
  • 一、Search组件封装
    • 1. 效果展示
    • 2. 功能分析
    • 3. 代码+详细注释
    • 4. 使用方式
  • 二、搜索结果展示组件封装
    • 1. 功能分析
    • 2. 代码+详细注释
  • 三、引用到文件,自行取用
  • 总结


前言

今天,我们来封装一个业务灵巧的组件,它集成了全局搜索和展示搜索结果的功能。通过配置文件,我们可以为不同的模块定制展示和跳转逻辑,集中管理不同模块,当要加一个模块时,只需要通过配置即可,从而减少重复的代码,并方便地进行维护和扩展。同时,我们将使用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. 代码+详细注释

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

// @/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;// 根据搜索结果数据的第一个元素获取搜索结果对应的URLconst 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. 使用方式

// 引入组件
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. 代码+详细注释

// @/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); // 根据搜索结果项获取对应的 URLconst 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)获取不同模块地址,展示名称的方法

// @/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)用到的类型声明

// @/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: stringnumber: numbertransactionsCount: numberproposalsCount: numberunclesCount: numberuncleBlockHashes: string[]reward: stringrewardStatus: 'pending' | 'issued'totalTransactionFee: stringreceivedTxFee: stringreceivedTxFeeStatus: 'pending' | 'calculated'totalCellCapacity: stringminerHash: stringminerMessage: stringtimestamp: numberdifficulty: stringepoch: numberlength: stringstartNumber: numberversion: numbernonce: stringtransactionsRoot: stringblockIndexInEpoch: stringminerReward: stringliveCellChanges: stringsize: numberlargestBlockInEpoch: numberlargestBlock: numbercycles: number | nullmaxCyclesInEpoch: number | nullmaxCycles: number | null
}
-------------------------------------------------------------------------------------------------------
// @/models/Transaction/index.ts
export interface Transaction {isBtcTimeLock: booleanisRgbTransaction: booleanrgbTxid: string | nulltransactionHash: string// FIXME: this type declaration should be fixed by adding a transformation between internal state and response of APIblockNumber: number | stringblockTimestamp: number | stringtransactionFee: stringincome: stringisCellbase: booleantargetBlockNumber: numberversion: numberdisplayInputs: anydisplayOutputs: anyliveCellChanges: stringcapacityInvolved: stringrgbTransferStep: string | nulltxStatus: stringdetailedMessage: stringbytes: numberlargestTxInEpoch: numberlargestTx: numbercycles: number | nullmaxCyclesInEpoch: number | nullmaxCycles: number | nullcreateTimestamp?: number
}

总结

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


http://www.ppmy.cn/devtools/56470.html

相关文章

音频处理新纪元:AudioLM 长序列音频数据的智能优化策略

&#x1f30c; 音频处理新纪元&#xff1a;AudioLM 长序列音频数据的智能优化策略 &#x1f680; 在音频分析和深度学习领域&#xff0c;长序列音频数据的处理一直是一个挑战。长序列不仅包含丰富的信息&#xff0c;也带来了计算复杂度高、内存消耗大等问题。AudioLM&#xff…

昇思25天学习打卡营第2天|MindSpore快速入门-张量

张量 Tensor 张量&#xff08;Tensor&#xff09;是一个可用来表示在一些矢量、标量和其他张量之间的线性关系的多线性函数&#xff0c;这些线性关系的基本例子有内积、外积、线性映射以及笛卡儿积。 张量是一种特殊的数据结构&#xff0c;与数组和矩阵非常相似。张量&#x…

.NET C# 使用OpenCV实现人脸识别

.NET C# 使用OpenCV实现模型训练、人脸识别 码图~~~ 1 引入依赖 OpenCvSHarp4 - 4.10.0.20240616 OpenCvSHarp4.runtime.win - 4.10.0.20240616 2 人脸数据存储结构 runtime directory | face | {id}_{name} | *.jpg id - 不可重复 name - 人名 *.jpg - 人脸照片3 Demo 3.…

基于springboot、logback的日志脱敏组件

Logback⽇志数据脱敏⼯具&#xff1a;隐私和安全的守护者 概述 在涉及敏感数据的⽇志记录环境中&#xff0c;数据保护和个⼈隐私⽆疑是⾄关重要的领域。确保敏感数据不被泄露&#xff0c;脱敏处理成为必不可少的⼀步。数据脱敏是⼀种技术⼿段&#xff0c;其将敏感信息转换为不…

ARM 240625

练习&#xff1a; 汇编实现1-100累加&#xff0c;结果保存在r0 .text 声明下面内容都属于文本段内容 .globl _start 声明 _start 是一个全局启用的标签_start: 封装 _start 标签&#xff0c;汇编的标签和C中函数类似mov r0,#0 mov 把0 搬运到 r0 寄存器mov r1,#1 mov 把1 …

【使用webrtc-streamer解析rtsp视频流】

webrtc-streamer WebRTC (Web Real-Time Communications) 是一项实时通讯技术&#xff0c;它允许网络应用或者站点&#xff0c;在不借助中间媒介的情况下&#xff0c;建立浏览器之间点对点&#xff08;Peer-to-Peer&#xff09;的连接&#xff0c;实现视频流和&#xff08;或&a…

Android中ViewModel+LiveData+DataBinding的配合使用(kotlin)

Android 中 ViewModel、LiveData 和 Data Binding 的配合使用&#xff08;Kotlin&#xff09; 摘要 本文将介绍如何在 Android 开发中结合使用 ViewModel、LiveData 和 Data Binding 进行数据绑定和状态更新。我们将详细探讨这三者之间的关系&#xff0c;并展示如何在 Kotlin…

uni-app的showModal提示框,进行删除的二次确认,可自定义确定或取消操作

实现效果&#xff1a; 此处为删除的二次确认示例&#xff0c;点击删除按钮时出现该提示&#xff0c;该提示写在js script中。 实现方式&#xff1a; 通过uni.showModal进行提示&#xff0c;success为确认状态下的操作自定义&#xff0c;此处调用后端接口进行了删除操作&#…