Средняя45 мин VK, Telegram, Яндекс Дзен

Новостная лента

Проектируем ленту как VK и Telegram

  • Infinite Scroll
  • Real-time
  • Виртуализация
  • WebSocket

Описание задачи

Вам предлагают спроектировать ленту новостей для социальной сети масштаба VK или Telegram — продукта с десятками миллионов DAU. Лента — центральный экран приложения: пользователь открывает его чаще всего, скроллит дольше всего и именно здесь формируется основное «время на платформе».

Лента должна показывать посты от друзей и подписок в персонализированном порядке: текстовые посты, фотографии, видео, репосты. Каждый пост поддерживает реакции (лайки, эмоджи), комментарии и шаринг. Пользователь может создать новый пост прямо из ленты.

Почему это популярная задача на интервью

Новостная лента затрагивает практически все аспекты фронтенд-архитектуры: рендеринг длинных списков, real-time обновления, оптимистичные обновления, кэширование, работу с медиа и адаптивную верстку. Это идеальная «песочница» для демонстрации глубины знаний.

На реальном интервью уточните: это web-приложение или PWA? Какой целевой рынок устройств? Нужна ли offline-поддержка? Ответы на эти вопросы повлияют на ваши архитектурные решения.

В рамках этого разбора мы пройдём по всем пяти шагам методологии RADIO — от сбора требований до конкретных оптимизаций — и построим архитектуру, которую можно защитить на собеседовании уровня Senior/Staff.

Requirements Requirements — Сбор требований

Функциональные требования

  • Отображение постов — текст, фото (до 10 в карусели), видео с автоплеем в ленте (muted). Каждый пост содержит аватар автора, имя, время публикации.
  • Бесконечная прокрутка (infinite scroll) — новые посты подгружаются по мере скролла. Нет пагинации кнопками.
  • Реакции — лайк, набор эмоджи. Отображение счётчика и списка отреагировавших.
  • Комментарии — раскрывающаяся секция под постом, подгрузка по требованию, возможность оставить комментарий.
  • Шаринг — репост внутри сети + генерация ссылки для внешних платформ.
  • Создание поста — модальное окно или inline-редактор: текст + прикрепление медиа.

Нефункциональные требования

  • Производительность — LCP < 2 секунд. Первые 20 постов видны сразу после загрузки страницы.
  • Масштабируемость рендеринга — лента работает плавно при 10 000+ постов в сессии (виртуализация обязательна).
  • Real-time — новые посты от друзей появляются без перезагрузки, пользователь видит уведомление «N новых постов».
  • Offline — кешированная лента доступна без сети (Service Worker), публикация ставится в очередь.
  • Адаптивность — мобильная и десктопная версии из одной кодовой базы (responsive + media queries).
Совет для интервью: задайте вопрос про масштаб — «Сколько постов публикуется в секунду?» Это покажет, что вы думаете о нагрузке на WebSocket-канал и стратегии батчинга обновлений.

Architecture Architecture — Архитектура

Компонентное дерево

Начнём с высокоуровневого дерева. Каждый компонент имеет чёткую зону ответственности:

App
├── FeedPage
│   ├── FeedHeader          // заголовок, фильтры (Лента / Рекомендации)
│   ├── NewPostsBanner      // «5 новых постов — показать»
│   ├── FeedList            // виртуализированный список
│   │   └── PostCard[]      // один пост
│   │       ├── PostHeader  // аватар, имя, время
│   │       ├── PostBody    // текст + медиа
│   │       ├── PostMedia   // фото-карусель или видеоплеер
│   │       ├── PostActions // лайк, коммент, шар, сохранить
│   │       └── PostComments// раскрывающийся блок комментов
│   └── ScrollSentinel      // IntersectionObserver для подгрузки
└── CreatePostModal         // модалка создания поста

Стратегия рендеринга

Используем гибридный подход: SSR для первых 20 постов (быстрый FCP, SEO для публичных профилей), далее — CSR с подгрузкой через API. Next.js App Router позволяет сделать серверный компонент для начального рендера и клиентский для интерактивной ленты:

// FeedPage — Server Component (первая загрузка)
const initialPosts = await fetchFeed({ limit: 20 });

