Прогрессивная загрузка иерархии документов

Практическое руководство по использованию механизма инкрементальной загрузки дерева документов

Введение

Что такое прогрессивная загрузка?

Прогрессивная загрузка (Progressive Loading) — это механизм инкрементальной загрузки иерархических данных, который позволяет загружать дерево документов частями по мере необходимости, вместо загрузки всей структуры сразу.

Практический пример: Представьте, что у вас есть дерево документов с 5 уровнями вложенности и 10,000 документов. Вместо загрузки всех 10,000 документов при открытии страницы, система загружает только первый уровень (например, 5 компаний). Когда пользователь раскрывает компанию, загружаются её сделки. Когда раскрывает сделку — загружаются её документы.

Зачем это нужно?

  • Производительность: Загружаются только видимые данные, что ускоряет начальную загрузку страницы в 10-100 раз
  • Масштабируемость: Система работает одинаково быстро с 100 и с 100,000 документов
  • UX: Пользователь видит первые данные мгновенно, остальные подгружаются по требованию
  • Экономия ресурсов: Снижение нагрузки на сервер, базу данных и сетевой трафик

Ключевые возможности

  • Загрузка по уровням глубины (depth-based loading)
  • Drill-down навигация с фильтрацией по родительскому пути
  • Динамическая группировка данных
  • Два режима глубины: inclusive (до уровня включительно) и exact (точно на уровне)
  • Метаданные для UI (has_children, children_count, document_count)
  • Умное включение документов на конечных узлах
  • Поддержка пагинации и сортировки

Как это работает

Пошаговый процесс

Шаг 1: Начальная загрузка (depth=1)

Пользователь открывает страницу документов. Frontend отправляет запрос:

POST /api/v1/web/documents/query
{
  "query_type": "structured",
  "depth": 1,
  "depth_mode": "inclusive",
  "parent_path": "",
  "include_documents": false
}

Результат: Загружается только первый уровень иерархии (например, список компаний):

├── Company A [has_children: true, children_count: 3] ├── Company B [has_children: true, children_count: 2] └── Company C [has_children: false, document_count: 1]
Шаг 2: Drill-down навигация

Пользователь кликает на "Company A". Frontend отправляет:

POST /api/v1/web/documents/query
{
  "query_type": "structured",
  "depth": 1,
  "depth_mode": "inclusive",
  "parent_path": "company-a-uuid/",
  "include_documents": false
}

Результат: Загружаются дочерние элементы Company A:

Company A/ ├── Deal 1 [has_children: true, children_count: 5] ├── Deal 2 [has_children: true, children_count: 3] └── Deal 3 [has_children: false, document_count: 2]
Шаг 3: Загрузка документов

Пользователь кликает на "Deal 1". Frontend отправляет:

POST /api/v1/web/documents/query
{
  "query_type": "structured",
  "depth": 1,
  "depth_mode": "inclusive",
  "parent_path": "company-a-uuid/deal-1-uuid/",
  "include_documents": true
}

Результат: Загружаются подразделы сделки с документами:

Company A / Deal 1/ ├── Contracts [has_children: false, documents: [doc1.pdf, doc2.pdf]] ├── Invoices [has_children: false, documents: [inv1.pdf, inv2.pdf]] └── Reports [has_children: true, children_count: 2]

Архитектура системы

Механизм прогрессивной загрузки работает на нескольких уровнях:

  1. Frontend (JavaScript):
    • Управляет состоянием дерева (раскрытые/закрытые узлы)
    • Отслеживает, какие узлы уже загружены
    • Формирует запросы с правильными параметрами
    • Обновляет UI после получения данных
  2. Backend (Go):
    • Handler валидирует входные параметры
    • UseCase выполняет бизнес-логику запроса
    • Repository фильтрует документы по hierarchy_path
    • Service строит дерево с метаданными
  3. Database (PostgreSQL):
    • Использует материализованный путь (hierarchy_path)
    • Индекс на hierarchy_path для быстрой фильтрации
    • LIKE запросы для получения поддеревьев

Материализованный путь

