文章目錄
- 前言
- 一、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組件封裝】。關注本欄目,將實時更新。