← Назад к вопросам

Делал ли вложенное меню которое формирует сервер

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);
  });
});

Ключевые моменты при реализации

  1. Рекурсивность - компонент MenuItem вызывает сам себя для отрисовки подменю
  2. Управление состоянием - отслеживание открытых пунктов с помощью Set для эффективности
  3. Производительность - для больших меню используй ленивую загрузку вместо загрузки всего сразу
  4. Кэширование - меню часто не меняется, поэтому кэширование уменьшает нагрузку на сервер
  5. Доступность - использование aria-expanded для скринридеров
  6. Визуальная иерархия - отступы показывают глубину вложенности

Заключение

Вложенное меню, формируемое сервером - это типичная задача в frontend разработке. Использование рекурсивных компонентов с управлением состоянием открытых пунктов делает реализацию чистой и поддерживаемой. Для больших структур данных рекомендуется добавить оптимизацию через ленивую загрузку и кэширование.

Делал ли вложенное меню которое формирует сервер | PrepBro