Основа прогрессивной загрузки — это хранение полного пути к каждому документу в поле hierarchy_path:

-- Примеры hierarchy_path в базе данных
"company-uuid-1/"                                    -- Company A (level 1)
"company-uuid-1/deal-uuid-1/"                        -- Deal 1 (level 2)
"company-uuid-1/deal-uuid-1/contracts-uuid-1/"       -- Contracts (level 3)
"company-uuid-1/deal-uuid-1/contracts-uuid-1/doc1/"  -- Document 1 (level 4)

Фильтрация поддерева:

-- Получить все элементы внутри "Company A / Deal 1"
SELECT * FROM documents
WHERE hierarchy_path LIKE 'company-uuid-1/deal-uuid-1/%'
Преимущества подхода:
  • Быстрая фильтрация по индексу (B-tree или GIN)
  • Не требуется рекурсивных запросов
  • Простота реализации и понимания
  • Масштабируемость на миллионы записей

API Endpoints

POST /api/v1/web/documents/query

Основной endpoint для комплексного запроса документов с поддержкой прогрессивной загрузки, группировки, фильтрации и сортировки.

Authentication

Требуется JWT токен:

Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...

Получить токен можно через POST /api/v1/auth/login

Request Headers

Content-Type: application/json
Authorization: Bearer {your_jwt_token}

Request Body Schema

Поле Тип Обязательно Описание
query_type string Да Тип запроса: "structured", "search", "filter"
depth int Нет Глубина загрузки дерева (1, 2, 3...). По умолчанию: 1
depth_mode string Нет "inclusive" (до уровня включительно) или "exact" (точно на уровне)
parent_path string Нет Путь родителя для drill-down (например: "uuid1/uuid2/")
group_by []string Нет Массив ключей для группировки
include_documents bool Нет Включать ли документы в узлы. По умолчанию: true
limit int Нет Лимит результатов для пагинации
offset int Нет Смещение для пагинации

Response Schema

Поле Тип Описание
data []Node Массив узлов дерева
total int Общее количество документов (без пагинации)

Node Schema

Поле Тип Описание
key string Ключ узла (название уровня иерархии)
value string Значение узла (ID или UUID)
label string Читаемая метка для отображения
level int Относительный уровень узла (1, 2, 3...)
document_count int Количество документов в узле
has_children bool Есть ли дочерние узлы
children_count int Количество дочерних узлов
documents []Document Массив документов (если include_documents=true)
children []Node Массив дочерних узлов

Status Codes

Код Описание
200 Успешный запрос
400 Неверные параметры запроса
401 Не авторизован (отсутствует или невалидный JWT токен)
403 Доступ запрещен (нет прав на ресурс)
500 Внутренняя ошибка сервера

Параметры запроса

depth - Глубина загрузки

Определяет, сколько уровней иерархии загрузить относительно текущего узла.

Пример: depth=1

Загружает только непосредственные дочерние элементы:

{
  "depth": 1,
  "parent_path": "company-a/"
}

Результат: Загрузятся только сделки компании A, но не их подразделы.

Пример: depth=2

Загружает два уровня вложенности:

{
  "depth": 2,
  "parent_path": "company-a/"
}

Результат: Загрузятся сделки компании A и их подразделы (но не документы внутри подразделов).

Рекомендация: Для drill-down навигации используйте depth=1, чтобы минимизировать объем передаваемых данных и ускорить загрузку.

depth_mode - Режим глубины

Определяет, как интерпретировать параметр depth.

Режим Описание Когда использовать
inclusive Загружает все уровни ДО указанной глубины включительно Drill-down навигация, прогрессивная загрузка
exact Загружает ТОЛЬКО указанный уровень Когда нужны элементы точно на определенном уровне
Сравнение режимов

Структура:

Company A/ ├── Deal 1/ │ └── Contracts/ │ └── doc1.pdf └── Deal 2/ └── Invoices/ └── inv1.pdf

inclusive (depth=2): Вернет Deal 1, Deal 2, Contracts, Invoices