// FeedList — Client Component (интерактивность)
"use client";
<VirtualizedFeed initialData={initialPosts} />

Управление состоянием

Глобальный store (Zustand или Redux Toolkit) с нормализованной структурой: сущности разделены по типам (posts, users, comments), ссылки по ID. Это позволяет обновлять один пост (лайк) без пересоздания всего списка.

Локальный стейт — UI-состояния: открыта ли модалка, раскрыты ли комментарии, позиция карусели. Эти данные не нужны в глобальном store.

Real-time слой

WebSocket-соединение для получения событий: new_post, update_post, delete_post. Новые посты не вставляются в ленту мгновенно (это дёргает скролл), а копятся в буфере. Пользователь видит баннер «N новых постов» и сам решает, когда их показать.

Data Model Data Model — Модель данных

Основные сущности

Опишем TypeScript-интерфейсы, которые формируют контракт между фронтендом и API:

interface Post {
  id: string;
  authorId: string;
  content: string;
  media: Media[];
  createdAt: string;       // ISO-8601
  likesCount: number;
  commentsCount: number;
  isLiked: boolean;        // для текущего пользователя
  repostOf?: string;       // id оригинального поста
}

interface User {
  id: string;
  name: string;
  avatarUrl: string;
  isOnline?: boolean;
}

interface Comment {
  id: string;
  postId: string;
  authorId: string;
  text: string;
  createdAt: string;
}

interface Media {
  type: "image" | "video";
  url: string;
  thumbnailUrl: string;
  width: number;
  height: number;
  duration?: number;       // только для видео, секунды
}

Состояние на клиенте (Store)

Нормализованная структура избавляет от дублирования и упрощает точечные обновления:

interface FeedState {
  postIds: string[];                    // порядок в ленте
  entities: {
    posts: Record<string, Post>;
    users: Record<string, User>;
    comments: Record<string, Comment>;
  };
  cursor: string | null;               // для пагинации
  hasMore: boolean;
  pendingNewPosts: string[];           // буфер real-time постов
  isLoading: boolean;
  error: string | null;
}

Почему нормализация критична

Представьте: один пользователь написал 50 постов в ленте. Без нормализации его аватар хранится 50 раз. Если аватар обновится, придётся обойти весь массив. С нормализацией обновление entities.users[userId].avatarUrl мгновенно отражается во всех постах, потому что PostCard подписан на users[post.authorId].

На интервью: нарисуйте связь «Post → authorId → User» на доске. Это демонстрирует, что вы мыслите сущностями и связями, а не вложенными объектами.

Interface Interface — API и контракты

REST API Endpoints

Выбираем REST: предсказуемый, хорошо кэшируемый, привычный для бэкенд-команд. Курсорная пагинация вместо offset — надёжнее при вставке новых постов.

// Получение ленты (курсорная пагинация)
GET /api/feed?cursor={cursor}&limit=20
→ {
    posts: Post[],          // с вложенными author: User
    nextCursor: string | null,
    hasMore: boolean
  }

// Создание нового поста
POST /api/posts
Body: { content: string, mediaIds?: string[] }
→ { post: Post }

// Лайк поста
POST /api/posts/{id}/like
→ { liked: boolean, likesCount: number }

// Удаление лайка
DELETE /api/posts/{id}/like
→ { liked: boolean, likesCount: number }

// Комментарии к посту
GET /api/posts/{id}/comments?cursor={cursor}&limit=10
→ { comments: Comment[], nextCursor, hasMore }

// Добавление комментария
POST /api/posts/{id}/comments
Body: { text: string }
→ { comment: Comment }

WebSocket-протокол

Отдельное соединение для real-time событий. Клиент подписывается при загрузке ленты:

// Подключение
ws://api.example.com/feed/realtime?token={jwt}

// Входящие события (server → client)
{ type: "new_post",    data: Post }
{ type: "update_post", data: Partial<Post> & { id: string } }
{ type: "delete_post", data: { id: string } }
{ type: "typing",      data: { postId: string, userId: string } }

Контракт компонентов

Props верхнего уровня для ключевых компонентов:

// FeedList принимает серверные данные как initial
interface FeedListProps {
  initialPosts: Post[];
  initialCursor: string | null;
}

