Неожиданные вопросы на собеседовании Frontend (React + TypeScript)
Сегодня мы разберём реальное собеседование на позицию middle frontend-разработчика с зарплатной вилкой 240–280 тыс. рублей на руки. В ходе интервью кандидат продемонстрировал уверенное владение Next.js, понимание различных видов рендеринга (SSR, ISR, статический), виртуального DOM и гидратации в React, а также затронул темы оптимизации производительности, микрофронтендов, TypeScript, React Query и работы с окружением. Несмотря на некоторые пробелы в деталях работы TypeScript на уровне компиляции и взаимодействия серверного и клиентского состояния в SSR-приложениях, в целом кандидат показал практический опыт и способность рассуждать о сложных архитектурных решениях.
Вопрос 1. Расскажи про опыт работы с Next.js. На каких проектах использовал и с какими роутерами работал?
Таймкод: 00:00:43
Ответ собеседника: Правильный. Работал достаточно много с Next.js. Абсолютно все проекты, за исключением одного (дизайн-система на основе Storybook), были написаны на Next.js. Работал как с App Router, так и с Pages Router. Знаком со всеми видами рендеринга, использовал многие из них неоднократно, работал с метадатой.
Правильный ответ:
Ответ кандидата хороший, но для позиции Go-разработчика важно понимать, что Next.js — это фреймворк для фронтенда, и если основной опыт связан с Go, то упоминание Next.js может быть частью полного стека, но не основной фокус.
Тем не менее, для полноты картины приведу развёрнутый ответ, который был бы уместен на интервью для позиции Go-разработчика с опытом работы с фронтендом.
Опыт работы с Next.js в контексте Go-разработчика
В моей практике Next.js использовался как фронтенд-слой, интегрированный с бэкендом на Go. Основной фокус был на построении полноценных веб-приложений, где Go отвечал за API-слой, бизнес-логику и взаимодействие с базой данных, а Next.js — за клиентскую часть и рендеринг.
Проекты и задачи
-
Проект 1: Внутренний инструмент аналитики
Использовал Next.js с App Router для построения дашбордов. Рендеринг: SSR (Server-Side Rendering) для динамических данных, SSG (Static Site Generation) для статичных страниц. Бэкенд на Go предоставлял REST API с JWT-аутентификацией.
Особенности: использовалgetServerSideProps(Pages Router) иasync components(App Router) для загрузки данных с Go-сервера. -
Проект 2: E-commerce платформа
Next.js с Pages Router для каталога товаров и корзины. Использовал ISR (Incremental Static Regeneration) для обновления цен без пересборки всего сайта.
Особенности: интеграция с Go-микросервисом для обработки заказов и инвентаря. -
Проект 3: Дизайн-система на Storybook
Единственный проект без Next.js. Сосредоточился на создании переиспользуемых UI-компонентов, которые позже интегрировались в Next.js-проектах.
Работа с роутерами
- Pages Router: Использовал
pages/директорию,getStaticProps,getServerSideProps,getStaticPaths. Подходит для простых приложений с чёткой структурой маршрутов. - App Router: Перешёл на него в последних проектах. Использовал
app/директорию,layout.tsx,loading.tsx,error.tsx. Поддерживает React Server Components, что позволяет уменьшить клиентский JavaScript.
Виды рендеринга
- SSR: Для страниц, требующих актуальных данных при каждом запросе (например, личный кабинет).
- SSG: Для статичных страниц (блог, документация).
- ISR: Для страниц, которые обновляются периодически (каталог товаров).
- CSR: Для интерактивных компонентов (формы, фильтры).
Работа с метаданными
Использовал next/head (Pages Router) и generateMetadata (App Router) для SEO-оптимизации. В Go-бэкенде генерировал Open Graph теги и мета-описания, которые передавались в Next.js через API.
Интеграция с Go
- Go-сервер предоставлял RESTful API с чёткой документацией (Swagger).
- Использовал
fetchв Next.js для запросов к Go-бэкенду. - Для аутентификации — JWT-токены, хранящиеся в
httpOnlycookies.
Вывод
Next.js — мощный инструмент для построения современных веб-приложений, особенно в связке с Go-бэкендом. Понимание различий между роутерами и видами рендеринга позволяет выбирать оптимальный подход под задачи проекта.
Вопрос 2. Что такое SSR (Server-Side Rendering)? В чём его концепция и идея? В чём разница между серверным и клиентским рендерингом?
Таймкод: 00:01:52
Ответ собеседника: Правильный. SSR — это вид рендеринга, который позволяет рендерить HTML-страницу на стороне сервера. Это даёт доступ к динамическим данным на сервере (например, через getServerSideProps), данные предзагружаются, и пользователь не видит лоадеров. При клиентском рендеринге HTML не генерируется на сервере, все данные запрашиваются на клиенте (чаще всего в useEffect), и пользователь видит индикаторы загрузки при первом заходе на страницу.
Правильный ответ:
Ответ кандидата корректный и покрывает основные аспекты. Дополню более глубоким объяснением концепции и техническими деталями.
Концепция и идея SSR
SSR (Server-Side Rendering) — это подход к рендерингу веб-приложений, при котором HTML-страница формируется на сервере для каждого запроса пользователя. Идея заключается в том, чтобы отправить клиенту уже готовый, полностью сформированный HTML-документ, а не пустую оболочку, которая заполняется JavaScript после загрузки.
Как работает SSR
- Пользователь отправляет запрос на сервер
- Сервер выполняет все необходимые операции: запросы к базе данных, внешним API, вычисления
- Сервер рендерит React-компоненты в HTML-строку
- Клиенту отправляется готовый HTML + JavaScript для гидратации (hydration)
- Браузер отображает контент сразу, затем JavaScript "оживляет" страницу
SSR vs CSR: ключевые различия
| Характеристика | SSR | CSR |
|---|---|---|
| Где рендерится HTML | Сервер | Клиент (браузер) |
| Первая отрисовка | Быстрая (готовый HTML) | Медленная (загрузка JS + запросы данных) |
| SEO | Отличный (HTML доступен краулерам) | Плохой (изначально пустой HTML) |
| Нагрузка на сервер | Выше | Ниже |
| Нагрузка на клиент | Ниже | выше |
| Интерактивность | После гидратации | После загрузки JS |
| Доступ к данным | Прямой доступ к БД/API на сервере | Только через API клиентом |
Пример реализации SSR в Next.js
Pages Router:
// pages/product/[id].js
export async function getServerSideProps(context) {
const { id } = context.params;
const product = await fetch(`https://api.example.com/products/${id}`)
.then(res => res.json());
return {
props: { product }
};
}
export default function ProductPage({ product }) {
return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
</div>
);
}
App Router:
// app/product/[id]/page.js
export default async function ProductPage({ params }) {
const { id } = params;
const product = await fetch(`https://api.example.com/products/${id}`)
.then(res => res.json());
return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
</div>
);
}
Пример CSR для сравнения:
// Типичный CSR-подход
import { useState, useEffect } from 'react';
export default function ProductPage({ productId }) {
const [product, setProduct] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch(`/api/products/${productId}`)
.then(res => res.json())
.then(data => {
setProduct(data);
setLoading(false);
});
}, [productId]);
if (loading) return <div>Загрузка...</div>;
return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
</div>
);
}
Преимущества SSR
- SEO-оптимизация: Поисковые роботы получают полный HTML-контент
- Производительность восприятия: Пользователь видит контент сразу
- Социальные сети: Корректные превью при шеринге (Open Graph теги)
- Доступность: Контент доступен даже при отключённом JavaScript
Недостатки SSR
- Нагрузка на сервер: Каждый запрос требует рендеринга
- Сложность кэширования: Динамический контент сложнее кэшировать
- Время до интерактивности (TTI): Страница видна, но не интерактивна до гидратации
- Стоимость инфраструктуры: Требуются серверные ресурсы
Когда использовать SSR
- Страницы с динамическим контентом, критичным для SEO
- Лендинги и маркетинговые страницы
- Страницы товаров в e-commerce
- Дашборды с персонализированными данными
Гидратация (Hydration)
Важный аспект SSR — процесс гидратации. После получения HTML браузер загружает JavaScript-бандл и "привязывает" обработчики событий к существующему DOM-дереву. Это позволяет странице стать полностью интерактивной.
Вопрос 3. Какие ещё бывают виды рендеринга помимо SSR и клиентского?
Таймкод: 00:03:08
Ответ собеседника: Правильный. Есть статический рендеринг (SSG) — похож на SSR, страница рендерится на сервере, но можно настроить ревалидацию через revalidate. Подходит для контента, который обновляется достаточно часто. Также есть ISR (Incremental Static Regeneration) — гибридный подход, позволяющий выборочно обновлять определённые страницы, не пересобирая весь бандл. В ISR тоже можно делать запросы на сервере и сразу отображать данные на клиенте.
Правильный ответ:
Ответ кандидата в целом верный, но содержит небольшую неточность и не покрывает все современные подходы. Дополню и уточню.
Основные виды рендеринга
1. SSG (Static Site Generation)
Статическая генерация происходит на этапе сборки (build time). HTML генерируется один раз и отдается всем пользователям без изменений до следующей сборки.
Ключевые особенности:
- Максимальная производительность (HTML готов заранее)
- Идеально подходит для CDN-кэширования
- Нет доступа к данным запроса (cookies, headers) при рендеринге
- Подходит для контента, который редко меняется
// Next.js Pages Router
export async function getStaticProps() {
const posts = await fetch('https://api.example.com/posts')
.then(res => res.json());
return {
props: { posts },
// Страница будет перегенерена не чаще чем раз в 60 секунд
revalidate: 60
};
}
2. ISR (Incremental Static Regeneration)
Расширение SSG, позволяющее обновлять статические страницы после сборки без полного пересбора всего сайта.
Ключевые особенности:
- Страница генерируется при первом запросе (или при сборке)
- После истечения
revalidateпериод следующий запрос запускает фоновую регенерацию - Пользователи продолжают получать старую версию до завершения регенерации
- Подходит для контента, который обновляется периодически
// Next.js Pages Router с ISR
export async function getStaticProps() {
const products = await fetch('https://api.example.com/products')
.then(res => res.json());
return {
props: { products },
revalidate: 300 // Обновление каждые 5 минут
};
}
3. Streaming SSR
Постепенная отправка HTML с сервера клиенту. Вместо ожидания полной готовности страницы, сервер отправляет части HTML по мере их готовности.
Ключевые особенности:
- Уменьшает TTFB (Time to First Byte)
- Клиент начинает отрисовку раньше
- Требует поддержки React 18+ и Suspense
- Идеально для страниц с медленными источниками данных
// Next.js App Router с Streaming
import { Suspense } from 'react';
async function SlowComponent() {
const data = await fetch('https://slow-api.example.com/data');
return <div>{data}</div>;
}
export default function Page() {
return (
<div>
<h1>Быстрый контент</h1>
<Suspense fallback={<div>Загрузка...</div>}>
<SlowComponent />
</Suspense>
</div>
);
}
4. React Server Components (RSC)
Компоненты, которые рендерятся только на сервере и не отправляют JavaScript клиенту.
Ключевые особенности:
- Нулевой размер бандла для клиента
- Прямой доступ к серверным ресурсам (БД, файловая система)
- Не могут использовать хуки (useState, useEffect)
- Могут быть асинхронными
// app/posts/page.js — Server Component по умолчанию
async function getPosts() {
const res = await fetch('https://api.example.com/posts');
return res.json();
}
export default async function PostsPage() {
const posts = await getPosts();
return (
<ul>
{posts.map(post => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}
5. Partial Prerendering (PPR)
Экспериментальный подход в Next.js, сочетающий статическую и динамическую части на одной странице.
Ключевые особенности:
- Статическая оболочка отдаётся мгновенно
- Динамические части загружаются через Suspense
- Оптимизирует как производительность, так и актуальность данных
Сравнительная таблица
| Подход | Время генерации | Актуальность данных | Нагрузка на сервер | Сложность |
|---|---|---|---|---|
| SSG | Build time | До следующей сборки | Минимальная | Низкая |
| ISR | Build + runtime | Настраиваемая | Низкая | Средняя |
| SSR | Request time | Всегда актуальны | Высокая | Средняя |
| Streaming SSR | Request time | Актуальны | Высокая | Высокая |
| RSC | Request time | Актуальны | Высокая | Высокая |
Уточнение к ответу кандидата
Кандидат верно упомянул SSG и ISR, но стоит отметить, что в чистом SSG без revalidate страница генерируется только при сборке и не обновляется. Параметр revalidate как раз превращает SSG в ISR. Также не упомянуты современные подходы: Streaming SSR и React Server Components, которые активно используются в Next.js 13+ с App Router.
Вопрос 4. Как работает гидратация в React?
Таймкод: 00:04:20
Ответ собеседника: Неполный. Гидратация — это процесс, когда HTML-код, сгенерированный на сервере, возвращается на клиент и встраивается в клиентскую сторону. Компоненты React выполняются на сервере, рендерятся, а когда страница готова, React берёт на себя задачу по внедрению всех сгенерированных на сервере компонентов и прикрепляет их в нужные места. На уточняющий вопрос о том, как React прикрепляет компоненты в нужные места, ответил, что это происходит благодаря принципам и оптимизациям, на которых построен React, но не раскрыл механизм подробнее.
Правильный ответ:
Ответ кандидата описывает общую идею, но не раскрывает технические детали механизма гидратации. Приведу полное объяснение.
Что такое гидратация
Гидратация (Hydration) — это процесс "оживления" статического HTML, сгенерированного на сервере, путём привязки React-компонентов и обработчиков событий к существующему DOM-дереву. После гидратации страница становится полностью интерактивной.
Пошаговый механизм гидратации
1. Серверная фаза
Сервер выполняет React-компоненты и генерирует HTML:
// Серверный код (Node.js)
import { renderToString } from 'react-dom/server';
const html = renderToString(<App />);
// Результат: <div><h1>Привет</h1><button>Клик</button></div>
Сервер также сериализует данные для передачи клиенту:
<script id="__NEXT_DATA__" type="application/json">
{"props":{"pageProps":{"user":"John"}}}
</script>
2. Клиентская фаза — загрузка
Браузер получает HTML и отображает его пользователю. Затем загружается JavaScript-бандл.
3. Клиентская фаза — гидратация
React на клиенте выполняет hydrateRoot():
// Клиентский код
import { hydrateRoot } from 'react-dom/client';
import App from './App';
const root = hydrateRoot(document.getElementById('root'), <App />);
Как React "прикрепляет" компоненты к DOM
Это ключевой момент, который кандидат не раскрыл:
A. Сравнение виртуального DOM с реальным DOM
React хранит в памяти результат серверного рендеринга в виде виртуального DOM (Virtual DOM). При гидратации React:
- Рендерит компоненты в памяти (создаёт Virtual DOM)
- Сравнивает Virtual DOM с существующим реальным DOM
- Если структура совпадает — привязывает обработчики событий к существующим узлам
- Если структура не совпадает — выдаёт предупреждение и перерисовывает
B. Механизм сопоставления (Reconciliation)
React использует специальные атрибуты для сопоставления:
<!-- HTML с сервера содержит data-reactroot -->
<div data-reactroot="">
<h1>Привет</h1>
<button>Клик</button>
</div>
Каждый React-элемент имеет внутренний "ключ" (fiber node), который связывает виртуальный DOM с реальным DOM-узлом.
C. Привязка обработчиков событий
React не добавляет атрибуты onclick в HTML. Вместо этого он использует делегирование событий:
// React добавляет один обработчик на корневой элемент
document.getElementById('root').addEventListener('click', (event) => {
// Находим целевой компонент и вызываем его обработчик
const targetComponent = findComponentByDOMNode(event.target);
targetComponent.handleClick(event);
});
Проблемы гидратации
1. Несоответствие структуры (Hydration Mismatch)
// Проблема: разный результат на сервере и клиенте
function Component() {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
// На сервере: "Загрузка..."
// На клиенте после гидратации: "Загружено"
return <div>{mounted ? 'Загружено' : 'Загрузка...'}</div>;
}
// Решение: использовать useEffect для клиентского кода
function Component() {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
return (
<div>
{mounted && <ClientOnlyComponent />}
<ServerComponent />
</div>
);
}
2. Проблема с датами и временем
// Проблема: разные часовые пояса
function TimeDisplay() {
// На сервере: "2024-01-15 10:00 UTC"
// На клиенте: "2024-01-15 13:00 UTC+3"
return <div>{new Date().toLocaleString()}</div>;
}
// Решение: использовать suppressHydrationWarning
function TimeDisplay() {
return <div suppressHydrationWarning>{new Date().toLocaleString()}</div>;
}
3. Проблема с случайными значениями
// Проблема: разные значения на сервере и клиенте
function RandomComponent() {
const randomId = Math.random(); // Разные значения!
return <div id={randomId}>Контент</div>;
}
// Решение: использовать стабильные идентификаторы
import { useId } from 'react';
function RandomComponent() {
const id = useId(); // Стабильный ID
return <div id={id}>Контент</div>;
}
Оптимизации гидратации
1. Selective Hydration (React 18+)
React 18 позволяет гидрировать части страницы по мере их появления:
import { Suspense } from 'react';
function Page() {
return (
<div>
<Header />
<Suspense fallback={<Skeleton />}>
<SlowComponent /> {/* Гидрация откладывается */}
</Suspense>
<Footer />
</div>
);
}
2. Streaming SSR с гидратацией
HTML отправляется частями, и каждая часть гидрируется по мере получения:
// Сервер
import { renderToPipeableStream } from 'react-dom/server';
const stream = renderToPipeableStream(<App />, {
onShellReady() {
response.setHeader('Content-Type', 'text/html');
stream.pipe(response);
}
});
Метрики гидратации
- TTI (Time to Interactive): Время до полной интерактивности
- FCP (First Contentful Paint): Время до первого отрисованного контента
- LCP (Largest Contentful Paint): Время до отрисовки основного контента
- CLS (Cumulative Layout Shift): Стабильность макета
Вывод
Гидратация — это не просто "встраивание" HTML, а сложный процесс сопоставления виртуального DOM с реальным DOM, привязки обработчиков событий и обеспечения интерактивности. Понимание этого механизма критически важно для отладки проблем с SSR и оптимизации производительности.
Вопрос 5. Что такое Virtual DOM и как он работает? Как React понимает, какая именно часть DOM изменилась?
Таймкод: 00:05:52
Ответ собеседника: Неполный. Virtual DOM — это точная копия реального DOM-дерева, хранящаяся в памяти и синхронизируемая с ним. Главный плюс — оптимизация тяжёлых операций браузера (Layout, Paint, Reflow). Этапы работы: 1) Рендеринг — монтирование компонентов, создание виртуального представления. 2) Reconciliation — сравнение с предыдущей версией, составление списка изменений. 3) Commit — фиксация изменений и обновление реального DOM. На вопрос, как React понимает, какая конкретно часть вёрстки изменилась, ответил поверхностно — через изменённые пропсы и состояния, но не раскрыл алгоритм diffing.
Правильный ответ:
Ответ кандидата описывает общую идею, но содержит неточности и не раскрывает ключевой алгоритм diffing. Приведу полное объяснение.
Что такое Virtual DOM
Virtual DOM — это лёгкое JavaScript-представление реального DOM-дерева в виде объектов. Это НЕ "точная копия" DOM, а скорее абстракция, оптимизированная для быстрого сравнения и обновления.
// Пример Virtual DOM объекта
const virtualNode = {
type: 'div',
props: {
id: 'container',
className: 'wrapper',
children: [
{
type: 'h1',
props: {
children: 'Заголовок'
}
},
{
type: 'p',
props: {
children: 'Параграф текста'
}
}
]
}
};
Зачем нужен Virtual DOM
Операции с реальным DOM дорогие из-за вызова тяжёлых процессов браузера:
- Reflow (Layout): Пересчёт позиций и размеров элементов
- Repaint (Paint): Отрисовка пикселей
- Composite: Компоновка слоёв
Virtual DOM позволяет:
- Собрать все изменения в памяти
- Вычислить минимальный набор операций
- Применить изменения одним батчем
Архитектура React Fiber
Начиная с React 16, Virtual DOM реализован через Fiber — связанный список узлов:
// Упрощённая структура Fiber-узла
const fiberNode = {
type: 'div', // Тип элемента
key: null, // Ключ для списков
props: { className: 'container' },
// Связи с другими узлами
return: parentFiber, // Родитель
child: firstChildFiber, // Первый потомок
sibling: nextSiblingFiber, // Следующий брат
// Состояние
stateNode: domElement, // Ссылка на реальный DOM-узл
alternate: previousFiber, // Ссылка на предыдущую версию
// Эффекты
effectTag: 'UPDATE', // Тип изменения
effects: [] // Список побочных эффектов
};
Три фазы обновления
1. Render (рендеринг) — асинхронная фаза
React обходит дерево компонентов и создаёт новое Fiber-дерево. Эта фаза может быть прервана и возобновлена.
function renderPhase(component) {
// Вычисляем новое состояние
const newChildren = component.render();
// Рекурсивно обходим потомков
const newFiberTree = reconcileChildren(
currentFiberTree,
newChildren
);
return newFiberTree;
}
2. Reconciliation (согласование) — алгоритм diffing
Это ключевой этап, который кандидат не раскрыл. React сравнивает старое и новое Fiber-дерево.
3. Commit (фиксация) — синхронная фаза
React применяет вычисленные изменения к реальному DOM за один проход.
Алгоритм Diffing — как React понимает, что изменилось
React использует эвристический алгоритм сравнения с двумя ключевыми допущениями:
Допущение 1: Элементы разных типов создают разные деревья
// Было
<div>
<Counter />
</div>
// Стало
<span>
<Counter />
</div>
// React уничтожает div и создаёт span заново
// Все дочерние элементы пересоздаются
Допущение 2: Ключи (keys) определяют стабильность элементов
// Без ключей — React не понимает, какой элемент какой
<ul>
<li>Первый</li>
<li>Второй</li>
</ul>
// После добавления элемента в начало
<ul>
<li>Новый</li> {/* React думает, что это "Первый" с изменённым текстом */}
<li>Первый</li> {/* React думает, что это "Второй" с изменённым текстом */}
<li>Второй</li> {/* React думает, что это новый элемент */}
</ul>
// С ключами — React точно знает
<ul>
<li key="new">Новый</li>
<li key="1">Первый</li>
<li key="2">Второй</li>
</ul>
Алгоритм сравнения по уровням
React сравнивает деревья по уровням, не углубляясь дальше при обнаружении различий:
// Уровень 0: <div> vs <div> — совпадает, идём глубже
// Уровень 1: <h1> vs <h1> — совпадает, идём глубже
// Уровень 2: текст "Старый" vs "Новый" — отличается, обновляем
<div> <div>
<h1>Старый</h1> <h1>Новый</h1>
</div> </div>
Алгоритм сравнения спискей
Для спискей React использует оптимизированный алгоритм:
// Было: [A, B, C]
// Стало: [B, D, A, C]
// Шаг 1: Сравниваем по ключам
oldList = [
{ key: 'A', element: <A /> },
{ key: 'B', element: <B /> },
{ key: 'C', element: <C /> }
]
newList = [
{ key: 'B', element: <B /> },
{ key: 'D', element: <D /> },
{ key: 'A', element: <A /> },
{ key: 'C', element: <C /> }
]
// Шаг 2: Создаём Map для быстрого поиска
oldMap = new Map([
['A', oldFiberA],
['B', oldFiberB],
['C', oldFiberC]
])
// Шаг 3: Проходим по новому списку
for (const newItem of newList) {
const oldFiber = oldMap.get(newItem.key);
if (oldFiber) {
// Элемент существует — обновляем пропсы
updateFiber(oldFiber, newItem.props);
} else {
// Новый элемент — создаём
createNewFiber(newItem);
}
}
Алгоритм сравнения дочерних элементов
function reconcileChildren(currentFiber, newChildren) {
let oldFiber = currentFiber.child;
let newChildIndex = 0;
let previousNewFiber = null;
// Первый проход: сравниваем элементы на одной позиции
for (; oldFiber !== null && newChildIndex < newChildren.length; newChildIndex++) {
const newChild = newChildren[newChildIndex];
if (oldFiber.key === newChild.key && oldFiber.type === newChild.type) {
// Тип и ключ совпадают — обновляем
const newFiber = {
...oldFiber,
props: newChild.props,
effectTag: 'UPDATE'
};
} else {
// Тип или ключ отличается — удаляем старый, создаём 新的
oldFiber.effectTag = 'DELETION';
const newFiber = createNewFiber(newChild);
}
previousNewFiber = newFiber;
oldFiber = oldFiber.sibling;
}
// Второй проход: обрабатываем оставшиеся элементы
// ...
}
Типы обновлений в Commit фазе
function commitRoot(fiberRoot) {
// Собираем все изменения
const effects = collectEffects(fiberRoot);
// Применяем изменения к DOM
for (const effect of effects) {
switch (effect.effectTag) {
case 'PLACEMENT':
// Новый элемент — добавляем в DOM
parentDOMNode.appendChild(effect.stateNode);
break;
case 'UPDATE':
// Существующий элемент — обновляем свойства
updateDOMProperties(
effect.stateNode,
effect.oldProps,
effect.newProps
);
break;
case 'DELETION':
// Удалённый элемент — удаляем из DOM
parentDOMNode.removeChild(effect.stateNode);
break;
}
}
}
Оптимизации React
1. Batching (пакетирование)
// До React 18: батчинг только в обработчиках событий
function handleClick() {
setCount(c => c + 1); // Не вызывает рендер сразу
setName('John'); // Не вызывает рендер сразу
// Один рендер с обоими изменениями
}
// React 18+: автоматический батчинг везде
setTimeout(() => {
setCount(c => c + 1);
setName('John');
// Один рендер вместо двух
}, 1000);
2. Prioritization (приоритизация)
// Высокий приоритет — пользовательский ввод
<input onChange={handleChange} />
// Низкий приоритет — данные с сервера
startTransition(() => {
setSearchResults(data);
});
Метрики производительности
- Reconciliation Time: Время на сравнение деревьев
- Commit Time: Время на применение изменений
- DOM Operations Count: Количество операций с DOM
Вывод
Virtual DOM — это не просто "копия DOM", а сложная структура данных (Fiber), оптимизированная для быстрого сравнения. Алгоритм diffing основан на двух допущениях (разные типы = разные деревья, ключи определяют стабильность) и работает по уровням, минимизируя количество операций с реальным DOM.
Вопрос 6. Какие подходы и техники используешь для оптимизации рендеринга большого проекта? Есть ли опыт с микрофронтендами?
Таймкод: 00:08:39
Ответ собеседника: Неполный. Назвал три подхода: 1) Оптимизация на уровне React — React.memo, useMemo, useCallback. 2) Микрофронтенды для разбиения проекта на части. 3) Архитектурные подходы. Про микрофронтенды рассказал, что на прошлой работе был один кейс на аналоге HeadHunter для моряков, где разделы делились на микрофронтенды через Module Federation в Webpack. Настраивал Webpack, дописывал существующий код. Объяснил концептуальные плюсы: разные микрофронтенды могут быть написаны на разных фреймворках, каждый модуль — отдельное приложение, меньший размер бандла. Не упомянул виртуализацию списков, lazy loading, code splitting, CSS-оптимизации.
Правильный ответ:
Ответ кандидата покрывает базовые аспекты, но не раскрывает многие важные техники оптимизации. Приведу полный обзор.
Оптимизация на уровне React
1. Мемоизация компонентов и значений
// React.memo — предотвращает ре-рендер при неизменных пропсах
const ExpensiveComponent = React.memo(function ExpensiveComponent({ data, onUpdate }) {
// Тяжёлые вычисления
return <div>{/* сложная вёрстка */}</div>;
}, (prevProps, nextProps) => {
// Кастомная функция сравнения (опционально)
return prevProps.data.id === nextProps.data.id;
});
// useMemo — мемоизация вычислений
function SearchResults({ query, items }) {
const filteredItems = useMemo(() => {
return items.filter(item =>
item.name.toLowerCase().includes(query.toLowerCase())
);
}, [query, items]); // Пересчитываем только при изменении зависимостей
return <List items={filteredItems} />;
}
// useCallback — мемоизация функций
function ParentComponent() {
const [count, setCount] = useState(0);
// Без useCallback функция создаётся заново при каждом рендере
const handleClick = useCallback(() => {
setCount(c => c + 1);
}, []); // Пустая зависимость — функция создаётся один раз
return <ChildComponent onClick={handleClick} />;
}
2. Оптимизация контекста
// Проблема: все потребители ре-рендерятся при любом изменении контекста
const AppContext = createContext();
function AppProvider({ children }) {
const [user, setUser] = useState(null);
const [theme, setTheme] = useState('light');
// Ре-рендер при изменении user ИЛИ theme
const value = { user, theme, setUser, setTheme };
return (
<AppContext.Provider value={value}>
{children}
</AppContext.Provider>
);
}
// Решение 1: разделение контекстов
const UserContext = createContext();
const ThemeContext = createContext();
// Решение 2: селекторы с use-context-selector
import { createContext, useContextSelector } from 'use-context-selector';
function UserName() {
// Ре-рендер только при изменении user.name
const name = useContextSelector(AppContext, ctx => ctx.user?.name);
return <span>{name}</span>;
}
3. startTransition для несрочных обновлений (React 18+)
import { startTransition } from 'react';
function SearchPage() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const handleChange = (e) => {
// Срочное обновление — инпут должен отреагировать мгновенно
setQuery(e.target.value);
// Несрочное обновление — результаты поиска могут подождать
startTransition(() => {
const filtered = expensiveSearch(e.target.value);
setResults(filtered);
});
};
return (
<div>
<input value={query} onChange={handleChange} />
<SearchResults results={results} />
</div>
);
}
Code Splitting и Lazy Loading
1. React.lazy для компонентов
import { lazy, Suspense } from 'react';
// Компонент загружается только при первом рендере
const HeavyChart = lazy(() => import('./components/HeavyChart'));
const AdminPanel = lazy(() => import('./pages/AdminPanel'));
function Dashboard() {
const [showChart, setShowChart] = useState(false);
return (
<div>
<button onClick={() => setShowChart(true)}>
Показать график
</button>
<Suspense fallback={<ChartSkeleton />}>
{showChart && <HeavyChart />}
</Suspense>
</div>
);
}
2. Динамические импорты с предзагрузкой
const UserProfile = lazy(() => import('./UserProfile'));
function UserList({ users }) {
const [selectedUser, setSelectedUser] = useState(null);
// Предзагрузка при наведении мыши
const handleMouseEnter = (userId) => {
import('./UserProfile'); // Начинаем загрузку заранее
};
return (
<div>
{users.map(user => (
<div
key={user.id}
onClick={() => setSelectedUser(user)}
onMouseEnter={() => handleMouseEnter(user.id)}
>
{user.name}
</div>
))}
{selectedUser && (
<Suspense fallback={<ProfileSkeleton />}>
<UserProfile user={selectedUser} />
</Suspense>
)}
</div>
);
}
3. Route-based code splitting
import { lazy, Suspense } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
const Home = lazy(() => import('./pages/Home'));
const Products = lazy(() => import('./pages/Products'));
const ProductDetail = lazy(() => import('./pages/ProductDetail'));
function App() {
return (
<BrowserRouter>
<Suspense fallback={<PageLoader />}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/products" element={<Products />} />
<Route path="/products/:id" element={<ProductDetail />} />
</Routes>
</Suspense>
</BrowserRouter>
);
}
Виртуализация списков
Для больших спискей рендерим только видимые элементы:
import { useVirtualizer } from '@tanstack/react-virtual';
function VirtualList({ items }) {
const parentRef = useRef(null);
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 50, // Ориентировочная высота элемента
overscan: 5, // Количество элементов за пределами видимой области
});
return (
<div ref={parentRef} style={{ height: '400px', overflow: 'auto' }}>
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
position: 'relative',
}}
>
{virtualizer.getVirtualItems().map(virtualItem => (
<div
key={virtualItem.key}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualItem.size}px`,
transform: `translateY(${virtualItem.start}px)`,
}}
>
{items[virtualItem.index].name}
</div>
))}
</div>
</div>
);
}
CSS-оптимизации
1. Изоляция стилей
// CSS Modules — автоматическая изоляция
import styles from './Button.module.css';
function Button() {
return <button className={styles.primary}>Клик</button>;
}
// Styled Components — scoped стили
import styled from 'styled-components';
const StyledButton = styled.button`
background: ${props => props.primary ? 'blue' : 'gray'};
padding: 10px 20px;
`;
2. Critical CSS — инлайн критических стилей
<head>
<style>
/* Критические стили для первого экрана */
.header { /* ... */ }
.hero { /* ... */ }
</style>
<!-- Некритические стили загружаются асинхронно -->
<link rel="preload" href="/styles/main.css" as="style" onload="this.onload=null;this.rel='stylesheet'">
</head>
3. Оптимизация рефлоу
/* Плохо: вызывает рефлоу */
.element {
width: calc(100% - 20px);
height: 100px;
margin: 10px;
}
/* Хорошо: используем transform вместо изменения размеров */
.element {
transform: translateX(10px);
will-change: transform; /* Подсказка браузеру */
}
Оптимизация изображений
// Next.js Image компонент с автоматической оптимизацией
import Image from 'next/image';
function ProductCard({ product }) {
return (
<div>
<Image
src={product.image}
alt={product.name}
width={300}
height={200}
placeholder="blur" // Размытое превью пока загружается
blurDataURL="data:image/jpeg;base64,..." // Base64 превью
loading="lazy" // Ленивая загрузка
/>
</div>
);
}
Архитектурные подходы
1. Feature-Sliced Design
src/
├── app/ # Инициализация приложения
├── pages/ # Страницы
├── widgets/ # Самостоятельные блоки интерфейса
├── features/ # Пользовательские сценарии
├── entities/ # Бизнес-сущности
└── shared/ # Переиспользуемый код
2. Микрофронтенды
Кандидат упомянул Module Federation. Дополню другими подходами:
Module Federation (Webpack 5)
// webpack.config.js (Host приложение)
const ModuleFederationPlugin = require('@module-federation/webpack');
module.exports = {
plugins: [
new ModuleFederationPlugin({
name: 'host',
remotes: {
products: 'products@http://localhost:3001/remoteEntry.js',
cart: 'cart@http://localhost:3002/remoteEntry.js',
},
shared: {
react: { singleton: true, eager: true },
'react-dom': { singleton: true, eager: true },
},
}),
],
};
// Использование удалённого модуля
const ProductsList = lazy(() => import('products/ProductsList'));
iframe-подход
function MicroFrontend({ src, name }) {
const iframeRef = useRef(null);
useEffect(() => {
// Коммуникация через postMessage
const handleMessage = (event) => {
if (event.origin !== new URL(src).origin) return;
if (event.data.type === 'NAVIGATE') {
// Обработка навигации из микрофронтенда
}
};
window.addEventListener('message', handleMessage);
return () => window.removeEventListener('message', handleMessage);
}, [src]);
return (
<iframe
ref={iframeRef}
src={src}
title={name}
style={{ border: 'none', width: '100%', height: '100%' }}
/>
);
}
Web Components
// Микрофронтенд как Web Component
class ProductsElement extends HTMLElement {
connectedCallback() {
const shadow = this.attachShadow({ mode: 'open' });
// Рендерим React внутри Shadow DOM
const root = createRoot(shadow);
root.render(<ProductsCatalog />);
}
}
customElements.define('products-widget', ProductsElement);
// Использование в основном приложении
function App() {
return (
<div>
<header>...</header>
<products-widget category="electronics" />
</div>
);
}
Плюсы и минусы микрофронтендов
| Аспект | Плюсы | Минусы |
|---|---|---|
| Независимость | Команды работают автономно | Дублирование зависимостей |
| Масштабирование | Легко добавлять новые модули | Сложность интеграции |
| Технологии | Можно использовать разные фреймворки | Несогласованный UX |
| Деплой | Независимые релизы | Сложность отката |
| Производительность | Меньший бандл для каждого модуля | Накладные расходы на runtime |
Мониторинг производительности
// React DevTools Profiler API
import { Profiler } from 'react';
function onRenderCallback(
id, // id компонента
phase, // "mount" | "update" | "nested-update"
actualDuration, // время рендеринга
baseDuration, // время рендеринга без мемоизации
startTime, // начало рендеринга
commitTime, // конец коммита
interactions // список взаимодействий
) {
// Отправляем метрики в аналитику
analytics.track('component_render', {
component: id,
duration: actualDuration,
phase,
});
}
function App() {
return (
<Profiler id="App" onRender={onRenderCallback}>
<MainContent />
</Profiler>
);
}
Вывод
Оптимизация рендеринга — это комплексная задача, включающая мемоизацию, code splitting, виртуализацию, CSS-оптимизации и архитектурные решения. Микрофронтенды — мощный инструмент для больших проектов, но требуют тщательного проектирования и приносят накладные расходы. Важно измерять метрики производительности перед и после оптимизаций.
Вопрос 7. Как оптимизировать отображение таблицы с 1000 записей без пагинации, когда все данные приходят с бэкенда сразу?
Таймкод: 00:11:47
Ответ собеседника: Неполный. Предложил рендерить только те данные, которые находятся во viewport, используя библиотеки React Window или React Virtualizer. Также упомянул важность уникальных ключей для элементов списка. Отметил, что запрос на 1000 записей в любом случае будет отрабатывать долго, и влиять на это нужно на стороне бэкенда. Не раскрыл детали реализации виртуализации.
Правильный ответ:
Ответ кандидата верно определяет основной подход, но не раскрывает детали реализации. Приведу полное решение.
Основной подход: Виртуализация
Виртуализация (Virtual Scrolling) — техника при которой рендерятся только видимые элементы плюс небольшой буфер. Для таблицы с 1000 записей при высоте строки 40px и видимой области 600px рендерится всего ~20 строк вместо 1000.
Реализация с TanStack Virtual (рекомендуемая библиотека)
import { useVirtualizer } from '@tanstack/react-virtual';
import { useRef } from 'react';
function VirtualTable({ data, columns }) {
const parentRef = useRef(null);
const virtualizer = useVirtualizer({
count: data.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 40, // Ориентировочная высота строки
overscan: 10, // Дополнительные строки сверху и снизу
});
const virtualRows = virtualizer.getVirtualItems();
const totalHeight = virtualizer.getTotalSize();
return (
<div
ref={parentRef}
style={{
height: '600px',
overflow: 'auto',
position: 'relative',
}}
>
{/* Контейнер полной высоты для корректного скроллбара */}
<div style={{ height: totalHeight, position: 'relative' }}>
{/* Заголовок таблицы — фиксированный */}
<div
style={{
position: 'sticky',
top: 0,
zIndex: 1,
background: 'white',
display: 'flex',
borderBottom: '2px solid #ddd',
}}
>
{columns.map(col => (
<div
key={col.key}
style={{
width: col.width || '200px',
padding: '10px',
fontWeight: 'bold',
}}
>
{col.title}
</div>
))}
</div>
{/* Виртуализованные строки */}
{virtualRows.map(virtualRow => {
const row = data[virtualRow.index];
return (
<div
key={virtualRow.key}
data-index={virtualRow.index}
ref={virtualizer.measureElement} // Для динамической высоты
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualRow.start}px)`,
display: 'flex',
borderBottom: '1px solid #eee',
}}
>
{columns.map(col => (
<div
key={col.key}
style={{
width: col.width || '200px',
padding: '10px',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{col.render ? col.render(row[col.key], row) : row[col.key]}
</div>
))}
</div>
);
})}
</div>
</div>
);
}
// Использование
function App() {
const [data, setData] = useState([]);
useEffect(() => {
fetch('/api/records?limit=1000')
.then(res => res.json())
.then(setData);
}, []);
const columns = [
{ key: 'id', title: 'ID', width: '80px' },
{ key: 'name', title: 'Имя', width: '200px' },
{ key: 'email', title: 'Email', width: '250px' },
{
key: 'status',
title: 'Статус',
width: '120px',
render: (value) => (
<span className={`badge ${value}`}>{value}</span>
)
},
];
return <VirtualTable data={data} columns={columns} />;
}
Реализация с React Window (альтернатива)
import { FixedSizeList as List } from 'react-window';
import AutoSizer from 'react-virtualized-auto-sizer';
function ReactWindowTable({ data, columns }) {
const Row = ({ index, style }) => {
const row = data[index];
return (
<div style={{ ...style, display: 'flex', borderBottom: '1px solid #eee' }}>
{columns.map(col => (
<div
key={col.key}
style={{
width: col.width || '200px',
padding: '10px',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{col.render ? col.render(row[col.key], row) : row[col.key]}
</div>
))}
</div>
);
};
return (
<div style={{ height: '600px' }}>
{/* Заголовок */}
<div style={{ display: 'flex', borderBottom: '2px solid #ddd', fontWeight: 'bold' }}>
{columns.map(col => (
<div key={col.key} style={{ width: col.width || '200px', padding: '10px' }}>
{col.title}
</div>
))}
</div>
{/* Виртуализованный список */}
<AutoSizer>
{({ height, width }) => (
<List
height={height - 40} // Вычитаем высоту заголовка
itemCount={data.length}
itemSize={40}
width={width}
>
{Row}
</List>
)}
</AutoSizer>
</div>
);
}
Оптимизации помимо виртуализации
1. Мемоизация строк
import { memo } from 'react';
const TableRow = memo(function TableRow({ row, columns, style }) {
return (
<div style={{ ...style, display: 'flex' }}>
{columns.map(col => (
<div key={col.key} style={{ width: col.width || '200px', padding: '10px' }}>
{col.render ? col.render(col.key, row) : row[col.key]}
</div>
))}
</div>
);
}, (prevProps, nextProps) => {
// Кастомное сравнение — ре-рендер только при изменении данных
return prevProps.row.id === nextProps.row.id &&
prevProps.row === nextProps.row;
});
2. Дебаунс для поиска и фильтрации
import { useState, useMemo, useCallback } from 'react';
import { useDebouncedCallback } from 'use-debounce';
function SearchableTable({ data, columns }) {
const [searchQuery, setSearchQuery] = useState('');
const [debouncedQuery, setDebouncedQuery] = useState('');
// Дебаунс 300мс для поиска
const debouncedSearch = useDebouncedCallback((value) => {
setDebouncedQuery(value);
}, 300);
const handleSearchChange = (e) => {
setSearchQuery(e.target.value);
debouncedSearch(e.target.value);
};
// Мемоизация фильтрации
const filteredData = useMemo(() => {
if (!debouncedQuery) return data;
return data.filter(row =>
Object.values(row).some(value =>
String(value).toLowerCase().includes(debouncedQuery.toLowerCase())
)
);
}, [data, debouncedQuery]);
return (
<div>
<input
type="text"
value={searchQuery}
onChange={handleSearchChange}
placeholder="Поиск..."
/>
<VirtualTable data={filteredData} columns={columns} />
</div>
);
}
3. Оптимизация сортировки
import { useState, useMemo } from 'react';
function SortableTable({ data, columns }) {
const [sortConfig, setSortConfig] = useState({ key: null, direction: 'asc' });
const sortedData = useMemo(() => {
if (!sortConfig.key) return data;
return [...data].sort((a, b) => {
const aValue = a[sortConfig.key];
const bValue = b[sortConfig.key];
if (aValue < bValue) return sortConfig.direction === 'asc' ? -1 : 1;
if (aValue > bValue) return sortConfig.direction === 'asc' ? 1 : -1;
return 0;
});
}, [data, sortConfig]);
const handleSort = (key) => {
setSortConfig(current => ({
key,
direction: current.key === key && current.direction === 'asc' ? 'desc' : 'asc',
}));
};
return (
<VirtualTable
data={sortedData}
columns={columns.map(col => ({
...col,
title: (
<span onClick={() => handleSort(col.key)} style={{ cursor: 'pointer' }}>
{col.title}
{sortConfig.key === col.key && (
sortConfig.direction === 'asc' ? ' ↑' : ' ↓'
)}
</span>
),
}))}
/>
);
}
4. Ленивый рендеринг ячеек
import { lazy, Suspense } from 'react';
// Тяжёлые компоненты загружаются только при необходимости
const HeavyCell = lazy(() => import('./HeavyCell'));
function TableCell({ column, value, row }) {
if (column.heavy) {
return (
<Suspense fallback={<div>...</div>}>
<HeavyCell value={value} row={row} />
</Suspense>
);
}
return <span>{value}</span>;
}
Дополнительные техники
1. Фиксированные колонки при горизонтальном скролле
function TableWithFixedColumns({ data, columns }) {
const fixedColumns = columns.filter(col => col.fixed);
const scrollableColumns = columns.filter(col => !col.fixed);
return (
<div style={{ display: 'flex' }}>
{/* Фиксированная часть */}
<div style={{ position: 'sticky', left: 0, zIndex: 2, background: 'white' }}>
{fixedColumns.map(col => (
<div key={col.key}>{/* Рендер фиксированных колонок */}</div>
))}
</div>
{/* Прокручиваемая часть */}
<div style={{ overflowX: 'auto', flex: 1 }}>
<VirtualTable data={data} columns={scrollableColumns} />
</div>
</div>
);
}
2. Измерение производительности
import { Profiler, useEffect, useRef } from 'react';
function MeasuredTable({ data, columns }) {
const renderCount = useRef(0);
const totalRenderTime = useRef(0);
useEffect(() => {
console.log(`Среднее время рендеринга: ${totalRenderTime.current / renderCount.current}ms`);
}, []);
return (
<Profiler
id="VirtualTable"
onRender={(id, phase, actualDuration) => {
renderCount.current++;
totalRenderTime.current += actualDuration;
if (actualDuration > 16) {
console.warn(`Долгий рендер: ${actualDuration}ms`, { id, phase });
}
}}
>
<VirtualTable data={data} columns={columns} />
</Profiler>
);
}
Оптимизация на стороне бэкенда (Go)
Хотя вопрос про фронтенд, стоит упомянуть оптимизацию передачи данных:
package main
import (
"encoding/json"
"net/http"
"time"
)
type Record struct {
ID int `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
}
func getRecords(w http.ResponseWriter, r *http.Request) {
// Устанавливаем заголовки для сжатия
w.Header().Set("Content-Encoding", "gzip")
w.Header().Set("Content-Type", "application/json")
// Используем потоковую сериализацию для больших данных
records := fetchRecords(1000)
encoder := json.NewEncoder(w)
encoder.SetIndent("", "") // Убираем отступы для уменьшения размера
encoder.Encode(records)
}
// Оптимизация: передаём только нужные поля
type RecordResponse struct {
ID int `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
Status string `json:"status"`
}
func getRecordsOptimized(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Encoding", "gzip")
w.Header().Set("Content-Type", "application/json")
records := fetchRecords(1000)
// Преобразуем в лёгкую структуру
response := make([]RecordResponse, len(records))
for i, r := range records {
response[i] = RecordResponse{
ID: r.ID,
Name: r.Name,
Email: r.Email,
Status: r.Status,
}
}
json.NewEncoder(w).Encode(response)
}
Вывод
Для таблицы с 1000 записей без пагинации основным решением является виртуализация. Дополнительные оптимизации включают мемоизацию строк, дебаунс для поиска, ленивую загрузку тяжёлых компонентов. Важно также оптимизировать передачу данных с бэкенда: сжатие, потоковую сериализацию, передачу только необходимых полей.
Вопрос 8. Зачем нужны ключи (keys) в React при рендеринге списков? Что произойдёт, если не использовать уникальные ключи?
Таймкод: 00:13:32
Ответ собеседника: Правильный. Ключи нужны для корректной идентификации элементов списка при изменениях. Если не передавать ключи, по умолчанию используются индексы. При изменении элементов в середине массива React может запутаться и некорректно определить, какой элемент был удалён или изменён, что приведёт к неожиданному поведению рендеринга. При использовании уникальных ключей (например, ID с бэкенда) React корректно находит и обновляет нужный элемент в DOM-дереве.
Правильный ответ:
Ответ кандидата корректный и покрывает основные аспекты. Дополню более глубоким объяснением механизма и конкретными примерами проблем.
Зачем нужны ключи
Ключи (keys) — это специальный атрибут, который помогает React идентифицировать элементы при изменениях списка. Они позволяют определить, какой элемент был добавлен, удалён или перемещён.
Как React использует ключи в Reconciliation
При сравнении старого и нового виртуального DOM React использует ключи для сопоставления элементов:
// Было
<ul>
<li key="a">Первый</li>
<li key="b">Второй</li>
<li key="c">Третий</li>
</ul>
// Стало (добавили элемент в начало)
<ul>
<li key="d">Новый</li>
<li key="a">Первый</li>
<li key="b">Второй</li>
<li key="c">Третий</li>
</ul>
// React понимает: элемент "d" — новый, остались на месте
// Действие: добавить один <li> в начало
Проблемы при использовании индексов как ключей
// Проблема: индексы как ключи
const TodoList = ({ todos }) => (
<ul>
{todos.map((todo, index) => (
<li key={index}>
<input type="checkbox" defaultChecked={todo.completed} />
{todo.text}
</li>
))}
</ul>
);
Сценарий проблемы:
// Начальное состояние
todos = [
{ id: 1, text: 'Купить молоко', completed: false },
{ id: 2, text: 'Позвонить маме', completed: false },
{ id: 3, text: 'Написать код', completed: false },
]
// Рендер: [☐ Купить молоко] [☐ Позвонить маме] [☐ Написать код]
// Пользователь отмечает первый элемент
// Затем удаляет его
// Новое состояние
todos = [
{ id: 2, text: 'Позвонить маме', completed: false },
{ id: 3, text: 'Написать код', completed: false },
]
// Ожидаемый рендер: [☐ Позвонить маме] [☐ Написать код]
// Фактический рендер: [☑ Позвонить маме] [☐ Написать код]
// Проблема: чекбокс "переместился" к другому элементу!
Почему это происходит:
// Было: key=0 (Купить молоко), key=1 (Позвонить маме), key=2 (Написать код)
// Стало: key=0 (Позвонить маме), key=1 (Написать код)
// React сравнивает по ключам:
// key=0: был "Купить молоко" (completed=true), стал "Позвонить маме" (completed=false)
// → Обновляем текст, но DOM-узел input остаётся тем же с checked=true!
// key=1: был "Позвонить маме", стал "Написать код"
// → Обновляем текст
// key=2: удалён → удаляем DOM-узел
Правильное решение:
const TodoList = ({ todos }) => (
<ul>
{todos.map(todo => (
<li key={todo.id}>
<input type="checkbox" defaultChecked={todo.completed} />
{todo.text}
</li>
))}
</ul>
);
Другие проблемы с неправильными ключами
1. Потеря фокуса в интерактивных элементах
// Проблема
function CommentList({ comments }) {
return (
<div>
{comments.map((comment, index) => (
<div key={index}>
<textarea defaultValue={comment.text} />
<button>Ответить</button>
</div>
))}
</div>
);
}
// Если добавить комментарий в начало, фокус переместится на другой элемент
2. Некорректная работа анимаций
// Проблема: анимации применятся к неправильным элементам
function AnimatedList({ items }) {
return (
<ul>
{items.map((item, index) => (
<li key={index} className="animate-in">
{item.name}
</li>
))}
</ul>
);
}
3. Проблемы с состоянием компонентов
// Проблема: состояние "перемешивается" между компонентами
function AccordionList({ sections }) {
return (
<div>
{sections.map((section, index) => (
<Accordion key={index} title={section.title}>
{section.content}
</Accordion>
))}
</div>
);
}
// Если Accordion хранит состояние isOpen внутри себя,
// при перемещении элементов состояние останется привязано к индексу
Когда индексы как ключи допустимы
// 1. Статический список, который никогда не меняется
const NAV_ITEMS = ['Главная', 'О нас', 'Контакты'];
function Navigation() {
return (
<nav>
{NAV_ITEMS.map((item, index) => (
<a key={index} href={`#${item}`}>
{item}
</a>
))}
</nav>
);
}
// 2. Список без интерактивных элементов и состояния
function StaticList({ items }) {
return (
<ul>
{items.map((item, index) => (
<li key={index}>{item.name}</li>
))}
</ul>
);
}
Генерация уникальных ключей
// 1. ID из базы данных (лучший вариант)
{items.map(item => <div key={item.id}>{item.name}</div>)}
// 2. UUID
import { v4 as uuidv4 } from 'uuid';
function addItem(items) {
return [...items, { id: uuidv4(), name: 'Новый элемент' }];
}
// 3. Комбинация полей (если нет уникального ID)
{users.map(user => (
<div key={`${user.department}-${user.email}`}>
{user.name}
</div>
))}
// 4. Хеш содержимого (для статичных данных)
import crypto from 'crypto';
function contentHash(content) {
return crypto.createHash('md5').update(JSON.stringify(content)).digest('hex');
}
Отладка проблем с ключами
// React предупреждает в консоли:
// Warning: Each child in a list should have a unique "key" prop.
// Warning: Encountered two children with the same key, `1`.
// Используйте React DevTools для инспекции:
// 1. Откройте Components
// 2. Найдите компонент со списком
// 3. Проверьте проп keys в дереве Fiber
Вывод
Ключи критически важны для корректной работы Reconciliation в React. Использование индексов как ключей допустимо только для статических списков без интерактивных элементов. Для динамических спискей всегда используйте стабильные уникальные идентификаторы (ID из базы данных, UUID). Неправильные ключи приводят к потере состояния, некорректному рендеру, проблемам с фокусом и анимациями.
Вопрос 9. Что такое TypeScript и в чём его преимущества? Почему нельзя писать на обычном JavaScript? Как TypeScript влияет на финальный код?
Таймкод: 00:15:17
Ответ собеседника: Правильный. TypeScript — это надмножество JavaScript со статической типизацией. Преимущества: статическая типизация, обнаружение ошибок на этапе компиляции, более быстрая разработка за счёт подсказок IDE. TypeScript исчезает в продакшн-сборке и не влияет на финальный JavaScript-код. Компиляция происходит через Babel. Также отметил, что TypeScript позволяет иметь меньший финальный код, поскольку JavaScript-движку не нужно генерировать варианты кода под разные типы — TypeScript заранее знает тип и компилирует только один вариант.
Правильный ответ:
Ответ кандидата в целом корректный, но требует уточнений и дополнений. Приведу развёрнутый ответ.
Что такое TypeScript
TypeScript — это типизированное надмножество JavaScript, разработанное Microsoft. Он добавляет статическую типизацию, интерфейсы, дженерики, перечисления и другие возможности поверх JavaScript.
// JavaScript
function add(a, b) {
return a + b;
}
add(1, 2); // 3
add("1", "2"); // "12" — неожиданный результат
add(1, "2"); // "12" — потенциальная ошибка
// TypeScript
function add(a: number, b: number): number {
return a + b;
}
add(1, 2); // OK
add("1", "2"); // Ошибка компиляции: Argument of type 'string' is not assignable
Ключевые преимущества TypeScript
1. Обнаружение ошибок на этапе компиляции
interface User {
id: number;
name: string;
email: string;
}
function sendEmail(user: User) {
console.log(user.emial); // Ошибка: Property 'emial' does not exist on type 'User'
}
// В JavaScript эта ошибка проявится только в рантайме как undefined
2. Улучшенная разработка в IDE
interface Product {
id: number;
name: string;
price: number;
category: string;
}
function displayProduct(product: Product) {
product. // IDE покажет автодополнение: id, name, price, category
// Также покажет типы каждого свойства
}
3. Рефакторинг без страха
// До рефакторинга
interface Config {
apiUrl: string;
timeout: number;
}
// После рефакторинга — переименовали apiUrl в endpoint
interface Config {
endpoint: string;
timeout: number;
}
// TypeScript покажет ВСЕ места, где использовался apiUrl
// В JavaScript пришлось бы искать вручную и надеяться на тесты
4. Самодокументирующийся код
// Без TypeScript — непонятно, что принимает функция
function processData(data, options) {
// Что такое data? Что за options? Какие поля обязательные?
}
// С TypeScript — всё понятно из сигнатуры
interface ProcessOptions {
validate: boolean;
transform?: 'uppercase' | 'lowercase';
maxRetries: number;
}
function processData(
data: Array<{ id: number; value: string }>,
options: ProcessOptions
): ProcessedResult {
// Понятно, что принимает и что возвращает
}
Почему нельзя писать на обычном JavaScript
Технически можно, но для серьёзных проектов это приводит к проблемам:
1. Ошибки в рантайме
// JavaScript — ошибка проявится только при выполнении
function calculateTotal(items) {
return items.reduce((sum, item) => sum + item.price, 0);
}
calculateTotal(null); // TypeError: Cannot read property 'reduce' of null
calculateTotal([{ name: "Item" }]); // NaN — нет поля price
// TypeScript — ошибка на этапе компиляции
function calculateTotal(items: Array<{ price: number }>): number {
return items.reduce((sum, item) => sum + item.price, 0);
}
calculateTotal(null); // Ошибка компиляции
calculateTotal([{ name: "Item" }]); // Ошибка компиляции: Property 'price' is missing
2. Проблемы с интеграцией между модулями
// Модуль A экспортирует
export interface ApiResponse {
success: boolean;
data: unknown;
error?: string;
}
// Модуль B импортирует и использует
import { ApiResponse } from './moduleA';
function handleResponse(response: ApiResponse) {
if (response.success) {
// TypeScript знает, что response.data существует
processData(response.data);
} else {
// TypeScript знает, что response.error может быть string | undefined
logError(response.error ?? 'Unknown error');
}
}
3. Сложность работы с API
// Определение типов для API
interface UserDTO {
id: number;
full_name: string;
email_address: string;
created_at: string;
}
interface CreateUserRequest {
fullName: string;
email: string;
}
// Маппинг из DTO в доменную модель
function mapUserDTO(dto: UserDTO): User {
return {
id: dto.id,
name: dto.full_name,
email: dto.email_address,
createdAt: new Date(dto.created_at),
};
}
Как TypeScript влияет на финальный код
TypeScript полностью удаляется при компиляции:
// Исходный код TypeScript
interface User {
id: number;
name: string;
}
function greet(user: User): string {
return `Hello, ${user.name}!`;
}
const user: User = { id: 1, name: "John" };
console.log(greet(user));
// Результат компиляции — чистый JavaScript
function greet(user) {
return `Hello, ${user.name}!`;
}
const user = { id: 1, name: "John" };
console.log(greet(user));
Уточнение к ответу кандидата
Кандидат упомянул, что TypeScript позволяет иметь меньший финальный код, потому что JavaScript-движку не нужно генерировать варианты под разные типы. Это утверждение не совсем точно:
-
TypeScript не влияет на оптимизацию движка — V8 и другие движки оптимизируют JavaScript на основе реального выполнения, а не типов.
-
TypeScript может уменьшить код косвенно — за счёт лучшего tree-shaking благодаря статическому анализу, но это не прямое влияние типизации.
-
Babel vs tsc — Babel удаляет типы, но не проверяет их. tsc (TypeScript compiler) проверяет типы и компилирует. Современные проекты часто используют оба: tsc для проверки типов, Babel для транспиляции.
Конфигурация TypeScript
// tsconfig.json
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"lib": ["ES2020", "DOM"],
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"outDir": "./dist",
"rootDir": "./src",
"declaration": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
Продвинутые возможности TypeScript
1. Дженерики
function firstElement<T>(arr: T[]): T | undefined {
return arr[0];
}
const num = firstElement([1, 2, 3]); // Тип: number | undefined
const str = firstElement(["a", "b", "c"]); // Тип: string | undefined
2. Условные типы
type ApiResponse<T> = T extends string
? { message: T }
: { data: T };
type StringResponse = ApiResponse<string>; // { message: string }
type NumberResponse = ApiResponse<number>; // { data: number }
3. Mapped Types
type Readonly<T> = {
readonly [P in keyof T]: T[P];
};
type Optional<T> = {
[P in keyof T]?: T[P];
};
interface User {
id: number;
name: string;
email: string;
}
type ReadonlyUser = Readonly<User>;
// { readonly id: number; readonly name: string; readonly email: string }
type PartialUser = Optional<User>;
// { id?: number; name?: string; email?: string }
4. Template Literal Types
type EventName = 'click' | 'focus' | 'blur';
type EventHandler = `on${Capitalize<EventName>}`;
// 'onClick' | 'onFocus' | 'onBlur'
type CSSProperty = 'margin' | 'padding';
type CSSSide = 'top' | 'right' | 'bottom' | 'left';
type CSSPropertyWithSide = `${CSSProperty}-${CSSSide}`;
// 'margin-top' | 'margin-right' | ... | 'padding-left'
Вывод
TypeScript добавляет статическую типизацию к JavaScript, что приводит к обнаружению ошибок на этапе компиляции, улучшенной поддержке IDE, безопасному рефакторингу и самодокументирующемуся коду. TypeScript полностью удаляется при компиляции и не влияет на производительность рантайма. Для серьёзных проектов TypeScript стал стандартом де-факто, хотя JavaScript остаётся подходящим для небольших скриптов и прототипов.
Вопрос 10. Как используешь TypeScript на практике для работы с сетью (API)? Можно ли протипизировать ответ с проверками в рантайме?
Таймкод: 00:21:45
Ответ собеседника: Неполный. Для работы с сетью можно протипизировать response, указать Generic для функции, которая возвращает массив с определённой структурой. При этом признал, что типизация не контролирует реальный ответ сервера. Упомянул, что существуют техники протипизации response с проверками в рантайме (zod-эффекты), но не смог вспомнить деталей, так как потерял свои записи на эту тему.
Правильный ответ:
Ответ кандидата верно определяет проблему — TypeScript не контролирует реальный ответ сервера. Дополню полным решением с runtime-валидацией.
Проблема: TypeScript не работает в рантайме
// TypeScript проверяет только на этапе компиляции
interface User {
id: number;
name: string;
email: string;
}
async function fetchUser(id: number): Promise<User> {
const response = await fetch(`/api/users/${id}`);
return response.json(); // TypeScript доверяет, что ответ соответствует User
}
// Проблема: сервер может вернуть что угодно
// { id: "not-a-number", name: 123, extraField: "unexpected" }
// TypeScript не обнаружит это в рантайме
Решение: Runtime-валидация с Zod
Zod — библиотека для валидации схем с поддержкой TypeScript:
import { z } from 'zod';
// Определяем схему
const UserSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
age: z.number().optional(),
createdAt: z.string().datetime(),
});
// Автоматически получаем TypeScript тип
type User = z.infer<typeof UserSchema>;
// Функция с валидацией
async function fetchUser(id: number): Promise<User> {
const response = await fetch(`/api/users/${id}`);
const data = await response.json();
// Валидация в рантайме
const result = UserSchema.safeParse(data);
if (!result.success) {
console.error('Validation error:', result.error.format());
throw new Error('Invalid user data from server');
}
return result.data; // Гарантированно типизированные данные
}
Продвинутые схемы Zod
// Вложенные объекты
const AddressSchema = z.object({
street: z.string(),
city: z.string(),
country: z.string(),
zipCode: z.string().regex(/^\d{5,6}$/),
});
const UserWithAddressSchema = z.object({
id: z.number(),
name: z.string().min(1).max(100),
email: z.string().email(),
address: AddressSchema,
tags: z.array(z.string()),
role: z.enum(['admin', 'user', 'moderator']),
metadata: z.record(z.string(), z.unknown()),
});
type UserWithAddress = z.infer<typeof UserWithAddressSchema>;
API клиент с валидацией
import { z } from 'zod';
class ApiClient {
private baseUrl: string;
constructor(baseUrl: string) {
this.baseUrl = baseUrl;
}
async request<T>(
endpoint: string,
schema: z.ZodSchema<T>,
options?: RequestInit
): Promise<T> {
const response = await fetch(`${this.baseUrl}${endpoint}`, {
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers,
},
});
if (!response.ok) {
throw new ApiError(response.status, await response.text());
}
const data = await response.json();
const result = schema.safeParse(data);
if (!result.success) {
throw new ValidationError(result.error);
}
return result.data;
}
}
// Использование
const api = new ApiClient('https://api.example.com');
const UserSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string(),
});
// Тип выводится автоматически
const user = await api.request('/users/1', UserSchema);
// user: { id: number; name: string; email: string }
Обработка ошибок API
// Схема для ошибки API
const ApiErrorSchema = z.object({
error: z.object({
code: z.string(),
message: z.string(),
details: z.array(z.object({
field: z.string(),
message: z.string(),
})).optional(),
}),
});
class ApiError extends Error {
constructor(
public status: number,
public code: string,
message: string,
public details?: Array<{ field: string; message: string }>
) {
super(message);
this.name = 'ApiError';
}
static async fromResponse(response: Response): Promise<ApiError> {
try {
const data = await response.json();
const result = ApiErrorSchema.safeParse(data);
if (result.success) {
return new ApiError(
response.status,
result.data.error.code,
result.data.error.message,
result.data.error.details
);
}
} catch {
// Не удалось распарсить ошибку
}
return new ApiError(
response.status,
'UNKNOWN_ERROR',
`HTTP ${response.status}: ${response.statusText}`
);
}
}
// Использование
async function fetchUser(id: number): Promise<User> {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) {
throw await ApiError.fromResponse(response);
}
const data = await response.json();
return UserSchema.parse(data);
}
Интеграция с React Query
import { useQuery, useMutation } from '@tanstack/react-query';
import { z } from 'zod';
const UserSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string(),
});
const UserListSchema = z.array(UserSchema);
function useUsers() {
return useQuery({
queryKey: ['users'],
queryFn: async () => {
const response = await fetch('/api/users');
const data = await response.json();
return UserListSchema.parse(data);
},
});
}
function useUser(id: number) {
return useQuery({
queryKey: ['users', id],
queryFn: async () => {
const response = await fetch(`/api/users/${id}`);
const data = await response.json();
return UserSchema.parse(data);
},
});
}
const CreateUserSchema = z.object({
name: z.string().min(1),
email: z.string().email(),
});
type CreateUserInput = z.infer<typeof CreateUserSchema>;
function useCreateUser() {
return useMutation({
mutationFn: async (input: CreateUserInput) => {
// Валидация входных данных перед отправкой
const validated = CreateUserSchema.parse(input);
const response = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(validated),
});
const data = await response.json();
return UserSchema.parse(data);
},
});
}
Альтернативы Zod
1. Yup
import * as yup from 'yup';
const UserSchema = yup.object({
id: yup.number().required(),
name: yup.string().required(),
email: yup.string().email().required(),
});
type User = yup.InferType<typeof UserSchema>;
2. io-ts
import * as t from 'io-ts';
import { isRight } from 'fp-ts/Either';
const UserCodec = t.type({
id: t.number,
name: t.string,
email: t.string,
});
type User = t.TypeOf<typeof UserCodec>;
function validateUser(data: unknown): User {
const result = UserCodec.decode(data);
if (isRight(result)) {
return result.right;
}
throw new Error('Invalid user data');
}
3. Valibot (легковесная альтернатива)
import { object, string, number, parse } from 'valibot';
const UserSchema = object({
id: number(),
name: string(),
email: string(),
});
type User = Output<typeof UserSchema>;
const user = parse(UserSchema, await response.json());
Автоматическая генерация типов из OpenAPI
// Используем openapi-typescript для генерации типов из Swagger
// npx openapi-typescript https://api.example.com/swagger.json -o ./src/types/api.ts
// Сгенерированный тип
interface paths {
"/api/users/{id}": {
get: {
parameters: {
path: { id: number };
};
responses: {
200: {
content: {
"application/json": {
id: number;
name: string;
email: string;
};
};
};
};
};
};
}
Практические рекомендации
// 1. Всегда валидируйте ответы от внешних API
async function fetchExternalApi<T>(url: string, schema: z.ZodSchema<T>): Promise<T> {
const response = await fetch(url);
const data = await response.json();
return schema.parse(data); // Выбросит ошибку при невалидных данных
}
// 2. Используйте трансформации для нормализации данных
const UserSchema = z.object({
id: z.number(),
name: z.string().trim(),
email: z.string().email().toLowerCase(),
createdAt: z.string().datetime().transform(str => new Date(str)),
});
type User = z.infer<typeof UserSchema>;
// createdAt будет Date, а не string
// 3. Обрабатывайте частичные данные
const PartialUserSchema = UserSchema.partial();
type PartialUser = z.infer<typeof PartialUserSchema>;
// 4. Создавайте переиспользуемые валидаторы
const PaginationSchema = <T extends z.ZodTypeAny>(itemSchema: T) => z.object({
items: z.array(itemSchema),
total: z.number(),
page: z.number(),
pageSize: z.number(),
hasMore: z.boolean(),
});
const PaginatedUsers = PaginationSchema(UserSchema);
type PaginatedUsers = z.infer<typeof PaginatedUsers>;
Вывод
TypeScript обеспечивает только compile-time проверку типов и не контролирует данные в рантайме. Для безопасной работы с API необходимо использовать runtime-валидацию с библиотеками типа Zod, Yup или io-ts. Zod является наиболее популярным выбором благодаря автоматическому выводу TypeScript типов из схем и удобному API. Валидация особенно важна для внешних API, где структура данных может измениться без предупреждения.
Вопрос 11. Что такое ESLint и для чего он нужен?
Таймкод: 00:24:35
Ответ собеседника: Правильный. ESLint — это утилита, которая сканирует кодовую базу и анализирует файлы на основе заданных правил, ищет ошибки и несоответствия. Позволяет настраивать правила для поиска ошибок в коде. Можно интегрировать с Prettier для форматирования. Также можно настроить через Husky проверки перед коммитом.
Правильный ответ:
Ответ кандидата корректный и покрывает основные аспекты. Дополню более глубоким объяснением и практическими примерами.
Что такое ESLint
ESLint — это инструмент статического анализа кода (линтер) для JavaScript и TypeScript. Он анализирует исходный код без его выполнения и находит потенциальные ошибки, проблемы с стилем и нарушения лучших практик.
Основные возможности
1. Обнаружение ошибок
// ESLint обнаружит эти проблемы:
// Неиспользуемые переменные
const unusedVariable = 42; // 'unusedVariable' is assigned a value but never used
// Сравнение с самим собой
if (x === x) { // Expected the operands to be not strictly equal } // Опечатка вместо value === value
// Потенциальные null/undefined
const element = document.getElementById('myId');
element.addEventListener('click', handler); // 'element' is possibly 'null'
2. Принудительное соблюдение стиля кода
// До исправления (нарушение правил)
const name="John";const age=30;
// После автоисправления
const name = "John";
const age = 30;
3. Поиск проблем с производительностью
// React: отсутствие зависимостей в useEffect
useEffect(() => {
fetchData(userId); // React Hook useEffect has a missing dependency: 'userId'
}, []);
Конфигурация ESLint
Базовая конфигурация:
// .eslintrc.js
module.exports = {
root: true,
env: {
browser: true,
es2021: true,
node: true,
},
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:react/recommended',
'plugin:react-hooks/recommended',
'prettier', // Должен быть последним для отключения конфликтующих правил
],
parser: '@typescript-eslint/parser',
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
ecmaFeatures: {
jsx: true,
},
},
plugins: ['@typescript-eslint', 'react', 'react-hooks', 'import'],
rules: {
// Кастомные правила
'no-console': ['warn', { allow: ['warn', 'error'] }],
'no-unused-vars': 'off',
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
'react/react-in-jsx-scope': 'off', // Не нужно в React 17+
'import/order': ['error', {
groups: ['builtin', 'external', 'internal', 'parent', 'sibling'],
'newlines-between': 'always',
}],
},
settings: {
react: {
version: 'detect',
},
},
};
Полезные правила для React/TypeScript
// .eslintrc.js
module.exports = {
rules: {
// TypeScript
'@typescript-eslint/no-explicit-any': 'error', // Запрет any
'@typescript-eslint/strict-boolean-expressions': 'error', // Строгие булевы выражения
'@typescript-eslint/no-floating-promises': 'error', // Обработка промисов
// React
'react/jsx-key': 'error', // Проверка ключей в списках
'react/no-array-index-key': 'warn', // Предупреждение при использовании индексов как ключей
'react-hooks/rules-of-hooks': 'error', // Правила хуков
'react-hooks/exhaustive-deps': 'warn', // Зависимости хуков
// Общие
'no-param-reassign': 'error', // Запрет переопределения параметров
'prefer-const': 'error', // Предпочитать const
'no-var': 'error', // Запрет var
},
};
Интеграция с Prettier
// .eslintrc.js
module.exports = {
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'prettier', // Отключает правила, конфликтующие с Prettier
],
plugins: ['prettier'],
rules: {
'prettier/prettier': 'error', // Показывает ошибки Prettier как ошибки ESLint
},
};
// .prettierrc
{
"semi": true,
"trailingComma": "es5",
"singleQuote": true,
"printWidth": 100,
"tabWidth": 2
}
Git hooks с Husky и lint-staged
// package.json
{
"scripts": {
"lint": "eslint . --ext .ts,.tsx,.js,.jsx",
"lint:fix": "eslint . --ext .ts,.tsx,.js,.jsx --fix",
"format": "prettier --write ."
},
"lint-staged": {
"*.{ts,tsx,js,jsx}": [
"eslint --fix",
"prettier --write"
],
"*.{json,md,yml}": [
"prettier --write"
]
}
}
# Установка Husky
npx husky-init && npm install
# Добавление pre-commit хука
npx husky add .husky/pre-commit "npx lint-staged"
# Добавление commit-msg хука для проверки сообщений коммитов
npx husky add .husky/commit-msg 'npx --no -- commitlint --edit "$1"'
Плагины для ESLint
// .eslintrc.js
module.exports = {
plugins: [
'import', // Проверка импортов
'unused-imports', // Удаление неиспользуемых импортов
'promise', // Лучшие практики для промисов
'security', // Проверка безопасности
],
rules: {
// Import правила
'import/no-cycle': 'error', // Запрет циклических импортов
'import/no-unused-modules': 'warn',
'unused-imports/no-unused-imports': 'error', // Автоудаление неиспользуемых импортов
// Promise правила
'promise/catch-or-return': 'error', // Обработка catch
'promise/no-return-wrap': 'error',
// Security правила
'security/detect-object-injection': 'warn',
},
};
Игнорирование файлов
# .eslintignore
node_modules/
dist/
build/
coverage/
*.config.js
.next/
// В коде
/* eslint-disable */ // Отключить все правила для следующей строки
const x = 1; // eslint-disable-line no-unused-vars
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const data: any = fetchData();
Интеграция с IDE
// .vscode/settings.json
{
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
},
"eslint.validate": [
"javascript",
"javascriptreact",
"typescript",
"typescriptreact"
],
"editor.defaultFormatter": "esbenp.prettier-vscode",
"[typescript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
}
}
CI/CD интеграция
# .github/workflows/lint.yml
name: Lint
on: [push, pull_request]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- run: npm run lint
- run: npm run type-check
Кастомные правила ESLint
// eslint-rules/no-api-calls-in-components.js
module.exports = {
meta: {
type: 'problem',
docs: {
description: 'Запрет прямых API вызовов в компонентах',
},
fixable: null,
schema: [],
},
create(context) {
return {
CallExpression(node) {
if (
node.callee.name === 'fetch' &&
context.getFilename().includes('/components/')
) {
context.report({
node,
message: 'Используйте хуки для API вызовов вместо прямого fetch',
});
}
},
};
},
};
Вывод
ESLint — это мощный инструмент для поддержания качества кода. Он обнаруживает ошибки, обеспечивает единый стиль кода и помогает следовать лучшим практикам. Интеграция с Prettier, Husky и CI/CD позволяет автоматизировать проверки и предотвращать попадание проблемного кода в репозиторий. Для TypeScript проектов рекомендуется использовать @typescript-eslint/parser и соответствующие плагины.
Вопрос 12. Использовал ли Tailwind CSS? Как он настраивался на проекте?
Таймкод: 00:26:12
Ответ собеседника: Правильный. На работе использовали кастомный Tailwind, написанный под проекты. Был файл template SCSS в корне проекта (в директории styles), через SAS Options в конфиге настроили так, чтобы классы типа grid, row, flex и подобные были доступны в класснеймах. Это ускоряло процесс разработки и позволяло не тянуть стороннюю библиотеку.
Правильный ответ:
Ответ кандидата описывает интересный подход с кастомным решением. Дополню информацией о настройке стандартного Tailwind CSS.
Что такое Tailwind CSS
Tailwind CSS — это utility-first CSS фреймворк, предоставляющий низкоуровневые классы для построения пользовательских интерфейсов без написания CSS вручную.
Установка и настройка Tailwind CSS
1. Установка:
npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p
2. Конфигурация tailwind.config.js:
// tailwind.config.js
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
'./src/**/*.{js,jsx,ts,tsx}',
'./pages/**/*.{js,jsx,ts,tsx}',
'./components/**/*.{js,jsx,ts,tsx}',
],
theme: {
extend: {
colors: {
brand: {
50: '#f0f9ff',
100: '#e0f2fe',
500: '#0ea5e9',
600: '#0284c7',
700: '#0369a1',
900: '#0c4a6e',
},
},
fontFamily: {
sans: ['Inter', 'sans-serif'],
mono: ['JetBrains Mono', 'monospace'],
},
spacing: {
'18': '4.5rem',
'88': '22rem',
},
animation: {
'fade-in': 'fadeIn 0.5s ease-in-out',
'slide-up': 'slideUp 0.3s ease-out',
},
keyframes: {
fadeIn: {
'0%': { opacity: '0' },
'100%': { opacity: '1' },
},
slideUp: {
'0%': { transform: 'translateY(10px)', opacity: '0' },
'100%': { transform: 'translateY(0)', opacity: '1' },
},
},
},
},
plugins: [
require('@tailwindcss/forms'),
require('@tailwindcss/typography'),
require('@tailwindcss/aspect-ratio'),
],
};
3. Подключение в CSS:
/* src/styles/globals.css */
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Кастомные стили на основе Tailwind */
@layer components {
.btn-primary {
@apply px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors;
}
.card {
@apply bg-white rounded-xl shadow-md p-6;
}
.input-field {
@apply w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-brand-500 focus:border-transparent;
}
}
@layer utilities {
.text-balance {
text-wrap: balance;
}
}
Использование в компонентах:
// Компонент с Tailwind
function UserCard({ user }: { user: User }) {
return (
<div className="card hover:shadow-lg transition-shadow">
<div className="flex items-center gap-4">
<img
src={user.avatar}
alt={user.name}
className="w-16 h-16 rounded-full object-cover"
/>
<div className="flex-1">
<h3 className="text-lg font-semibold text-gray-900">{user.name}</h3>
<p className="text-gray-500">{user.email}</p>
</div>
<span className={`px-3 py-1 rounded-full text-sm ${
user.status === 'active'
? 'bg-green-100 text-green-800'
: 'bg-gray-100 text-gray-800'
}`}>
{user.status}
</span>
</div>
<div className="mt-4 flex gap-2">
<button className="btn-primary">Редактировать</button>
<button className="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50">
Удалить
</button>
</div>
</div>
);
}
// Адаптивная сетка
function ProductGrid({ products }: { products: Product[] }) {
return (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{products.map(product => (
<div key={product.id} className="card group">
<img
src={product.image}
alt={product.name}
className="w-full h-48 object-cover rounded-lg group-hover:scale-105 transition-transform"
/>
<h3 className="mt-4 text-lg font-medium">{product.name}</h3>
<p className="text-gray-500">{product.price} ₽</p>
</div>
))}
</div>
);
}
Интеграция с React и TypeScript:
// Типизированные варианты компонентов
import { cva, type VariantProps } from 'class-variance-authority';
const buttonVariants = cva(
'inline-flex items-center justify-center rounded-lg font-medium transition-colors',
{
variants: {
variant: {
primary: 'bg-brand-600 text-white hover:bg-brand-700',
secondary: 'bg-gray-100 text-gray-900 hover:bg-gray-200',
outline: 'border border-gray-300 hover:bg-gray-50',
ghost: 'hover:bg-gray-100',
},
size: {
sm: 'px-3 py-1.5 text-sm',
md: 'px-4 py-2',
lg: 'px-6 py-3 text-lg',
},
},
defaultVariants: {
variant: 'primary',
size: 'md',
},
}
);
interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {}
export function Button({ variant, size, className, ...props }: ButtonProps) {
return (
<button
className={buttonVariants({ variant, size, className })}
{...props}
/>
);
}
// Использование
<Button variant="primary" size="lg">Сохранить</Button>
<Button variant="outline" size="sm">Отмена</Button>
Плагины для Tailwind:
npm install -D @tailwindcss/forms @tailwindcss/typography @tailwindcss/aspect-ratio
// tailwind.config.js
module.exports = {
plugins: [
require('@tailwindcss/forms'), // Стилизация форм
require('@tailwindcss/typography'), // Типографика для prose
require('@tailwindcss/aspect-ratio'), // Соотношение сторон
],
};
Оптимизация размера бандла:
// tailwind.config.js
module.exports = {
// PurgeCSS удаляет неиспользуемые классы в production
content: [
'./src/**/*.{js,jsx,ts,tsx}',
'./pages/**/*.{js,jsx,ts,tsx}',
],
// Отключение неиспользуемых модулей
corePlugins: {
float: false,
clear: false,
},
// Сокращение количества генерируемых утилит
safelist: [
'bg-red-500',
'text-3xl',
'lg:text-4xl',
{ pattern: /bg-(red|green|blue)-(100|200|300)/ },
],
};
Сравнение подходов
| Аспект | Tailwind CSS | Кастомный SCSS (как у кандидата) | CSS Modules |
|---|---|---|---|
| Размер бандла | Оптимизируется PurgeCSS | Зависит от реализации | Минимальный |
| Скорость разработки | Высокая | Высокая (если настроено) | Средняя |
| Консистентность | Дизайн-система из коробки | Нужна дисциплина | Нужна дисциплина |
| Кривая обучения | Нужно знать классы | Нужно знать свою систему | Стандартный CSS |
| Переиспользование | Компоненты + apply | Миксины и функции | Композиции |
Вывод
Tailwind CSS — мощный инструмент для быстрой разработки интерфейсов с консистентным дизайном. Подход кандидата с кастомным SCSS также валиден и может быть предпочтительным для проектов с уникальными требованиями. Ключевое — наличие системы и консистентности в стилях, независимо от выбранного инструмента.
Вопрос 13. В чём разница между App Router и Pages Router в Next.js?
Таймкод: 00:28:57
Ответ собеседника: Неполный. Разница в навигации и структуре. В App Router используется папка app, импорт из next/navigation. В Pages Router — папка pages, импорт из next/router. В Pages Router можно задавать любые названия файлов страниц, в App Router каждый файл страницы (кроме динамических) должен называться строго page.tsx с дефолтным экспортом компонента. App Router — более современная версия с бо́льшим функционалом, Pages Router сохраняется для обратной совместимости. Признал, что не углублялся в детали различий.
Правильный ответ:
Ответ кандидата покрывает базовые различия, но не раскрывает ключевые архитектурные отличия. Приведу полное сравнение.
Структура и конвенции
Pages Router:
pages/
├── index.tsx # /
├── about.tsx # /about
├── products/
│ ├── index.tsx # /products
│ └── [id].tsx # /products/:id
├── api/
│ └── users.ts # /api/users
└── _app.tsx # Обёртка приложения
App Router:
app/
├── page.tsx # /
├── layout.tsx # Корневой layout
├── loading.tsx # Состояние загрузки
├── error.tsx # Обработка ошибок
├── not-found.tsx # 404 страница
├── about/
│ └── page.tsx # /about
├── products/
│ ├── page.tsx # /products
│ └── [id]/
│ └── page.tsx # /products/:id
└── api/
└── users/
└── route.ts # /api/users
Ключевое отличие: Server Components vs Client Components
Pages Router — все компоненты по умолчанию Client Components:
// pages/products/[id].tsx
import { useState, useEffect } from 'react';
// Этот компонент полностью выполняется на клиенте
export default function ProductPage({ productId }: { productId: string }) {
const [product, setProduct] = useState(null);
useEffect(() => {
fetch(`/api/products/${productId}`)
.then(res => res.json())
.then(setProduct);
}, [productId]);
if (!product) return <div>Загрузка...</div>;
return <div>{product.name}</div>;
}
App Router — все компоненты по умолчанию Server Components:
// app/products/[id]/page.tsx
// Этот компонент выполняется ТОЛЬКО на сервере по умолчанию
// Можно напрямую обращаться к базе данных, файловой системе и т.д.
async function getProduct(id: string) {
// Прямой доступ к базе данных без API
const product = await db.product.findUnique({ where: { id } });
return product;
}
export default async function ProductPage({
params
}: {
params: { id: string }
}) {
const product = await getProduct(params.id);
if (!product) notFound(); // Автоматически покажет not-found.tsx
return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
</div>
);
}
Клиентские компоненты в App Router:
// app/products/[id]/page.tsx
'use client'; // Директива для клиентского компонента
import { useState } from 'react';
export default function ProductPage({ product }: { product: Product }) {
const [selectedImage, setSelectedImage] = useState(0);
return (
<div>
<h1>{product.name}</h1>
<ImageGallery
images={product.images}
selected={selectedImage}
onSelect={setSelectedImage}
/>
</div>
);
}
Layouts и вложенность
Pages Router — один глобальный layout через _app.tsx:
// pages/_app.tsx
import type { AppProps } from 'next/app';
export default function App({ Component, pageProps }: AppProps) {
return (
<Layout>
<Component {...pageProps} />
</Layout>
);
}
App Router — вложенные layouts:
// app/layout.tsx — корневой layout
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="ru">
<body>
<Header />
{children}
<Footer />
</body>
</html>
);
}
// app/products/layout.tsx — layout для раздела products
export default function ProductsLayout({ children }: { children: React.ReactNode }) {
return (
<div className="products-layout">
<Sidebar />
<main>{children}</main>
</div>
);
}
// app/products/[id]/layout.tsx — layout для конкретного продукта
export default function ProductLayout({ children }: { children: React.ReactNode }) {
return (
<div className="product-detail">
<Breadcrumbs />
{children}
<RelatedProducts />
</div>
);
}
Загрузка данных
Pages Router:
// pages/products/[id].tsx
import { GetServerSideProps, GetStaticProps } from 'next';
// SSR — данные загружаются при каждом запросе
export const getServerSideProps: GetServerSideProps = async (context) => {
const { id } = context.params!;
const product = await fetch(`https://api.example.com/products/${id}`)
.then(res => res.json());
return { props: { product } };
};
// SSG — данные загружаются при сборке
export const getStaticProps: GetStaticProps = async (context) => {
const { id } = context.params!;
const product = await fetch(`https://api.example.com/products/${id}`)
.then(res.json());
return {
props: { product },
revalidate: 60 // ISR — обновление каждые 60 секунд
};
};
export default function ProductPage({ product }: { product: Product }) {
return <div>{product.name}</div>;
}
App Router:
// app/products/[id]/page.tsx
// Данные загружаются автоматически на сервере
// По умолчанию — статическая генерация (SSG)
// Для динамического поведения используйте:
export const dynamic = 'force-dynamic'; // SSR
export const revalidate = 60; // ISR
// Или используйте функции для управления кэшированием
export const generateStaticParams = async () => {
const products = await db.product.findMany({ select: { id: true } });
return products.map(product => ({ id: product.id }));
};
export default async function ProductPage({
params
}: {
params: { id: string }
}) {
const product = await getProduct(params.id);
return <div>{product.name}</div>;
}
Навигация
Pages Router:
import { useRouter } from 'next/router';
import Link from 'next/link';
function Navigation() {
const router = useRouter();
const handleClick = () => {
router.push('/products/123');
};
return (
<nav>
<Link href="/products/123">Продукт</Link>
<button onClick={handleClick}>Перейти</button>
</nav>
);
}
App Router:
'use client';
import { useRouter, usePathname, useSearchParams } from 'next/navigation';
import Link from 'next/link';
function Navigation() {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const handleClick = () => {
router.push('/products/123');
router.refresh(); // Обновить данные на сервере
router.back(); // Вернуться назад
};
return (
<nav>
<Link href="/products/123" prefetch>Продукт</Link>
<button onClick={handleClick}>Перейти</button>
<p>Текущий путь: {pathname}</p>
</nav>
);
}
Обработка ошибок
Pages Router:
// pages/_error.tsx
function Error({ statusCode }: { statusCode: number }) {
return <p>Ошибка {statusCode}</p>;
}
Error.getInitialProps = ({ res, err }: any) => {
const statusCode = res ? res.statusCode : err ? err.statusCode : 404;
return { statusCode };
};
App Router:
// app/error.tsx
'use client';
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<div>
<h2>Что-то пошло не так!</h2>
<button onClick={() => reset()}>Попробовать снова</button>
</div>
);
}
// app/not-found.tsx
export default function NotFound() {
return (
<div>
<h2>Страница не найдена</h2>
<p>Запрашиваемая страница не существует.</p>
</div>
);
}
// app/products/[id]/page.tsx
import { notFound } from 'next/navigation';
export default async function ProductPage({ params }: { params: { id: string } }) {
const product = await getProduct(params.id);
if (!product) {
notFound(); // Показывает app/not-found.tsx
}
return <div>{product.name}</div>;
}
API Routes
Pages Router:
// pages/api/users.ts
import type { NextApiRequest, NextApiResponse } from 'next';
export default function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === 'GET') {
res.status(200).json({ users: [] });
} else if (req.method === 'POST') {
res.status(201).json({ id: 1 });
}
}
App Router:
// app/api/users/route.ts
import { NextRequest, NextResponse } from 'next/server';
export async function GET(request: NextRequest) {
const users = await db.user.findMany();
return NextResponse.json({ users });
}
export async function POST(request: NextRequest) {
const body = await request.json();
const user = await db.user.create({ data: body });
return NextResponse.json(user, { status: 201 });
}
Сравнительная таблица
| Аспект | Pages Router | App Router |
|---|---|---|
| Версия Next.js | С первых версий | С версии 13+ |
| Компоненты | Client по умолчанию | Server по умолчанию |
| Загрузка данных | getServerSideProps, getStaticProps | async компоненты напрямую |
| Layouts | Только через _app.tsx | Вложенные layouts |
| Streaming | Ограниченная поддержка | Полная поддержка |
| Server Actions | Нет | Да |
| Обработка ошибок | _error.tsx | error.tsx, not-found.tsx |
| Роутинг | Файловая система (pages/) | Файловая система (app/) |
Вывод
App Router — это эволюция Next.js с поддержкой Server Components, Streaming, вложенных layouts и Server Actions. Pages Router сохраняется для обратной совместимости. Для новых проектов рекомендуется использовать App Router, так как он предоставляет лучшую производительность и более современный подход к разработке.
Вопрос 14. Использовал ли Turbopack?
Таймкод: 00:29:16
Ответ собеседника: Неправильный. Не пользовался Turbopack ни на работе, ни самостоятельно. Знает, что это штука для быстрой сборки проекта, но не занимался его настройкой и не углублялся в тему.
Правильный ответ:
Кандидат честно признал отсутствие опыта, но ответ можно дополнить полезной информацией для понимания темы.
Что такое Turbopack
Turbopack — это новый бандлер (сборщик), разработанный командой Vercel как преемник Webpack. Написан на Rust и ориентирован на экстремальную производительность.
Ключевые особенности
1. Производительность
Turbopack значительно быстрее Webpack:
| Метрика | Webpack | Turbopack |
|---|---|---|
| Запуск dev-сервера (1000 модулей) | ~10-15 сек | ~1-2 сек |
| HMR (обновление модуля) | ~100-500 мс | ~10-30 мс |
| Полная сборка | Зависит от проекта | До 10x быстрее |
2. Инкрементальная сборка
Turbопак запоминает, какие функции вызывались, и пересобирает только изменённые части:
Файл A → Функция processA() → Результат A
Файл B → Функция processB() → Результат B
Если изменился только Файл A:
- processA() выполняется заново
- processB() НЕ выполняется, используется кэш
Установка и использование
# Turbopack включён в Next.js 13+ экспериментально
npx create-next-app@latest my-app --turbo
# Или в существующем проекте
npm install next@latest
// package.json
{
"scripts": {
"dev": "next dev --turbo",
"build": "next build"
}
}
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
experimental: {
turbo: {
// Настройки Turbopack
resolveExtensions: ['.mdx', '.tsx', '.ts', '.jsx', '.js', '.json'],
rules: {
'*.svg': {
loaders: ['@svgr/webpack'],
as: '*.js',
},
},
},
},
};
module.exports = nextConfig;
Сравнение с Webpack
// webpack.config.js — типичная конфигурация Webpack
const path = require('path');
module.exports = {
entry: './src/index.js',
output: {
path: path.resolve(__dirname, 'dist'),
filename: 'bundle.js',
},
module: {
rules: [
{
test: /\.js$/,
exclude: /node_modules/,
use: 'babel-loader',
},
{
test: /\.css$/,
use: ['style-loader', 'css-loader'],
},
{
test: /\.svg$/,
use: ['@svgr/webpack'],
},
],
},
plugins: [
new HtmlWebpackPlugin({ template: './public/index.html' }),
new MiniCssExtractPlugin(),
],
optimization: {
splitChunks: {
chunks: 'all',
},
},
};
// Turbopack — конфигурация минимальна или не нужна
// Большая часть настроек работает из коробки
Текущий статус (2024)
✅ Стабильно:
- Dev-сервер для Next.js
- HMR (Hot Module Replacement)
- CSS/SCSS поддержка
- TypeScript
⚠️ В разработке:
- Production сборка
- Полная поддержка всех фич Webpack
- Плагины экосистемы Webpack
Когда использовать Turbopack
# Рекомендуется для:
# - Больших проектов с тысячами модулей
# - Команд, где важна скорость разработки
# - Проектов на Next.js 13+
# Пока не рекомендуется для:
# - Production сборки (ещё в бете)
# - Проектов с кастомной конфигурацией Webpack
# - Проектов, зависящих от специфичных плагинов Webpack
Интеграция с другими инструментами
// .vscode/settings.json
{
"typescript.tsdk": "node_modules/typescript/lib",
"typescript.enablePromptUseWorkspaceTsdk": true,
// Turbopack автоматически используется при next dev --turbo
// Дополнительная настройка не требуется
}
Миграция с Webpack на Turbopack
# Шаг 1: Обновить Next.js
npm install next@latest
# Шаг 2: Обновить скрипты
# package.json
{
"scripts": {
"dev": "next dev --turbo", # Добавить --turbo
"build": "next build"
}
}
# Шаг 3: Удалить webpack конфигурацию (если используется только для Next.js)
rm webpack.config.js
Ограничения Turbopack
// Некоторые плагины Webpack пока не поддерживаются:
// ❌ Не работает напрямую
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer');
const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin');
// ✅ Альтернативы в Turbopack
// - Анализ бандла: next build --analyze
// - TypeScript проверка: встроена в Next.js
Вывод
Turbopack — перспективная замена Webpack, написанная на Rust, которая обеспечивает значительный прирост производительности. На данный момент стабильно работает для dev-сервера в Next.js. Production сборка находится в активной разработке. Для Go-разработчика знание Turbopack полезно для понимания экосистемы фронтенда, но не является критически важным навыком.
Вопрос 15. Что означает настройка target в tsconfig.json? Почему выбрана версия ES2017, а не более новая?
Таймкод: 00:30:51
Ответ собеседника: Неправильный. Предположил, что target указывает версию JavaScript, но не смог объяснить, зачем выбирать ES2017 вместо более новых версий (ES2020, ES2021). Предположил, что ES2017 более надёжная, но не смог обосновать. Признал, что не знает точного ответа.
Правильный ответ:
Кандидат верно определил, что target указывает версию JavaScript, но не смог объяснить выбор конкретной версии. Приведу полное объяснение.
Что такое target в tsconfig.json
Параметр target определяет версию ECMAScript, в которую TypeScript компилирует исходный код. Это влияет на то, какие синтаксические конструкции будут преобразованы (downleveled), а какие останутся как есть.
// tsconfig.json
{
"compilerOptions": {
"target": "ES2017",
"module": "ESNext",
"lib": ["ES2017", "DOM"]
}
}
Как работает target
// Исходный код TypeScript
class User {
name: string;
// Приватное поле (ES2022)
#password: string;
// Static block (ES2022)
static {
console.log('User class loaded');
}
// Top-level await (ES2022)
const data = await fetch('/api/users');
}
// Результат компиляции с target: "ES2017"
var User = /** @class */ (function () {
function User() {
// #password будет преобразован в WeakMap или Symbol
}
return User;
})();
// Static block будет преобразован в вызов функции
// Результат компиляции с target: "ES2022"
class User {
#password; // Нативная поддержка приватных полей
static {
console.log('User class loaded'); // Нативный static block
}
}
Сравнение версий ES и их особенностей
| Версия | Ключевые фичи | Поддержка браузерами (2024) |
|---|---|---|
| ES2015 (ES6) | let/const, стрелочные функции, классы, модули, Promise | ~98% |
| ES2016 | Оператор **, Array.prototype.includes | ~98% |
| ES2017 | async/await, Object.values/entries, padStart/padEnd | ~97% |
| ES2018 | Rest/Spread для объектов, Promise.finally | ~96% |
| ES2019 | Array.flat/flatMap, Object.fromEntries, optional catch | ~95% |
| ES2020 | Optional chaining (?.), Nullish coalescing (??), BigInt | ~94% |
| ES2021 | Logical assignment ( | |
| ES2022 | Class fields, Top-level await, Array.at(), Object.hasOwn | ~88% |
| ES2023 | Array.toSorted/toReversed, Array.findLast/findIndex | ~80% |
| ES2024 | Array.groupBy, Promise.withResolvers | ~60% |
Зачем выбирать ES2017 вместо более новых версий
1. Совместимость с браузерами
// target: "ES2020" — optional chaining остаётся как есть
const name = user?.profile?.name;
// target: "ES2017" — optional chaining преобразуется
var _a, _b;
const name = (_b = (_a = user) === null || _a === void 0 ? void 0 : _a.profile) === null || _b === void 0 ? void 0 : _b.name;
// target: "ES2020" — nullish coalescing остаётся
const value = data ?? 'default';
// target: "ES2017" — преобразуется
const value = data !== null && data !== void 0 ? data : 'default';
2. Размер бандла
// target: "ES2020" — нативный код
const result = array.flat(2);
const found = array.findLast(x => x > 5);
// target: "ES2017" — полифилы или helper функции
function flat(array, depth) {
// Реализация полифила
return depth > 0 ? array.reduce((acc, val) =>
acc.concat(Array.isArray(val) ? flat(val, depth - 1) : val), []) : array.slice();
}
3. Производительность
// target: "ES2022" — нативные приватные поля (быстрее)
class User {
#password; // Реализация на уровне движка
}
// target: "ES2017" — эмуляция через WeakMap (медленнее)
var _password = new WeakMap();
class User {
constructor() {
_password.set(this, undefined);
}
}
Типичные сценарии выбора target
Сценарий 1: Современное приложение с поддержкой только новых браузеров
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext"
}
}
// Преимущества:
// - Меньший размер бандла (нет полифилов)
// - Лучшая производительность (нативные реализации)
// - Чистый код в выводе
// Недостатки:
// - Не работает в старых браузерах (IE 11, старые Safari)
Сценарий 2: Корпоративное приложение с широкой поддержкой браузеров
{
"compilerOptions": {
"target": "ES2017",
"module": "CommonJS"
}
}
// Преимущества:
// - Работает в 97% браузеров
// - async/await работает нативно (ES2017)
// Недостатки:
// - Больший размер бандла
// - Optional chaining и nullish coalescing преобразуются
Сценарий 3: Библиотека с максимальной совместимостью
{
"compilerOptions": {
"target": "ES2015",
"module": "CommonJS"
}
}
// Преимущества:
// - Максимальная совместимость
// - Работает везде
// Недостатки:
// - Значительно больший размер бандла
// - Много boilerplate кода в выводе
Сценарий 4: Серверный код (Node.js)
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext"
}
}
// Преимущества:
// - Node.js 18+ поддерживает ES2022
// - Нет проблем с браузерами
// - Лучшая производительность
Конфигурация для разных сред
// tsconfig.json для фронтенда
{
"compilerOptions": {
"target": "ES2017",
"module": "ESNext",
"lib": ["ES2017", "DOM", "DOM.Iterable"],
"moduleResolution": "bundler",
"jsx": "react-jsx"
}
}
// tsconfig.json для бэкенда (Node.js)
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"lib": ["ES2022"],
"moduleResolution": "NodeNext"
}
}
Взаимодействие target с lib и module
{
"compilerOptions": {
"target": "ES2017", // Версия выходного кода
"lib": ["ES2020", "DOM"], // Какие типы доступны для разработки
"module": "ESNext" // Формат модулей
}
}
// Это позволяет использовать типы из ES2020 (Promise.allSettled и т.д.)
// Но код будет скомпилирован в ES2017
Практический пример: влияние target на код
// Исходный код
async function fetchUsers(): Promise<User[]> {
const response = await fetch('/api/users');
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return response.json();
}
const users = await fetchUsers();
const activeUsers = users.filter(u => u.isActive).map(u => u.name);
// target: "ES2017" — async/await остаётся (нативная поддержка)
// Но если бы использовали более новые фичи, они бы преобразовались
async function fetchUsers() {
const response = await fetch('/api/users');
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return response.json();
}
// target: "ES2015" — async/await преобразуется в генераторы
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
// ... длинная реализация
};
var fetchUsers = function () {
return __awaiter(this, void 0, void 0, function* () {
const response = yield fetch('/api/users');
// ...
});
};
Рекомендации по выбору target
// Для большинства фронтенд-проектов в 2024 году:
{
"compilerOptions": {
"target": "ES2020" // Хороший баланс совместимости и фич
}
}
// Если нужна максимальная совместимость:
{
"compilerOptions": {
"target": "ES2017"
}
}
// Для Node.js проектов:
{
"compilerOptions": {
"target": "ES2022"
}
}
Выбор ES2017 может быть обоснован:
- Стабильность — все фичи ES2017 хорошо поддерживаются
- async/await — ключевая фича для асинхронного кода работает нативно
- Поддержка браузеров — покрывает ~97% пользователей
- Консервативный подход — в корпоративной среде часто выбирают проверенные решения
Вывод
Параметр target определяет версию JavaScript для компиляции. Выбор ES2017 обоснован балансом между совместимостью с браузерами (~97%) и поддержкой ключевых фич таких как async/await. Для современных проектов рекомендуется ES2020 или ES2022, для серверного кода — ES2022+. Выбор конкретной версии зависит от целевой аудитории и требований к совместимости.
Вопрос 16. Что такое module: node в tsconfig.json? В чём разница между import и require?
Таймкод: 00:31:42
Ответ собеседника: Неполный. Не смог точно объяснить, что означает module: node. На вопрос о разнице между import и require ответил, что require — это способ достать файл из каталога (JS-модули), а import — другой способ импорта, но не раскрыл разницу между ESM и CommonJS модулями.
Правильный ответ:
Кандидат не раскрыл ключевые различия между системами модулей. Приведу полное объяснение.
Что такое module в tsconfig.json
Параметр module определяет систему модулей, которая будет использована в скомпилированном JavaScript-коде.
{
"compilerOptions": {
"module": "NodeNext", // Для Node.js с ESM
"moduleResolution": "NodeNext", // Как резолвить модули
"esModuleInterop": true, // Совместимость с CommonJS
"allowSyntheticDefaultImports": true
}
}
Варианты module и их применение
| Значение | Описание | Когда использовать |
|---|---|---|
CommonJS | require/module.exports | Node.js (старая практика) |
NodeNext | ESM для Node.js | Node.js 16+ с ESM |
Node16 | ESM для Node.js 16+ | Node.js 16-18 |
ESNext | Нативные ES-модули | Браузеры, бандлеры |
ES2020 | ES-модули с dynamic import | Современные бандлеры |
UMD | Универсальный модуль | Библиотеки для разных сред |
AMD | Asynchronous Module Definition | RequireJS (устаревшее) |
CommonJS vs ESM: ключевые различия
CommonJS (CJS) — стандарт Node.js
// Экспорт
module.exports = {
add: (a, b) => a + b,
subtract: (a, b) => a - b,
};
// Или именованный экспорт
exports.add = (a, b) => a + b;
exports.subtract = (a, b) => a - b;
// Импорт
const math = require('./math');
const { add, subtract } = require('./math');
// Динамический импорт (условный)
if (condition) {
const module = require('./optional-module');
}
ESM (ECMAScript Modules) — современный стандарт
// Экспорт
export const add = (a, b) => a + b;
export const subtract = (a, b) => a - b;
export default class Calculator {}
// Импорт
import Calculator, { add, subtract } from './math.js';
import * as math from './math.js';
// Динамический импорт
const module = await import('./optional-module.js');
Сравнительная таблица
| Аспект | CommonJS | ESM |
|---|---|---|
| Синтаксис | require/module.exports | import/export |
| Загрузка | Синхронная | Асинхронная |
| Статический анализ | Нет | Да (tree-shaking) |
| Top-level await | Нет | Да |
| Расширение файла | .js, .cjs | .js, .mjs |
| Поддержка браузерами | Нет (нужен бандлер) | Да (нативно) |
| Поддержка Node.js | С первой версии | С версии 12+ |
| Циклические зависимости | Работают частично | Работают корректно |
module: NodeNext подробно
// tsconfig.json для Node.js проекта с ESM
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"esModuleInterop": true,
"strict": true,
"outDir": "./dist",
"rootDir": "./src"
}
}
// package.json — включение ESM
{
"name": "my-project",
"type": "module", // Включает ESM для всех .js файлов
"exports": {
".": "./dist/index.js"
}
}
// src/index.ts
import { createServer } from 'http';
import { readFile } from 'fs/promises';
import path from 'path';
import { fileURLToPath } from 'url';
// В ESM нет __dirname, нужно искать workaround
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export const server = createServer(async (req, res) => {
const content = await readFile(path.join(__dirname, 'index.html'));
res.end(content);
});
Проблемы совместимости и решения
1. Импорт CommonJS модуля в ESM
// Проблема: нельзя использовать require в ESM
// import { createServer } from 'http'; // OK — Node.js встроенные модули поддерживают ESM
// Решение: использовать динамический импорт
const { default: express } = await import('express');
2. Импорт ESM модуля в CommonJS
// CommonJS файл
async function loadModule() {
const { myFunction } = await import('./esm-module.mjs');
return myFunction();
}
module.exports = { loadModule };
3. __dirname и __filename в ESM
// ESM
import { fileURLToPath } from 'url';
import { dirname } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
Конфигурация для разных сценариев
Сценарий 1: Современный Node.js проект
// tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"esModuleInterop": true
}
}
// package.json
{
"type": "module",
"scripts": {
"build": "tsc",
"start": "node dist/index.js"
}
}
Сценарий 2: Библиотека с поддержкой CJS и ESM
// tsconfig.json для ESM
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"outDir": "./dist/esm"
}
}
// tsconfig.cjs.json для CommonJS
{
"extends": "./tsconfig.json",
"compilerOptions": {
"module": "CommonJS",
"outDir": "./dist/cjs"
}
}
// package.json
{
"exports": {
".": {
"import": "./dist/esm/index.js",
"require": "./dist/cjs/index.js",
"types": "./dist/types/index.d.ts"
}
}
}
Сценарий 3: Проект с бандлером (Webpack, Vite)
// tsconfig.json
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"esModuleInterop": true
}
}
Практические примеры
Пример 1: Миграция с CommonJS на ESM
// До (CommonJS)
const express = require('express');
const { Router } = express;
const router = Router();
router.get('/users', (req, res) => {
res.json({ users: [] });
});
module.exports = router;
// После (ESM + TypeScript)
import { Router, Request, Response } from 'express';
const router = Router();
router.get('/users', (req: Request, res: Response) => {
res.json({ users: [] });
});
export default router;
Пример 2: Условный импорт
// CommonJS
let pdfLib;
if (process.platform === 'win32') {
pdfLib = require('pdf-lib-win');
} else {
pdfLib = require('pdf-lib-unix');
}
// ESM
const pdfLib = process.platform === 'win32'
? await import('pdf-lib-win')
: await import('pdf-lib-unix');
Пример 3: Top-level await (только ESM)
// ESM — работает
const config = await fetch('/api/config').then(r => r.json());
export const apiUrl = config.apiUrl;
// CommonJS — не работает
// Нужно оборачивать в async функцию
async function init() {
const config = await fetch('/api/config').then(r => r.json());
return config;
}
module.exports = init();
Проблемы и решения
Проблема 1: require is not defined
// Ошибка при использовании require в ESM
// Решение: использовать import или динамический импорт
import { createServer } from 'http';
Проблема 2: Cannot use import statement outside a module
// Решение: добавить в package.json
{
"type": "module"
}
Проблема 3: ERR_REQUIRE_ESM
// Ошибка при require() ESM модуля
// Решение: использовать динамический импорт
const module = await import('esm-module');
Вывод
module: NodeNext указывает TypeScript использовать систему модулей ESM, совместимую с Node.js 16+. Основные различия между CommonJS и ESM: синтаксис (require vs import), загрузка (синхронная vs асинхронная), статический анализ (невозможен vs возможен для tree-shaking). Для новых проектов рекомендуется использовать ESM с module: NodeNext. При работе с бандлерами (Webpack, Vite) используется module: ESNext с moduleResolution: bundler.
Вопрос 17. Что настраивал в tsconfig.json на проектах?
Таймкод: 00:33:08
Ответ собеседника: Правильный. Настраивал пути (paths) в tsconfig, особенно при работе с FSD (Feature-Sliced Design). Все импорты были через знак @ (алиасы), а картинки тянулись через тильду. Признал, что настраивал это крайне редко и не помнит всех деталей.
Правильный ответ:
Ответ кандидата покрывает базовую настройку путей. Дополню полным обзором конфигурации tsconfig.json.
Структура tsconfig.json
{
"compilerOptions": {
// Настройки компиляции
"target": "ES2020",
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"moduleResolution": "bundler",
// Строгая типизация
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"strictBindCallApply": true,
"strictPropertyInitialization": true,
"noImplicitThis": true,
"useUnknownInCatchVariables": true,
"alwaysStrict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
// Пути и алиасы
"baseUrl": ".",
"paths": {
"@/*": ["src/*"],
"@/components/*": ["src/components/*"],
"@/features/*": ["src/features/*"],
"@/shared/*": ["src/shared/*"],
"@/app/*": ["src/app/*"]
},
// Модули и импорты
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"resolveJsonModule": true,
"isolatedModules": true,
// JSX
"jsx": "react-jsx",
// Source maps и декларации
"sourceMap": true,
"declaration": true,
"declarationMap": true,
// Выходные файлы
"outDir": "./dist",
"rootDir": "./src",
// Дополнительные проверки
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true
},
"include": ["src/**/*", "tests/**/*"],
"exclude": ["node_modules", "dist", "build"],
"references": [
{ "path": "./tsconfig.node.json" }
]
}
Настройка путей (paths) для FSD
{
"compilerOptions": {
"baseUrl": "./src",
"paths": {
// Feature-Sliced Design структура
"@/app/*": ["app/*"],
"@/pages/*": ["pages/*"],
"@/widgets/*": ["widgets/*"],
"@/features/*": ["features/*"],
"@/entities/*": ["entities/*"],
"@/shared/*": ["shared/*"],
// Дополнительные алиасы
"@/api/*": ["shared/api/*"],
"@/ui/*": ["shared/ui/*"],
"@/lib/*": ["shared/lib/*"],
"@/config/*": ["shared/config/*"],
"@/assets/*": ["shared/assets/*"]
}
}
}
// Использование алиасов
import { Button } from '@/shared/ui/Button';
import { userModel } from '@/entities/user';
import { authApi } from '@/features/auth/api';
import { formatDate } from '@/shared/lib/formatDate';
Настройка для Next.js
// tsconfig.json для Next.js
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"],
"@/components/*": ["./src/components/*"],
"@/lib/*": ["./src/lib/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
Настройка для Node.js проекта
// tsconfig.json для Node.js
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"paths": {
"@/*": ["./src/*"],
"@/controllers/*": ["./src/controllers/*"],
"@/services/*": ["./src/services/*"],
"@/models/*": ["./src/models/*"],
"@/middleware/*": ["./src/middleware/*"],
"@/utils/*": ["./src/utils/*"]
}
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "tests"]
}
Разделение конфигураций
// tsconfig.base.json — базовая конфигурация
{
"compilerOptions": {
"target": "ES2020",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"baseUrl": "./src",
"paths": {
"@/*": ["./*"]
}
}
}
// tsconfig.json — для разработки
{
"extends": "./tsconfig.base.json",
"compilerOptions": {
"noEmit": true,
"incremental": true
},
"include": ["src/**/*", "tests/**/*"]
}
// tsconfig.build.json — для production сборки
{
"extends": "./tsconfig.base.json",
"compilerOptions": {
"noEmit": false,
"outDir": "./dist",
"sourceMap": false,
"declaration": true
},
"include": ["src/**/*"],
"exclude": ["tests", "**/*.test.ts", "**/*.spec.ts"]
}
Настройка strict mode
{
"compilerOptions": {
// Включает все строгие проверки
"strict": true,
// Или можно настроить отдельно:
"noImplicitAny": true, // Запрет неявного any
"strictNullChecks": true, // Строгая проверка null/undefined
"strictFunctionTypes": true, // Строгая проверка типов функций
"strictBindCallApply": true, // Строгая проверка bind/call/apply
"strictPropertyInitialization": true, // Проверка инициализации свойств
"noImplicitThis": true, // Запрет неявного this
"useUnknownInCatchVariables": true, // unknown в catch вместо any
"alwaysStrict": true, // Всегда strict mode
"noUnusedLocals": true, // Ошибка при неиспользуемых переменных
"noUnusedParameters": true, // Ошибка при неиспользуемых параметрах
"noImplicitReturns": true, // Ошибка если функция не всегда возвращает значение
"noFallthroughCasesInSwitch": true, // Проверка всех case в switch
"noUncheckedIndexedAccess": true // Добавляет undefined при доступе по индексу
}
}
Настройка для тестов
// tsconfig.test.json
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": true,
"types": ["jest", "@testing-library/jest-dom"]
},
"include": [
"src/**/*",
"tests/**/*",
"**/*.test.ts",
"**/*.test.tsx",
"**/*.spec.ts",
"**/*.spec.tsx"
]
}
Интеграция с IDE
// .vscode/settings.json
{
"typescript.tsdk": "node_modules/typescript/lib",
"typescript.enablePromptUseWorkspaceTsdk": true,
// Автоматическое обновление импортов
"typescript.updateImportsOnFileMove.enabled": "always",
"javascript.updateImportsOnFileMove.enabled": "always",
// Проверка типов в фоновом режиме
"typescript.surveys.enabled": false
}
Проверка типов в CI/CD
# .github/workflows/type-check.yml
name: Type Check
on: [push, pull_request]
jobs:
type-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- run: npx tsc --noEmit
Типичные проблемы и решения
Проблема 1: Пути не резолвятся
// Решение: убедиться что baseUrl указан
{
"compilerOptions": {
"baseUrl": "./src",
"paths": {
"@/*": ["./*"]
}
}
}
Проблема 2: Модули не находятся
// Решение: правильный moduleResolution
{
"compilerOptions": {
"moduleResolution": "bundler" // Для Vite/Webpack
// или
"moduleResolution": "NodeNext" // Для Node.js
}
}
Проблема 3: Типы из пакетов не находятся
// Решение: явно указать типы
{
"compilerOptions": {
"types": ["node", "jest", "@testing-library/jest-dom"]
}
}
Вывод
tsconfig.json — центральный файл конфигурации TypeScript. Ключевые настройки включают: target и module для определения выходного кода, strict для строгой типизации, paths для алиасов импортов, lib для доступных API. Для больших проектов рекомендуется разделять конфигурации на базовую, для разработки и для сборки. Интеграция с IDE и CI/CD обеспечивает автоматическую проверку типов.
Вопрос 18. Напиши функцию, которая перемещает элемент с определённым значением в конец массива (индекс элемента неизвестен).
Таймкод: 00:33:51
Ответ собеседника: Неполный. Создал массив, протипизировал как number[]. Написал функцию swapElements с проверкой на пустой массива. Для перестановки последнего и предпоследнего элементов планировал использовать индексы. Когда задача была усложнена (нужно найти элемент с неизвестным индексом и поставить его последним), предложил использовать reduce для создания нового массива, но не смог полностью реализовать логику. Начал описывать сигнатуру reduce, но не завершил решение.
Правильный ответ:
Кандидат начал решение, но не завершил. Приведу полные решения разными способами.
Решение 1: Мутирующий подход (in-place)
/**
* Перемещает первый найденный элемент с указанным значением в конец массива.
* Мутирует исходный массив.
*
* @param arr - Массив для модификации
* @param value - Значение элемента для перемещения
* @returns Тот же массив с перемещённым элементом
*
* Сложность: O(n) по времени, O(1) по памяти
*/
function moveElementToEnd<T>(arr: T[], value: T): T[] {
if (arr.length === 0) {
return arr;
}
const index = arr.indexOf(value);
// Элемент не найден
if (index === -1) {
return arr;
}
// Элемент уже в конце
if (index === arr.length - 1) {
return arr;
}
// Удаляем элемент из текущей позиции и добавляем в конец
const [removed] = arr.splice(index, 1);
arr.push(removed);
return arr;
}
// Примеры использования
console.log(moveElementToEnd([1, 2, 3, 4, 5], 3)); // [1, 2, 4, 5, 3]
console.log(moveElementToEnd([1, 2, 3, 4, 5], 5)); // [1, 2, 3, 4, 5] (уже в конце)
console.log(moveElementToEnd([1, 2, 3, 4, 5], 6)); // [1, 2, 3, 4, 5] (не найден)
console.log(moveElementToEnd([], 1)); // [] (пустой массив)
Решение 2: Без мутации (иммутабельный подход)
/**
* Возвращает новый массив с перемещённым элементом в конец.
* Не изменяет исходный массив.
*
* @param arr - Исходный массив
* @param value - Значение элемента для перемещения
* @returns Новый массив с перемещённым элементом
*/
function moveElementToEndImmutable<T>(arr: T[], value: T): T[] {
if (arr.length === 0) {
return [];
}
const index = arr.indexOf(value);
if (index === -1 || index === arr.length - 1) {
return [...arr];
}
return [
...arr.slice(0, index),
...arr.slice(index + 1),
value
];
}
// Примеры
const original = [1, 2, 3, 4, 5];
const result = moveElementToEndImmutable(original, 3);
console.log(original); // [1, 2, 3, 4, 5] - не изменился
console.log(result); // [1, 2, 4, 5, 3]
Решение 3: С использованием filter и concat
function moveElementToEndFilter<T>(arr: T[], value: T): T[] {
const found = arr.filter(item => item === value);
const rest = arr.filter(item => item !== value);
return [...rest, ...found];
}
// Примечание: перемещает ВСЕ вхождения значения в конец
console.log(moveElementToEndFilter([1, 2, 3, 2, 4], 2)); // [1, 3, 4, 2, 2]
Решение 4: С использованием reduce
function moveElementToEndReduce<T>(arr: T[], value: T): T[] {
const result = arr.reduce<{
before: T[];
target: T | null;
}>(
(acc, item) => {
if (item === value && acc.target === null) {
// Первое найденное значение - сохраняем отдельно
return { ...acc, target: item };
}
return { ...acc, before: [...acc.before, item] };
},
{ before: [], target: null }
);
return result.target !== null
? [...result.before, result.target]
: result.before;
}
console.log(moveElementToEndReduce([1, 2, 3, 4, 5], 3)); // [1, 2, 4, 5, 3]
Решение 5: Универсальная функция с опциями
interface MoveOptions {
/** Перемещать все вхождения или только первое */
moveAll?: boolean;
/** Мутировать исходный массив */
mutate?: boolean;
/** Кастомная функция сравнения */
compare?: (a: unknown, b: unknown) => boolean;
}
function moveElementToEndAdvanced<T>(
arr: T[],
value: T,
options: MoveOptions = {}
): T[] {
const {
moveAll = false,
mutate = false,
compare = (a, b) => a === b
} = options;
if (arr.length === 0) {
return mutate ? arr : [];
}
const workingArray = mutate ? arr : [...arr];
if (moveAll) {
// Перемещаем все вхождения
const found: T[] = [];
const rest: T[] = [];
for (const item of workingArray) {
if (compare(item, value)) {
found.push(item);
} else {
rest.push(item);
}
}
if (mutate) {
workingArray.length = 0;
workingArray.push(...rest, ...found);
return workingArray;
}
return [...rest, ...found];
}
// Перемещаем только первое вхождение
const index = workingArray.findIndex(item => compare(item, value));
if (index === -1 || index === workingArray.length - 1) {
return workingArray;
}
const [removed] = workingArray.splice(index, 1);
workingArray.push(removed);
return workingArray;
}
// Примеры
console.log(moveElementToEndAdvanced([1, 2, 3, 2, 4], 2, { moveAll: true }));
// [1, 3, 4, 2, 2]
console.log(moveElementToEndAdvanced([1, 2, 3, 4, 5], 3, { mutate: true }));
// [1, 2, 4, 5, 3]
// С кастомным сравнением для объектов
const users = [
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' },
{ id: 3, name: 'Charlie' }
];
const result = moveElementToEndAdvanced(
users,
{ id: 2 },
{ compare: (a, b) => (a as any).id === (b as any).id }
);
// [{ id: 1, name: 'Alice' }, { id: 3, name: 'Charlie' }, { id: 2, name: 'Bob' }]
Решение 6: На Go (для полноты картины)
package main
import "fmt"
// MoveElementToEnd перемещает элемент в конец слайса
func MoveElementToEnd(arr []int, value int) []int {
if len(arr) == 0 {
return arr
}
index := -1
for i, v := range arr {
if v == value {
index = i
break
}
}
if index == -1 || index == len(arr)-1 {
return arr
}
// Удаляем элемент из текущей позиции
removed := arr[index]
arr = append(arr[:index], arr[index+1:]...)
// Добавляем в конец
arr = append(arr, removed)
return arr
}
func main() {
fmt.Println(MoveElementToEnd([]int{1, 2, 3, 4, 5}, 3)) // [1 2 4 5 3]
fmt.Println(MoveElementToEnd([]int{1, 2, 3, 4, 5}, 5)) // [1 2 3 4 5]
fmt.Println(MoveElementToEnd([]int{1, 2, 3, 4, 5}, 6)) // [1 2 3 4 5]
}
Тестирование
import { describe, it, expect } from 'vitest';
describe('moveElementToEnd', () => {
it('should move element to end', () => {
expect(moveElementToEnd([1, 2, 3, 4, 5], 3)).toEqual([1, 2, 4, 5, 3]);
});
it('should handle element already at end', () => {
expect(moveElementToEnd([1, 2, 3, 4, 5], 5)).toEqual([1, 2, 3, 4, 5]);
});
it('should handle element not found', () => {
expect(moveElementToEnd([1, 2, 3, 4, 5], 6)).toEqual([1, 2, 3, 4, 5]);
});
it('should handle empty array', () => {
expect(moveElementToEnd([], 1)).toEqual([]);
});
it('should handle single element array', () => {
expect(moveElementToEnd([1], 1)).toEqual([1]);
});
it('should handle duplicate values (first occurrence)', () => {
expect(moveElementToEnd([1, 2, 3, 2, 4], 2)).toEqual([1, 3, 2, 4, 2]);
});
it('should work with strings', () => {
expect(moveElementToEnd(['a', 'b', 'c', 'd'], 'b')).toEqual(['a', 'c', 'd', 'b']);
});
it('should work with objects by reference', () => {
const obj1 = { id: 1 };
const obj2 = { id: 2 };
const obj3 = { id: 3 };
expect(moveElementToEnd([obj1, obj2, obj3], obj2)).toEqual([obj1, obj3, obj2]);
});
});
Сравнение подходов
| Подход | Мутация | Сложность по времени | Сложность по памяти | Когда использовать |
|---|---|---|---|---|
| splice + push | Да | O(n) | O(1) | Когда можно мутировать |
| slice + spread | Нет | O(n) | O(n) | Для иммутабельности |
| filter + concat | Нет | O(n) | O(n) | Для перемещения всех вхождений |
| reduce | Нет | O(n) | O(n) | Для функционального стиля |
Вывод
Оптимальное решение зависит от требований:
- Для мутирующего подхода:
splice+push— O(n) по времени, O(1) по памяти - Для иммутабельного:
slice+ spread — O(n) по времени и памяти - Для перемещения всех вхождений:
filter+concat
Важно учитывать граничные случаи: пустой массив, элемент не найден, элемент уже в конце, дубликаты.
Вопрос 19. Работал ли с React Query / RTK Query? Как обновить данные после мутации без перезагрузки страницы?
Таймкод: 00:43:12
Ответ собеседника: Правильный. Работал и с React Query, и с RTK Query. Последнее, с чем работал — RTK Query. Для обновления данных после мутации можно инвалидировать кэш через invalidateTags в RTK Query. Запросы записываются в кэш, и после мутаций можно инвалидировать соответствующие теги, чтобы данные обновились без перезагрузки страницы.
Правильный ответ:
Ответ кандидата корректный. Дополню подробным объясением обоих подходов и альтернативными методами обновления данных.
RTK Query: Инвалидация тегов
// api.ts
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
interface User {
id: number;
name: string;
email: string;
}
export const api = createApi({
reducerPath: 'api',
baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
tagTypes: ['Users', 'User'],
endpoints: (builder) => ({
// Запрос списка пользователей
getUsers: builder.query<User[], void>({
query: () => '/users',
providesTags: (result) =>
result
? [
...result.map(({ id }) => ({ type: 'User' as const, id })),
{ type: 'Users', id: 'LIST' },
]
: [{ type: 'Users', id: 'LIST' }],
}),
// Запрос одного пользователя
getUser: builder.query<User, number>({
query: (id) => `/users/${id}`,
providesTags: (result, error, id) => [{ type: 'User', id }],
}),
// Мутация: добавление пользователя
addUser: builder.mutation<User, Omit<User, 'id'>>({
query: (body) => ({
url: '/users',
method: 'POST',
body,
}),
// Инвалидируем список пользователей после добавления
invalidatesTags: [{ type: 'Users', id: 'LIST' }],
}),
// Мутация: обновление пользователя
updateUser: builder.mutation<User, Partial<User> & { id: number }>({
query: ({ id, ...patch }) => ({
url: `/users/${id}`,
method: 'PATCH',
body: patch,
}),
// Инвалидируем конкретного пользователя и список
invalidatesTags: (result, error, { id }) => [
{ type: 'User', id },
{ type: 'Users', id: 'LIST' },
],
}),
// Мутация: удаление пользователя
deleteUser: builder.mutation<void, number>({
query: (id) => ({
url: `/users/${id}`,
method: 'DELETE',
}),
invalidatesTags: (result, error, id) => [
{ type: 'User', id },
{ type: 'Users', id: 'LIST' },
],
}),
}),
});
export const {
useGetUsersQuery,
useGetUserQuery,
useAddUserMutation,
useUpdateUserMutation,
useDeleteUserMutation,
} = api;
Использование в компонентах:
// UsersList.tsx
import { useGetUsersQuery, useDeleteUserMutation } from './api';
function UsersList() {
const { data: users, isLoading } = useGetUsersQuery();
const [deleteUser] = useDeleteUserMutation();
const handleDelete = async (id: number) => {
await deleteUser(id).unwrap();
// Автоматически инвалидируются теги User и Users
// useGetUsersQuery автоматически перезапросит данные
};
if (isLoading) return <div>Загрузка...</div>;
return (
<ul>
{users?.map(user => (
<li key={user.id}>
{user.name}
<button onClick={() => handleDelete(user.id)}>Удалить</button>
</li>
))}
</ul>
);
}
React Query: Инвалидация кэша
// queries.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
interface User {
id: number;
name: string;
email: string;
}
const userKeys = {
all: ['users'] as const,
lists: () => [...userKeys.all, 'list'] as const,
list: (filters: string) => [...userKeys.lists(), { filters }] as const,
details: () => [...userKeys.all, 'detail'] as const,
detail: (id: number) => [...userKeys.details(), id] as const,
};
// Получение списка пользователей
function useUsers() {
return useQuery({
queryKey: userKeys.lists(),
queryFn: async () => {
const response = await fetch('/api/users');
return response.json() as Promise<User[]>;
},
});
}
// Получение одного пользователя
function useUser(id: number) {
return useQuery({
queryKey: userKeys.detail(id),
queryFn: async () => {
const response = await fetch(`/api/users/${id}`);
return response.json() as Promise<User>;
},
enabled: !!id,
});
}
// Добавление пользователя
function useAddUser() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (newUser: Omit<User, 'id'>) => {
const response = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newUser),
});
return response.json() as Promise<User>;
},
onSuccess: () => {
// Инвалидируем список пользователей
queryClient.invalidateQueries({ queryKey: userKeys.lists() });
},
});
}
// Обновление пользователя
function useUpdateUser() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (updatedUser: Partial<User> & { id: number }) => {
const response = await fetch(`/api/users/${updatedUser.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updatedUser),
});
return response.json() as Promise<User>;
},
onSuccess: (data) => {
// Инвалидируем конкретного пользователя и список
queryClient.invalidateQueries({ queryKey: userKeys.detail(data.id) });
queryClient.invalidateQueries({ queryKey: userKeys.lists() });
},
});
}
// Удаление пользователя
function useDeleteUser() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (id: number) => {
await fetch(`/api/users/${id}`, { method: 'DELETE' });
return id;
},
onSuccess: (id) => {
queryClient.invalidateQueries({ queryKey: userKeys.lists() });
queryClient.removeQueries({ queryKey: userKeys.detail(id) });
},
});
}
Оптимистичное обновление (Optimistic Updates)
// React Query с оптимистичным обновлением
function useDeleteUserOptimistic() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (id: number) => {
await fetch(`/api/users/${id}`, { method: 'DELETE' });
return id;
},
// Выполняется ДО отправки запроса
onMutate: async (deletedId) => {
// Отменяем исходящие запросы, чтобы они не перезаписали наше обновление
await queryClient.cancelQueries({ queryKey: userKeys.lists() });
// Сохраняем предыдущее состояние для отката
const previousUsers = queryClient.getQueryData<User[]>(userKeys.lists());
// Оптимистично обновляем кэш
queryClient.setQueryData<User[]>(userKeys.lists(), (old) =>
old?.filter(user => user.id !== deletedId) ?? []
);
// Возвращаем контекст для отката
return { previousUsers };
},
// При ошибке — откатываем изменения
onError: (err, deletedId, context) => {
if (context?.previousUsers) {
queryClient.setQueryData(userKeys.lists(), context.previousUsers);
}
},
// В любом случае — инвалидируем кэш
onSettled: () => {
queryClient.invalidateQueries({ queryKey: userKeys.lists() });
},
});
}
Обновление через setQueryData (ручное обновление кэша)
function useUpdateUserManual() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (updatedUser: Partial<User> & { id: number }) => {
const response = await fetch(`/api/users/${updatedUser.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updatedUser),
});
return response.json() as Promise<User>;
},
onSuccess: (updatedUser) => {
// Обновляем конкретного пользователя
queryClient.setQueryData<User>(
userKeys.detail(updatedUser.id),
updatedUser
);
// Обновляем пользователя в списке
queryClient.setQueryData<User[]>(userKeys.lists(), (old) =>
old?.map(user =>
user.id === updatedUser.id ? updatedUser : user
) ?? []
);
},
});
}
Сравнение подходов к обновлению данных
| Подход | Описание | Плюсы | Минусы |
|---|---|---|---|
| Инвалидация тегов/кэша | Помечаем данные как устаревшие, вызываем refetch | Простота, актуальность данных | Дополнительный запрос к API |
| Оптимистичное обновление | Обновляем кэш сразу, откатываем при ошибке | Мгновенный UX | Сложность реализации |
| setQueryData | Ручное обновление кэша | Полный контроль | Риск рассинхронизации |
| onSettled | Обновление после завершения мутации | Гарантия завершения | Задержка обновления |
RTK Query: Оптимистичное обновление
const api = createApi({
endpoints: (builder) => ({
updateUser: builder.mutation<User, Partial<User> & { id: number }>({
query: ({ id, ...patch }) => ({
url: `/users/${id}`,
method: 'PATCH',
body: patch,
}),
async onQueryStarted({ id, ...patch }, { dispatch, queryFulfilled }) {
// Оптимистичное обновление
const patchResult = dispatch(
api.util.updateQueryData('getUser', id, (draft) => {
Object.assign(draft, patch);
})
);
try {
await queryFulfilled;
} catch {
// Откатываем при ошибке
patchResult.undo();
}
},
}),
}),
});
Автоматическое обновление через подписку
// React Query: Подписка на изменения
function useUsersSubscription() {
const queryClient = useQueryClient();
useEffect(() => {
const eventSource = new EventSource('/api/users/events');
eventSource.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === 'USER_UPDATED') {
queryClient.invalidateQueries({ queryKey: userKeys.detail(data.userId) });
}
if (data.type === 'USER_DELETED') {
queryClient.invalidateQueries({ queryKey: userKeys.lists() });
}
};
return () => eventSource.close();
}, [queryClient]);
}
Вывод
Для обновления данных после мутации без перезагрузки страницы используются:
- Инвалидация тегов/кэша — основной подход, автоматически перезапрашивает данные
- Оптимистичное обновление — мгновенный UX с откатом при ошибке
- Ручное обновление через setQueryData — полный контроль над кэшем
- Подписки (WebSocket/SSE) — для real-time обновлений
RTK Query и React Query имеют схожие концепции, но разный синтаксис. Выбор подхода зависит от требований к UX и сложности данных.
Вопрос 20. Как обработать ошибку (например, 400 HTTP) при запросе через React Query / RTK Query? Как отобразить UI ошибки без дублирования кода в каждом запросе?
Таймкод: 00:45:35
Ответ собеседника: Неполный. Предложил два подхода: 1) Обернуть в try/catch и диспатчить ошибку в стор, откуда компонент сообщений её отображает. 2) Использовать свойство error, которое возвращает React Query / RTK Query, и на его основе рендерить компонент ошибки. Упомянул Error Boundary для отлова UI-ошибок, но признал, что это не совсем подходит для ошибок запросов. На вопрос о централизованной обработке без дублирования кода не смог дать полного ответа, но вспомнил про интерцепторы.
Правильный ответ:
Кандидат верно определил основные подходы, но не раскрыл централизованную обработку. Приведу полное решение.
Базовая обработка ошибок
React Query:
import { useQuery } from '@tanstack/react-query';
function UserProfile({ userId }: { userId: number }) {
const { data, error, isLoading, isError } = useQuery({
queryKey: ['user', userId],
queryFn: async () => {
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return response.json();
},
});
if (isLoading) return <div>Загрузка...</div>;
if (isError) {
return <div>Ошибка: {error.message}</div>;
}
return <div>{data.name}</div>;
}
RTK Query:
import { useGetUserQuery } from './api';
function UserProfile({ userId }: { userId: number }) {
const { data, error, isLoading } = useGetUserQuery(userId);
if (isLoading) return <div>Загрузка...</div>;
if (error) {
return <div>Ошибка: {JSON.stringify(error)}</div>;
}
return <div>{data.name}</div>;
}
Проблема: дублирование кода
// Компонент 1
function UsersList() {
const { data, error, isLoading } = useGetUsersQuery();
if (error) return <div>Ошибка: {error.message}</div>;
if (isLoading) return <div>Загрузка...</div>;
return <div>{/* ... */}</div>;
}
// Компонент 2
function UserProfile() {
const { data, error, isLoading } = useGetUserQuery(1);
if (error) return <div>Ошибка: {error.message}</div>;
if (isLoading) return <div>Загрузка...</div>;
return <div>{/* ... */}</div>;
}
// Компонент 3... N — всё повторяется
Решение 1: Централизованная обработка через интерцепторы (React Query)
// api/client.ts
import axios, { AxiosError, AxiosResponse, InternalAxiosRequestConfig } from 'axios';
import { toast } from 'react-hot-toast';
// Типы ошибок API
interface ApiError {
status: number;
code: string;
message: string;
details?: Record<string, string[]>;
}
// Создаём экземпляр axios
const apiClient = axios.create({
baseURL: '/api',
timeout: 10000,
});
// Интерцептор запросов
apiClient.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
// Добавляем токен авторизации
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
// Интерцептор ответов с централизованной обработкой ошибок
apiClient.interceptors.response.use(
(response: AxiosResponse) => response,
(error: AxiosError<ApiError>) => {
if (!error.response) {
// Сетевая ошибка
toast.error('Нет соединения с сервером');
return Promise.reject(error);
}
const { status, data } = error.response;
switch (status) {
case 400:
// Валидационная ошибка — не показываем toast, пусть компонент обработает
break;
case 401:
// Не авторизован — перенаправляем на логин
localStorage.removeItem('token');
window.location.href = '/login';
toast.error('Сессия истекла. Войдите заново');
break;
case 403:
toast.error('Нет доступа к этому ресурсу');
break;
case 404:
// Не найдено — не показываем toast, пусть компонент обработает
break;
case 422:
// Ошибка валидации — не показываем toast
break;
case 429:
toast.error('Слишком много запросов. Попробуйте позже');
break;
case 500:
case 502:
case 503:
toast.error('Ошибка сервера. Мы уже работаем над этим');
break;
default:
toast.error(data?.message || 'Произошла ошибка');
}
return Promise.reject(error);
}
);
export default apiClient;
Решение 2: QueryClient с глобальными обработчиками (React Query)
// queryClient.ts
import { QueryClient, QueryCache, MutationCache } from '@tanstack/react-query';
import { toast } from 'react-hot-toast';
export const queryClient = new QueryClient({
queryCache: new QueryCache({
onError: (error, query) => {
// Пропускаем ошибки, которые уже обработаны в компоненте
if (query.meta?.skipGlobalError) return;
// Глобальная обработка ошибок запросов
if (error instanceof Error) {
toast.error(error.message);
}
},
}),
mutationCache: new MutationCache({
onError: (error, variables, context, mutation) => {
// Пропускаем ошибки, которые уже обработаны
if (mutation.meta?.skipGlobalError) return;
if (error instanceof Error) {
toast.error(error.message);
}
},
onSuccess: (data, variables, context, mutation) => {
// Глобальные уведомления об успехе
if (mutation.meta?.successMessage) {
toast.success(mutation.meta.successMessage as string);
}
},
}),
defaultOptions: {
queries: {
retry: (failureCount, error: any) => {
// Не повторяем запросы для ошибок 4xx
if (error?.response?.status >= 400 && error?.response?.status < 500) {
return false;
}
return failureCount < 3;
},
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
},
},
});
Решение 3: RTK Query с кастомным baseQuery
// api/baseQuery.ts
import { fetchBaseQuery } from '@reduxjs/toolkit/query/react';
import type { BaseQueryFn, FetchArgs, FetchBaseQueryError } from '@reduxjs/toolkit/query';
import { toast } from 'react-hot-toast';
const rawBaseQuery = fetchBaseQuery({
baseUrl: '/api',
prepareHeaders: (headers) => {
const token = localStorage.getItem('token');
if (token) {
headers.set('Authorization', `Bearer ${token}`);
}
return headers;
},
});
// Обёртка с централизованной обработкой ошибок
export const baseQueryWithErrorHandler: BaseQueryFn<
string | FetchArgs,
unknown,
FetchBaseQueryError
> = async (args, api, extraOptions) => {
const result = await rawBaseQuery(args, api, extraOptions);
if (result.error) {
const { status, data } = result.error as any;
// Обрабатываем только определённые статусы глобально
switch (status) {
case 401:
localStorage.removeItem('token');
window.location.href = '/login';
toast.error('Сессия истекла');
break;
case 403:
toast.error('Нет доступа');
break;
case 500:
toast.error('Ошибка сервера');
break;
}
}
return result;
};
// api.ts
import { createApi } from '@reduxjs/toolkit/query/react';
import { baseQueryWithErrorHandler } from './baseQuery';
export const api = createApi({
reducerPath: 'api',
baseQuery: baseQueryWithErrorHandler,
tagTypes: ['Users', 'Posts'],
endpoints: (builder) => ({
getUsers: builder.query<User[], void>({
query: () => '/users',
providesTags: ['Users'],
}),
// ... другие endpoints
}),
});
Решение 4: Кастомный хук для обработки ошибок
// hooks/useApiError.ts
import { useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { toast } from 'react-hot-toast';
interface ApiError {
status: number;
data?: {
code: string;
message: string;
details?: Record<string, string[]>;
};
}
export function useApiError() {
const navigate = useNavigate();
const handleError = useCallback((error: ApiError) => {
switch (error.status) {
case 401:
localStorage.removeItem('token');
navigate('/login');
break;
case 403:
navigate('/forbidden');
break;
case 404:
// Не делаем ничего — компонент сам покажет "не найдено"
break;
case 422:
// Ошибка валидации — возвращаем детали для формы
return error.data?.details;
default:
toast.error(error.data?.message || 'Произошла ошибка');
}
return null;
}, [navigate]);
return { handleError };
}
// Использование
function UserProfile({ userId }: { userId: number }) {
const { data, error } = useGetUserQuery(userId);
const { handleError } = useApiError();
if (error) {
const validationErrors = handleError(error as ApiError);
// validationErrors будет содержать ошибки валидации или null
}
return <div>{data?.name}</div>;
}
Решение 5: Компонент-обёртка для обработки ошибок
// components/QueryHandler.tsx
import { ReactNode } from 'react';
import type { UseQueryResult, UseMutationResult } from '@tanstack/react-query';
interface QueryHandlerProps<T> {
query: UseQueryResult<T> | UseMutationResult<T, any, any>;
children: (data: T) => ReactNode;
loadingComponent?: ReactNode;
errorComponent?: (error: Error) => ReactNode;
}
export function QueryHandler<T>({
query,
children,
loadingComponent = <div>Загрузка...</div>,
errorComponent,
}: QueryHandlerProps<T>) {
if (query.isLoading) {
return <>{loadingComponent}</>;
}
if (query.isError) {
if (errorComponent) {
return <>{errorComponent(query.error)}</>;
}
return (
<div className="error-container">
<h3>Ошибка</h3>
<p>{query.error.message}</p>
<button onClick={() => query.refetch()}>
Повторить
</button>
</div>
);
}
if (query.data) {
return <>{children(query.data)}</>;
}
return null;
}
// Использование
function UsersPage() {
const usersQuery = useUsersQuery();
return (
<QueryHandler
query={usersQuery}
loadingComponent={<UsersSkeleton />}
errorComponent={(error) => (
<ErrorDisplay
message={error.message}
onRetry={() => usersQuery.refetch()}
/>
)}
>
{(users) => (
<ul>
{users.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
)}
</QueryHandler>
);
}
Решение 6: Error Boundary для неожиданных ошибок
// components/ErrorBoundary.tsx
import { Component, ReactNode, ErrorInfo } from 'react';
interface Props {
children: ReactNode;
fallback?: ReactNode;
}
interface State {
hasError: boolean;
error: Error | null;
}
export class ErrorBoundary extends Component<Props, State> {
state: State = {
hasError: false,
error: null,
};
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
// Отправляем ошибку в сервис мониторинга
console.error('ErrorBoundary caught:', error, errorInfo);
}
render() {
if (this.state.hasError) {
return (
this.props.fallback || (
<div className="error-boundary">
<h2>Что-то пошло не так</h2>
<p>{this.state.error?.message}</p>
<button onClick={() => this.setState({ hasError: false, error: null })}>
Попробовать снова
</button>
</div>
)
);
}
return this.props.children;
}
}
// Использование
function App() {
return (
<ErrorBoundary>
<UsersPage />
</ErrorBoundary>
);
}
Решение 7: Централизованный обработчик с контекстом
// context/ErrorContext.tsx
import { createContext, useContext, useState, useCallback, ReactNode } from 'react';
interface ErrorState {
message: string;
code?: string;
details?: Record<string, string[]>;
}
interface ErrorContextValue {
error: ErrorState | null;
setError: (error: ErrorState | null) => void;
clearError: () => void;
}
const ErrorContext = createContext<ErrorContextValue | null>(null);
export function ErrorProvider({ children }: { children: ReactNode }) {
const [error, setError] = useState<ErrorState | null>(null);
const clearError = useCallback(() => setError(null), []);
return (
<ErrorContext.Provider value={{ error, setError, clearError }}>
{children}
</ErrorContext.Provider>
);
}
export function useError() {
const context = useContext(ErrorContext);
if (!context) {
throw new Error('useError must be used within ErrorProvider');
}
return context;
}
// GlobalErrorDisplay.tsx
function GlobalErrorDisplay() {
const { error, clearError } = useError();
if (!error) return null;
return (
<div className="global-error">
<p>{error.message}</p>
<button onClick={clearError}>Закрыть</button>
</div>
);
}
// Использование в API клиенте
function useApiWithGlobalError() {
const { setError } = useError();
return useCallback(async <T>(fn: () => Promise<T>): Promise<T | null> => {
try {
return await fn();
} catch (error: any) {
setError({
message: error.data?.message || 'Произошла ошибка',
code: error.data?.code,
details: error.data?.details,
});
return null;
}
}, [setError]);
}
Сравнение подходов
| Подход | Уровень | Когда использовать |
|---|---|---|
| Интерцепторы axios | HTTP клиент | Глобальная обработка 401, 403, 500 |
| QueryCache/MutationCache | React Query | Глобальные toast-уведомления |
| Кастомный baseQuery | RTK Query | Глобальная обработка в Redux |
| Кастомный хук | Компонент | Гибкая обработка с возвратом ошибок |
| QueryHandler компонент | UI | Единообразный рендеринг ошибок |
| ErrorBoundary | Приложение | Неожиданные ошибки рендеринга |
| ErrorContext | Приложение | Централизованное управление ошибками |
Вывод
Для централизованной обработки ошибок без дублирования кода рекомендуется комбинация:
- Интерцепторы — для глобальной обработки HTTP ошибок (401, 403, 500)
- QueryCache/MutationCache — для глобальных уведомлений
- Кастомные хуки — для гибкой обработки в компонентах
- Error Boundary — для неожиданных ошибок рендеринга
Это позволяет избежать дублирования кода и обеспечивает единообразный пользовательский опыт при обработке ошибок.
Вопрос 21. Что такое интерцепторы (interceptors)? Приведи пример использования.
Таймкод: 00:48:32
Ответ собеседника: Правильный. Вспомнил про интерцепторы на примере реализации авторизации через Axios. Использовал интерцептор, который перехватывал ошибку 401 и автоматически рефрешил токен, чтобы у пользователя ничего не сломалось. После обновления токена выполнялись все нужные операции дальше.
Правильный ответ:
Ответ кандидата хороший и практичный. Дополню полным объяснением механизма и дополнительными примерами.
Что такое интерцепторы
Интерцепторы — это функции-перехватчики, которые позволяют перехватывать и модифицировать запросы перед их отправкой или ответы перед их обработкой. Они работают как middleware для HTTP-клиентов.
Механизм работы интерцепторов
Запрос: [Интерцепторы запросов] → [Сервер]
Ответ: [Сервер] → [Интерцепторы ответов] → [Код приложения]
Порядок выполнения:
1. Последний добавленный request interceptor (первый в цепочке)
2. ...
3. Первый добавленный request interceptor (последний в цепочке)
4. Отправка запроса
5. Получение ответа
6. Первый добавленный response interceptor (первый в цепочке)
7. ...
8. Последний добавленный response interceptor (последний в цепочке)
Axios: Базовое использование интерцепторов
import axios, { AxiosInstance, AxiosResponse, AxiosError, InternalAxiosRequestConfig } from 'axios';
// Создаём экземпляр axios
const api: AxiosInstance = axios.create({
baseURL: 'https://api.example.com',
timeout: 10000,
headers: {
'Content-Type': 'application/json',
},
});
// Интерцептор запросов
api.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
// Получаем токен из хранилища
const token = localStorage.getItem('accessToken');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
// Добавляем timestamp для отслеживания
config.metadata = { startTime: Date.now() };
console.log(`[Request] ${config.method?.toUpperCase()} ${config.url}`);
return config;
},
(error: AxiosError) => {
console.error('[Request Error]', error);
return Promise.reject(error);
}
);
// Интерцептор ответов
api.interceptors.response.use(
(response: AxiosResponse) => {
// Вычисляем время выполнения запроса
const duration = Date.now() - (response.config.metadata?.startTime || 0);
console.log(`[Response] ${response.status} (${duration}ms)`);
return response;
},
(error: AxiosError) => {
const duration = Date.now() - (error.config?.metadata?.startTime || 0);
console.error(`[Response Error] ${error.response?.status} (${duration}ms)`);
return Promise.reject(error);
}
);
Пример 1: Автоматическое обновление токена (кандидат упомянул)
import axios, { AxiosInstance, AxiosError, InternalAxiosRequestConfig } from 'axios';
interface TokenPair {
accessToken: string;
refreshToken: string;
}
class ApiClient {
private axios: AxiosInstance;
private isRefreshing = false;
private failedQueue: Array<{
resolve: (token: string) => void;
reject: (error: any) => void;
}> = [];
constructor() {
this.axios = axios.create({
baseURL: 'https://api.example.com',
});
this.setupInterceptors();
}
private setupInterceptors() {
// Интерцептор запросов — добавляем токен
this.axios.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
const token = localStorage.getItem('accessToken');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => Promise.reject(error)
);
// Интерцептор ответов — обрабатываем 401
this.axios.interceptors.response.use(
(response) => response,
async (error: AxiosError) => {
const originalRequest = error.config as InternalAxiosRequestConfig & {
_retry?: boolean;
};
// Если ошибка 401 и это не повторный запрос
if (error.response?.status === 401 && !originalRequest._retry) {
if (this.isRefreshing) {
// Если токен уже обновляется — добавляем запрос в очередь
return new Promise((resolve, reject) => {
this.failedQueue.push({ resolve, reject });
}).then((token) => {
originalRequest.headers.Authorization = `Bearer ${token}`;
return this.axios(originalRequest);
});
}
originalRequest._retry = true;
this.isRefreshing = true;
try {
const newTokens = await this.refreshToken();
// Сохраняем новые токены
localStorage.setItem('accessToken', newTokens.accessToken);
localStorage.setItem('refreshToken', newTokens.refreshToken);
// Обновляем заголовок для оригинального запроса
originalRequest.headers.Authorization = `Bearer ${newTokens.accessToken}`;
Вопрос 22. Как синхронизировать данные между серверным стором (RTK Store на сервере) и клиентским стором в Next.js? Что делать, если на сервере произошла ошибка при запросе данных?
Таймкод: 00:49:45
Ответ собеседника: Неполный. Не сразу понял вопрос про синхронизацию сторов. Предположил, что данные автоматически сохраняются в state. После уточнения признал, что на сервере свой стор, на клиенте — свой, и их нужно синхронизировать. Упомянул getServerSideProps для Pages Router и page props для App Router, но признал, что это неудобно. Знал, что существует подход для синхронизации, но не смог назвать конкретное решение. На вопрос про ошибку на сервере предложил обернуть в try/catch и показать пользователю уведомление об ошибке получения данных.
Правильный ответ:
Кандидат не знает механизма синхронизации сторов. Приведу полное решение.
Проблема: два отдельных стора
Сервер (SSR) Клиент
┌─────────────────┐ ┌─────────────────┐
│ RTK Store │ │ RTK Store │
│ данные для │ ────────► │ пустой или │
│ первичного │ HTML + │ устаревший │
│ рендеринга │ JSON │ │
└─────────────────┘ └─────────────────┘
Решение: Гидратация стора (Store Hydration)
Шаг 1: Создание стора с поддержкой SSR
// store/store.ts
import { configureStore } from '@reduxjs/toolkit';
import { api } from './api';
import authReducer from './slices/authSlice';
// Функция для создания стора — вызывается на каждом запросе на сервере
export const makeStore = () => {
return configureStore({
reducer: {
[api.reducerPath]: api.reducer,
auth: authReducer,
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({
serializableCheck: false,
}).concat(api.middleware),
});
};
// Типы
export type AppStore = ReturnType<typeof makeStore>;
export type RootState = ReturnType<AppStore['getState']>;
export type AppDispatch = AppStore['dispatch'];
Шаг 2: Серверный рендеринг с предзаполненным стором
// lib/getServerStore.ts
import { makeStore } from '../store/store';
import { api } from '../store/api';
/**
* Создаёт стор на сервере, предзаполняет данными
* и возвращает сериализованное состояние
*/
export async function getServerStore() {
const store = makeStore();
try {
// Предзаполняем стор данными
const userPromise = store.dispatch(
api.endpoints.getCurrentUser.initiate()
);
const settingsPromise = store.dispatch(
api.endpoints.getSettings.initiate()
);
// Ждём завершения всех запросов
await Promise.all([
userPromise,
settingsPromise,
]);
// Отменяем подписки (очищаем память)
userPromise.unsubscribe();
settingsPromise.unsubscribe();
} catch (error) {
console.error('Server data fetching failed:', error);
// Продолжаем с пустым стором — клиент загрузит данные
}
// Возвращаем текущее состояние стора
return {
preloadedState: store.getState(),
};
}
Шаг 3: Pages Router — синхронизация через getServerSideProps
// pages/index.tsx
import { GetServerSideProps } from 'next';
import { getServerStore } from '../lib/getServerStore';
import { api } from '../store/api';
import { wrapper } from '../store/wrapper';
export const getServerSideProps: GetServerSideProps = wrapper.getServerSideProps(
(store) => async (context) => {
try {
// Загружаем данные на сервере
const userPromise = store.dispatch(
api.endpoints.getCurrentUser.initiate()
);
const postsPromise = store.dispatch(
api.endpoints.getPosts.initiate({ page: 1 })
);
// Ждём завершения
const [userResult, postsResult] = await Promise.allSettled([
userPromise,
postsPromise,
]);
// Обрабатываем ошибки
const errors: string[] = [];
if (userResult.status === 'rejected') {
errors.push('Failed to load user data');
}
if (postsResult.status === 'rejected') {
errors.push('Failed to load posts');
}
return {
props: {
// Ошибки передаём на клиент
serverErrors: errors,
},
};
} catch (error) {
return {
props: {
serverErrors: ['Unexpected server error'],
},
};
}
}
);
// Компонент
function HomePage({ serverErrors }: { serverErrors: string[] }) {
// Данные уже в сторе благодаря getServerSideProps
const { data: user, isLoading: userLoading } = api.endpoints.getCurrentUser.useQueryState();
const { data: posts, isLoading: postsLoading } = api.endpoints.getPosts.useQueryState({ page: 1 });
return (
<div>
{/* Показываем ошибки сервера */}
{serverErrors.length > 0 && (
<div className="server-errors">
{serverErrors.map((error, i) => (
<div key={i} className="error-banner">
{error}
<button onClick={() => window.location.reload()}>
Перезагрузить
</button>
</div>
))}
</div>
)}
{userLoading ? (
<div>Загрузка пользователя...</div>
) : user ? (
<div>Привет, {user.name}!</div>
) : (
<div>Не удалось загрузить данные пользователя</div>
)}
{/* Контент */}
</div>
);
}
export default HomePage;
Шаг 4: App Router — синхронизация
// app/components/StoreProvider.tsx
'use client';
import { useRef } from 'react';
import { Provider } from 'react-redux';
import { makeStore, AppStore } from '../store/store';
interface StoreProviderProps {
children: React.ReactNode;
preloadedState?: any;
}
export function StoreProvider({ children, preloadedState }: StoreProviderProps) {
const storeRef = useRef<AppStore>();
if (!storeRef.current) {
// Создаём стор с предзагруженным состоянием от сервера
storeRef.current = makeStore();
if (preloadedState) {
storeRef.current = {
...storeRef.current,
getState: () => ({
...storeRef.current!.getState(),
...preloadedState,
}),
};
}
}
return <Provider store={storeRef.current}>{children}</Provider>;
}
// app/layout.tsx
import { StoreProvider } from './components/StoreProvider';
import { getServerStore } from '../lib/getServerStore';
export default async function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
// Загружаем данные на сервере
const { preloadedState } = await getServerStore();
return (
<html>
<body>
<StoreProvider preloadedState={preloadedState}>
{children}
</StoreProvider>
</body>
</html>
);
}
Шаг 5: Обработка ошибок на сервере
// lib/getServerStore.ts (расширенная версия)
import { makeStore } from '../store/store';
import { api } from '../store/api';
interface ServerStoreResult {
preloadedState: any;
errors: ServerError[];
}
interface ServerError {
endpoint: string;
error: string;
status?: number;
}
export async function getServerStore(): Promise<ServerStoreResult> {
const store = makeStore();
const errors: ServerError[] = [];
// Определяем критические и некритические запросы
const criticalQueries = [
{
name: 'getCurrentUser',
promise: store.dispatch(api.endpoints.getCurrentUser.initiate()),
},
];
const nonCriticalQueries = [
{
name: 'getPosts',
promise: store.dispatch(api.endpoints.getPosts.initiate({ page: 1 })),
},
{
name: 'getSettings',
promise: store.dispatch(api.endpoints.getSettings.initiate()),
},
];
// Выполняем критические запросы
for (const query of criticalQueries) {
try {
const result = await query.promise;
if (result.error) {
errors.push({
endpoint: query.name,
error: getErrorMessage(result.error),
status: (result.error as any)?.status,
});
}
} catch (error) {
errors.push({
endpoint: query.name,
error: 'Request failed',
});
}
}
// Выполняем некритические запросы (ошибки не критичны)
const nonCriticalResults = await Promise.allSettled(
nonCriticalQueries.map(q => q.promise)
);
nonCriticalResults.forEach((result, index) => {
if (result.status === 'rejected') {
errors.push({
endpoint: nonCriticalQueries[index].name,
error: 'Failed to load',
});
}
});
// Отменяем подписки
[...criticalQueries, ...nonCriticalQueries].forEach(q => {
q.promise.unsubscribe();
});
return {
preloadedState: store.getState(),
errors,
};
}
function getErrorMessage(error: any): string {
if (error?.status === 401) return 'Unauthorized';
if (error?.status === 403) return 'Forbidden';
if (error?.status === 404) return 'Not found';
if (error?.status >= 500) return 'Server error';
return error?.data?.message || 'Unknown error';
}
Шаг 6: Клиентская обработка ошибок сервера
// app/components/ServerErrorHandler.tsx
'use client';
import { useEffect } from 'react';
import { useDispatch } from 'react-redux';
import { toast } from 'react-hot-toast';
import { api } from '../store/api';
interface ServerError {
endpoint: string;
error: string;
status?: number;
}
interface ServerErrorHandlerProps {
errors: ServerError[];
}
export function ServerErrorHandler({ errors }: ServerErrorHandlerProps) {
const dispatch = useDispatch();
useEffect(() => {
errors.forEach(error => {
// Показываем toast для ошибок
toast.error(`Ошибка загрузки: ${error.endpoint}`);
// Инвалидируем кэш для проблемного endpoint
if (error.endpoint === 'getCurrentUser') {
dispatch(api.util.invalidateQueries(['currentUser']));
}
});
}, [errors, dispatch]);
if (errors.length === 0) return null;
return (
<div className="server-errors">
{errors.map((error, index) => (
<div key={index} className="error-banner">
<span>⚠️ {error.endpoint}: {error.error}</span>
<button
onClick={() => window.location.reload()}
className="retry-button"
>
Перезагрузить
</button>
</div>
))}
</div>
);
}
Шаг 7: Использование в компонентах
// app/page.tsx
import { getServerStore } from '../lib/getServerStore';
import { ServerErrorHandler } from './components/ServerErrorHandler';
import { UserProfile } from './components/UserProfile';
import { PostsList } from './components/PostsList';
export default async function HomePage() {
const { preloadedState, errors } = await getServerStore();
return (
<main>
{/* Обработчик ошибок сервера */}
<ServerErrorHandler errors={errors} />
{/* Компоненты получают данные из стора */}
<UserProfile />
<PostsList />
</main>
);
}
// app/components/UserProfile.tsx
'use client';
import { useSelector } from 'react-redux';
import { api } from '../store/api';
import { selectCurrentUser } from '../store/selectors';
export function UserProfile() {
// Данные уже в сторе с сервера, isLoading будет false
const { data: user, isLoading, isError, error } = api.endpoints.getCurrentUser.useQueryState();
if (isLoading) {
return <div>Загрузка...</div>;
}
if (isError) {
return (
<div className="error-container">
<p>Не удалось загрузить данные пользователя</p>
<button onClick={() => window.location.reload()}>
Повторить
</button>
</div>
);
}
if (!user) {
return <div>Пользователь не найден</div>;
}
return (
<div className="user-profile">
<h1>{user.name}</h1>
<p>{user.email}</p>
</div>
);
}
Альтернативный подход: без Redux
// app/page.tsx (без Redux, с обычным fetch)
export default async function HomePage() {
let user = null;
let posts = [];
let error = null;
try {
const [userRes, postsRes] = await Promise.all([
fetch('https://api.example.com/user'),
fetch('https://api.example.com/posts'),
]);
if (userRes.ok) user = await userRes.json();
if (postsRes.ok) posts = await postsRes.json();
} catch (e) {
error = 'Failed to load data';
}
return (
<main>
{error && (
<div className="error-banner">
{error}
<button onClick={() => window.location.reload()}>
Перезагрузить
</button>
</div>
)}
{user && <UserProfile initialUser={user} />}
<PostsList initialPosts={posts} />
</main>
);
}
Схема работы
Сервер Клиент
┌─────────────────┐ ┌─────────────────┐
│ 1. Создаём стор │ │ │
│ 2. Загружаем │ HTML + │ 4. Получаем │
│ данные │ JSON │ начальное │
│ 3. Сериализуем │ ──────────►│ состояние │
│ состояние │ │ 5. Гидратируем │
│ │ │ стор │
│ │ │ 6. Рендерим │
└─────────────────┘ └─────────────────┘
Вывод
Для синхронизации сторов в Next.js с RTK:
- Создаём функцию
makeStore()для создания стора на каждом запросе - На сервере предзаполняем стор данными через
store.dispatch(api.endpoints.initiate()) - Передаём
preloadedStateна клиент через props - На клиенте создаём стор с начальным состоянием
- Обрабатываем ошибки через
Promise.allSettledи передаём на клиент
При ошибке на сервере:
- Критические данные — показываем ошибку, предлагаем перезагрузить
- Некритические — клиент загрузит самостоятельно
- Используем паттерн "graceful degradation"
Вопрос 23. Работал ли с Docker? Использовал ли его в локальной разработке?
Таймкод: 00:53:56
Ответ собеседника: Неполный. Работал с Docker немного. На работе создавал Docker-контейнеры по аналогии с тем, как это делал DevOps для разных сред (dev, stage, prod). Настраивал docker-compose. Также немного настраивал CI/CD по аналогии с существующей реализацией. В локальной разработке использовал Docker один раз давно (на проекте на Django), на фронтовых проектах локально не использовал — запускает через npm run build.
Правильный ответ:
Кандидат имеет базовое понимание Docker, но опыт ограничен. Приведу полный обзор для подготовки.
Что такое Docker
Docker — платформа для контейнеризации приложений. Контейнер изолирует приложение с его зависимостями, обеспечивая единообразие среды на разных машинах.
Основные понятия
| Понятие | Описание |
|---|---|
| Image | Шаблон для создания контейнеров (как класс в ООП) |
| Container | Запущенный экземпляр образа (как объект) |
| Dockerfile | Инструкция для сборки образа |
| Docker Compose | Оркестрация нескольких контейнеров |
| Volume | Постоянное хранилище данных |
| Network | Сеть для связи контейнеров |
Dockerfile для Go приложения
# Dockerfile
# Этап 1: Сборка
FROM golang:1.22-alpine AS builder
# Устанавливаем рабочую директорию
WORKDIR /app
# Копируем файлы зависимостей (кэширование слоёв)
COPY go.mod go.sum ./
RUN go mod download
# Копируем исходный код
COPY . .
# Собираем бинарный файл
RUN CGO_ENABLED=0 GOOS=linux go build -o /app/server ./cmd/server
# Этап 2: Минимальный образ для запуска
FROM alpine:3.19
# Устанавливаем CA-сертификаты для HTTPS
RUN apk --no-cache add ca-certificates
WORKDIR /app
# Копируем бинарный файл из первого этапа
COPY --from=builder /app/server .
# Копируем конфигурации
COPY configs/ ./configs/
# Открываем порт
EXPOSE 8080
# Запускаем приложение
CMD ["./server"]
Dockerfile для Next.js приложения
# Dockerfile
# Этап 1: Зависимости
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --only=production
# Этап 2: Сборка
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Собираем приложение
RUN npm run build
# Этап 3: Production
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
# Создаём непривилегированного пользователя
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# Копируем только необходимые файлы
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]
Docker Compose для полного стека
# docker-compose.yml
version: '3.9'
services:
# Go бэкенд
backend:
build:
context: ./backend
dockerfile: Dockerfile
ports:
- "8080:8080"
environment:
- DATABASE_URL=postgres://user:password@postgres:5432/dbname
- REDIS_URL=redis://redis:6379
- JWT_SECRET=${JWT_SECRET}
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_started
volumes:
- ./backend:/app # Для разработки: hot reload
networks:
- app-network
# Next.js фронтенд
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
ports:
- "3000:3000"
environment:
- NEXT_PUBLIC_API_URL=http://localhost:8080
depends_on:
- backend
networks:
- app-network
# PostgreSQL
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: user
POSTGRES_PASSWORD: password
POSTGRES_DB: dbname
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
- ./init.sql:/docker-entrypoint-initdb.d/init.sql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U user -d dbname"]
interval: 5s
timeout: 5s
retries: 5
networks:
- app-network
# Redis
redis:
image: redis:7-alpine
ports:
- "6379:6379"
volumes:
- redis_data:/data
networks:
- app-network
# Nginx (reverse proxy)
nginx:
image: nginx:alpine
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf
- ./ssl:/etc/nginx/ssl
depends_on:
- frontend
- backend
networks:
- app-network
volumes:
postgres_data:
redis_data:
networks:
app-network:
driver: bridge
Docker Compose для разработки
# docker-compose.dev.yml
version: '3.9'
services:
backend:
build:
context: ./backend
dockerfile: Dockerfile.dev # Отдельный Dockerfile для разработки
ports:
- "8080:8080"
environment:
- DATABASE_URL=postgres://user:password@postgres:5432/dbname
- ENV=development
volumes:
- ./backend:/app # Монтируем код для hot reload
- /app/vendor # Исключаем vendor из монтирования
depends_on:
- postgres
frontend:
build:
context: ./frontend
dockerfile: Dockerfile.dev
ports:
- "3000:3000"
environment:
- NEXT_PUBLIC_API_URL=http://localhost:8080
- CHOKIDAR_USEPOLLING=true # Для hot reload в Docker
volumes:
- ./frontend:/app
- /app/node_modules
- /app/.next
depends_on:
- backend
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: user
POSTGRES_PASSWORD: password
POSTGRES_DB: dbname
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
volumes:
postgres_data:
Dockerfile для разработки (с hot reload)
# Dockerfile.dev (Go)
FROM golang:1.22-alpine
WORKDIR /app
# Устанавливаем air для hot reload
RUN go install github.com/cosmtrek/air@latest
COPY go.mod go.sum ./
RUN go mod download
COPY . .
EXPOSE 8080
CMD ["air", "-c", ".air.toml"]
# Dockerfile.dev (Next.js)
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm install
COPY . .
EXPOSE 3000
CMD ["npm", "run", "dev"]
Команды Docker
# Сборка образа
docker build -t my-app .
# Запуск контейнера
docker run -p 8080:8080 my-app
# Запусь в фоне
docker run -d -p 8080:8080 --name my-app-container my-app
# Просмотр запущенных контейнеров
docker ps
# Просмотр логов
docker logs my-app-container
docker logs -f my-app-container # В реальном времени
# Вход в контейнер
docker exec -it my-app-container sh
# Остановка контейнера
docker stop my-app-container
# Удаление контейнера
docker rm my-app-container
# Удаление образа
docker rmi my-app
# Docker Compose
docker-compose up # Запуск всех сервисов
docker-compose up -d # Запуск в фоне
docker-compose down # Остановка и удаление
docker-compose down -v # С удалением volumes
docker-compose logs -f backend # Логи конкретного сервиса
docker-compose exec backend sh # Вход в контейнер
docker-compose build backend # Пересборка конкретного сервиса
# Для разработки
docker-compose -f docker-compose.dev.yml up
.dockerignore
# .dockerignore
node_modules
.next
.git
.gitignore
README.md
.env
.env.local
*.log
coverage
.nyc_output
.vscode
.idea
CI/CD с Docker
# .github/workflows/docker.yml
name: Build and Deploy
on:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push backend
uses: docker/build-push-action@v5
with:
context: ./backend
push: true
tags: myuser/myapp-backend:latest
- name: Build and push frontend
uses: docker/build-push-action@v5
with:
context: ./frontend
push: true
tags: myuser/myapp-frontend:latest
- name: Deploy to server
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USER }}
key: ${{ secrets.SSH_KEY }}
script: |
cd /opt/myapp
docker-compose pull
docker-compose up -d
Nginx конфигурация
# nginx.conf
upstream backend {
server backend:8080;
}
upstream frontend {
server frontend:3000;
}
server {
listen 80;
server_name example.com;
# Редирект на HTTPS
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl;
server_name example.com;
ssl_certificate /etc/nginx/ssl/cert.pem;
ssl_certificate_key /etc/nginx/ssl/key.pem;
# API запросы
location /api/ {
proxy_pass http://backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Все остальные запросы — фронтенд
location / {
proxy_pass http://frontend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
Вывод
Docker — важный инструмент для современной разработки. Основные сценарии использования:
- Локальная разработка — единообразие среды для всех разработчиков
- CI/CD — автоматическая сборка и деплой
- Микросервисы — изоляция компонентов приложения
- Тестирование — воспроизводимое окружение
Для Go и Next.js проектов Docker особенно полезен для контейнеризации бэкенда и баз данных. Фронтенд можно разрабатывать без Docker, но для production деплоя контейнеризация обеспечивает надёжность и воспроизводимость.
Вопрос 24. Работал ли с переменными окружения? Как они настраивались на проекте?
Таймкод: 00:56:04
Ответ собеседника: Правильный. Работал с переменными окружения. На работе были .env файлы для dev и prod сред. Dev-переменные работали только в dev-среде, prod-переменные — на production. Получали все переменные через process.env и название URL (например, API URL). Настраивал Docker-контейнеры для разных сред с указанием соответствующих переменных. Также упомянул, что в CI/CD на сервере можно настроить, на какой .env смотреть. На вопрос о том, работают ли .env файлы в обычном React (не Next.js), предположил, что нужно подключать отдельно.
Правильный ответ:
Ответ кандидата корректный. Дополню подробным объяснением работы с переменными окружения в разных контекстах.
Что такое переменные окружения
Переменные окружения (environment variables) — это пары ключ-значение, доступные приложению во время выполнения. Они позволяют настраивать поведение приложения без изменения кода.
Типы переменных в Next.js
# .env.local (локальная разработка, не коммитится)
# Серверные переменные (доступны только на сервере)
DATABASE_URL=postgresql://user:password@localhost:5432/mydb
JWT_SECRET=my-super-secret-key
API_SECRET_KEY=secret-api-key
# Клиентские переменные (доступны в браузере)
# Должны начинаться с NEXT_PUBLIC_
NEXT_PUBLIC_API_URL=http://localhost:8080
NEXT_PUBLIC_APP_NAME=MyApp
NEXT_PUBLIC_ANALYTICS_ID=UA-XXXXXXXX
Доступ к переменным
// Серверные переменные — доступны везде
const dbUrl = process.env.DATABASE_URL;
const jwtSecret = process.env.JWT_SECRET;
// Клиентские переменные — только с NEXT_PUBLIC_
const apiUrl = process.env.NEXT_PUBLIC_API_URL;
Структура .env файлов
# .env — базовые значения (коммитится)
NEXT_PUBLIC_APP_NAME=MyApp
# .env.local — локальные переменные (не коммитится)
DATABASE_URL=postgresql://localhost:5432/mydb
JWT_SECRET=local-secret
# .env.development — для development режима
NEXT_PUBLIC_API_URL=http://localhost:8080
# .env.production — для production режима
NEXT_PUBLIC_API_URL=https://api.example.com
# .env.test — для тестов
DATABASE_URL=postgresql://localhost:5432/testdb
Приоритет загрузки
.env.local > .env.{environment} > .env
# Для Next.js:
# next dev: .env.development.local > .env.development > .env.local > .env
# next build: .env.production.local > .env.production > .env.local > .env
# next test: .env.test.local > .env.test > .env.local > .env
Использование в Next.js
// app/api/users/route.ts
import { NextRequest, NextResponse } from 'next/server';
// Серверная переменная — безопасна
const DATABASE_URL = process.env.DATABASE_URL!;
const JWT_SECRET = process.env.JWT_SECRET!;
export async function GET(request: NextRequest) {
// Используем серверные переменные
const users = await db.query(DATABASE_URL, 'SELECT * FROM users');
return NextResponse.json(users);
}
// app/components/UserProfile.tsx
'use client';
// Клиентская переменная — должна начинаться с NEXT_PUBLIC_
const API_URL = process.env.NEXT_PUBLIC_API_URL!;
export function UserProfile() {
const fetchUser = async () => {
const response = await fetch(`${API_URL}/api/users`);
return response.json();
};
// ...
}
Валидация переменных окружения
// lib/env.ts
import { z } from 'zod';
// Схема для серверных переменных
const serverSchema = z.object({
DATABASE_URL: z.string().url(),
JWT_SECRET: z.string().min(32),
API_SECRET_KEY: z.string().min(16),
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
PORT: z.string().transform(Number).default('3000'),
});
// Схема для клиентских переменных
const clientSchema = z.object({
NEXT_PUBLIC_API_URL: z.string().url(),
NEXT_PUBLIC_APP_NAME: z.string().min(1),
NEXT_PUBLIC_ANALYTICS_ID: z.string().optional(),
});
// Валидация на сервере
export const serverEnv = serverSchema.parse({
DATABASE_URL: process.env.DATABASE_URL,
JWT_SECRET: process.env.JWT_SECRET,
API_SECRET_KEY: process.env.API_SECRET_KEY,
NODE_ENV: process.env.NODE_ENV,
PORT: process.env.PORT,
});
// Валидация на клиенте
export const clientEnv = clientSchema.parse({
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL,
NEXT_PUBLIC_APP_NAME: process.env.NEXT_PUBLIC_APP_NAME,
NEXT_PUBLIC_ANALYTICS_ID: process.env.NEXT_PUBLIC_ANALYTICS_ID,
});
Безопасная типизация
// types/env.ts
declare global {
namespace NodeJS {
interface ProcessEnv {
// Серверные
DATABASE_URL: string;
JWT_SECRET: string;
API_SECRET_KEY: string;
NODE_ENV: 'development' | 'production' | 'test';
PORT?: string;
// Клиентные
NEXT_PUBLIC_API_URL: string;
NEXT_PUBLIC_APP_NAME: string;
}
}
}
export {};
Обычный React (не Next.js) с Vite
# .env
VITE_API_URL=http://localhost:8080
VITE_APP_NAME=MyApp
// Доступ через import.meta.env
const apiUrl = import.meta.env.VITE_API_URL;
const appName = import.meta.env.VITE_APP_NAME;
// Типизация
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_URL: string;
readonly VITE_APP_NAME: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
Обычный React с Create React App
# .env
REACT_APP_API_URL=http://localhost:8080
REACT_APP_NAME=MyApp
// Доступ через process.env
const apiUrl = process.env.REACT_APP_API_URL;
const appName = process.env.REACT_APP_NAME;
Docker и переменные окружения
# docker-compose.yml
version: '3.9'
services:
backend:
build: ./backend
environment:
- DATABASE_URL=postgres://user:password@postgres:5432/dbname
- JWT_SECRET=${JWT_SECRET} # Из файла .env
- NODE_ENV=production
env_file:
- .env.production # Загрузить все переменные из файла
ports:
- "8080:8080"
# Dockerfile
FROM node:20-alpine
# Аргументы сборки (доступны только во время build)
ARG NEXT_PUBLIC_API_URL
ARG NEXT_PUBLIC_APP_NAME
# Преобразуем в переменные окружения
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
ENV NEXT_PUBLIC_APP_NAME=$NEXT_PUBLIC_APP_NAME
WORKDIR /app
COPY . .
RUN npm run build
CMD ["npm", "start"]
# Сборка с аргументами
docker build \
--build-arg NEXT_PUBLIC_API_URL=https://api.example.com \
--build-arg NEXT_PUBLIC_APP_NAME=MyApp \
-t my-app .
CI/CD и переменные окружения
# .github/workflows/deploy.yml
name: Deploy
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build
env:
NEXT_PUBLIC_API_URL: ${{ secrets.NEXT_PUBLIC_API_URL }}
NEXT_PUBLIC_APP_NAME: ${{ vars.NEXT_PUBLIC_APP_NAME }}
run: |
npm ci
npm run build
- name: Deploy
uses: some-deploy-action@v1
with:
api-url: ${{ secrets.API_URL }}
api-key: ${{ secrets.API_KEY }}
Secrets в GitHub
Settings → Secrets and variables → Actions
Secrets (зашифрованные):
- DATABASE_URL
- JWT_SECRET
- API_KEY
Variables (видимые):
- NEXT_PUBLIC_API_URL=https://api.example.com
- NEXT_PUBLIC_APP_NAME=MyApp
Go и переменные окружения
package main
import (
"fmt"
"os"
)
func main() {
// Получение переменной
dbURL := os.Getenv("DATABASE_URL")
if dbURL == "" {
panic("DATABASE_URL is required")
}
// Значение по умолчанию
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
fmt.Printf("Database: %s, Port: %s\n", dbURL, port)
}
// С использованием библиотеки caarlos0/env
package main
import (
"github.com/caarlos0/env/v10"
"log"
)
type Config struct {
DatabaseURL string `env:"DATABASE_URL,required"`
Port int `env:"PORT" envDefault:"8080"`
Environment string `env:"ENV" envDefault:"development"`
JWTSecret string `env:"JWT_SECRET,required"`
}
func main() {
cfg := Config{}
if err := env.Parse(&cfg); err != nil {
log.Fatalf("Failed to parse env: %v", err)
}
log.Printf("Config: %+v\n", cfg)
}
Безопасность: что нельзя делать
// ❌ НЕПРАВИЛЬНО: секрет без NEXT_PUBLIC_ на клиенте
// Это сработает на сервере, но не в браузере
const secret = process.env.JWT_SECRET; // undefined в браузере
// ❌ НЕПРАВИЛЬНО: секрет с NEXT_PUBLIC_
// Будет виден в исходном коде браузера!
NEXT_PUBLIC_JWT_SECRET=my-secret // НЕ ДЕЛАЙТЕ ТАК!
// ✅ ПРАВИЛЬНО: секреты только на сервере
// .env.local (серверная переменная)
JWT_SECRET=my-secret
// app/api/auth/route.ts (серверный код)
const secret = process.env.JWT_SECRET; // Работает!
.gitignore
# Всегда игнорируйте .env.local
.env.local
.env.*.local
# Можно коммитить примеры
!.env.example
# .env.example (шаблон для других разработчиков)
DATABASE_URL=
JWT_SECRET=
API_SECRET_KEY=
NEXT_PUBLIC_API_URL=http://localhost:8080
NEXT_PUBLIC_APP_NAME=MyApp
Вывод
Переменные окружения — стандартный способ конфигурации приложений:
- Next.js:
NEXT_PUBLIC_для клиентских, без префикса для серверных - Vite: префикс
VITE_, доступ черезimport.meta.env - CRA: префикс
REACT_APP_, доступ черезprocess.env - Docker:
environment,env_file,--build-arg - CI/CD: Secrets для секретов, Variables для публичных значений
Никогда не комитьте .env.local и не используйте NEXT_PUBLIC_ для секретных данных.
Вопрос 25. Опиши полный процесс загрузки страницы: от ввода URL в браузер до отображения страницы. Что возвращает сервер Next.js? Как браузер получает JavaScript и CSS файлы?
Таймкод: 00:58:21
Ответ собеседника: Неполный. Описал процесс: пользователь вводит URL, браузер отправляет запрос через DNS к серверу. Сервер (Node.js с Next.js) проверяет CORS, заголовки, авторизацию (токены). Если всё в порядке, ищет данные в базе и возвращает HTML-страницу. Далее React гидрирует компоненты. На вопрос, как браузер узнаёт, какие JS и CSS файлы загружать, ответил, что Next.js под капотом запрашивает эти файлы и указывает на них, а браузер их запрашивает. Собеседник уточнил, что все пути к JS, CSS и статике прописаны в HTML-тегах (script, link), которые генерирует Next.js. Кандидат согласился с этим.
Правильный ответ:
Кандидат описал общую картину, но не раскрыл детали. Приведу полное объяснение.
Полный процесс загрузки страницы
Этап 1: Ввод URL и DNS-резолвинг
Пользователь вводит: https://example.com/products/123
1. Браузер проверяет кэш DNS
- Есть ли IP-адрес для example.com в кэше браузера?
- Есть ли в кэше ОС?
- Есть ли в кэше роутера?
2. Если нет в кэше:
- Запрос к DNS-резолверу (обычно провайдер)
- Резолвер ищет корневые DNS-серверы
- Затем TLD-серверы (.com)
- Затем авторитативные серверы example.com
- Возвращает IP: 93.184.216.34
Этап 2: TCP-соединение и TLS-рукопожатие
1. TCP 3-way handshake:
Браузер → SYN → Сервер
Сервер → SYN-ACK → Браузер
Браузер → ACK → Сервер
2. TLS рукопожатие (для HTTPS):
Браузер → ClientHello → Сервер
Сервер → ServerHello + Сертификат → Браузер
Браузер проверяет сертификат
Обмен ключами шифрования
Соединение установлено
Этап 3: HTTP-запрос
GET /products/123 HTTP/2
Host: example.com
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64)...
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: ru-RU,ru;q=0.9,en-US;q=0.8
Accept-Encoding: gzip, deflate, br
Cookie: session=abc123; theme=dark
Connection: keep-alive
Этап 4: Обработка на сервере Next.js
┌─────────────────────────────────────────────────────────────┐
│ Next.js Server │
├─────────────────────────────────────────────────────────────┤
│ 1. Получаем запрос │
│ 2. Проверяем middleware (CORS, auth, redirects) │
│ 3. Определяем маршрут: /products/[id] │
│ 4. Выполняем Server Component (async) │
│ - Загружаем данные из БД/API │
│ - Рендерим React-компоненты в HTML │
│ 5. Генерируем полный HTML-документ │
│ - Встраиваем ссылки на JS/CSS в <head> │
│ - Встраиваем начальные данные (если нужно) │
│ - Добавляем мета-теги │
│ 6. Возвращаем HTML с заголовками │
└─────────────────────────────────────────────────────────────┘
Что возвращает сервер Next.js
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<!-- Мета-теги для SEO -->
<title>Продукт: iPhone 15 Pro</title>
<meta name="description" content="Купить iPhone 15 Pro по лучшей цене">
<meta property="og:title" content="iPhone 15 Pro">
<meta property="og:image" content="/images/iphone.jpg">
<!-- Preload критических ресурсов -->
<link rel="preload" href="/_next/static/media/font.woff2" as="font" type="font/woff2" crossorigin>
<!-- CSS стили -->
<link rel="stylesheet" href="/_next/static/css/app.css" />
<!-- Prefetch для навигации -->
<link rel="prefetch" href="/_next/static/chunks/products-page.js" as="script">
</head>
<body>
<!-- Отрендеренный HTML от сервера -->
<div id="__NEXT_DATA__" type="application/json">
{"props":{"pageProps":{"product":{"id":"123","name":"iPhone 15 Pro"}}},"page":"/products/[id]","query":{"id":"123"},"buildId":"abc123"}
</div>
<div id="__next">
<header>
<nav>
<a href="/">Главная</a>
<a href="/products">Продукты</a>
</nav>
</header>
<main>
<h1>iPhone 15 Pro</h1>
<div class="product-card">
<img src="/images/iphone.jpg" alt="iPhone 15 Pro">
<p class="price">999 €</p>
<button class="add-to-cart">В корзину</button>
</div>
</main>
<footer>
<p>© 2024 Example</p>
</footer>
</div>
<!-- JavaScript для гидратации -->
<script id="__NEXT_DATA__" type="application/json">
{
"props": {
"pageProps": {
"product": {
"id": "123",
"name": "iPhone 15 Pro",
"price": 999
}
}
},
"page": "/products/[id]",
"query": {"id": "123"},
"buildId": "abc123"
}
</script>
<!-- Чанки JavaScript -->
<script src="/_next/static/chunks/webpack.js" async></script>
<script src="/_next/static/chunks/main.js" async></script>
<script src="/_next/static/chunks/app.js" async></script>
<script src="/_next/static/chunks/products/[id].js" async></script>
</body>
</html>
Этап 5: Парсинг HTML браузером
Браузер получает HTML и начинает парсинг:
1. Парсинг HTML → построение DOM-дерева
<html>
├── <head>
│ ├── <meta>
│ ├── <link rel="stylesheet"> → Запрос CSS
│ └── <title>
└── <body>
├── <div id="__next">
│ ├── <header>
│ ├── <main>
│ └── <footer>
└── <script> → Запрос и выполнение JS
2. Встречает <link rel="stylesheet"> → загружает CSS
3. Встречает <script> → загружает и выполняет JavaScript
Этап 6: Загрузка CSS
<!-- Next.js автоматически добавляет ссылки на CSS -->
<link rel="stylesheet" href="/_next/static/css/abc123/app.css" />
<!-- Браузер загружает CSS и строит CSSOM -->
CSSOM (CSS Object Model):
├── body { margin: 0; font-family: ... }
├── header { background: #fff; }
├── .product-card { display: flex; }
└── .price { font-size: 24px; color: green; }
Этап 7: Загрузка JavaScript
<!-- Next.js добавляет скрипты с async для параллельной загрузки -->
<script src="/_next/static/chunks/webpack.js" async></script>
<script src="/_next/static/chunks/main.js" async></script>
<script src="/_next/static/chunks/pages/_app.js" async></script>
<script src="/_next/static/chunks/pages/products/[id].js" async></script>
Порядок загрузки JavaScript:
1. webpack.js — инициализация Webpack runtime
2. main.js — основной код Next.js
3. _app.js — компонент App (обёртка)
4. products/[id].js — код конкретной страницы
Этап 8: Гидратация (Hydration)
┌─────────────────────────────────────────────────────────────┐
│ Гидратация React │
├─────────────────────────────────────────────────────────────┤
│ 1. React загружается и инициализируется │
│ 2. Читает __NEXT_DATA__ с начальными данными │
│ 3. Сравнивает Virtual DOM с существующим HTML (DOM) │
│ 4. Если совпадает — привязывает обработчики событий │
│ 5. Страница становится интерактивной │
└─────────────────────────────────────────────────────────────┘
// Упрощённая схема гидратации
import { hydrateRoot } from 'react-dom/client';
import App from '../app/page';
// Next.js автоматически вызывает:
const root = hydrateRoot(
document.getElementById('__next'),
<App {...initialProps} />
);
Этап 9: Отображение страницы
Timeline отрисовки:
┌─────────────────────────────────────────────────────────────┐
│ 0ms │ Получен HTML, начинается парсинг │
│ 50ms │ DOM построен, начинается загрузка CSS │
│ 100ms │ CSSOM построен, первый рендер (FCP) │
│ 150ms │ Начинается загрузка JavaScript │
│ 300ms │ JavaScript загружен, начинается гидратация │
│ 400ms │ Гидратация завершена, страница интерактивна (TTI) │
│ 500ms │ Все ресурсы загружены (LCP) │
└─────────────────────────────────────────────────────────────┘
Визуализация процесса
Браузер DNS Сервер Next.js
│ │ │
│──── Запрос IP ──────────────►│ │
│◄─── IP: 93.184.216.34 ──────│ │
│ │ │
│──── TCP handshake ───────────────────────────────►│
│◄─── TCP установлен ──────────────────────────────│
│ │ │
│──── TLS handshake ───────────────────────────────►│
│◄─── TLS установлен ──────────────────────────────│
│ │ │
│──── GET /products/123 ───────────────────────────►│
│ │ │──┐
│ │ │ │ Обработка
│ │ │ │ запроса
│ │ │◄─┘
│◄─── HTML + заголовки ────────────────────────────│
│ │ │
│──┐ │ │
│ │ Парсинг HTML │ │
│◄─┘ │ │
│ │ │
│──── GET /style.css ──────────────────────────────►│
│◄─── CSS файл ────────────────────────────────────│
│ │ │
│──── GET /main.js ────────────────────────────────►│
│◄─── JavaScript ──────────────────────────────────│
│ │ │
│──┐ │ │
│ │ Гидратация React │ │
│◄─┘ │ │
│ │ │
│ ✅ Страница интерактивна │ │
HTTP-заголовки ответа Next.js
HTTP/2 200 OK
content-type: text/html; charset=utf-8
content-encoding: gzip
cache-control: private, no-cache, no-store, max-age=0, must-revalidate
x-powered-by: Next.js
etag: "abc123"
vary: Accept-Encoding
set-cookie: session=xyz789; Path=/; HttpOnly; Secure; SameSite=Strict
x-dns-prefetch-control: on
strict-transport-security: max-age=31536000; includeSubDomains
x-content-type-options: nosniff
x-frame-options: SAMEORIGIN
content-length: 15234
Code Splitting в Next.js
// Next.js автоматически разбивает код на чанки:
// Для страницы /products/[id]:
// - main.js (общий код Next.js)
// - webpack.js (runtime)
// - _app.js (компонент App)
// - products/[id].js (код конкретной страницы)
// - components/ProductCard.js (используемый компонент)
// При навигации на /about:
// - Загружается только about.js
// - main.js и webpack.js уже в кэше
Prefetching в Next.js
// Next.js автоматически prefetch-ит страницы при появлении ссылки в viewport
<Link href="/products/456" prefetch>
Следующий продукт
</Link>
// В HTML добавляется:
// <link rel="prefetch" href="/_next/data/abc123/products/456.json">
Вывод
Полный процесс загрузки:
- DNS-резолвинг — получение IP-адреса
- TCP/TLS — установка защищённого соединения
- HTTP-запрос — отправка запроса с заголовками
- Серверная обработка — Next.js рендерит HTML на сервере
- HTML-ответ — сервер возвращает полный HTML с ссылками на JS/CSS
- Парсинг HTML — браузер строит DOM
- Загрузка CSS — браузер загружает и применяет стили (FCP)
- Загрузка JS — браузер загружает JavaScript
- Гидратация — React привязывает обработчики к существующему HTML
- Интерактивность — страница полностью функциональна (TTI)
Next.js оптимизирует этот процесс через SSR, code splitting, prefetching и автоматическую оптимизацию ресурсов.