exact (depth=2): Вернет только Contracts, Invoices

parent_path - Путь родителя

Фильтрует результаты, оставляя только элементы внутри указанного узла.

Важно: Путь должен заканчиваться слэшем (/) и содержать UUID элементов, разделенные слэшами.
Примеры parent_path
""                                           // Корень дерева
"550e8400-e29b-41d4-a716-446655440000/"     // Company A
"550e8400-e29b-41d4-a716-446655440000/      // Company A / Deal 1
 e29b-41d4-a716-446655440001/"

group_by - Группировка

Массив ключей иерархии для группировки документов.

Пример группировки
{
  "group_by": ["company_id", "deal_id", "document_type"]
}

Результат: Документы будут сгруппированы по компаниям, затем по сделкам, затем по типам документов.

include_documents - Включение документов

Определяет, включать ли документы в ответ.

Оптимизация:
  • Используйте include_documents: false на промежуточных уровнях (компании, сделки)
  • Используйте include_documents: true только на конечных узлах, где нужно показать файлы
  • Это сокращает размер ответа в 5-10 раз

limit и offset - Пагинация

Стандартные параметры для пагинации результатов.

Пример пагинации
// Первая страница (50 элементов)
{
  "limit": 50,
  "offset": 0
}

// Вторая страница
{
  "limit": 50,
  "offset": 50
}

Примеры использования

Пример 1: Начальная загрузка страницы

Загружаем только первый уровень иерархии без документов для быстрой отрисовки.

Запрос
curl -X POST https://api.example.com/api/v1/web/documents/query \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." \
  -d '{
    "query_type": "structured",
    "depth": 1,
    "depth_mode": "inclusive",
    "parent_path": "",
    "group_by": [],
    "include_documents": false
  }'
Ответ
{
  "data": [
    {
      "key": "company_id",
      "value": "550e8400-e29b-41d4-a716-446655440000",
      "label": "Company A",
      "level": 1,
      "document_count": 0,
      "has_children": true,
      "children_count": 3,
      "documents": [],
      "children": []
    },
    {
      "key": "company_id",
      "value": "550e8400-e29b-41d4-a716-446655440001",
      "label": "Company B",
      "level": 1,
      "document_count": 0,
      "has_children": true,
      "children_count": 2,
      "documents": [],
      "children": []
    }
  ],
  "total": 1250
}
Результат: Загружено 2 узла (компании) вместо 1250 документов. Экономия: ~99% трафика.

Пример 2: Drill-down в компанию

Пользователь кликает на "Company A", загружаем её сделки.

Запрос
curl -X POST https://api.example.com/api/v1/web/documents/query \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." \
  -d '{
    "query_type": "structured",
    "depth": 1,
    "depth_mode": "inclusive",
    "parent_path": "550e8400-e29b-41d4-a716-446655440000/",
    "group_by": [],
    "include_documents": false
  }'
Ответ
{
  "data": [
    {
      "key": "deal_id",
      "value": "e29b-41d4-a716-446655440001",
      "label": "Deal X - 2024",
      "level": 1,
      "document_count": 0,
      "has_children": true,
      "children_count": 5,
      "documents": [],
      "children": []
    },
    {
      "key": "deal_id",
      "value": "e29b-41d4-a716-446655440002",
      "label": "Deal Y - 2024",
      "level": 1,
      "document_count": 0,
      "has_children": true,
      "children_count": 3,
      "documents": [],
      "children": []
    }
  ],
  "total": 800
}

Пример 3: Загрузка документов

Пользователь кликает на "Deal X", загружаем подразделы с документами.

Запрос
curl -X POST https://api.example.com/api/v1/web/documents/query \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." \
  -d '{
    "query_type": "structured",
    "depth": 1,
    "depth_mode": "inclusive",
    "parent_path": "550e8400-e29b-41d4-a716-446655440000/e29b-41d4-a716-446655440001/",
    "group_by": [],
    "include_documents": true
  }'
