React + Antd 实现优雅的应用内搜索组件

React + Antd 实现优雅的应用内搜索组件

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

组件架构

整个搜索组件由以下部分组成:

  1. AppSearch: 主组件,渲染搜索图标和触发搜索模态框
  2. AppSearchModal: 搜索模态框,包含搜索输入框和结果列表
  3. AppSearchFooter: 显示快捷键提示
  4. 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是搜索功能的核心,它实现了以下功能:

  1. 菜单数据的获取和处理
  2. 关键词搜索
  3. 键盘导航(上下箭头、回车、ESC等)
  4. 结果高亮与滚动定位
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 是一个强大的图标解决方案,它提供了以下优势:

  1. 统一API: 通过统一的API访问多个图标集(包括Material Design, Ant Design等)
  2. 按需加载: 只加载实际使用的图标,减少体积
  3. 无需管理图标资源: 无需下载和维护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的新一代样式解决方案,它提供了:

  1. 主题变量访问: 直接访问Ant Design的主题变量
  2. TypeScript支持: 完全类型化的API
  3. 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的命令面板功能,它提供了以下特性:

  1. 友好的UI: 简洁直观的搜索界面
  2. 键盘导航: 支持键盘快捷键,提高使用效率
  3. 模糊搜索: 支持模糊匹配菜单项
  4. 响应式设计: 适配移动端和桌面端

这个组件展示了如何使用 React + Antd 实现优雅的应用内搜索组件,通过合理的架构设计和组件拆分,实现了复杂功能的简洁实现。

希望这篇文章对你有所启发,欢迎在评论区分享你的想法和改进建议!

相关推荐
AI程序员罗尼32 分钟前
React SSR 水合(Hydration)详解
react.js
AI程序员罗尼39 分钟前
React 服务端渲染 (SSR) 详解
react.js
AI程序员罗尼39 分钟前
React useEffect 在服务端渲染中的执行行为
react.js
Rachel_wang2 小时前
如何安装并启动 electron-prokit(react+ts) 项目
react.js·electron
就是我2 小时前
如何用lazy+ Suspense实现组件延迟加载
javascript·react native·react.js
新时代农民工Top4 小时前
React + JavaScript 实现可拖拽进度条
前端·javascript·react.js
小钰能吃三碗饭5 小时前
第八篇:【React 性能调优】从优化实践到自动化性能监控
前端·javascript·react.js
Jackson_Mseven5 小时前
如何从0到1搭建基于antd的monorepo库——使用dumi进行文档展示(五)
前端·react.js·ant design
Kairo_015 小时前
使用 Node.js、Express 和 React 构建强大的 API
react.js·node.js·express
前端摸鱼杭小哥6 小时前
前端何时能出个"秦始皇"一统天下?我是真学不动啦!
前端·vue.js·react.js