第五章、菜单导航栏

57 阅读4分钟

菜单导航栏

版本一:纯 React + CSS 实现

src/components/HorizontalMenu.tsx

// src/components/HorizontalMenu.tsx
import React from "react";
import { NavLink } from "react-router-dom";
import { menuConfig, MenuItem } from "@/types/menu";
import "./HorizontalMenu.css";

const HorizontalMenu: React.FC = () => {
  const renderMenu = (menus: MenuItem[]) => {
    return menus.map((item) => (
      <li key={item.key} className="menu-item">
        <NavLink
          to={item.path}
          className={({ isActive }) =>
            isActive ? "menu-link active" : "menu-link"
          }
        >
          {item.label}
        </NavLink>

        {item.children && (
          <ul className="submenu">
            {item.children.map((sub) => (
              <li key={sub.key} className="submenu-item">
                <NavLink
                  to={sub.path}
                  className={({ isActive }) =>
                    isActive ? "submenu-link active" : "submenu-link"
                  }
                >
                  {sub.label}
                </NavLink>
              </li>
            ))}
          </ul>
        )}
      </li>
    ));
  };

  return (
    <nav className="menu-container">
      <ul className="menu-list">{renderMenu(menuConfig)}</ul>
    </nav>
  );
};

export default HorizontalMenu;

HorizontalMenu.css

.menu-container {
  background: #fff;
  border-bottom: 1px solid #eaeaea;
  padding: 0 16px;
}

.menu-list {
  display: flex;
  list-style: none;
  margin: 0;
  padding: 0;
}

.menu-item {
  position: relative;
  margin-right: 20px;
}

.menu-link {
  display: block;
  padding: 12px 16px;
  color: #333;
  text-decoration: none;
  transition: color 0.3s;
}

.menu-link:hover {
  color: #1677ff;
}

.menu-link.active {
  color: #1677ff;
  border-bottom: 2px solid #1677ff;
}

.submenu {
  position: absolute;
  top: 44px;
  left: 0;
  background: #fff;
  list-style: none;
  border: 1px solid #eaeaea;
  padding: 8px 0;
  display: none;
  min-width: 120px;
  z-index: 10;
}

.menu-item:hover .submenu {
  display: block;
}

.submenu-item {
  padding: 0;
}

.submenu-link {
  display: block;
  padding: 8px 16px;
  color: #333;
  text-decoration: none;
}

.submenu-link:hover,
.submenu-link.active {
  background: #f5f5f5;
  color: #1677ff;
}

说明

版本一(纯 React + CSS)中,子菜单是通过 hover 自动显示的:

.menu-item:hover .submenu { display: block; }

版本二:Ant Design 实现(更简洁)

import React from "react";
import { Menu } from "antd";
import type { MenuProps } from "antd";
import { useNavigate, useLocation } from "react-router-dom";
import { menuConfig } from "@/types/menu";

const HorizontalMenu: React.FC = () => {
  const navigate = useNavigate();
  const location = useLocation();

  const items: MenuProps["items"] = menuConfig.map((item) => ({
    key: item.path,
    label: item.label,
    children: item.children?.map((sub) => ({
      key: sub.path,
      label: sub.label,
    })),
  }));

  return (
    <Menu
      mode="horizontal"
      selectedKeys={[location.pathname]}
      items={items}
      onClick={({ key }) => navigate(key)}
      style={{ borderBottom: "1px solid #eee" }}
    />
  );
};

export default HorizontalMenu;

版本三:纯 React + CSS 实现(悬停展开/收缩加强版)

MenuItemComponent.tsx

封装单个菜单项(包含子菜单逻辑)

import React, { useRef, useState } from "react";
import { NavLink, useLocation } from "react-router-dom";
import { MenuItem } from "@/types/menu";
import "./HorizontalMenu.css";

interface Props {
  item: MenuItem;
}