Ответ
{
  "data": [
    {
      "key": "document_type",
      "value": "contracts",
      "label": "Contracts",
      "level": 1,
      "document_count": 3,
      "has_children": false,
      "children_count": 0,
      "documents": [
        {
          "id": "doc-001",
          "name": "Contract_2024_01.pdf",
          "size": 245678,
          "mime_type": "application/pdf",
          "created_at": "2024-01-15T10:30:00Z",
          "hierarchy": ["550e8400-e29b-41d4-a716-446655440000", "e29b-41d4-a716-446655440001", "contracts"]
        },
        {
          "id": "doc-002",
          "name": "Contract_2024_02.pdf",
          "size": 189234,
          "mime_type": "application/pdf",
          "created_at": "2024-02-10T14:20:00Z",
          "hierarchy": ["550e8400-e29b-41d4-a716-446655440000", "e29b-41d4-a716-446655440001", "contracts"]
        }
      ],
      "children": []
    },
    {
      "key": "document_type",
      "value": "invoices",
      "label": "Invoices",
      "level": 1,
      "document_count": 2,
      "has_children": false,
      "children_count": 0,
      "documents": [
        {
          "id": "doc-003",
          "name": "Invoice_001.pdf",
          "size": 98765,
          "mime_type": "application/pdf",
          "created_at": "2024-01-20T09:15:00Z",
          "hierarchy": ["550e8400-e29b-41d4-a716-446655440000", "e29b-41d4-a716-446655440001", "invoices"]
        }
      ],
      "children": []
    }
  ],
  "total": 5
}

Пример 4: Загрузка нескольких уровней сразу

Для определенных сценариев можно загрузить несколько уровней за один запрос.

Запрос
curl -X POST https://api.example.com/api/v1/web/documents/query \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." \
  -d '{
    "query_type": "structured",
    "depth": 3,
    "depth_mode": "inclusive",
    "parent_path": "",
    "group_by": [],
    "include_documents": false,
    "limit": 100
  }'
Внимание: Загрузка нескольких уровней сразу может привести к большому объему данных. Используйте пагинацию (limit) и отключайте документы (include_documents: false).

Пример 5: Пагинация на большом наборе данных

При большом количестве элементов на уровне используйте пагинацию.

Запрос (страница 1)
curl -X POST https://api.example.com/api/v1/web/documents/query \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." \
  -d '{
    "query_type": "structured",
    "depth": 1,
    "depth_mode": "inclusive",
    "parent_path": "550e8400-e29b-41d4-a716-446655440000/",
    "include_documents": true,
    "limit": 50,
    "offset": 0
  }'
Запрос (страница 2)
{
  "query_type": "structured",
  "depth": 1,
  "depth_mode": "inclusive",
  "parent_path": "550e8400-e29b-41d4-a716-446655440000/",
  "include_documents": true,
  "limit": 50,
  "offset": 50
}

Вычисление количества страниц:

const totalPages = Math.ceil(response.total / limit);
// Если total = 800, limit = 50, то totalPages = 16

Пример 6: Точная глубина (exact mode)

Загрузить элементы только на конкретном уровне.

Запрос
curl -X POST https://api.example.com/api/v1/web/documents/query \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." \
  -d '{
    "query_type": "structured",
    "depth": 3,
    "depth_mode": "exact",
    "parent_path": "",
    "include_documents": true
  }'
Результат: Вернутся только элементы на 3-м уровне иерархии (например, подразделы сделок), но не элементы уровней 1 и 2.

Формат ответа

Структура ответа

API всегда возвращает объект с полями data и total:

{
  "data": [...],      // Массив узлов дерева
  "total": 1250       // Общее количество документов (игнорирует limit/offset)
}

Структура узла (Node)

Каждый элемент в массиве data является узлом дерева:

{
  "key": "company_id",                          // Имя ключа иерархии
  "value": "550e8400-e29b-41d4-a716-446655440000",  // UUID или ID значения
  "label": "Company A",                         // Читаемое название для UI
  "level": 1,                                   // Относительный уровень (1, 2, 3...)
  "document_count": 150,                        // Количество документов в узле
  "has_children": true,                         // Есть ли дочерние узлы
  "children_count": 5,                          // Количество дочерних узлов
  "documents": [...],                           // Массив документов (если include_documents=true)
  "children": [...]                             // Массив дочерних узлов (если depth > 1)
}

