В IndexedDB отсутствует механизм SQL-подобных JOIN, поэтому
объединение данных из разных таблиц в Dexie.js строится на уровне
приложения. Основной подход заключается в ручной агрегации сущностей
через асинхронные запросы и последующую синхронизацию результатов.
Ключевым инструментом для этого становится Promise.all(),
позволяющий параллельно загружать связанные данные и минимизировать
общее время ожидания.
Dexie.js предоставляет удобный API поверх IndexedDB, но не скрывает фундаментального ограничения: каждая таблица остаётся независимой, а связи реализуются через ключи и индексы.
IndexedDB изначально проектировался как низкоуровневое хранилище с ограниченной реляционной логикой. Dexie.js не добавляет SQL-слой, чтобы сохранить:
Поэтому объединение данных переносится в JavaScript-слой, где разработчик сам определяет стратегию агрегации.
Типичный сценарий включает несколько таблиц с логическими связями:
db.version(1).stores({
users: '++id, name',
orders: '++id, userId, productId',
products: '++id, title, price'
});
Связь:
orders.userId → users.idorders.productId → products.idЭто классическая схема, где один заказ связан с пользователем и товаром.
Прямолинейный подход часто приводит к множеству последовательных запросов:
const orders = await db.orders.toArray();
const result = [];
for (const order of orders) {
const user = await db.users.get(order.userId);
const product = await db.products.get(order.productId);
result.push({ ...order, user, product });
}
Проблема:
При росте данных производительность резко деградирует.
Оптимизация заключается в параллельной загрузке связанных сущностей:
const orders = await db.orders.toArray();
const result = await Promise.all(
orders.map(async (order) => {
const [user, product] = await Promise.all([
db.users.get(order.userId),
db.products.get(order.productId)
]);
return {
...order,
user,
product
};
})
);
Преимущества:
Недостаток сохраняется: при большом количестве заказов возникает множество параллельных запросов.
Более эффективный подход заключается в предварительном сборе всех ключей:
const orders = await db.orders.toArray();
const userIds = [...new Set(orders.map(o => o.userId))];
const productIds = [...new Set(orders.map(o => o.productId))];
const [users, products] = await Promise.all([
db.users.bulkGet(userIds),
db.products.bulkGet(productIds)
]);
Здесь используется bulkGet, позволяющий извлекать сразу
множество записей за один запрос на таблицу.
После получения массивов данных необходимо восстановить связи:
const userMap = new Map(users.map(u => [u.id, u]));
const productMap = new Map(products.map(p => [p.id, p]));
const result = orders.map(order => ({
...order,
user: userMap.get(order.userId),
product: productMap.get(order.productId)
}));
Преимущества:
Комбинированный подход включает:
const orders = await db.orders.toArray();
const userIds = new Set();
const productIds = new Set();
for (const o of orders) {
userIds.add(o.userId);
productIds.add(o.productId);
}
const [users, products] = await Promise.all([
db.users.bulkGet([...userIds]),
db.products.bulkGet([...productIds])
]);
const usersById = Object.fromEntries(users.filter(Boolean).map(u => [u.id, u]));
const productsById = Object.fromEntries(products.filter(Boolean).map(p => [p.id, p]));
const joined = orders.map(o => ({
...o,
user: usersById[o.userId],
product: productsById[o.productId]
}));
При наличии цепочек связей (например, orders → users → addresses) применяется многоуровневая агрегация:
const orders = await db.orders.toArray();
const userIds = orders.map(o => o.userId);
const users = await db.users.bulkGet(userIds);
const addressIds = users.map(u => u.addressId);
const addresses = await db.addresses.bulkGet(addressIds);
const addressMap = new Map(addresses.map(a => [a.id, a]));
const userMap = new Map(users.map(u => [
u.id,
{ ...u, address: addressMap.get(u.addressId) }
]));
const result = orders.map(o => ({
...o,
user: userMap.get(o.userId)
}));
Сложность возрастает линейно по уровням, но остаётся управляемой при правильном батчинге.
Для более динамических структур используется каскадный
Promise.all():
const orders = await db.orders.toArray();
const result = await Promise.all(
orders.map(async (order) => {
const userPromise = db.users.get(order.userId);
const productPromise = db.products.get(order.productId);
const user = await userPromise;
const addressPromise = db.addresses.get(user.addressId);
const [product, address] = await Promise.all([
productPromise,
addressPromise
]);
return {
...order,
user: {
...user,
address
},
product
};
})
);
Такой подход балансирует между читаемостью и контролем над параллелизмом.
Чрезмерное использование Promise.all() без батчинга
приводит к перегрузке IndexedDB.
Повторяющиеся userId или productId вызывают
дублирование загрузок.
При отсутствии проверок null возможны разрывы
связей:
user: usersById[o.userId] ?? null
IndexedDB не гарантирует наличие связанных записей. Поэтому при джойнах важно учитывать:
Практика предполагает безопасную агрегацию:
const safeUser = userMap.get(order.userId) || { id: null, name: 'Unknown' };
Эффективность ручных джойнов в Dexie.js определяется:
Promise.all().Оптимальная стратегия:
get операций;bulkGet вместо множественных
get;Promise.all без необходимости.В реальных приложениях часто комбинируются разные подходы:
bulkGet для основных связей;Promise.all для вторичных данных;Так формируется адаптивная модель ручных джойнов, где каждая операция контролируется на уровне приложения, а не базы данных.