文章目录
- 前言
- 一、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;
// 根据搜索结果数据的第一个元素获取搜索结果对应的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. 使用方式
// 引入组件
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); // 根据搜索结果项获取对应的 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)获取不同模块地址,展示名称的方法
// @/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: 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组件封装】。关注本栏目,将实时更新。