Метаданные узла

has_children

Булевый флаг, указывающий, есть ли у узла дочерние элементы. Используется для отображения иконки раскрытия в UI.

Пример использования в UI
// JavaScript
if (node.has_children) {
  // Показать иконку expand/collapse
  showExpandIcon(node);
} else {
  // Узел конечный, иконку не показывать
  hideExpandIcon(node);
}

children_count

Количество дочерних узлов. Полезно для отображения бейджа с количеством.

Пример в UI
// Отобразить: "Company A (5)"
const label = `${node.label} (${node.children_count})`;

document_count

Количество документов, прикрепленных к узлу. Не включает документы из дочерних узлов.

Примечание: document_count показывает количество документов в самом узле, а не суммарное количество во всех дочерних узлах.

Структура документа

Каждый документ в массиве documents имеет следующую структуру:

{
  "id": "doc-001",                              // Уникальный ID документа
  "name": "Contract_2024.pdf",                  // Имя файла
  "size": 245678,                               // Размер в байтах
  "mime_type": "application/pdf",               // MIME-тип
  "created_at": "2024-01-15T10:30:00Z",        // Дата создания (ISO 8601)
  "updated_at": "2024-01-15T10:30:00Z",        // Дата обновления
  "hierarchy": [                                // Массив иерархии (UUID элементов)
    "550e8400-e29b-41d4-a716-446655440000",
    "e29b-41d4-a716-446655440001",
    "contracts"
  ],
  "hierarchy_path": "550e8400-e29b-41d4-a716-446655440000/e29b-41d4-a716-446655440001/contracts/",
  "metadata": {                                 // Дополнительные метаданные (опционально)
    "author": "John Doe",
    "department": "Legal",
    "tags": ["contract", "2024"]
  }
}

Пустой ответ

Если элементов не найдено, API вернет пустой массив:

{
  "data": [],
  "total": 0
}

Обработка ошибок

При ошибке API возвращает объект с полем error:

// 400 Bad Request
{
  "error": "Invalid depth parameter: must be a positive integer"
}

// 401 Unauthorized
{
  "error": "Invalid or expired JWT token"
}

// 500 Internal Server Error
{
  "error": "Internal server error occurred"
}

Интеграция

Frontend интеграция

Пример реализации прогрессивной загрузки на frontend с использованием JavaScript.

Шаг 1: Создание состояния дерева

// Инициализация состояния
const treeState = {
  data: [],              // Массив узлов верхнего уровня
  expandedNodes: new Set(), // Множество раскрытых узлов
  loadedPaths: new Set(),   // Множество загруженных путей (для кеширования)
};

Шаг 2: Начальная загрузка

async function loadInitialTree() {
  const response = await fetch('/api/v1/web/documents/query', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `Bearer ${getJWTToken()}`
    },
    body: JSON.stringify({
      query_type: 'structured',
      depth: 1,
      depth_mode: 'inclusive',
      parent_path: '',
      include_documents: false
    })
  });

  const result = await response.json();

  if (response.ok) {
    treeState.data = result.data;
    treeState.loadedPaths.add('');
    renderTree();
  } else {
    handleError(result.error);
  }
}

Шаг 3: Обработка раскрытия узла

async function expandNode(node, nodePath) {
  // Проверка, не загружен ли уже путь
  if (treeState.loadedPaths.has(nodePath)) {
    treeState.expandedNodes.add(nodePath);
    renderTree();
    return;
  }

  // Формируем parent_path из nodePath
  const parentPath = buildParentPath(nodePath);

  // Загружаем дочерние элементы
  const response = await fetch('/api/v1/web/documents/query', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `Bearer ${getJWTToken()}`
    },
    body: JSON.stringify({
      query_type: 'structured',
      depth: 1,
      depth_mode: 'inclusive',
      parent_path: parentPath,
      include_documents: isLeafLevel(node)  // Документы только на конечных узлах
    })
  });

  const result = await response.json();

  if (response.ok) {
    // Находим реальный узел в дереве и обновляем его
    const actualNode = findNodeByPath(treeState.data, nodePath);
    if (actualNode) {
      actualNode.children = result.data;
      treeState.loadedPaths.add(nodePath);
      treeState.expandedNodes.add(nodePath);
      renderTree();
    }
  } else {
    handleError(result.error);
  }
}