// PostCard — чистый компонент, данные из store
interface PostCardProps {
  postId: string;  // вместо полного объекта — подписка на store
}
Обратите внимание: PostCard принимает только postId, а не весь объект Post. Это позволяет каждому компоненту подписаться только на нужный slice store — при обновлении лайка перерисовывается только этот PostCard, а не вся лента.

Optimizations Optimizations — Оптимизации

Виртуализация списка

Ключевая оптимизация для ленты. Библиотеки react-window или react-virtuoso рендерят только видимые элементы (~10 из тысяч). Это радикально снижает количество DOM-узлов и потребление памяти.

import { Virtuoso } from "react-virtuoso";

<Virtuoso
  data={postIds}
  endReached={loadMore}
  overscan={3}
  itemContent={(index, postId) => <PostCard postId={postId} />}
/>

react-virtuoso предпочтительнее react-window для ленты, потому что поддерживает динамическую высоту элементов — посты с фото и без будут разной высоты.

Lazy loading медиа

Изображения и видео загружаются только при попадании во viewport через IntersectionObserver. Для изображений используем loading="lazy" и srcset с размерами под разные экраны. Для видео — загрузка постера + первого сегмента при видимости, полная загрузка при нажатии play.

Оптимистичные обновления

Лайк — классический пример: UI обновляется мгновенно, запрос летит в фоне. При ошибке — откат состояния + тост с сообщением.

async function toggleLike(postId: string) {
  // 1. Мгновенное обновление UI
  store.togglePostLike(postId);
  try {
    // 2. Запрос на сервер
    await api.toggleLike(postId);
  } catch {
    // 3. Откат при ошибке
    store.togglePostLike(postId);
    toast.error("Не удалось поставить лайк");
  }
}

Кэширование и offline

  • Stale-while-revalidate — лента показывается из кэша, пока свежие данные загружаются. Пользователь видит контент мгновенно.
  • Service Worker — кэширует статику (JS, CSS, шрифты) + последние N постов для offline-доступа.
  • IndexedDB — хранит написанные offline-посты для отправки при восстановлении сети.

Image optimization

  • CDN с автоматической конвертацией в WebP/AVIF по заголовку Accept
  • srcset с тремя размерами: 400w, 800w, 1200w
  • Blur placeholder (LQIP) — base64-превью 20×20 px, показывается до загрузки
  • Aspect ratio через aspect-ratio: {width}/{height} — предотвращает layout shift

Smooth scrolling

Debounce scroll events, requestAnimationFrame для обновлений, will-change: transform на скроллящемся контейнере. Избегаем синхронных layout reads в scroll-хендлерах — это главная причина jank.

Итоги и ключевые trade-offs

Ключевые архитектурные решения

  • SSR для первых 20 постов + CSR для остального — компромисс между скоростью первой загрузки и интерактивностью. На интервью объясните: «Серверный рендер даёт быстрый FCP, а клиентский — плавную бесконечную прокрутку».
  • Нормализованный store — сложнее в настройке, но критичен для производительности при большом количестве постов. Альтернатива — denormalized array — проще, но O(n) для обновления одного поста.
  • WebSocket вместо polling — экономит трафик и батарею на мобильных, но требует инфраструктуры для поддержки миллионов соединений. На интервью упомяните: «Для MVP можно начать с long polling, затем мигрировать на WebSocket».
  • Виртуализация — обязательна. Без неё при 500 постах DOM содержит ~5000 узлов, при виртуализации — ~100. Но добавляет сложность: динамическая высота, сохранение scroll position, accessibility.

Что запомнить для интервью

  • Начните с требований: уточните масштаб (DAU), платформы и offline.
  • Всегда обсуждайте trade-offs: «Я выбрал X, потому что в нашем контексте важнее A, но при другом масштабе я бы выбрал Y».
  • Покажите знание конкретных инструментов: react-virtuoso, IntersectionObserver, stale-while-revalidate.
  • Не забывайте про edge cases: медленная сеть, устаревший кэш, конфликт real-time и пагинации.
Частый вопрос интервьюера: «Что произойдёт, если пока пользователь скроллит вниз, сверху появляется 50 новых постов?» Ответ: «Новые посты буферизируются, скролл не прыгает, пользователь видит баннер. При клике — плавная прокрутка вверх с вставкой новых постов».