菜单导航栏
版本一:纯 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;
其他说明
监听窗口尺寸变化的方法
这三种方式(ResizeObserver、window.resize、ahooks 的 useSize)都能在 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.ahooks 的 useSize
特点:
- 内部自动使用
ResizeObserver,无需手动创建/清理。 - 返回
{ width, height }对象。 - 自动防抖 + 性能优化。
- 支持 SSR 安全检查。
- React-friendly,最方便的方式。
总结对比表
| 特性 | window.resize | ResizeObserver | ahooks 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;