const MenuItemComponent: React.FC<Props> = ({ item }) => {
  const [isOpen, setIsOpen] = useState(false);
  const timeoutRef = useRef<NodeJS.Timeout | null>(null);
  const location = useLocation();

  // 鼠标进入与离开
  const handleMouseEnter = () => {
    if (timeoutRef.current) {
      clearTimeout(timeoutRef.current);
      timeoutRef.current = null;
    }
    setIsOpen(true);
  };

  const handleMouseLeave = () => {
    timeoutRef.current = setTimeout(() => {
      setIsOpen(false);
    }, 200);
  };

  // ✅ 父菜单激活逻辑:当前路径等于父路径或在其子路径下
  const isActive =
    location.pathname === item.path ||
    (item.children && location.pathname.startsWith(item.path + "/"));

  return (
    <li
      className="menu-item"
      onMouseEnter={handleMouseEnter}
      onMouseLeave={handleMouseLeave}
    >
      <div className={`menu-link ${isActive ? "active" : ""}`}>
        <NavLink
          to={item.path}
          className={({ isActive: linkActive }) =>
            linkActive || isActive ? "menu-label active" : "menu-label"
          }
          end
        >
          {item.label}
        </NavLink>
        {item.children && <span className="arrow">{isOpen ? "▲" : "▼"}</span>}
      </div>

      {item.children && (
        <ul className={`submenu ${isOpen ? "open" : ""}`}>
          {item.children.map((sub) => (
            <li key={sub.key} className="submenu-item">
              <NavLink
                to={sub.path}
                className={({ isActive }) =>
                  isActive ? "submenu-link active" : "submenu-link"
                }
              >
                {sub.label}
              </NavLink>
            </li>
          ))}
        </ul>
      )}
    </li>
  );
};

export default MenuItemComponent;

HorizontalMenu.tsx

主文件只负责遍历渲染,不再包含逻辑

import React from "react";
import { menuConfig } from "@/types/menu";
import MenuItemComponent from "./MenuItemComponent";
import "./HorizontalMenu.css";

const HorizontalMenu: React.FC = () => {
  return (
    <nav className="menu-container">
      <ul className="menu-list">
        {menuConfig.map((item) => (
          <MenuItemComponent key={item.key} item={item} />
        ))}
      </ul>
    </nav>
  );
};

export default HorizontalMenu;

版本四:“响应式/动态”菜单(更多版)

自动计算菜单可容纳数量,当窗口宽度变化时,
超出可视宽度的菜单自动折叠进「更多」菜单中。 并且希望通过 itemLen(每个菜单项平均宽度)和 marginLeft(每个菜单项左边距)两个变量灵活控制布局。

  • HorizontalMenu.tsx
import React from "react";
import { menuConfig } from "@/types/menu";
import MenuItemComponent from "./MenuItemComponent";
import "./HorizontalMenu.css";

/** 每个菜单项平均宽度(px) */
export const itemLen = 120;
/** 每个菜单项左边距 */
export const marginLeft = 20;

const HorizontalMenu: React.FC = () => {
 /**获取菜单容器Dom */
  const menuRef = useRef<HTMLDivElement | null>(null);
 /** 监听窗口尺寸变化 */
  const { width } = useSize(menuRef) ?? { width: 0, height: 0 }
  /** 动态计算能容纳的菜单数量 */
  const resData: MenuItem[] = useMemo(() => {
    const showNum = Math.floor(width / (itemLen + marginLeft))
    if (showNum >= menuConfig.length) return menuConfig
      return [
      ...menuConfig.slice(0, showNum - 1),//给更多留一个位置
      {
        key: 'more',
        label: '更多',
        path: '#',
        children: menuConfig.slice(showNum - 1),
      },
   ]
  },[width])
  
  return (
    <nav className="menu-container"  ref={menuRef}>
      <ul className="menu-list">
        {resData.map((item) => (
          <MenuItemComponent key={item.key} item={item} />
        ))}
      </ul>
    </nav>
  );
};

export default HorizontalMenu;
  • MenuItemComponent.tsx
import React, { useRef, useState } from "react";
import { NavLink, useLocation } from "react-router-dom";
import { MenuItem } from "@/types/menu";
import "./HorizontalMenu.css";

interface Props {
  item: MenuItem;
}

