При разработке приложения часто возникают сложности с удобством доступа к вложенным данным. Например, сложно быстро извлечь последние комментарии к вопросам в Q&A (Вопросы и Ответы), если комментарии вложены внутрь самих вопросов. В таком случае необходимо обойти все вопросы, собрать комментарии и отсортировать их по времени.
Возьмем для примера Q&A Хекслета. У каждого вопроса есть количество лайков и комментарии, у которых в свою очередь тоже есть лайки. Эти данные можно представить так:
const posts = [
{
id: 1,
text: "Как писать код?",
likesCount: 2,
likedBy: ["user1", "user3"],
comments: [
{
answer: "Открой редактор!",
likesCount: 1,
createdAt: "11-12-2022",
},
{
answer: "Сидя!",
likesCount: 3,
createdAt: "11-12-2022",
},
],
},
{
id: 2,
text: "Что лучше: vim или emacs?",
likesCount: 2,
likedBy: ["user2", "user3"],
comments: [
{
answer: "FAR зе бест!",
likesCount: 100,
createdAt: "11-12-2022",
},
],
},
];
const state = { posts };
Иерархическое представление данных хорошо отражает их структуру. Сразу видно, что к чему относится. Данные удобно выводить и достаточно удобно изменять. Особенно если вывод на экране совпадает с их структурой, и данные между собой не пересекаются. Вопросы и Ответы Хекслета как раз такой пример. Каждый вопрос живет своей независимой жизнью (кое-какие зависимости есть, но они не касаются самих данных).
Однако, если данные связаны, то иерархическая структура усложняет сквозные выборки. Представьте себе, что надо выводить 10 последних комментариев. Как это сделать? Придется ходить по всем топикам, брать все комментарии, объединять и искать самые свежие.
const comments = posts.flatMap((p) => p.comments);
const sortedComments = comments.sort(
(c1, c2) => new Date(c2.createdAt) - new Date(c1.createdAt),
);
const lastComments = sortedComments.slice(0, 10);
Тут стоит сказать, что большая часть нужных выборок должна делать на бекенде и отправляться клиенту в готовом виде. Если же нужно делать такие расчеты на клиенте, то к организации объекта состояния можно подойти с другой стороны.
Создание индексов
Один из способов выйти из этой ситуации — начать дублировать данные. Создавать дополнительные структуры, оптимизированные под конкретные задачи. И хотя, в общем, это не лишено смысла, все же ручной способ поддерживать эти структуры ничего хорошего не принесет. В тех же базах данных за формирование индексов отвечает сама база данных. Нам как программистам не надо об этом заботиться. А здесь придется внедрять дополнительную синхронизацию во все этапы: добавление, изменение и удаление.
Ниже пример создания объекта, оптимизированного под лайкнутые посты конкретными пользователями. Выборка вопросов, которые лайкнул конкретный пользователь (user1
), превращается в простую операцию извлечения данных из объекта по ключу.
// Отдельная структура под хранение id постов, которые лакйнул каждый пользователь
const userLikesIndex = {
user1: [1, 2],
user2: [1, 2],
user3: [1],
user4: [1, 2],
};
const postsLikedByUser1 = userLikesIndex["user1"];
console.log(postsLikedByUser1);
Но тут возникает новая проблема: синхронизация данных. При изменении лайков (например, пользователь убрал лайк), нужно обновить оба объекта:
function removeLike(userId, postId) {
const post = state.posts.find((p) => p.id === postId);
post.likes = post.likes.filter((uId) => uId !== userId);
userLikesIndex[userId] = userLikesIndex[userId].filter(
(pId) => pId !== postId,
);
}
// Пример удаления лайка:
removeLike("user1", "post2");
Эти действия нужно выполнять при каждом изменении (добавление, удаление, изменение), и это легко приводит к ошибкам и рассогласованности данных.
Именно поэтому клиентское приложение редко занимается такими вещами вручную. Подобные структуры (индексы) автоматически создаются и поддерживаются на бэкенде или с помощью специальных библиотек управления состоянием. А сейчас активно развиваются полноценные клиентские базы данных, которые умеют с этим работать.
Нормализация данных
Другой способ – нормализовать данные, прямо как в реляционных базах данных. Представить их плоскими массивами. Например, так:
const posts = [
{
id: 1,
text: "Как писать код?",
},
{
id: 2,
text: "Что лучше: vim или emacs?",
},
];
// Для работы с нормализованными данными необходимо
// использовать идентификаторы для связи сущностей
const comments = [
{
id: 1,
postId: 1,
answer: "Открой редактор!",
likesCount: 1,
createdAt: "11-12-2022",
},
{
id: 2,
postId: 1,
answer: "Сидя!",
likesCount: 3,
createdAt: "11-12-2022",
},
{
id: 3,
postId: 2,
answer: "FAR зе бест!",
likesCount: 100,
createdAt: "11-12-2022",
},
];
const postLikes = [
{ postId: 1, userId: "user1" },
{ postId: 1, userId: "user3" },
{ postId: 2, userId: "user2" },
{ postId: 2, userId: "user3" },
];
Нормализованные данные существенно упрощают выполнение разнообразных выборок. Например, получение последних комментариев ко всем постам теперь выглядит проще и быстрее:
const sortedComments = state.comments.sort(
(a, b) => new Date(b.createdAt) - new Date(a.createdAt),
);
const lastComments = sortedComments.slice(0, 10);
Также упрощается получение постов, лайкнутых конкретным пользователем:
const postsLikedByUser1 = state.postLikes.filter(
(like) => like.userId === "user1",
);
Нормализованные данные тоже требуют синхронизации, но в основном для удаления. В нашем примере удаление поста должно приводить к удалению комментариев и лайков.
function removePost(postId, state) {
return {
posts: state.posts.filter((post) => post.id !== postId),
comments: state.comments.filter((comment) => comment.postId !== postId),
postLikes: state.postLikes.filter((like) => like.postId !== postId),
};
}
// Пример удаления поста с id = 1
const newState = removePost(1, state);
console.log(newState);
И нормализованные данные требуют чуть более сложного кода при простых запросах типа «все комментарии конкретного поста»:
const postId = 1;
const commentsForPost = state.comments.filter((c) => c.postId === postId);
Но в целом это небольшая цена за преимущества, которые вы получите: простоту поддержки, отсутствие дублирования и легкость масштабирования. Именно поэтому большинство современных фронтенд-фреймворков и библиотек управления состоянием рекомендуют использовать именно нормализованный подход к хранению данных.
Что по производительности? Является это проблемой или нет — вопрос открытый. Как правило, нет. Фронтенд очень редко оперирует большими количествами, например, десятками и сотнями тысяч. Чаще всего размеры коллекций ограничиваются сотней элементов. Но если производительность важна, то для этого используют разнообразные механизмы от кеширования до использования полноценных баз данных.
Какой подход выбрать?
Во многом ответ зависит от задачи и механизма управления состоянием. Как правило, в этих библиотеках есть документация, которая раскрывает как оптимальнее хранить данные внутри.
Если же данные хранятся в обычных объектах без использования библиотек, то можно пойти следующим путем:
- Для простоты всегда делать все плоским. Привыкнув к этому подходу, потом проще работать с любыми данными.
- Использовать вложенные структуры там где нет связи между данными и использовать плоские, там где нужно постоянно анализировать весь набор.
Итог
Большинство фронтенд-приложений лучше всего работают с нормализованными данными, поскольку такой подход упрощает поддержку и масштабирование. Используйте иерархию только тогда, когда ваши данные полностью независимы друг от друга, и вы уверены, что это не изменится со временем.

Остались вопросы? Задайте их в разделе «Обсуждение»
Вам ответят команда поддержки Хекслета или другие студенты
Для полного доступа к курсу нужен базовый план
Базовый план откроет полный доступ ко всем курсам, упражнениям и урокам Хекслета, проектам и пожизненный доступ к теории пройденных уроков. Подписку можно отменить в любой момент.