Прогрессивная загрузка иерархии документов
Практическое руководство по использованию механизма инкрементальной загрузки дерева документов
Введение
Что такое прогрессивная загрузка?
Прогрессивная загрузка (Progressive Loading) — это механизм инкрементальной загрузки иерархических данных, который позволяет загружать дерево документов частями по мере необходимости, вместо загрузки всей структуры сразу.
Зачем это нужно?
- Производительность: Загружаются только видимые данные, что ускоряет начальную загрузку страницы в 10-100 раз
- Масштабируемость: Система работает одинаково быстро с 100 и с 100,000 документов
- UX: Пользователь видит первые данные мгновенно, остальные подгружаются по требованию
- Экономия ресурсов: Снижение нагрузки на сервер, базу данных и сетевой трафик
Ключевые возможности
- Загрузка по уровням глубины (depth-based loading)
- Drill-down навигация с фильтрацией по родительскому пути
- Динамическая группировка данных
- Два режима глубины: inclusive (до уровня включительно) и exact (точно на уровне)
- Метаданные для UI (has_children, children_count, document_count)
- Умное включение документов на конечных узлах
- Поддержка пагинации и сортировки
Как это работает
Пошаговый процесс
Пользователь открывает страницу документов. Frontend отправляет запрос:
POST /api/v1/web/documents/query
{
"query_type": "structured",
"depth": 1,
"depth_mode": "inclusive",
"parent_path": "",
"include_documents": false
}
Результат: Загружается только первый уровень иерархии (например, список компаний):
Пользователь кликает на "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:
Пользователь кликает на "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
}
Результат: Загружаются подразделы сделки с документами:
Архитектура системы
Механизм прогрессивной загрузки работает на нескольких уровнях:
-
Frontend (JavaScript):
- Управляет состоянием дерева (раскрытые/закрытые узлы)
- Отслеживает, какие узлы уже загружены
- Формирует запросы с правильными параметрами
- Обновляет UI после получения данных
-
Backend (Go):
- Handler валидирует входные параметры
- UseCase выполняет бизнес-логику запроса
- Repository фильтрует документы по hierarchy_path
- Service строит дерево с метаданными
-
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,
"parent_path": "company-a/"
}
Результат: Загрузятся только сделки компании A, но не их подразделы.
Загружает два уровня вложенности:
{
"depth": 2,
"parent_path": "company-a/"
}
Результат: Загрузятся сделки компании A и их подразделы (но не документы внутри подразделов).
depth=1,
чтобы минимизировать объем передаваемых данных и ускорить загрузку.
depth_mode - Режим глубины
Определяет, как интерпретировать параметр depth.
| Режим | Описание | Когда использовать |
|---|---|---|
inclusive |
Загружает все уровни ДО указанной глубины включительно | Drill-down навигация, прогрессивная загрузка |
exact |
Загружает ТОЛЬКО указанный уровень | Когда нужны элементы точно на определенном уровне |
Структура:
inclusive (depth=2): Вернет Deal 1, Deal 2, Contracts, Invoices
exact (depth=2): Вернет только Contracts, Invoices
parent_path - Путь родителя
Фильтрует результаты, оставляя только элементы внутри указанного узла.
/) и содержать UUID элементов, разделенные слэшами.
"" // Корень дерева
"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: 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: Пагинация на большом наборе данных
При большом количестве элементов на уровне используйте пагинацию.
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
}'
{
"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
}'
Формат ответа
Структура ответа
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.
// JavaScript
if (node.has_children) {
// Показать иконку expand/collapse
showExpandIcon(node);
} else {
// Узел конечный, иконку не показывать
hideExpandIcon(node);
}
children_count
Количество дочерних узлов. Полезно для отображения бейджа с количеством.
// Отобразить: "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 производительности
Возможные причины:
- Отсутствует индекс на
hierarchy_path- создайте индекс - Загружается слишком много документов - используйте пагинацию
- Слишком большая глубина (depth > 3) - уменьшите depth
- База данных перегружена - масштабируйте БД или добавьте read replicas
Решения:
- Очищайте children при закрытии узла
- Используйте виртуализацию для больших списков
- Ограничивайте количество одновременно раскрытых узлов
- Не храните полные объекты документов, только ID и метаданные