React + Antd 实现优雅的应用内搜索组件
在现代Web应用中,快速导航和搜索功能是提升用户体验的关键要素。本文将介绍一个基于React+Antd的应用内搜索组件AppSearch
,它提供了类似VSCode命令面板的体验,使用户能够通过关键词快速搜索并导航到应用内不同页面。

组件架构
整个搜索组件由以下部分组成:
AppSearch
: 主组件,渲染搜索图标和触发搜索模态框AppSearchModal
: 搜索模态框,包含搜索输入框和结果列表AppSearchFooter
: 显示快捷键提示AppSearchKeyItem
: 用于渲染快捷键图标
同时,还包含几个自定义Hook:
useMenuSearch
: 处理菜单搜索逻辑useClickOutside
: 处理点击外部关闭模态框useRefs
: 管理多个React引用
主组件实现
tsx
import React, { useState } from 'react';
import { Tooltip } from 'antd';
import { createStyles } from 'antd-style';
import { Icon } from '@iconify/react';
import AppSearchModal from './components/AppSearchModal';
// 使用antd-style创建样式
const useStyles = createStyles(({}) => ({
search: {
padding: '0 8px',
cursor: 'pointer',
},
}));
const AppSearch: React.FC = () => {
const { styles } = useStyles();
const [showModal, setShowModal] = useState<boolean>(false);
return (
<div className={styles.search} onClick={() => setShowModal(true)}>
<Tooltip title="搜索">
<Icon icon="ant-design:search-outlined" />
</Tooltip>
<AppSearchModal visible={showModal} onClose={() => setShowModal(false)} />
</div>
);
};
export default AppSearch;
主组件实现十分简洁,通过useState
管理模态框的显示状态,点击搜索图标时打开模态框。
搜索模态框组件
搜索模态框是核心交互界面,它使用createPortal
将组件渲染到DOM树的其他位置(本例中是document.body
),这样可以避免父组件样式对模态框的影响。
tsx
// 模态框组件核心结构
const AppSearchModal: React.FC<AppSearchModalProps> = ({ visible, onClose }) => {
const { styles, cx } = useStyles();
const modalRef = useRef<HTMLDivElement>(null);
const scrollRef = useRef<HTMLUListElement>(null);
const [itemRefs, setItemRefs] = useRefs<HTMLLIElement>();
// 使用自定义hook处理搜索逻辑
const { handleSearch, searchResult, keyword, activeIndex, handleEnter, handleMouseEnter } =
useMenuSearch({
itemRefs,
scrollRef,
onClose,
});
// 关闭模态框并清空搜索结果
const handleClose = () => {
onClose();
searchResult.length = 0;
};
// 点击外部关闭模态框
useClickOutside(modalRef, handleClose);
const isNotData = !keyword || searchResult.length === 0;
if (!visible) return null;
const modalContent = (
<div className={styles.mask} onClick={(e) => e.stopPropagation()}>
<div className={styles.modal} ref={modalRef}>
{/* 搜索输入框 */}
<div className={styles.inputWrapper}>
<Input
classNames={{ input: styles.input }}
placeholder="搜索"
autoFocus
allowClear
onChange={handleSearch}
prefix={<Icon icon="ant-design:search-outlined" />}
/>
<span className={styles.cancel} onClick={handleClose}>
取消
</span>
</div>
{/* 搜索结果区域 */}
{isNotData && <div className={styles.notData}>没有找到相关结果</div>}
{!isNotData && (
<ul className={styles.list} ref={scrollRef}>
{searchResult.map((item, index) => (
<li
key={item.path}
ref={setItemRefs(index)}
data-index={index}
onMouseEnter={handleMouseEnter}
onClick={handleEnter}
className={cx(styles.item, {
[styles.itemActive]: activeIndex === index,
})}
>
{item.icon && (
<div className={styles.itemIcon}>
<Icon icon={item.icon} />
</div>
)}
<div className={styles.itemText}>{item.name}</div>
<div className={styles.itemEnter} data-role="item-enter">
<Icon icon="ant-design:enter-outlined" />
</div>
</li>
))}
</ul>
)}
<AppSearchFooter />
</div>
</div>
);
// 使用createPortal将模态框渲染到body
return createPortal(modalContent, document.body);
};
搜索逻辑的核心Hook - useMenuSearch
useMenuSearch
是搜索功能的核心,它实现了以下功能:
- 菜单数据的获取和处理
- 关键词搜索
- 键盘导航(上下箭头、回车、ESC等)
- 结果高亮与滚动定位
tsx
export function useMenuSearch({ itemRefs, scrollRef, onClose }: UseMenuSearchProps) {
const [keyword, setKeyword] = useState('');
const [searchResult, setSearchResult] = useState<SearchResult[]>([]);
const [activeIndex, setActiveIndex] = useState(-1);
const menuListRef = useRef<Menu[]>([]);
// 初始化菜单数据
useEffect(() => {
initMenu();
}, []);
/**
* 处理菜单树搜索,返回搜索结果列表
* @param menuTree 菜单树
* @param reg 搜索正则
* @param parent 父菜单项(用于构建层级名称)
* @returns 搜索结果列表
*/
const searchMenu = useCallback((menuTree: Menu[], reg: RegExp, parent?: Menu): SearchResult[] => {
const results: SearchResult[] = [];
menuTree.forEach((menu) => {
const { name, path, icon, children, hideMenu, meta, internalOrExternal } = menu;
const hasChildren = Array.isArray(children) && children.length > 0;
const hideChildren = meta?.hideChildrenInMenu;
// 判断当前菜单是否匹配搜索条件
const isMatch = !hideMenu && reg.test(name);
// 如果菜单匹配且是叶子节点或配置了隐藏子菜单,则添加到结果中
if (isMatch && (!hasChildren || hideChildren)) {
results.push({
name: parent?.name ? `${parent.name} > ${name}` : name,
path,
icon,
internalOrExternal,
});
}
// 处理子菜单
if (hasChildren && !hideChildren) {
results.push(...searchMenu(children, reg, menu));
}
});
return results;
}, []);
// 搜索处理,使用debounce防抖优化
const handleSearch = useCallback(
debounce((e: React.ChangeEvent<HTMLInputElement>) => {
const key = e.target.value;
setKeyword(key.trim());
if (!key) {
setSearchResult([]);
return;
}
const reg = createSearchReg(key);
setSearchResult(searchMenu(menuListRef.current, reg));
setActiveIndex(0);
}, 200),
[searchMenu],
);
// 搜索结果导航与键盘事件处理
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
switch (e.key) {
case 'Enter':
handleEnter();
break;
case 'ArrowUp':
handleUp();
break;
case 'ArrowDown':
handleDown();
break;
case 'Escape':
handleClose();
break;
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [handleEnter, handleUp, handleDown, handleClose]);
return {
handleSearch,
searchResult,
keyword,
activeIndex,
handleMouseEnter,
handleEnter,
};
}
辅助Hook介绍
useClickOutside - 点击外部区域监听
tsx
/**
* 监听点击元素外部的事件
* @param ref 目标元素的ref
* @param handler 点击外部时的回调函数
* @param enabled 是否启用该hook,默认为true
*/
export const useClickOutside = (
ref: RefObject<HTMLElement>,
handler: () => void,
enabled = true,
) => {
useEffect(() => {
if (!enabled) return;
const debouncedHandler = debounce((event: MouseEvent | TouchEvent) => {
if (!ref.current || ref.current.contains(event.target as Node)) return;
handler();
}, 100);
document.body.addEventListener('mousedown', debouncedHandler);
document.body.addEventListener('touchstart', debouncedHandler);
return () => {
document.body.removeEventListener('mousedown', debouncedHandler);
document.body.removeEventListener('touchstart', debouncedHandler);
debouncedHandler.cancel();
};
}, [ref, handler, enabled]);
};
useRefs - 管理多个元素引用
tsx
/**
* 管理多个元素的refs
* @returns [refs数组, 设置ref的函数]
*/
export function useRefs<T extends HTMLElement = HTMLElement>(): UseRefsReturn<T> {
const refs = useRef<T[]>([]);
useEffect(() => {
refs.current = [];
});
const setRefs = useCallback(
(index: number) => (instance: T) => {
refs.current[index] = instance;
},
[],
);
return [refs, setRefs];
}
底部快捷键提示组件
tsx
export const AppSearchFooter: React.FC = () => {
const { styles } = useStyles();
return (
<div className={styles.footer}>
<AppSearchKeyItem className={styles.item} icon="ant-design:enter-outlined" />
<span>搜索</span>
<AppSearchKeyItem className={styles.item} icon="ant-design:arrow-up-outlined" />
<AppSearchKeyItem className={styles.item} icon="ant-design:arrow-down-outlined" />
<span>导航</span>
<AppSearchKeyItem className={styles.item} icon="mdi:keyboard-esc" />
<span>关闭</span>
</div>
);
};
技术亮点解析
Iconify 图标解决方案
Iconify 是一个强大的图标解决方案,它提供了以下优势:
- 统一API: 通过统一的API访问多个图标集(包括Material Design, Ant Design等)
- 按需加载: 只加载实际使用的图标,减少体积
- 无需管理图标资源: 无需下载和维护SVG文件
使用示例:
tsx
import { Icon } from '@iconify/react';
// 使用Ant Design图标
<Icon icon="ant-design:search-outlined" />
// 使用Material Design图标
<Icon icon="mdi:keyboard-esc" />
antd-style 样式解决方案
antd-style
是Ant Design的新一代样式解决方案,它提供了:
- 主题变量访问: 直接访问Ant Design的主题变量
- TypeScript支持: 完全类型化的API
- CSS-in-JS: 组织和隔离组件样式
使用示例:
tsx
import { createStyles } from 'antd-style';
const useStyles = createStyles(({ token }) => ({
container: {
color: token.colorPrimary,
backgroundColor: token.colorBgContainer,
borderRadius: token.borderRadiusLG,
}
}));
const Component = () => {
const { styles, cx } = useStyles();
return <div className={styles.container}>内容</div>;
};
可能存在的问题
在使用createStyles创建模态框样式时,由于编译后的样式类名发生了变化,所以如果使用以下方式,并不能选中.item-enter元素(选中菜单项尾部的enter图标)
tsx
itemActive: {
color: token.colorTextLightSolid,
backgroundColor: token.colorPrimary,
'.item-enter': {
opacity: 1,
},
}
itemEnter: {
width: '30px',
opacity: 0,
},
因此,采用了以下方式来实现:
tsx
itemActive: {
color: token.colorTextLightSolid,
backgroundColor: token.colorPrimary,
'& [data-role="item-enter"]': {
opacity: 1,
},
}
itemEnter: {
width: '30px',
opacity: 0,
},
<div className={styles.itemEnter} data-role="item-enter">
<Icon icon="ant-design:enter-outlined" />
</div>
其中:
&
表示当前选择器的引用[data-role="item-enter"]
是一个属性选择器,选择带有特定data属性的元素
PS: 暂时未找到createStyles关于此类嵌套样式的直接解决方案,有更好的实现方式也可以评论区交流一下
总结
通过这个搜索组件,我们实现了一个类似VS Code的命令面板功能,它提供了以下特性:
- 友好的UI: 简洁直观的搜索界面
- 键盘导航: 支持键盘快捷键,提高使用效率
- 模糊搜索: 支持模糊匹配菜单项
- 响应式设计: 适配移动端和桌面端
这个组件展示了如何使用 React + Antd 实现优雅的应用内搜索组件,通过合理的架构设计和组件拆分,实现了复杂功能的简洁实现。
希望这篇文章对你有所启发,欢迎在评论区分享你的想法和改进建议!