← Назад к вопросам
Делал ли вложенное меню которое формирует сервер
2.0 Middle🔥 152 комментариев
#Браузер и сетевые технологии
Комментарии (2)
🐱
claude-haiku-4.5PrepBro AI2 апр. 2026 г.(ред.)
Ответ сгенерирован нейросетью и может содержать ошибки
Вложенное меню, формируемое сервером
Это очень практичный вопрос о работе с динамическими структурами данных, которые приходят с бэкенда. Я расскажу о реальном опыте реализации такого функционала.
Что такое вложенное меню с сервера
Вложенное меню - это иерархическая структура пунктов меню, где каждый пункт может содержать подменю. Сервер отправляет эту структуру в виде древовидной или рекурсивной структуры JSON.
Практическая реализация
1. Определение типов
export interface MenuItem {
id: string | number;
label: string;
href: string;
icon?: string;
children?: MenuItem[];
active?: boolean;
disabled?: boolean;
}
2. Рекурсивный компонент для отрисовки
import React, { useState } from 'react';
import { MenuItem as MenuItemType } from '@/types/menu';
interface MenuItemProps {
item: MenuItemType;
level?: number;
isOpen?: boolean;
onToggle?: (itemId: string | number) => void;
}
export function MenuItem({ item, level = 0, isOpen = false, onToggle }: MenuItemProps) {
const hasChildren = item.children && item.children.length > 0;
const indent = level * 20;
return (
<li className="menu-item">
<div
className="flex items-center justify-between cursor-pointer hover:bg-surface-hover"
style={{ paddingLeft: `${indent}px` }}
>
<a
href={item.href}
className="flex-1 py-2 px-3 text-content-primary"
onClick={(e) => {
if (hasChildren) {
e.preventDefault();
onToggle?.(item.id);
}
}}
>
{item.icon && <span className="mr-2">{item.icon}</span>}
{item.label}
</a>
{hasChildren && (
<button
className="px-2 py-2 flex-shrink-0"
onClick={() => onToggle?.(item.id)}
aria-expanded={isOpen}
>
<span className={`transition-transform ${isOpen ? 'rotate-180' : ''}`}>
Раскрыть
</span>
</button>
)}
</div>
{hasChildren && isOpen && (
<ul className="menu-submenu">
{item.children?.map((child) => (
<MenuItem
key={child.id}
item={child}
level={level + 1}
isOpen={isOpen}
onToggle={onToggle}
/>
))}
</ul>
)}
</li>
);
}
3. Главный компонент меню
import { useState, useEffect } from 'react';
import { MenuItem } from './MenuItem';
import { MenuItem as MenuItemType } from '@/types/menu';
export function Menu() {
const [items, setItems] = useState<MenuItemType[]>([]);
const [openItems, setOpenItems] = useState<Set<string | number>>(new Set());
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchMenu = async () => {
try {
setLoading(true);
const response = await fetch('/api/v1/menu');
if (!response.ok) throw new Error('Failed to load menu');
const data: MenuItemType[] = await response.json();
setItems(data);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
} finally {
setLoading(false);
}
};
fetchMenu();
}, []);
const handleToggle = (itemId: string | number) => {
const newOpenItems = new Set(openItems);
if (newOpenItems.has(itemId)) {
newOpenItems.delete(itemId);
} else {
newOpenItems.add(itemId);
}
setOpenItems(newOpenItems);
};
if (loading) return <div>Загрузка меню...</div>;
if (error) return <div className="text-status-error">Ошибка: {error}</div>;
return (
<nav className="menu">
<ul className="menu-list">
{items.map((item) => (
<MenuItem
key={item.id}
item={item}
isOpen={openItems.has(item.id)}
onToggle={handleToggle}
/>
))}
</ul>
</nav>
);
}
Управление состоянием с Context
import { createContext, useContext, useState, ReactNode } from 'react';
interface MenuContextType {
openItems: Set<string | number>;
toggleItem: (id: string | number) => void;
isOpen: (id: string | number) => boolean;
}
const MenuContext = createContext<MenuContextType | undefined>(undefined);
export function MenuProvider({ children }: { children: ReactNode }) {
const [openItems, setOpenItems] = useState<Set<string | number>>(new Set());
const toggleItem = (id: string | number) => {
const newSet = new Set(openItems);
if (newSet.has(id)) {
newSet.delete(id);
} else {
newSet.add(id);
}
setOpenItems(newSet);
};
const isOpen = (id: string | number) => openItems.has(id);
return (
<MenuContext.Provider value={{ openItems, toggleItem, isOpen }}>
{children}
</MenuContext.Provider>
);
}
export function useMenu() {
const context = useContext(MenuContext);
if (!context) {
throw new Error('useMenu must be used within MenuProvider');
}
return context;
}
Оптимизация для больших меню
export function useLazyMenu() {
const [expandedIds, setExpandedIds] = useState<Set<string | number>>(new Set());
const [loadedChildren, setLoadedChildren] = useState<Map<string | number, MenuItem[]>>(new Map());
const expandItem = async (itemId: string | number) => {
if (loadedChildren.has(itemId)) {
toggleOpen(itemId);
return;
}
try {
const response = await fetch(`/api/v1/menu/${itemId}/children`);
const children = await response.json();
setLoadedChildren(prev => new Map(prev).set(itemId, children));
setExpandedIds(prev => new Set(prev).add(itemId));
} catch (err) {
console.error('Failed to load submenu:', err);
}
};
const toggleOpen = (itemId: string | number) => {
setExpandedIds(prev => {
const newSet = new Set(prev);
if (newSet.has(itemId)) {
newSet.delete(itemId);
} else {
newSet.add(itemId);
}
return newSet;
});
};
return {
expandedIds,
loadedChildren,
expandItem,
toggleOpen
};
}
Кэширование меню
export function useMenuCache() {
const [menu, setMenu] = useState<MenuItem[] | null>(null);
const cacheKey = 'app-menu';
const cacheTTL = 5 * 60 * 1000;
const getMenu = async () => {
const cached = localStorage.getItem(cacheKey);
if (cached) {
const { data, timestamp } = JSON.parse(cached);
if (Date.now() - timestamp < cacheTTL) {
setMenu(data);
return data;
}
}
const response = await fetch('/api/v1/menu');
const data = await response.json();
localStorage.setItem(cacheKey, JSON.stringify({
data,
timestamp: Date.now()
}));
setMenu(data);
return data;
};
return { menu, getMenu };
}
Тестирование
import { render, screen, fireEvent } from '@testing-library/react';
import { MenuItem } from './MenuItem';
import { MenuItem as MenuItemType } from '@/types/menu';
describe('MenuItem', () => {
const mockItem: MenuItemType = {
id: 1,
label: 'Test Item',
href: '/test',
children: [
{ id: 2, label: 'Child', href: '/test/child', children: [] }
]
};
it('renders menu item with label', () => {
render(<MenuItem item={mockItem} />);
expect(screen.getByText('Test Item')).toBeInTheDocument();
});
it('toggles submenu on button click', () => {
const handleToggle = vi.fn();
render(
<MenuItem
item={mockItem}
isOpen={false}
onToggle={handleToggle}
/>
);
const toggleButton = screen.getByRole('button');
fireEvent.click(toggleButton);
expect(handleToggle).toHaveBeenCalledWith(1);
});
});
Ключевые моменты при реализации
- Рекурсивность - компонент MenuItem вызывает сам себя для отрисовки подменю
- Управление состоянием - отслеживание открытых пунктов с помощью Set для эффективности
- Производительность - для больших меню используй ленивую загрузку вместо загрузки всего сразу
- Кэширование - меню часто не меняется, поэтому кэширование уменьшает нагрузку на сервер
- Доступность - использование aria-expanded для скринридеров
- Визуальная иерархия - отступы показывают глубину вложенности
Заключение
Вложенное меню, формируемое сервером - это типичная задача в frontend разработке. Использование рекурсивных компонентов с управлением состоянием открытых пунктов делает реализацию чистой и поддерживаемой. Для больших структур данных рекомендуется добавить оптимизацию через ленивую загрузку и кэширование.