В Dexie.js отсутствует встроенный механизм SQL-подобных JOIN-операций, однако поддержка связей между таблицами реализуется через комбинацию индексов, выборок по ключам и пакетных операций чтения. Основной принцип построения связей — хранение внешнего ключа в записи и последующее явное разрешение связей на уровне кода.
Типичный пример — связь пользователей и постов:
const db = new Dexie("AppDatabase");
db.version(1).stores({
users: "++id, name",
posts: "++id, userId, title"
});
Здесь posts.userId выступает в роли внешнего ключа,
указывающего на users.id.
Самый прямолинейный вариант — загрузка записи и последующий отдельный запрос к связанной таблице:
const post = await db.posts.get(1);
const user = await db.users.get(post.userId);
Подход прост, но при массовой обработке данных приводит к проблеме N+1 запросов.
При выборке списка постов и авторов:
const posts = await db.posts.toArray();
for (const post of posts) {
post.user = await db.users.get(post.userId);
}
Каждый пост инициирует отдельный запрос к таблице пользователей. При большом объёме данных это становится узким местом, особенно в IndexedDB, где операции асинхронны и имеют накладные расходы.
Dexie.js предоставляет эффективный механизм массового чтения по ключам:
const posts = await db.posts.toArray();
const userIds = posts.map(p => p.userId);
const users = await db.users.bulkGet(userIds);
Однако bulkGet возвращает массив с возможными
undefined значениями, если ключ отсутствует. Поэтому
требуется нормализация:
const userMap = new Map(users.map(u => [u.id, u]));
const enrichedPosts = posts.map(post => ({
...post,
user: userMap.get(post.userId)
}));
Этот подход устраняет N+1 проблему и снижает количество обращений к IndexedDB до двух операций.
Если количество связанных сущностей велико, полезно предварительно дедуплицировать ключи:
const userIds = [...new Set(posts.map(p => p.userId))];
const users = await db.users.bulkGet(userIds);
Это особенно важно при повторяющихся внешних ключах.
Если внешний ключ проиндексирован, можно использовать
where:
const posts = await db.posts.where("userId").equals(5).toArray();
Для получения пользователей по списку ID:
const users = await db.users.where("id").anyOf(userIds).toArray();
Метод anyOf часто используется как альтернатива
bulkGet, когда требуется более гибкая фильтрация.
Связь «один ко многим» является наиболее частым сценарием.
const user = await db.users.get(1);
const posts = await db.posts.where("userId").equals(user.id).toArray();
user.posts = posts;
Такой подход масштабируется лучше, чем множественные запросы внутри цикла.
Противоположный случай — добавление родительских сущностей к дочерним:
const posts = await db.posts.toArray();
const userIds = posts.map(p => p.userId);
const users = await db.users.bulkGet([...new Set(userIds)]);
const userMap = new Map(users.map(u => [u.id, u]));
const result = posts.map(post => ({
...post,
user: userMap.get(post.userId)
}));
Dexie.js не навязывает схему many-to-many, поэтому используется промежуточная таблица:
db.version(1).stores({
users: "++id, name",
groups: "++id, title",
userGroups: "++id, userId, groupId"
});
const links = await db.userGroups.where("userId").equals(1).toArray();
const groupIds = links.map(l => l.groupId);
const groups = await db.groups.bulkGet(groupIds);
const links = await db.userGroups.where("groupId").equals(10).toArray();
const userIds = links.map(l => l.userId);
const users = await db.users.bulkGet(userIds);
Критически важно индексировать внешние ключи:
db.version(1).stores({
posts: "++id, userId",
});
Без индекса where("userId") превращается в полное
сканирование таблицы.
Для сложных запросов полезны составные индексы:
db.version(1).stores({
posts: "++id, userId, [userId+createdAt]"
});
Это позволяет эффективно фильтровать посты пользователя по времени.
При консистентной выборке нескольких таблиц применяется
transaction:
const result = await db.transaction("r", db.users, db.posts, async () => {
const user = await db.users.get(1);
const posts = await db.posts.where("userId").equals(1).toArray();
return {
user,
posts
};
});
Транзакция гарантирует согласованное состояние данных на момент чтения.
На практике часто формируется слой агрегации данных:
async function getPostsWithUsers() {
const posts = await db.posts.toArray();
const userIds = [...new Set(posts.map(p => p.userId))];
const users = await db.users.bulkGet(userIds);
const userMap = new Map(users.map(u => [u.id, u]));
return posts.map(post => ({
...post,
user: userMap.get(post.userId)
}));
}
Этот паттерн становится базовой альтернативой SQL JOIN.
Для вложенных структур (например: пользователь → посты → комментарии):
const posts = await db.posts.where("userId").equals(1).toArray();
const postIds = posts.map(p => p.id);
const comments = await db.comments.where("postId").anyOf(postIds).toArray();
const commentsByPost = new Map();
for (const c of comments) {
if (!commentsByPost.has(c.postId)) {
commentsByPost.set(c.postId, []);
}
commentsByPost.get(c.postId).push(c);
}
const result = posts.map(post => ({
...post,
comments: commentsByPost.get(post.id) || []
}));
Такой подход позволяет расширять глубину связей без усложнения запросов к IndexedDB.
При работе с внешними ключами важно ограничивать поля:
const posts = await db.posts
.where("userId")
.equals(1)
.toArray(post => ({
id: post.id,
title: post.title,
userId: post.userId
}));
Снижение объёма данных уменьшает время сериализации и ускоряет работу памяти.
Если одни и те же связи запрашиваются многократно, используется локальный кэш:
const userCache = new Map();
async function getUser(id) {
if (userCache.has(id)) return userCache.get(id);
const user = await db.users.get(id);
userCache.set(id, user);
return user;
}
Этот подход особенно полезен при итеративной обработке коллекций.
Иногда связи не загружаются сразу, а вычисляются по требованию:
function attachUser(post) {
Object.defineProperty(post, "user", {
get: async () => db.users.get(post.userId)
});
return post;
}
Такой метод позволяет избегать лишних запросов, но требует осторожности при сериализации.