function buildParentPath(nodePath) {
  // nodePath: "company_id|uuid1/deal_id|uuid2"
  // Нужно: "uuid1/uuid2/"
  return nodePath.split('/').map(part => {
    const [key, value] = part.split('|');
    return value;
  }).join('/') + '/';
}

function findNodeByPath(nodes, path) {
  const parts = path.split('/').filter(p => p);
  let current = nodes;

  for (const part of parts) {
    const node = current.find(n => `${n.key}|${n.value}` === part);
    if (!node) return null;
    current = node.children || [];
  }

  return current;
}

Шаг 4: Обработка закрытия узла

function collapseNode(nodePath) {
  treeState.expandedNodes.delete(nodePath);
  renderTree();

  // Опционально: очистить дочерние элементы для экономии памяти
  const node = findNodeByPath(treeState.data, nodePath);
  if (node && shouldClearChildren(node)) {
    node.children = [];
    treeState.loadedPaths.delete(nodePath);
  }
}

Alpine.js интеграция

Пример с использованием Alpine.js для реактивности.

<div x-data="documentTree()">
  <template x-for="node in treeData" :key="node.key + '|' + node.value">
    <div class="tree-node">
      <div class="node-header" @click="toggleNode(node)">
        <span x-show="node.has_children">
          <span x-show="!isExpanded(node)">▶</span>
          <span x-show="isExpanded(node)">▼</span>
        </span>
        <span x-text="node.label"></span>
        <span class="badge" x-text="node.children_count"></span>
      </div>

      <div x-show="isExpanded(node)" class="node-children">
        <!-- Рекурсивная отрисовка детей -->
      </div>
    </div>
  </template>
</div>

<script>
function documentTree() {
  return {
    treeData: [],
    expandedNodes: new Set(),
    loadedPaths: new Set(),

    async init() {
      await this.loadInitialTree();
    },

    async loadInitialTree() {
      const result = await this.queryDocuments('', 1, false);
      this.treeData = result.data;
      this.loadedPaths.add('');
    },

    async toggleNode(node) {
      const nodePath = this.getNodePath(node);

      if (this.expandedNodes.has(nodePath)) {
        this.expandedNodes.delete(nodePath);
      } else {
        await this.expandNode(node, nodePath);
      }
    },

    async expandNode(node, nodePath) {
      if (!this.loadedPaths.has(nodePath)) {
        const parentPath = this.buildParentPath(nodePath);
        const result = await this.queryDocuments(parentPath, 1, this.isLeafLevel(node));

        const actualNode = this.findNodeByPath(nodePath);
        if (actualNode) {
          actualNode.children = result.data;
          this.loadedPaths.add(nodePath);
        }
      }

      this.expandedNodes.add(nodePath);
    },

    async queryDocuments(parentPath, depth, includeDocuments) {
      const response = await fetch('/api/v1/web/documents/query', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'Authorization': `Bearer ${this.getJWTToken()}`
        },
        body: JSON.stringify({
          query_type: 'structured',
          depth: depth,
          depth_mode: 'inclusive',
          parent_path: parentPath,
          include_documents: includeDocuments
        })
      });

      return await response.json();
    },

    isExpanded(node) {
      return this.expandedNodes.has(this.getNodePath(node));
    },

    getNodePath(node) {
      return `${node.key}|${node.value}`;
    }
  };
}
</script>

React интеграция

Пример с использованием React и хуков.

import { useState, useEffect } from 'react';