const MenuItemComponent: React.FC<Props> = ({ item }) => {
  const [isOpen, setIsOpen] = useState(false);
  const timeoutRef = useRef<NodeJS.Timeout | null>(null);
  const location = useLocation();

  const handleMouseEnter = () => {
    if (timeoutRef.current) {
      clearTimeout(timeoutRef.current);
      timeoutRef.current = null;
    }
    setIsOpen(true);
  };

  const handleMouseLeave = () => {
    timeoutRef.current = setTimeout(() => {
      setIsOpen(false);
    }, 200);
  };

  const isActive =
    location.pathname === item.path ||
    (item.children && location.pathname.startsWith(item.path + "/"));

  return (
    <li
      className="menu-item"
      style={{ width: itemLen + "px", marginLeft: marginLeft + "px" }}/**添加动态css样式*/
      onMouseEnter={handleMouseEnter}
      onMouseLeave={handleMouseLeave}
    >
      <div className={`menu-link ${isActive ? "active" : ""}`}>
        {item.path !== "#" ? (
          <NavLink
            to={item.path}
            className={({ isActive: linkActive }) =>
              linkActive || isActive ? "menu-label active" : "menu-label"
            }
            end
          >
            {item.label}
          </NavLink>
        ) : (
          <span className="menu-label">{item.label}</span>
        )}
        {item.children && <span className="arrow">{isOpen ? "▲" : "▼"}</span>}
      </div>

      {item.children && (
        <ul className={`submenu ${isOpen ? "open" : ""}`}>
          {item.children.map((sub) => (
            <li key={sub.key} className="submenu-item">
              <NavLink
                to={sub.path}
                className={({ isActive }) =>
                  isActive ? "submenu-link active" : "submenu-link"
                }
              >
                {sub.label}
              </NavLink>
            </li>
          ))}
        </ul>
      )}
    </li>
  );
};

export default MenuItemComponent;

其他说明

监听窗口尺寸变化的方法

这三种方式(ResizeObserverwindow.resizeahooksuseSize)都能在 React 项目中监听元素或窗口尺寸变化

1.window.resize 事件

特点:

  • 只能检测 整个窗口 尺寸变化。
  • 无法检测单个 DOM 元素的尺寸变化(比如某个 div 的宽度变了)。
  • 如果有多个组件监听 window.resize,可能造成性能浪费。
  • 可配合 throttle/debounce 优化。

2.ResizeObserver API

特点:

  • 可以监听 任何元素的宽高变化,不仅是窗口。
  • 响应更精准(支持容器大小变化、字体变化等)。
  • 性能较好,浏览器原生实现(异步批量触发)。
  • IE 不支持(现代浏览器都支持)。
  • React 推荐用于组件级自适应布局。
 /** 监听窗口尺寸变化 */
  useEffect(() => {
    updateVisibleCount();

    // 用 ResizeObserver 比 window.resize 更精确
    const observer = new ResizeObserver(() => {
      updateVisibleCount();
    });
    if (menuRef.current) observer.observe(menuRef.current);

    window.addEventListener("resize", updateVisibleCount);
    return () => {
      observer.disconnect();
      window.removeEventListener("resize", updateVisibleCount);
    };
  }, []);

3.ahooksuseSize

特点:

  • 内部自动使用 ResizeObserver,无需手动创建/清理。
  • 返回 { width, height } 对象。
  • 自动防抖 + 性能优化。
  • 支持 SSR 安全检查。
  • React-friendly,最方便的方式。

总结对比表

特性window.resizeResizeObserverahooks useSize
监听对象整个窗口任意 DOM 元素任意 DOM 元素
精度只能全局窗口变化精确到单个元素精确到单个元素
React 适配需要手动封装需手动 useEffect✅ 封装成 Hook
性能全局触发、可能多次渲染异步批量优化ResizeObserver
兼容性全浏览器支持IE 不支持依赖 ResizeObserver
使用难度简单中等✅ 最简单
推荐场景响应式布局调整元素级尺寸监听元素级尺寸监听(React)

使用方法

在你的布局组件( MainLayout.tsx)里:

import HorizontalMenu from "@/components/HorizontalMenu";

const MainLayout: React.FC = () => {
  return (
    <div>
      <HorizontalMenu />
      <div style={{ padding: "20px" }}>
        <Outlet /> {/* React Router 子路由 */}
      </div>
    </div>
  );
};

export default MainLayout;