function DocumentTree() {
  const [treeData, setTreeData] = useState([]);
  const [expandedNodes, setExpandedNodes] = useState(new Set());
  const [loadedPaths, setLoadedPaths] = useState(new Set());

  useEffect(() => {
    loadInitialTree();
  }, []);

  const loadInitialTree = async () => {
    const result = await queryDocuments('', 1, false);
    setTreeData(result.data);
    setLoadedPaths(new Set(['']));
  };

  const queryDocuments = async (parentPath, depth, includeDocuments) => {
    const response = await fetch('/api/v1/web/documents/query', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Authorization': `Bearer ${getJWTToken()}`
      },
      body: JSON.stringify({
        query_type: 'structured',
        depth,
        depth_mode: 'inclusive',
        parent_path: parentPath,
        include_documents: includeDocuments
      })
    });

    return await response.json();
  };

  const toggleNode = async (node, nodePath) => {
    if (expandedNodes.has(nodePath)) {
      setExpandedNodes(prev => {
        const newSet = new Set(prev);
        newSet.delete(nodePath);
        return newSet;
      });
    } else {
      await expandNode(node, nodePath);
    }
  };

  const expandNode = async (node, nodePath) => {
    if (!loadedPaths.has(nodePath)) {
      const parentPath = buildParentPath(nodePath);
      const result = await queryDocuments(parentPath, 1, isLeafLevel(node));

      setTreeData(prevData => {
        const newData = [...prevData];
        const actualNode = findNodeByPath(newData, nodePath);
        if (actualNode) {
          actualNode.children = result.data;
        }
        return newData;
      });

      setLoadedPaths(prev => new Set([...prev, nodePath]));
    }

    setExpandedNodes(prev => new Set([...prev, nodePath]));
  };

  return (
    <div className="tree">
      {treeData.map(node => (
        <TreeNode
          key={`${node.key}|${node.value}`}
          node={node}
          onToggle={toggleNode}
          isExpanded={expandedNodes.has(getNodePath(node))}
        />
      ))}
    </div>
  );
}

Управление JWT токеном

Пример функции для получения и обновления JWT токена.

let jwtToken = null;
let tokenExpiry = null;

async function getJWTToken() {
  // Проверяем, валиден ли текущий токен
  if (jwtToken && tokenExpiry && Date.now() < tokenExpiry) {
    return jwtToken;
  }

  // Получаем новый токен
  const response = await fetch('/api/v1/auth/login', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      username: 'user@example.com',
      password: 'password'
    })
  });

  const result = await response.json();

  if (response.ok) {
    jwtToken = result.token;
    // JWT обычно содержит exp claim, парсим его
    const payload = JSON.parse(atob(jwtToken.split('.')[1]));
    tokenExpiry = payload.exp * 1000; // Конвертируем в миллисекунды
    return jwtToken;
  } else {
    throw new Error('Failed to authenticate');
  }
}

// Автоматическое обновление токена за 5 минут до истечения
setInterval(async () => {
  if (tokenExpiry && Date.now() > tokenExpiry - 5 * 60 * 1000) {
    await getJWTToken();
  }
}, 60000); // Проверяем каждую минуту

Производительность

Оптимизация запросов

Лучшие практики

  • Используйте depth=1 для drill-down навигации
  • Отключайте include_documents на промежуточных уровнях
  • Используйте пагинацию при большом количестве элементов
  • Кешируйте загруженные пути на frontend
  • Не загружайте весь путь при drill-down, только следующий уровень

Сравнение производительности

Пример реальных показателей на дереве с 10,000 документов:

Сценарий Без прогрессивной загрузки С прогрессивной загрузкой Ускорение
Начальная загрузка 3500ms, 15MB 120ms, 5KB 29x быстрее
Drill-down на уровень N/A (все уже загружено) 80ms, 3KB -
Память в браузере 45MB 2-8MB (зависит от раскрытых узлов) 5-20x меньше

Кеширование на frontend

Для улучшения производительности реализуйте кеширование загруженных данных:

const cache = {
  paths: new Map(),    // Кеш загруженных путей
  ttl: 5 * 60 * 1000   // 5 минут
};

async function queryWithCache(parentPath, depth, includeDocuments) {
  const cacheKey = `${parentPath}|${depth}|${includeDocuments}`;

  // Проверяем кеш
  if (cache.paths.has(cacheKey)) {
    const cached = cache.paths.get(cacheKey);
    if (Date.now() - cached.timestamp < cache.ttl) {
      console.log('Cache hit:', cacheKey);
      return cached.data;
    }
  }

  // Загружаем с сервера
  const data = await queryDocuments(parentPath, depth, includeDocuments);

  // Сохраняем в кеш
  cache.paths.set(cacheKey, {
    data,
    timestamp: Date.now()
  });

  return data;
}

Оптимизация индексов в базе данных

Для максимальной производительности создайте индекс на hierarchy_path:

-- B-tree индекс для LIKE запросов с префиксом
CREATE INDEX idx_hierarchy_path ON documents(hierarchy_path);

-- GIN индекс для полнотекстового поиска (если используется)
CREATE INDEX idx_hierarchy_path_gin ON documents USING GIN (hierarchy_path gin_trgm_ops);

-- Частичный индекс для активных документов
CREATE INDEX idx_active_hierarchy_path
ON documents(hierarchy_path)
WHERE deleted_at IS NULL;

Мониторинг производительности

Отслеживайте ключевые метрики для оптимизации:

// Измерение времени запроса
const startTime = performance.now();
const result = await queryDocuments(parentPath, depth, includeDocuments);
const duration = performance.now() - startTime;

// Логирование метрик
console.log('Query performance:', {
  parent_path: parentPath,
  depth,
  include_documents: includeDocuments,
  duration_ms: duration.toFixed(2),
  result_count: result.data.length,
  total_documents: result.total
});

// Отправка в систему мониторинга (например, DataDog, New Relic)
if (duration > 1000) {
  analytics.track('slow_query', {
    endpoint: '/api/v1/web/documents/query',
    duration,
    parent_path: parentPath
  });
}

Рекомендации по глубине загрузки

Сценарий Рекомендуемая глубина include_documents Причина
Начальная загрузка 1 false Минимальный объем данных для быстрого старта
Drill-down навигация 1 false на промежуточных, true на конечных Загружать только следующий уровень по требованию
Экспорт/печать 3-5 true Загрузить полную структуру для экспорта
Поиск 0 (без ограничений) true Найти документы на любом уровне

Оптимизация для мобильных устройств

На мобильных устройствах используйте более агрессивную оптимизацию:

// Определение мобильного устройства
const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent);

// Настройки для мобильных
const mobileConfig = {
  depth: 1,                  // Всегда depth=1
  limit: 20,                 // Меньший лимит
  include_documents: false,  // По умолчанию без документов
  lazy_load_images: true     // Ленивая загрузка превью
};

// Использование
const config = isMobile ? mobileConfig : desktopConfig;

Пагинация vs Бесконечная прокрутка

Рекомендации по выбору:

  • Пагинация: Используйте, когда у пользователя есть четкая цель найти конкретный элемент
  • Бесконечная прокрутка: Используйте для browsing-сценариев, где пользователь просматривает контент
  • Виртуализация: Для списков с 1000+ элементами используйте виртуальную прокрутку (react-window, vue-virtual-scroller)

Troubleshooting производительности

Проблема: Медленные запросы (> 1s)

Возможные причины:

  1. Отсутствует индекс на hierarchy_path - создайте индекс
  2. Загружается слишком много документов - используйте пагинацию
  3. Слишком большая глубина (depth > 3) - уменьшите depth
  4. База данных перегружена - масштабируйте БД или добавьте read replicas
Проблема: Высокое потребление памяти в браузере

Решения:

  1. Очищайте children при закрытии узла
  2. Используйте виртуализацию для больших списков
  3. Ограничивайте количество одновременно раскрытых узлов
  4. Не храните полные объекты документов, только ID и метаданные

Clara CRM Team | Документация: Прогрессивная загрузка v2.0 | Обновлено: 18.11.2025