Собеседование FRONTEND разработчика на стажировку. Вопросы + live conding
Сегодня мы разберем собеседование на позицию фулстек-разработчика с акцентом на фронтенд-знания. Кандидат продемонстрировал глубокое понимание React, TypeScript и архитектурных подходов, успешно справившись с вопросами о мемоизации, состоянии и серверных компонентах. Однако алгоритмическая задача вызвала трудности, что подчеркивает разрыв между практическими навыками и теоретической подготовкой в области структур данных.
Вопрос 1. Расскажи о своём опыте разработки и проектах, которые ты делал.
Таймкод: 00:02:15
Ответ собеседника: Правильный. Разработкой занимается около пяти лет, но серьёзно — с третьего курса. Коммерческого опыта нет. Реально увлёкся 7-8 месяцев назад. Реализовал полную ротацию JWT-токенов через Redis с использованием OAuth и OpenID Connect на NestJS с микросервисной архитектурой. Также разрабатывал киноплатформу на микросервисах с использованием Drizzle ORM вместо Prisma. Использовал ConfigModule с Redis для хранения конфигураций и Dependency Injection для их внедрения. Создал кастомный фильтр исключений для корректной обработки ошибок на уровне микросервисов.
Правильный ответ:
Ответ собеседника в целом структурирован и демонстрирует практический опыт, хотя и без коммерческого контекста. Для позиции Go-разработчика стоит обратить внимание на несколько аспектов, которые можно было бы усилить или переформулировать с привязкой к Go-экосистеме.
Ротация JWT-токенов через Redis
Это важный и нетривиальный паттерн безопасности. Полная ротация токенов подразумевает не просто выпуск access/refresh пар, но и детектирование повторного использования refresh-токена (reuse detection), что является ключевым механизмом для предотвращения кражи токенов.
В контексте Go-разработки аналогичная реализация выглядела бы так:
package token
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"time"
"github.com/redis/go-redis/v9"
)
type RotationService struct {
redis *redis.Client
accessTTL time.Duration
refreshTTL time.Duration
}
type TokenPair struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
}
func (s *RotationService) Rotate(ctx context.Context, oldRefreshToken string) (*TokenPair, error) {
// Проверяем, не был ли токен уже использован (reuse detection)
exists, err := s.redis.Exists(ctx, "refresh:"+oldRefreshToken).Result()
if err != nil {
return nil, fmt.Errorf("redis check failed: %w", err)
}
if exists == 0 {
// Токен уже использован или истёк — возможная кража
// Инвалидируем всю семейство токенов для данного пользователя
userID, _ := s.redis.Get(ctx, "refresh_owner:"+oldRefreshToken).Result()
if userID != "" {
s.redis.Del(ctx, "token_family:"+userID)
}
return nil, fmt.Errorf("refresh token reuse detected — possible theft")
}
// Удаляем старый refresh-токен (single use)
s.redis.Del(ctx, "refresh:"+oldRefreshToken)
// Генерируем новую пару
newPair := &TokenPair{
AccessToken: generateSecureToken(32),
RefreshToken: generateSecureToken(64),
}
// Сохраняем новый refresh-токен с TTL
pipe := s.redis.Pipeline()
pipe.Set(ctx, "refresh:"+newPair.RefreshToken, "valid", s.refreshTTL)
pipe.Set(ctx, "refresh_owner:"+newPair.RefreshToken, extractUserID(oldRefreshToken), s.refreshTTL)
_, err = pipe.Exec(ctx)
if err != nil {
return nil, fmt.Errorf("failed to store new tokens: %w", err)
}
return newPair, nil
}
func generateSecureToken(length int) string {
bytes := make([]byte, length)
rand.Read(bytes)
return hex.EncodeToString(bytes)
}
Ключевые моменты реализации:
- Reuse detection — если refresh-токен использован повторно, это сигнализирует о компрометации, и вся цепочка токенов пользователя инвалидируется.
- Single use refresh tokens — каждый refresh-токен валиден только для однократного использования.
- Token family — все токены, выданные в рамках одной сессии, связаны в цепочку, что позволяет обнаружить параллельное использование.
Микросервисная архитектура и обработка ошибок
Создание кастомного фильтра исключений — правильный подход. В Go аналогом является middleware для централизованной обработки ошибок:
package middleware
import (
"encoding/json"
"log"
"net/http"
"time"
)
type ErrorResponse struct {
Status int `json:"status"`
Error string `json:"error"`
Message string `json:"message"`
Timestamp time.Time `json:"timestamp"`
TraceID string `json:"trace_id,omitempty"`
}
type AppError struct {
StatusCode int
Message string
Internal error
}
func (e *AppError) Error() string {
return e.Message
}
func ErrorHandler(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
traceID := r.Context().Value("trace_id").(string)
log.Printf("[PANIC] trace_id=%s error=%v", traceID, rec)
respondWithError(w, http.StatusInternalServerError, "Internal Server Error", traceID)
}
}()
next.ServeHTTP(w, r)
})
}
func respondWithError(w http.ResponseWriter, status int, message string, traceID string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(ErrorResponse{
Status: status,
Error: http.StatusText(status),
Message: message,
Timestamp: time.Now().UTC(),
TraceID: traceID,
})
}
Хранение конфигураций в Redis
Использование внешнего хранилища конфигураций — это паттерн, хорошо известный в распределённых системах. В Go-экосистеме для этого часто применяют etcd, Consul или Redis с механизмом pub/sub для уведомления сервисов об изменениях конфигурации в реальном времени.
Рекомендации для развития в Go
Для перехода на Go с Node.js/NestJS стоит обратить внимание на:
- Встроенную конкурентность Go (goroutines, channels) как основной инструмент для параллельной обработки запросов в микросервисах.
- Стандартную библиотеку
net/httpи фреймворки вроде chi, fiber или echo для построения HTTP-сервисов. - Паттерны graceful shutdown, health checks и circuit breaker, которые критически важны для микросервисной архитектуры.
- Использование
context.Contextдля управления временем жизни запросов, отмены операций и проброса метаданных (trace_id, span_id) между сервисами.
Вопрос 2. Как устроена микросервисная архитектура в твоём проекте и как сервисы общаются между собой?
Таймкод: 00:06:48
Ответ собеседника: Правильный. Реализовал отдельный config-сервис с ConfigModule. Порты и настройки хранились в Redis (порт 6379) — признал, что это плохая практика, но это был эксперимент. Через ConfigService получал опции для каждого микросервиса и внедрял их через Dependency Injection. Все микросервисы общались через общий инстанс Redis. Также создал фильтр исключений для микросервисов, чтобы ошибки были видны на клиенте, а не возвращались как непрозрачные ответы.
Правильный ответ:
Ответ демонстрирует понимание базовых принципов, но содержит архитектурные решения, которые действительно являются антипаттернами в продакшене. Разберём подробно.
Хранение конфигураций в Redis — почему это плохая практика
Использование Redis для хранения портов и настроек сервисов — это антипаттерн по нескольким причинам:
- Redis — это in-memory хранилище, и при его падении все сервисы теряют конфигурацию.
- Нет версионирования конфигураций — невозможно откатиться к предыдущему состоянию.
- Нет валидации схемы конфигурации при записи.
- Смешение ответственностей: Redis для кэширования и сессий, а не для конфигурации.
Правильные подходы для управления конфигурацией в микросервисах:
- Consul / etcd / ZooKeeper — распределённые хранилища ключ-значение с механизмами watch, обеспечивающие согласованность и высокую доступность.
- HashiCorp Vault — для секретов и чувствительных данных с ротацией и аудитом.
- Kubernetes ConfigMaps и Secrets — если инфраструктура работает в K8s.
- Spring Cloud Config / Consul Config — в Java-экосистеме.
- Файлы окружения + переменные среды — самый простой и надёжный подход, рекомендованный манифестом 12-factor app.
В Go-экосистеме типичный подход с Consul:
package config
import (
"context"
"fmt"
"log"
"time"
"github.com/hashicorp/consul/api"
)
type ConsulConfig struct {
client *api.Client
prefix string
}
func NewConsulConfig(address, prefix string) (*ConsulConfig, error) {
cfg := api.DefaultConfig()
cfg.Address = address
client, err := api.NewClient(cfg)
if err != nil {
return nil, fmt.Errorf("failed to create consul client: %w", err)
}
return &ConsulConfig{
client: client,
prefix: prefix,
}, nil
}
func (c *ConsulConfig) GetString(ctx context.Context, key string) (string, error) {
pair, _, err := c.client.KV().Get(c.prefix+"/"+key, nil)
if err != nil {
return "", fmt.Errorf("failed to get config key %s: %w", key, err)
}
if pair == nil {
return "", fmt.Errorf("config key %s not found", key)
}
return string(pair.Value), nil
}
func (c *ConsulConfig) Watch(ctx context.Context, key string, onChange func(string)) {
var lastIndex uint64
for {
select {
case <-ctx.Done():
return
default:
}
pair, meta, err := c.client.KV().Get(c.prefix+"/"+key, &api.QueryOptions{
WaitIndex: lastIndex,
WaitTime: 30 * time.Second,
})
if err != nil {
log.Printf("consul watch error: %v", err)
time.Sleep(5 * time.Second)
continue
}
if meta.LastIndex != lastIndex && pair != nil {
lastIndex = meta.LastIndex
onChange(string(pair.Value))
}
}
}
Общение сервисов через общий Redis — проблемы
Использование общего Redis для межсервисного взаимодействия — это антипаттерн, который создаёт:
- Точку отказа (SPOF) — при падении Redis все сервисы теряют связность.
- Отсутствие контрактов — нет строгого API между сервисами, формат сообщений не стандартизирован.
- Проблемы с трассировкой — невозможно отследить цепочку вызовов через общий кэш.
- Смешение паттернов — Redis используется одновременно как кэш, брокер сообщений и хранилище конфигураций.
Правильные паттерны межсервисного взаимодействия:
Синхронное взаимодействие:
package client
import (
"context"
"encoding/json"
"fmt"
"net/http"
"time"
"github.com/sony/gobreaker"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
)
type ServiceClient struct {
baseURL string
httpClient *http.Client
breaker *gobreaker.CircuitBreaker
}
func NewServiceClient(baseURL string) *ServiceClient {
settings := gobreaker.Settings{
Name: "user-service",
MaxRequests: 3,
Interval: 10 * time.Second,
Timeout: 30 * time.Second,
ReadyToTrip: func(counts gobreaker.Counts) bool {
failureRatio := float64(counts.TotalFailures) / float64(counts.Requests)
return counts.Requests >= 3 && failureRatio >= 0.6
},
}
return &ServiceClient{
baseURL: baseURL,
httpClient: &http.Client{
Timeout: 5 * time.Second,
Transport: otelhttp.NewTransport(http.DefaultTransport),
},
breaker: gobreaker.NewCircuitBreaker(settings),
}
}
func (c *ServiceClient) GetUser(ctx context.Context, userID string) (*User, error) {
result, err := c.breaker.Execute(func() (interface{}, error) {
url := fmt.Sprintf("%s/api/users/%s", c.baseURL, userID)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("unexpected status: %d", resp.StatusCode)
}
var user User
if err := json.NewDecoder(resp.Body).Decode(&user); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return &user, nil
})
if err != nil {
return nil, err
}
return result.(*User), nil
}
Асинхронное взаимодействие через брокер сообщений:
package messaging
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/nats-io/nats.go"
)
type EventBus struct {
conn *nats.Conn
js nats.JetStreamContext
subjectPrefix string
}
type Event struct {
Type string `json:"type"`
Payload json.RawMessage `json:"payload"`
Timestamp time.Time `json:"timestamp"`
TraceID string `json:"trace_id"`
}
func NewEventBus(url, prefix string) (*EventBus, error) {
nc, err := nats.Connect(url,
nats.RetryOnFailedConnect(true),
nats.MaxReconnects(10),
nats.ReconnectWait(time.Second),
)
if err != nil {
return nil, fmt.Errorf("failed to connect to NATS: %w", err)
}
js, err := nc.JetStream()
if err != nil {
return nil, fmt.Errorf("failed to create JetStream context: %w", err)
}
// Создаём поток событий
_, err = js.AddStream(&nats.StreamConfig{
Name: "EVENTS",
Subjects: []string{prefix + ".>"},
Retention: nats.WorkQueuePolicy,
MaxMsgs: 100000,
})
if err != nil {
return nil, fmt.Errorf("failed to create stream: %w", err)
}
return &EventBus{
conn: nc,
js: js,
subjectPrefix: prefix,
}, nil
}
func (eb *EventBus) Publish(ctx context.Context, eventType string, payload interface{}) error {
data, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("failed to marshal payload: %w", err)
}
event := Event{
Type: eventType,
Payload: data,
Timestamp: time.Now().UTC(),
TraceID: getTraceID(ctx),
}
eventData, err := json.Marshal(event)
if err != nil {
return fmt.Errorf("failed to marshal event: %w", err)
}
subject := fmt.Sprintf("%s.%s", eb.subjectPrefix, eventType)
_, err = eb.js.Publish(subject, eventData, nats.Context(ctx))
if err != nil {
return fmt.Errorf("failed to publish event: %w", err)
}
return nil
}
func (eb *EventBus) Subscribe(eventType string, handler func(Event) error) error {
subject := fmt.Sprintf("%s.%s", eb.subjectPrefix, eventType)
consumerName := fmt.Sprintf("consumer-%s", eventType)
_, err := eb.js.AddConsumer("EVENTS", &nats.ConsumerConfig{
Durable: consumerName,
AckPolicy: nats.AckExplicitPolicy,
MaxDeliver: 3,
})
if err != nil {
return fmt.Errorf("failed to create consumer: %w", err)
}
_, err = eb.js.PullSubscribe(subject, consumerName, nats.Bind("EVENTS", consumerName))
if err != nil {
return fmt.Errorf("failed to subscribe: %w", err)
}
go func() {
for {
msgs, _ := eb.js.PullSubscribe(subject, consumerName).Fetch(10)
for _, msg := range msgs {
var event Event
if err := json.Unmarshal(msg.Data, &event); err != nil {
msg.Nak()
continue
}
if err := handler(event); err != nil {
msg.Nak()
continue
}
msg.Ack()
}
}
}()
return nil
}
Централизованная обработка ошибок в микросервисах
Создание единого формата ошибок — это правильный подход. В Go это реализуется через интерфейс error и стандартизированные HTTP-ответы:
package errors
import (
"encoding/json"
"fmt"
"net/http"
)
type ErrorCode string
const (
ErrCodeValidation ErrorCode = "VALIDATION_ERROR"
ErrCodeNotFound ErrorCode = "NOT_FOUND"
ErrCodeUnauthorized ErrorCode = "UNAUTHORIZED"
ErrCodeInternal ErrorCode = "INTERNAL_ERROR"
ErrCodeUnavailable ErrorCode = "SERVICE_UNAVAILABLE"
)
type AppError struct {
Code ErrorCode `json:"code"`
Message string `json:"message"`
Details string `json:"details,omitempty"`
StatusCode int `json:"-"`
Internal error `json:"-"`
}
func (e *AppError) Error() string {
if e.Internal != nil {
return fmt.Sprintf("[%s] %s: %v", e.Code, e.Message, e.Internal)
}
return fmt.Sprintf("[%s] %s", e.Code, e.Message)
}
func (e *AppError) Unwrap() error {
return e.Internal
}
func NewValidationError(details string) *AppError {
return &AppError{
Code: ErrCodeValidation,
Message: "Validation failed",
Details: details,
StatusCode: http.StatusBadRequest,
}
}
func NewNotFoundError(resource string) *AppError {
return &AppError{
Code: ErrCodeNotFound,
Message: fmt.Sprintf("%s not found", resource),
StatusCode: http.StatusNotFound,
}
}
func NewInternalError(err error) *AppError {
return &AppError{
Code: ErrCodeInternal,
Message: "Internal server error",
StatusCode: http.StatusInternalServerError,
Internal: err,
}
}
type ErrorResponse struct {
Error struct {
Code string `json:"code"`
Message string `json:"message"`
Details string `json:"details,omitempty"`
} `json:"error"`
}
func WriteError(w http.ResponseWriter, err error) {
var appErr *AppError
if errors.As(err, &appErr) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(appErr.StatusCode)
response := ErrorResponse{}
response.Error.Code = string(appErr.Code)
response.Error.Message = appErr.Message
response.Error.Details = appErr.Details
json.NewEncoder(w).Encode(response)
return
}
// Неизвестная ошибка — не раскрываем детали клиенту
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
response := ErrorResponse{}
response.Error.Code = string(ErrCodeInternal)
response.Error.Message = "Internal server error"
json.NewEncoder(w).Encode(response)
}
Рекомендуемая архитектура взаимодействия
Для микросервисной архитектуры рекомендуется следующий подход:
- Синхронное взаимодействие — gRPC для внутренней коммуникации (высокая производительность, строгие контракты через Protocol Buffers), REST для внешних API.
- Асинхронное взаимодействие — NATS JetStream или Apache Kafka для event-driven архитектуры.
- Service mesh — Istio или Linkerd для управления трафиком, mTLS, трассировки без изменения кода сервисов.
- Конфигурация — Consul/etcd для динамической конфигурации, Vault для секретов.
- Observability — OpenTelemetry для трассировки, Prometheus для метрик, Grafana для визуализации, ELK/Loki для логов.
Вопрос 3. Какая самая сложная задача, с которой ты сталкивался?
Таймкод: 00:08:48
Ответ собеседника: Правильный. Самой сложной оказалась задача с ротацией JWT-токенов — при обновлении страницы на фронтенде не обновлялся истёкший токен. Проблема заключалась в том, что интерцепторы обрабатывали 401-ответ и кастомные ошибки от JWT на неправильном уровне. Также отметил, что работа с OAuth была достаточно сложной.
Правильный ответ:
Проблема с ротацией JWT-токенов при обновлении страницы — это классическая и действительно нетривиальная задача. Разберём её глубже и покажем, как она решается на уровне архитектуры.
Суть проблемы
При обновлении страницы (F5 / Ctrl+R) фронтер теряет состояние в памяти, включая текущий access-токен. Если access-токен истёк, а refresh-токен ещё валиден, нужно выполнить тихий рефреш до того, как приложение начнёт рендериться. Проблема усугубляется, когда несколько параллельных запросов одновременно получают 401 — все они пытаются обновить токен, что приводит к race condition.
Архитектура решения на фронтенде
Правильный подход — разделение ответственностей между слоями:
- HTTP-клиент — перехватывает 401, ставит запрос в очередь, инициирует рефреш.
- Auth-сервис — управляет состоянием токенов, выполняет ротацию.
- Хранилище — персистентное хранение refresh-токена (httpOnly cookie или secure storage).
Реализация на Go (backend-часть с полной ротацией)
package auth
import (
"context"
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"strings"
"sync"
"time"
"github.com/redis/go-redis/v9"
"golang.org/x/crypto/argon2"
)
// TokenManager управляет жизненным циклом JWT-токенов
type TokenManager struct {
redis *redis.Client
accessTTL time.Duration
refreshTTL time.Duration
issuer string
mu sync.Mutex
}
type TokenFamily struct {
UserID string `json:"user_id"`
FamilyID string `json:"family_id"`
CreatedAt time.Time `json:"created_at"`
Revoked bool `json:"revoked"`
}
type TokenPair struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresIn int `json:"expires_in"`
TokenType string `json:"token_type"`
}
// GenerateTokenPair создаёт новую пару токенов для пользователя
func (tm *TokenManager) GenerateTokenPair(ctx context.Context, userID string) (*TokenPair, error) {
familyID := generateSecureID(16)
refreshToken := generateSecureID(64)
family := TokenFamily{
UserID: userID,
FamilyID: familyID,
CreatedAt: time.Now().UTC(),
Revoked: false,
}
familyData, err := json.Marshal(family)
if err != nil {
return nil, fmt.Errorf("failed to marshal token family: %w", err)
}
pipe := tm.redis.Pipeline()
// Сохраняем семейство токенов
pipe.Set(ctx,
fmt.Sprintf("token_family:%s", familyID),
familyData,
tm.refreshTTL,
)
// Связываем refresh-токен с семейством
pipe.Set(ctx,
fmt.Sprintf("refresh:%s", refreshToken),
familyID,
tm.refreshTTL,
)
// Связываем пользователя с активным семейством
pipe.Set(ctx,
fmt.Sprintf("user_family:%s", userID),
familyID,
tm.refreshTTL,
)
_, err = pipe.Exec(ctx)
if err != nil {
return nil, fmt.Errorf("failed to store tokens: %w", err)
}
// Генерируем access-токен (в реальности — подпись JWT)
accessToken := generateSecureID(32)
// Сохраняем access-токен с коротким TTL
pipe2 := tm.redis.Pipeline()
pipe2.Set(ctx,
fmt.Sprintf("access:%s", accessToken),
userID,
tm.accessTTL,
)
pipe2.Set(ctx,
fmt.Sprintf("access_family:%s", accessToken),
familyID,
tm.accessTTL,
)
_, err = pipe2.Exec(ctx)
if err != nil {
return nil, fmt.Errorf("failed to store access token: %w", err)
}
return &TokenPair{
AccessToken: accessToken,
RefreshToken: refreshToken,
ExpiresIn: int(tm.accessTTL.Seconds()),
TokenType: "Bearer",
}, nil
}
// RotateTokens выполняет ротацию токенов с обнаружением повторного использования
func (tm *TokenManager) RotateTokens(ctx context.Context, oldRefreshToken string) (*TokenPair, error) {
tm.mu.Lock()
defer tm.mu.Unlock()
// Получаем семейство, связанное с refresh-токеном
familyID, err := tm.redis.Get(ctx, fmt.Sprintf("refresh:%s", oldRefreshToken)).Result()
if err == redis.Nil {
// Токен не найден — возможно, уже использован или истёк
return nil, &AuthError{
Code: "TOKEN_REUSE_DETECTED",
Message: "Refresh token has been used or expired. Possible token theft.",
Status: http.StatusUnauthorized,
}
}
if err != nil {
return nil, fmt.Errorf("redis error: %w", err)
}
// Проверяем статус семейства
familyData, err := tm.redis.Get(ctx, fmt.Sprintf("token_family:%s", familyID)).Result()
if err != nil {
return nil, fmt.Errorf("failed to get token family: %w", err)
}
var family TokenFamily
if err := json.Unmarshal([]byte(familyData), &family); err != nil {
return nil, fmt.Errorf("failed to unmarshal token family: %w", err)
}
if family.Revoked {
return nil, &AuthError{
Code: "FAMILY_REVOKED",
Message: "Token family has been revoked due to suspected compromise.",
Status: http.StatusUnauthorized,
}
}
// Удаляем старый refresh-токен (single use)
tm.redis.Del(ctx, fmt.Sprintf("refresh:%s", oldRefreshToken))
// Генерируем новую пару в том же семействе
newRefreshToken := generateSecureID(64)
pipe := tm.redis.Pipeline()
// Связываем новый refresh-токен с тем же семейством
pipe.Set(ctx,
fmt.Sprintf("refresh:%s", newRefreshToken),
familyID,
tm.refreshTTL,
)
// Обновляем время последней активности семейства
family.CreatedAt = time.Now().UTC()
updatedFamilyData, _ := json.Marshal(family)
pipe.Set(ctx,
fmt.Sprintf("token_family:%s", familyID),
updatedFamilyData,
tm.refreshTTL,
)
_, err = pipe.Exec(ctx)
if err != nil {
return nil, fmt.Errorf("failed to store new tokens: %w", err)
}
// Генерируем новый access-токен
newAccessToken := generateSecureID(32)
pipe2 := tm.redis.Pipeline()
pipe2.Set(ctx,
fmt.Sprintf("access:%s", newAccessToken),
family.UserID,
tm.accessTTL,
)
pipe2.Set(ctx,
fmt.Sprintf("access_family:%s", newAccessToken),
familyID,
tm.accessTTL,
)
_, err = pipe2.Exec(ctx)
if err != nil {
return nil, fmt.Errorf("failed to store new access token: %w", err)
}
return &TokenPair{
AccessToken: newAccessToken,
RefreshToken: newRefreshToken,
ExpiresIn: int(tm.accessTTL.Seconds()),
TokenType: "Bearer",
}, nil
}
// RevokeFamily отзывает всё семейство токенов (при обнаружении компрометации)
func (tm *TokenManager) RevokeFamily(ctx context.Context, familyID string) error {
familyData, err := tm.redis.Get(ctx, fmt.Sprintf("token_family:%s", familyID)).Result()
if err != nil {
return fmt.Errorf("failed to get token family: %w", err)
}
var family TokenFamily
if err := json.Unmarshal([]byte(familyData), &family); err != nil {
return fmt.Errorf("failed to unmarshal token family: %w", err)
}
family.Revoked = true
updatedData, _ := json.Marshal(family)
// Помечаем семейство как отозванное
tm.redis.Set(ctx,
fmt.Sprintf("token_family:%s", familyID),
updatedData,
tm.refreshTTL,
)
// Удаляем связь пользователя с семейством
tm.redis.Del(ctx, fmt.Sprintf("user_family:%s", family.UserID))
return nil
}
// ValidateAccessToken проверяет валидность access-токена
func (tm *TokenManager) ValidateAccessToken(ctx context.Context, accessToken string) (string, error) {
userID, err := tm.redis.Get(ctx, fmt.Sprintf("access:%s", accessToken)).Result()
if err == redis.Nil {
return "", &AuthError{
Code: "TOKEN_EXPIRED",
Message: "Access token has expired",
Status: http.StatusUnauthorized,
}
}
if err != nil {
return "", fmt.Errorf("redis error: %w", err)
}
// Проверяем, не отозвано ли семейство
familyID, err := tm.redis.Get(ctx, fmt.Sprintf("access_family:%s", accessToken)).Result()
if err == nil {
familyData, err := tm.redis.Get(ctx, fmt.Sprintf("token_family:%s", familyID)).Result()
if err == nil {
var family TokenFamily
if json.Unmarshal([]byte(familyData), &family) == nil && family.Revoked {
return "", &AuthError{
Code: "TOKEN_REVOKED",
Message: "Token has been revoked",
Status: http.StatusUnauthorized,
}
}
}
}
return userID, nil
}
// AuthError — типизированная ошибка аутентификации
type AuthError struct {
Code string
Message string
Status int
}
func (e *AuthError) Error() string {
return e.Message
}
func generateSecureID(length int) string {
bytes := make([]byte, length)
rand.Read(bytes)
return base64.RawURLEncoding.EncodeToString(bytes)
}
Middleware для проверки токенов в Go
package middleware
import (
"context"
"net/http"
"strings"
"yourapp/auth"
)
type contextKey string
const UserIDKey contextKey = "user_id"
func AuthMiddleware(tm *auth.TokenManager) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
http.Error(w, `{"error":"missing authorization header"}`, http.StatusUnauthorized)
return
}
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || parts[0] != "Bearer" {
http.Error(w, `{"error":"invalid authorization format"}`, http.StatusUnauthorized)
return
}
accessToken := parts[1]
userID, err := tm.ValidateAccessToken(r.Context(), accessToken)
if err != nil {
if authErr, ok := err.(*auth.AuthError); ok {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(authErr.Status)
w.Write([]byte(fmt.Sprintf(`{"error":"%s","code":"%s"}`, authErr.Message, authErr.Code)))
return
}
http.Error(w, `{"error":"internal server error"}`, http.StatusInternalServerError)
return
}
ctx := context.WithValue(r.Context(), UserIDKey, userID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
Проблема с интерцепторами и её решение
Проблема, описанная собеседником, типична: когда интерцепторы обрабатывают 401 на неправильном уровне, возникает бесконечный цикл или потеря контекста ошибки. Правильная архитектура обработки:
- Уровень HTTP-клиента — перехват 401, постановка запросов в очередь, вызов рефреша.
- Уровень Auth-сервиса — управление состоянием, ротация, обработка ошибок.
- Уровень UI — редирект на логин при фатальных ошибках.
Ключевые принципы:
- Очередь запросов — при получении 401 все последующие запросы ставятся в очередь до завершения рефреша.
- Единый рефреш — только один запрос на обновление токенов в любой момент времени.
- Обработка конкурентных 401 — если несколько запросов получили 401 одновременно, все кроме первого ждут результата.
- Graceful degradation — при невозможности рефреша — редирект на страницу логина с сохранением исходного URL.
OAuth 2.0 и OpenID Connect — почему это сложно
Сложность OAuth/OIDC заключается в:
- Множестве grant types — authorization code, implicit, client credentials, device code, PKCE — каждый со своими нюансами безопасности.
- State management — корректная обработка state и nonce параметров для предотвращения CSRF и replay-атак.
- Token introspection — проверка токенов у провайдера, кэширование результатов.
- JWKS (JSON Web Key Set) — ротация ключей подписи, кэширование, обработка kid.
- Session management — корректный logout, back-channel logout, front-channel logout.
Вопрос 4. Как ты работаешь с ошибками в коде, какие инструменты отладки используешь?
Таймкод: 00:10:02
Ответ собеседника: Неполный. На бэкенде использует try-catch и конфигурации, но признал, что это долго. Для более быстрой отладки использует тесты. При этом отметил, что является новичком в написании тестов и имеет небольшой опыт в этой области.
Правильный ответ:
Ответ собеседника поверхностный и не раскрывает системный подход к отладке. Для Go-разработчика важно знать полный набор инструментов и практик. Разберём подробно.
Философия обработки ошибок в Go
В Go ошибки — это значения, а не исключения. Это принципиально отличается от try-catch в других языках. Ключевые принципы:
- Явная проверка ошибок на каждом вызове, который может вернуть ошибку.
- Оборачивание ошибок с контекстом через
fmt.Errorfс глаголом%w. - Использование
errors.Isиerrors.Asдля проверки типов ошибок. - Паника только для неисправимых ошибок (programmer errors), а не для ожидаемых сбоев.
Системный подход к обработке ошибок
package errors
import (
"errors"
"fmt"
)
// Определяем типизированные ошибки для разных слоёв приложения
// Domain errors
var (
ErrNotFound = errors.New("resource not found")
ErrUnauthorized = errors.New("unauthorized")
ErrValidation = errors.New("validation failed")
ErrConflict = errors.New("resource conflict")
)
// AppError — структурированная ошибка приложения
type AppError struct {
Code string `json:"code"`
Message string `json:"message"`
Op string `json:"op,omitempty"` // операция, в которой произошла ошибка
Err error `json:"-"` // внутренняя ошибка
StatusCode int `json:"-"`
}
func (e *AppError) Error() string {
if e.Err != nil {
return fmt.Sprintf("%s: %s: %v", e.Op, e.Code, e.Err)
}
return fmt.Sprintf("%s: %s", e.Op, e.Code)
}
func (e *AppError) Unwrap() error {
return e.Err
}
// Конструкторы ошибок с контекстом
func E(op string, code string, err error, statusCode int) *AppError {
return &AppError{
Op: op,
Code: code,
Err: err,
StatusCode: statusCode,
}
}
// Проверка типа ошибки
func IsNotFound(err error) bool {
var appErr *AppError
if errors.As(err, &appErr) {
return errors.Is(appErr.Err, ErrNotFound)
}
return errors.Is(err, ErrNotFound)
}
func IsValidation(err error) bool {
var appErr *AppError
if errors.As(err, &appErr) {
return errors.Is(appErr.Err, ErrValidation)
}
return errors.Is(err, ErrValidation)
}
Использование в слоях приложения
package service
import (
"context"
"database/sql"
"fmt"
"yourapp/errors"
"yourapp/models"
"yourapp/repository"
)
type UserService struct {
repo repository.UserRepository
}
func NewUserService(repo repository.UserRepository) *UserService {
return &UserService{repo: repo}
}
func (s *UserService) GetUser(ctx context.Context, id string) (*models.User, error) {
user, err := s.repo.FindByID(ctx, id)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
// Оборачиваем ошибку с контекстом операции
return nil, errors.E(
"UserService.GetUser",
"USER_NOT_FOUND",
fmt.Errorf("user with id %s not found", id),
404,
)
}
// Оборачиваем неизвестную ошибку
return nil, errors.E(
"UserService.GetUser",
"INTERNAL_ERROR",
fmt.Errorf("failed to fetch user: %w", err),
500,
)
}
return user, nil
}
func (s *UserService) CreateUser(ctx context.Context, input CreateUserInput) (*models.User, error) {
// Валидация входных данных
if err := input.Validate(); err != nil {
return nil, errors.E(
"UserService.CreateUser",
"VALIDATION_ERROR",
err,
400,
)
}
// Проверка уникальности
existing, err := s.repo.FindByEmail(ctx, input.Email)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return nil, errors.E(
"UserService.CreateUser",
"INTERNAL_ERROR",
fmt.Errorf("failed to check email uniqueness: %w", err),
500,
)
}
if existing != nil {
return nil, errors.E(
"UserService.CreateUser",
"DUPLICATE_EMAIL",
fmt.Errorf("email %s already registered", input.Email),
409,
)
}
user := &models.User{
Email: input.Email,
Name: input.Name,
}
if err := s.repo.Create(ctx, user); err != nil {
return nil, errors.E(
"UserService.CreateUser",
"INTERNAL_ERROR",
fmt.Errorf("failed to create user: %w", err),
500,
)
}
return user, nil
}
Инструменты отладки в Go
1. Delve — основной отладчик Go
# Установка
go install github.com/go-delve/delve/cmd/dlv@latest
# Отладка программы
dlv debug ./cmd/server
# Отладка тестов
dlv test ./internal/service
# Присоединение к запущенному процессу
dlv attach <pid>
# Отладка с бинарника (для продакшен-подобной среды)
dlv exec ./bin/server
Основные команды Delve:
# Установка брейкпоинтов
(dlv) break main.go:42
(dlv) break UserService.GetUser
(dlv) break repository.FindByID if userID == "123"
# Навигация
(dlv) continue # продолжить выполнение
(dlv) next # следующая строка (step over)
(dlv) step # шаг внутрь функции (step in)
(dlv) stepout # выйти из текущей функции
(dlv) restart # перезапустить программу
# Инспекция
(dlv) print userID # значение переменной
(dlv) locals # все локальные переменные
(dlv) args # аргументы функции
(dlv) goroutines # список горутин
(dlv) stack # стек вызовов
(dlv) vars # глобальные переменные
# Условные брейкпоинты
(dlv) condition 1 err != nil
2. pprof — профилирование
package main
import (
"net/http"
_ "net/http/pprof"
"runtime"
)
func main() {
// Настройка лимитов для профилирования
runtime.SetBlockProfileRate(1)
runtime.SetMutexProfileFraction(1)
// pprof доступен на /debug/pprof/
go func() {
http.ListenAndServe("localhost:6060", nil)
}()
// ... основной код приложения
}
Использование pprof:
# Профиль CPU (30 секунд)
go tool pprof http://localhost:6060/debug/pprof/profile?seconds=30
# Профиль кучи
go tool pprof http://localhost:6060/debug/pprof/heap
# Профиль горутин
go tool pprof http://localhost:6060/debug/pprof/goroutine
# Профиль блокировок
go tool pprof http://localhost:6060/debug/pprof/block
# Интерактивный режим pprof
(pprof) top # топ функций по потреблению
(pprof) list UserService.GetUser # исходный код функции
(pprof) web # граф вызовов в браузере
(pprof) png # экспорт в PNG
3. trace — трассировка выполнения
package main
import (
"os"
"runtime/trace"
)
func main() {
f, err := os.Create("trace.out")
if err != nil {
panic(err)
}
defer f.Close()
if err := trace.Start(f); err != nil {
panic(err)
}
defer trace.Stop()
// ... основной код
}
# Анализ трассировки
go tool trace trace.out
4. Structured logging с zap
package logger
import (
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
)
var log *zap.Logger
func Init(env string) error {
var config zap.Config
if env == "production" {
config = zap.NewProductionConfig()
config.EncoderConfig.TimeKey = "timestamp"
config.EncoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder
} else {
config = zap.NewDevelopmentConfig()
config.EncoderConfig.EncodeLevel = zapcore.CapitalColorLevelEncoder
}
var err error
log, err = config.Build(
zap.AddCaller(),
zap.AddStacktrace(zapcore.ErrorLevel),
)
return err
}
func Get() *zap.Logger {
return log
}
// Использование в сервисах
func (s *UserService) GetUser(ctx context.Context, id string) (*models.User, error) {
logger := Get().With(
zap.String("operation", "GetUser"),
zap.String("user_id", id),
zap.String("trace_id", getTraceID(ctx)),
)
logger.Info("fetching user")
user, err := s.repo.FindByID(ctx, id)
if err != nil {
logger.Error("failed to fetch user",
zap.Error(err),
zap.String("error_type", fmt.Sprintf("%T", err)),
)
return nil, err
}
logger.Info("user fetched successfully",
zap.String("user_email", user.Email),
)
return user, nil
}
5. Тестирование как инструмент отладки
package service
import (
"context"
"database/sql"
"errors"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)
// Мок репозитория
type MockUserRepository struct {
mock.Mock
}
func (m *MockUserRepository) FindByID(ctx context.Context, id string) (*models.User, error) {
args := m.Called(ctx, id)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*models.User), args.Error(1)
}
func (m *MockUserRepository) FindByEmail(ctx context.Context, email string) (*models.User, error) {
args := m.Called(ctx, email)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*models.User), args.Error(1)
}
func (m *MockUserRepository) Create(ctx context.Context, user *models.User) error {
args := m.Called(ctx, user)
return args.Error(0)
}
func TestUserService_GetUser_Success(t *testing.T) {
mockRepo := new(MockUserRepository)
svc := NewUserService(mockRepo)
expectedUser := &models.User{
ID: "user-123",
Email: "test@example.com",
Name: "Test User",
}
mockRepo.On("FindByID", mock.Anything, "user-123").Return(expectedUser, nil)
user, err := svc.GetUser(context.Background(), "user-123")
require.NoError(t, err)
assert.Equal(t, expectedUser.ID, user.ID)
assert.Equal(t, expectedUser.Email, user.Email)
mockRepo.AssertExpectations(t)
}
func TestUserService_GetUser_NotFound(t *testing.T) {
mockRepo := new(MockUserRepository)
svc := NewUserService(mockRepo)
mockRepo.On("FindByID", mock.Anything, "nonexistent").Return(nil, sql.ErrNoRows)
user, err := svc.GetUser(context.Background(), "nonexistent")
require.Error(t, err)
assert.Nil(t, user)
assert.True(t, errors.Is(err, sql.ErrNoRows))
mockRepo.AssertExpectations(t)
}
func TestUserService_CreateUser_DuplicateEmail(t *testing.T) {
mockRepo := new(MockUserRepository)
svc := NewUserService(mockRepo)
existingUser := &models.User{
ID: "existing-123",
Email: "test@example.com",
}
mockRepo.On("FindByEmail", mock.Anything, "test@example.com").Return(existingUser, nil)
input := CreateUserInput{
Email: "test@example.com",
Name: "Test User",
}
user, err := svc.CreateUser(context.Background(), input)
require.Error(t, err)
assert.Nil(t, user)
assert.Contains(t, err.Error(), "DUPLICATE_EMAIL")
mockRepo.AssertExpectations(t)
}
// Бенчмарки для выявления проблем производительности
func BenchmarkUserService_GetUser(b *testing.B) {
mockRepo := new(MockUserRepository)
svc := NewUserService(mockRepo)
mockRepo.On("FindByID", mock.Anything, "user-123").Return(&models.User{
ID: "user-123",
Email: "test@example.com",
}, nil)
ctx := context.Background()
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_, _ = svc.GetUser(ctx, "user-123")
}
}
6. Табличные тесты для исчерпывающей проверки
func TestUserService_CreateUser(t *testing.T) {
tests := []struct {
name string
input CreateUserInput
setupMock func(*MockUserRepository)
wantErr bool
errContains string
wantUser bool
}{
{
name: "success",
input: CreateUserInput{
Email: "new@example.com",
Name: "New User",
},
setupMock: func(m *MockUserRepository) {
m.On("FindByEmail", mock.Anything, "new@example.com").Return(nil, sql.ErrNoRows)
m.On("Create", mock.Anything, mock.AnythingOfType("*models.User")).Return(nil)
},
wantErr: false,
wantUser: true,
},
{
name: "duplicate email",
input: CreateUserInput{
Email: "existing@example.com",
Name: "Test User",
},
setupMock: func(m *MockUserRepository) {
m.On("FindByEmail", mock.Anything, "existing@example.com").Return(&models.User{ID: "123"}, nil)
},
wantErr: true,
errContains: "DUPLICATE_EMAIL",
},
{
name: "empty email",
input: CreateUserInput{
Email: "",
Name: "Test User",
},
setupMock: func(m *MockUserRepository) {},
wantErr: true,
errContains: "VALIDATION_ERROR",
},
{
name: "invalid email format",
input: CreateUserInput{
Email: "not-an-email",
Name: "Test User",
},
setupMock: func(m *MockUserRepository) {},
wantErr: true,
errContains: "VALIDATION_ERROR",
},
{
name: "database error on create",
input: CreateUserInput{
Email: "new@example.com",
Name: "New User",
},
setupMock: func(m *MockUserRepository) {
m.On("FindByEmail", mock.Anything, "new@example.com").Return(nil, sql.ErrNoRows)
m.On("Create", mock.Anything, mock.AnythingOfType("*models.User")).Return(errors.New("connection refused"))
},
wantErr: true,
errContains: "INTERNAL_ERROR",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockRepo := new(MockUserRepository)
tt.setupMock(mockRepo)
svc := NewUserService(mockRepo)
user, err := svc.CreateUser(context.Background(), tt.input)
if tt.wantErr {
require.Error(t, err)
assert.Contains(t, err.Error(), tt.errContains)
} else {
require.NoError(t, err)
}
if tt.wantUser {
assert.NotNil(t, user)
assert.Equal(t, tt.input.Email, user.Email)
} else {
assert.Nil(t, user)
}
mockRepo.AssertExpectations(t)
})
}
}
7. Интеграционные тесты с реальной БД
package integration
import (
"context"
"database/sql"
"testing"
_ "github.com/lib/pq"
"github.com/stretchr/testify/require"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/modules/postgres"
)
func TestUserRepository_Integration(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test")
}
ctx := context.Background()
// Запуск PostgreSQL в контейнере
pgContainer, err := postgres.RunContainer(ctx,
postgres.WithDatabase("testdb"),
postgres.WithUsername("testuser"),
postgres.WithPassword("testpass"),
postgres.WithInitScripts("testdata/init.sql"),
postgres.BasicWaitStrategies(),
)
require.NoError(t, err)
defer pgContainer.Terminate(ctx)
connStr, err := pgContainer.ConnectionString(ctx, "sslmode=disable")
require.NoError(t, err)
db, err := sql.Open("postgres", connStr)
require.NoError(t, err)
defer db.Close()
repo := repository.NewUserRepository(db)
t.Run("create and find user", func(t *testing.T) {
user := &models.User{
Email: "integration@test.com",
Name: "Integration Test",
}
err := repo.Create(ctx, user)
require.NoError(t, err)
require.NotEmpty(t, user.ID)
found, err := repo.FindByID(ctx, user.ID)
require.NoError(t, err)
require.Equal(t, user.Email, found.Email)
require.Equal(t, user.Name, found.Name)
})
}
Рекомендуемый набор инструментов для отладки
- Delve — интерактивная отладка, брейкпоинты, инспекция состояния.
- pprof — профилирование CPU, памяти, горутин, блокировок.
- trace — визуализация выполнения, обнаружение contention.
- zap/zerolog — структурированное логирование с контекстом.
- OpenTelemetry — распределённая трассировка между сервисами.
- testify + testcontainers — модульные и интеграционные тесты.
- golangci-lint — статический анализ, обнаружение потенциальных багов.
- go vet и go race — встроенные инструменты для поиска гонок данных.
Вопрос 5. Какие инструменты отладки используешь на фронтенде и бэкенде?
Таймкод: 00:10:39
Ответ собеседника: Правильный. На бэкенде использует try-catch и конфигурации, но признал, что это долго. Для более быстрой отладки применяет тесты, однако является новичком в написании тестов. На фронтенде использует дебаггер, DevTools для state management, React DevTools — отметил, что на фронтенде помощников значительно больше.
Правильный ответ:
Ответ собеседника слишком общий и не демонстрирует глубокого знания инструментов. Для Go-разработчика на позиции уровня middle+ и выше ожидается уверенное владение полным набором инструментов. Разберём подробно.
Инструменты отладки бэкенда на Go
1. Delve — основной отладчик
Delve — это полноценный отладчик Go, аналог GDB, но с пониманием специфики языка (горутины, каналы, интерфейсы).
# Установка
go install github.com/go-delve/delve/cmd/dlv@latest
# Отладка с текущей директории
dlv debug
# Отладка конкретного пакета с аргументами
dlv debug ./cmd/server -- --config=config.yaml
# Отладка тестов
dlv test ./internal/service/...
# Присоединение к запущенному процессу
dlv attach $(pgrep -f "myapp")
# Удалённая отладка (для Docker/Kubernetes)
dlv debug --headless --listen=:2345 --api-version=2 --accept-multiclient
Интерактивные команды Delve:
# Брейкпоинты
(dlv) break main.go:42 # по строке
(dlv) break handler.GetUser # по имени функции
(dlv) break repository.FindByID if id == "123" # условный
(dlv) cond 1 ctx.Value("trace_id") != "" # условие на существующий брейкпоинт
(dlv) clear 1 # удалить брейкпоинт
(dlv) breakpoints # список всех брейкпоинтов
# Навигация по коду
(dlv) continue # продолжить до следующего брейкпоинта
(dlv) next # следующая строка (step over — не заходить в функции)
(dlv) step # шаг внутрь функции (step in)
(dlv) stepout # выйти из текущей функции
(dlv) restart # перезапустить с начала
# Инспекция переменных
(dlv) print userID
(dlv) print req.Header
(dlv) print *user # разыменование указателя
(dlv) whatis err # тип переменной
(dlv) locals # все локальные переменные
(dlv) args # аргументы текущей функции
# Горутины и конкурентность
(dlv) goroutines # список всех горутин
(dlv) goroutine 15 # переключиться на горутину 15
(dlv) goroutine 15 stack # стек конкретной горутины
(dlv) stack # стек текущей горутины
(dlv) stack 20 # стек глубиной 20 фреймов
# Каналы
(dlv) print ch
(dlv) print len(ch)
(dlv) print cap(ch)
# Goroutine-specific брейкпоинты (для отладки гонок)
(dlv) break -g 15 handler.ProcessRequest
2. Логирование с контекстом через zap
package logger
import (
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
)
var globalLogger *zap.SugaredLogger
func Init(env string) error {
var cfg zap.Config
if env == "production" {
cfg = zap.NewProductionConfig()
cfg.EncoderConfig.TimeKey = "ts"
cfg.EncoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder
cfg.OutputPaths = []string{"stdout"}
cfg.ErrorOutputPaths = []string{"stderr"}
} else {
cfg = zap.NewDevelopmentConfig()
cfg.EncoderConfig.EncodeLevel = zapcore.CapitalColorLevelEncoder
}
baseLogger, err := cfg.Build(
zap.AddCallerSkip(1),
zap.AddStacktrace(zapcore.ErrorLevel),
)
if err != nil {
return err
}
globalLogger = baseLogger.Sugar()
return nil
}
func WithFields(fields ...zap.Field) *zap.SugaredLogger {
return globalLogger.Desugar().With(fields...).Sugar()
}
func WithContext(ctx context.Context) *zap.SugaredLogger {
traceID, _ := ctx.Value("trace_id").(spanID, _ := ctx.Value("span_id").(string)
return globalLogger.With(
zap.String("trace_id", traceID),
zap.String("span_id", spanID),
)
}
// Использование
func (s *UserService) GetUser(ctx context.Context, id string) (*models.User, error) {
log := logger.WithContext(ctx).With(zap.String("user_id", id))
log.Info("fetching user")
user, err := s.repo.FindByID(ctx, id)
if err != nil {
log.Error("failed to fetch user",
zap.Error(err),
zap.Duration("elapsed", time.Since(start)),
)
return nil, err
}
log.Info("user fetched", zap.String("email", user.Email))
return user, nil
}
3. pprof — профилирование в продакшене
package main
import (
"net/http"
_ "net/http/pprof"
"runtime"
"runtime/pprof"
"time"
)
func setupProfiling() {
// Включаем профилирование блокировок и мьютексов
runtime.SetBlockProfileRate(1) // профилировать все блокировки
runtime.SetMutexProfileFraction(10) // профилировать 10% contention-ов
// pprof endpoints доступны на /debug/pprof/
}
// Программное управление профилированием (для продакшена)
func startCPUProfile(duration time.Duration, outputPath string) error {
f, err := os.Create(outputPath)
if err != nil {
return err
}
if err := pprof.StartCPUProfile(f); err != nil {
f.Close()
return err
}
time.AfterFunc(duration, func() {
pprof.StopCPUProfile()
f.Close()
})
return nil
}
Анализ профилей:
# Захват профиля CPU на 30 секунд
curl -o cpu.prof http://localhost:6060/debug/pprof/profile?seconds=30
# Захват профиля кучи
curl -o heap.prof http://localhost:6060/debug/pprof/heap
# Захват профиля горутин
curl -o goroutines.prof http://localhost:6060/debug/pprof/goroutine
# Интерактивный анализ
go tool pprof -http=:8080 cpu.prof
# Команды в интерактивном режиме
(pprof) top20 # топ-20 функций по CPU
(pprof) top -cum # топ по кумулятивному времени
(pprof) list UserService.GetUser # исходный код с метриками
(pprof) web # SVG-граф в браузере
(pprof) traces # трейсы
(pprof) peek UserService # входящие/исходящие вызовы
# Сравнение двух профилей (до и после оптимизации)
go tool pprof -base before.prof after.prof
4. Трассировка выполнения
package main
import (
"context"
"fmt"
"runtime/trace"
)
func ProcessOrder(ctx context.Context, orderID string) error {
// Создаём трейс для конкретной операции
ctx, task := trace.NewTask(ctx, "ProcessOrder")
defer task.End()
// Логическая область внутри трейса
{
s := trace.StartSpan(ctx, "validate_order")
if err := validateOrder(orderID); err != nil {
trace.Logf(ctx, "error", "validation failed: %v", err)
s.End()
return err
}
s.End()
}
{
s := trace.StartSpan(ctx, "process_payment")
if err := processPayment(ctx, orderID); err != nil {
s.End()
return fmt.Errorf("payment failed: %w", err)
}
s.End()
}
return nil
}
# Анализ трейса
go tool trace trace.out
# В веб-интерфейсе trace:
# - View trace: временная шкала горутин, GC, системные вызовы
# - Goroutine analysis: распределение горутин по состоянияm
# - Network/Syscall blocking: блокировки на I/O
# - Scheduler latency: задержки планировщика
5. Обнаружение гонок данных
# Компиляция с детектором гонок
go run -race ./cmd/server
go test -race ./internal/...
go build -race -o myapp ./cmd/server
# Для интеграционных тестов
go test -race -count=100 ./integration/...
Пример гонки и её исправления:
// ПРОБЛЕМА: гонка данных
type Counter struct {
value int
}
func (c *Counter) Inc() {
c.value++ // DATA RACE — одновременная запись из нескольких горутин
}
func (c *Counter) Value() int {
return c.value // DATA RACE — одновременное чтение и запись
}
// РЕШЕНИЕ 1: sync.Mutex
type SafeCounter struct {
mu sync.Mutex
value int
}
func (c *SafeCounter) Inc() {
c.mu.Lock()
defer c.mu.Unlock()
c.value++
}
func (c *SafeCounter) Value() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.value
}
// РЕШЕНИЕ 2: sync/atomic (для простых типов)
type AtomicCounter struct {
value int64
}
func (c *AtomicCounter) Inc() {
atomic.AddInt64(&c.value, 1)
}
func (c *AtomicCounter) Value() int64 {
return atomic.LoadInt64(&c.value)
}
// РЕШЕНИЕ 3: sync.Map (для кэшей)
type Cache struct {
data sync.Map
}
func (c *Cache) Get(key string) (interface{}, bool) {
return c.data.Load(key)
}
func (c *Cache) Set(key string, value interface{}) {
c.data.Store(key, value)
}
6. Статический анализ
# golangci-lint — комплексный линтер
golangci-lint run ./...
golangci-lint run --enable-all --disable=lll ./...
# go vet — встроенный анализатор
go vet ./...
go vet -shadow ./... # обнаружение затенённых переменных
# Проверка на необработанные ошибки
go vet -unreachable ./...
# staticcheck — расширенный статический анализ
staticcheck ./...
Конфигурация .golangci.yml:
run:
timeout: 5m
go: "1.22"
linters:
enable:
- errcheck # непроверенные ошибки
- gosimple # упрощение кода
- govet # встроенный vet
- ineffassign # неиспользуемые присваивания
- staticcheck # расширенный анализ
- unused # неиспользуемый код
- gocyclo # цикломатическая сложность
- gosec # проблемы безопасности
- errorlint # правильное оборачивание ошибок
- gofmt # форматирование
- misspell # опечатки
- bodyclose # незакрытые body HTTP-ответов
- noctx # HTTP-запросы без context
- sqlclosecheck # незакрытые SQL-ресурсы
linters-settings:
gocyclo:
min-complexity: 15
gosec:
includes:
- G401 # слабая криптография
- G402 # TLS InsecureSkipVerify
- G404 # math/rand вместо crypto/rand
7. Тестовое покрытие
# Покрытие для всех пакетов
go test -cover ./...
# Детальный отчёт по покрытию
go test -coverprofile=coverage.out ./...
go tool cover -html=coverage.out -o coverage.html
# Покрытие с учётом интеграционных тестов
go test -cover -tags=integration -coverprofile=coverage.out ./...
# Проверка покрытия конкретных функций
go tool cover -func=coverage.out | grep "GetUser"
8. OpenTelemetry — распределённая трассировка
package telemetry
import (
"context"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/exporters/jaeger"
"go.opentelemetry.io/otel/sdk/resource"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
semconv "go.opentelemetry.io/otel/semconv/v1.21.0"
)
func InitTracer(serviceName, jaegerEndpoint string) (*sdktrace.TracerProvider, error) {
exp, err := jaeger.New(jaeger.WithCollectorEndpoint(
jaeger.WithEndpoint(jaegerEndpoint),
))
if err != nil {
return nil, err
}
tp := sdktrace.NewTracerProvider(
sdktrace.WithBatcher(exp),
sdktrace.WithResource(resource.NewWithAttributes(
semconv.SchemaURL,
semconv.ServiceNameKey.String(serviceName),
attribute.String("environment", "production"),
)),
sdktrace.WithSampler(sdktrace.TraceIDRatioBased(0.1)), // 10% сэмплирование
)
otel.SetTracerProvider(tp)
return tp, nil
}
// Использование в сервисах
func (s *UserService) GetUser(ctx context.Context, id string) (*models.User, error) {
tracer := otel.Tracer("user-service")
ctx, span := tracer.Start(ctx, "GetUser",
attribute.String("user.id", id),
)
defer span.End()
user, err := s.repo.FindByID(ctx, id)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return nil, err
}
span.SetAttributes(attribute.String("user.email", user.Email))
return user, nil
}
Сводная таблица инструментов
- Delve — интерактивная отладка, брейкпоинты, инспекция горутин и каналов.
- pprof — профилирование CPU, памяти, горутин, блокировок.
- trace — визуализация выполнения, обнаружение contention и проблем планировщика.
- go race — детектор гонок данных на этапе компиляции.
- zap/zerolog — структурированное логирование с контекстом запроса.
- golangci-lint — комплексный статический анализ кода.
- OpenTelemetry — распределённая трассировка между микросервисами.
- testify + testcontainers — модульные и интеграционные тесты с реальными зависимостями.
Вопрос 6. Как оцениваешь свой уровень владения React по 10-балльной шкале и что не хватает для более высокого уровня?
Таймкод: 00:12:53
Ответ собеседника: Правильный. Оценивает свой уровень на 5-6 из 10. Основная причина — нехватка практики из-за больших перерывов в разработке (иногда по полгода) из-за колледжа и жизненных обствятельств, несмотря на общий опыт около пяти лет.
Правильный ответ:
Ответ собеседника честный, но для позиции Go-разработчика вопрос о React вторичен. Тем не менее, самооценка 5-6 при пяти годах опыта с перерывами указывает на типичную проблему: знание базы без глубокого понимания продвинутых концепций. Разберём, что отличает разные уровни.
Шкала уровней владения React
Уровень 1-3: Junior
- Понимание JSX, компонентов, props, state.
- Использование useState, useEffect.
- Простые формы и обработка событий.
- Базовый роутинг с React Router.
- Не понимает, когда и почему происходит ре-рендер.
Уровень 4-5: Junior+/Middle-
- Кастомные хуки, контекст.
- Базовая оптимизация (React.memo, useMemo, useCallback).
- Работа с API через fetch/axios.
- Простые анимации.
- Не всегда понимает зависимости useEffect, допускает лишние ре-рендеры.
Уровень 6-7: Middle
- Глубокое понимание рендер-цикла и механизма reconciliation.
- Продвинутая оптимизация: профилирование с React DevTools Profiler.
- Состояние: когда useState, когда useReducer, когда внешний стор (Zustand, Jotai, Redux Toolkit).
- Error boundaries, Suspense, lazy loading.
- Паттерны: compound components, render props, HOC (и когда они не нужны).
- Тестирование: React Testing Library, Jest, моки, снапшоты.
Уровень 8-9: Middle+/Senior
- Архитектурные решения: разделение на слои, feature-sliced design.
- Server Components (Next.js App Router), streaming SSR.
- Понимание fiber architecture, priority scheduling.
- Написание собственных хуков с правильной мемоизацией.
- Оптимизация бандла: code splitting, tree shaking, dynamic imports.
- Accessibility (a11y), семантический HTML, ARIA.
- Производительность: virtualization (react-window), debounce/throttle, web workers.
Уровень 10: Expert
- Вклад в экосистему React (библиотеки, инструменты).
- Понимание исходного кода React.
- Архитектурные паттерны для крупных проектов (монорепозитории, микрофронтенды).
- Продвинутая работа с конкурентными фичами (useTransition, useDeferredValue, SuspenseList).
Что не хватает для перехода на уровень 7-8
1. Глубокое понимание рендер-механики
// Понимание того, когда происходит ре-рендер
// и как его предотвратить
// ❌ Плохо: создаётся новый объект при каждом рендере
<ChildComponent config={{ theme: "dark", locale: "en" }} />
// ✅ Хорошо: мемоизированный объект
const config = useMemo(() => ({ theme: "dark", locale: "en" }), []);
<ChildComponent config={config} />
// ❌ Плохо: новая функция при каждом рендере
<ChildComponent onClick={() => handleClick(id)} />
// ✅ Хорошо: мемоизированная функция
const handleClickMemo = useCallback(() => handleClick(id), [id]);
<ChildComponent onClick={handleClickMemo} />
// ✅ Ещё лучше: передача данных и коллбэка отдельно
// чтобы избежать ре-рендера при изменении только коллбэка
2. Правильное управление эффектами
// ❌ Плохо: бесконечный цикл из-за неправильных зависимостей
useEffect(() => {
fetchUser(userId).then(setUser);
}, [user]); // user изменяется → эффект снова запускается
// ✅ Хорошо: зависимость от внешнего параметра
useEffect(() => {
fetchUser(userId).then(setUser);
}, [userId]);
// ❌ Плохо: отсутствие cleanup
useEffect(() => {
const interval = setInterval(pollData, 5000);
}, []);
// ✅ Хорошо: очистка при размонтировании
useEffect(() => {
const interval = setInterval(pollData, 5000);
return () => clearInterval(interval);
}, []);
// ❌ Плохо: игнорирование ESLint правила exhaustive-deps
useEffect(() => {
doSomething(a, b);
}, [a]); // b не в зависимостях — потенциальный баг
// ✅ Хорошо: все зависимости указаны
useEffect(() => {
doSomething(a, b);
}, [a, b]);
3. Продвинутая оптимизация
// React.memo с кастомным сравнем
const UserCard = React.memo(({ user, onSelect }: UserCardProps) => {
return (
<div onClick={() => onSelect(user.id)}>
<h3>{user.name}</h3>
<p>{user.email}</p>
</div>
);
}, (prevProps, nextProps) => {
// Кастомное сравнение: ре-рендер только если изменились данные пользователя
return prevProps.user.id === nextProps.user.id &&
prevProps.user.name === nextProps.user.name;
});
// Виртуализация длинных списков
import { FixedSizeList as List } from 'react-window';
const VirtualizedList = ({ items }: { items: Item[] }) => {
const Row = ({ index, style }: { index: number; style: React.CSSProperties }) => (
<div style={style}>
<UserCard user={items[index]} onSelect={handleSelect} />
</div>
);
return (
<List
height={600}
itemCount={items.length}
itemSize={80}
width="100%"
>
{Row}
</List>
);
};
// useDeferredValue для отложенного обновления при вводе
const SearchResults = ({ query }: { query: string }) => {
const deferredQuery = useDeferredValue(query);
// Пока deferredQuery не обновился, показываем старые результаты
// с индикатором загрузки
const isStale = query !== deferredQuery;
return (
<div style={{ opacity: isStale ? 0.5 : 1 }}>
<Results query={deferredQuery} />
</div>
);
};
4. Продвинутое управление состоянием
// Zustand — лёгкая альтернатива Redux
import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';
interface AuthState {
user: User | null;
token: string | null;
isLoading: boolean;
error: string | null;
login: (email: string, password: string) => Promise<void>;
logout: () => void;
refreshToken: () => Promise<void>;
}
const useAuthStore = create<AuthState>()(
devtools(
persist(
immer((set, get) => ({
user: null,
token: null,
isLoading: false,
error: null,
login: async (email, password) => {
set((state) => { state.isLoading = true; state.error = null; });
try {
const { user, token } = await authApi.login(email, password);
set((state) => {
state.user = user;
state.token = token;
state.isLoading = false;
});
} catch (error) {
set((state) => {
state.error = error.message;
state.isLoading = false;
});
}
},
logout: () => {
set((state) => {
state.user = null;
state.token = null;
});
},
refreshToken: async () => {
const currentToken = get().token;
if (!currentToken) return;
try {
const { token } = await authApi.refresh(currentToken);
set((state) => { state.token = token; });
} catch {
get().logout();
}
},
})),
{ name: 'auth-storage', partialize: (state) => ({ token: state.token }) }
),
{ name: 'AuthStore' }
)
);
// Использование с селекторами для предотвращения лишних ре-рендеров
const UserName = () => {
// Только user.name — ре-рендер только при изменении имени
const userName = useAuthStore((state) => state.user?.name);
return <span>{userName}</span>;
};
5. Тестирование
// React Testing Library — правильный подход
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
const server = setupServer(
rest.get('/api/users/:id', (req, res, ctx) => {
return res(ctx.json({ id: req.params.id, name: 'Test User' }));
})
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
// Тест компонента с асинхронной загрузкой
test('loads and displays user', async () => {
render(<UserProfile userId="123" />);
// Состояние загрузки
expect(screen.getByText(/loading/i)).toBeInTheDocument();
// Ожидание появления данных
await waitFor(() => {
expect(screen.getByText('Test User')).toBeInTheDocument();
});
expect(screen.queryByText(/loading/i)).not.toBeInTheDocument();
});
// Тест интерактивности
test('handles user interaction', async () => {
const onSelect = jest.fn();
render(<UserList users={mockUsers} onSelect={onSelect} />);
await userEvent.click(screen.getByText('Select User 1'));
expect(onSelect).toHaveBeenCalledWith('user-1');
});
// Тест ошибок
test('displays error state', async () => {
server.use(
rest.get('/api/users/:id', (req, res, ctx) => {
return res(ctx.status(500), ctx.json({ error: 'Server error' }));
})
);
render(<UserProfile userId="123" />);
await waitFor(() => {
expect(screen.getByText(/error/i)).toBeInTheDocument();
});
});
Что стоит изучить для роста
- React 18+ конкурентные фичи — useTransition, useDeferredValue, Suspense, useId.
- Server Components — понимание разницы между серверными и клиентскими компонентами.
- Архитектурные паттерны — feature-sliced design, разделение на слои (ui/features/entities/shared).
- Производительность — React DevTools Profiler, Lighthouse, Core Web Vitals.
- Accessibility — семантика, ARIA, keyboard navigation, screen reader support.
- TypeScript с React — generics для компонентов, discriminated unions для пропсов.
Рекомендация для собеседника
Поскольку позиция — Go-разработчик, фокус стоит сместить на глубокое изучение Go и его экосистемы. Знание React на уровне 5-6 достаточно для понимания фронтенд-части, но для backend-позиции критически важнее уверенное владение Go, конкурентностью, профилированием и архитектурой микросервисов.
Вопрос 7. Зачем нужны фреймворки типа React и в чём их преимущество перед чистым JavaScript?
Таймкод: 00:13:38
Ответ собеседника: Правильный. Главное преимущество — клиентский рендеринг с актуальными данными. Обеспечивается динамическое обновление HTML, клиентская маршрутизация без перезагрузки страниц. Управление состоянием приложения через хуки, без необходимости напрямую обращаться к DOM. Наличие виртуального DOM для оптимизации дорогих операций с DOM.
Правильный ответ:
Ответ собеседника верный по сути, но поверхностный. Для полного понимания стоит раскрыть тему глубже — не только что делает React, но почему именно такой подход победил и какие архитектурные проблемы он решает.
Проблемы чистого JavaScript при разработке UI
1. Ручное управление DOM — источник багов
Без фреймворка разработчик вручную отслеживает изменения данных и обновляет DOM:
// ❌ Vanilla JS — ручное управление DOM
function renderUserList(users) {
const container = document.getElementById('user-list');
// Полная пересоздание DOM при каждом обновлении
container.innerHTML = '';
users.forEach(user => {
const el = document.createElement('div');
el.className = 'user-card';
el.innerHTML = `
<h3>${user.name}</h3>
<p>${user.email}</p>
<button onclick="deleteUser('${user.id}')">Delete</button>
`;
// Ручная привязка обработчиков событий
el.querySelector('.edit-btn').addEventListener('click', () => {
editUser(user.id);
});
container.appendChild(el);
});
// Ручное управление состоянием: где хранить данные?
// Как синхронизировать данные между компонентами?
// Как избежать утечек памяти при удалении элементов?
}
// Проблемы этого подхода:
// 1. Полная пересоздание DOM — дорого и теряет фокус/выделение
// 2. XSS-уязвимость через innerHTML
// 3. Утечки памяти: обработчики событий не удаляются автоматически
// 4. Нет единого источника правды — состояние разбросано
// 5. Сложность масштабирования: каждый новый компонент — новый кук кода
2. Проблема синхронизации состояния
// ❌ Vanilla JS — ручная синхронизация между компонентами
let cart = [];
let cartCount = 0;
let cartTotal = 0;
function addToCart(product) {
cart.push(product);
updateCartUI();
updateCartBadge();
updateCartTotal();
saveToLocalStorage();
// Забыли обновить что-то? Баг.
}
function removeFromCart(productId) {
cart = cart.filter(p => p.id !== productId);
updateCartUI();
updateCartBadge();
updateCartTotal();
saveToLocalStorage();
// Дублирование кода, рассинхронизация состояния
}
// Каждый компонент должен знать обо всех остальных
// При добавлении нового компонента — меняем все существующие
Как React решает эти проблемы
1. Декларативный подход вместо императивного
// ✅ React — декларативное описание UI
interface UserListProps {
users: User[];
onDelete: (id: string) => void;
onEdit: (id: string) => void;
}
const UserList: React.FC<UserListProps> = ({ users, onDelete, onEdit }) => {
return (
<div className="user-list">
{users.map(user => (
<UserCard
key={user.id}
user={user}
onDelete={() => onDelete(user.id)}
onEdit={() => onEdit(user.id)}
/>
))}
</div>
);
};
// React сам решает, какие DOM-операции выполнить
// Мы описываем "как должен выглядеть UI при этих данных"
// А не "как обновить DOM при изменении данных"
2. Единый источник правды
// ✅ React — состояние в одном месте
interface CartItem {
id: string;
name: string;
price: number;
quantity: number;
}
const ShoppingCart: React.FC = () => {
const [items, setItems] = useState<CartItem[]>([]);
// Вычисляемые значения автоматически пересчитываются
const totalItems = items.reduce((sum, item) => sum + item.quantity, 0);
const totalPrice = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
const addItem = useCallback((product: Product) => {
setItems(prev => {
const existing = prev.find(item => item.id === product.id);
if (existing) {
return prev.map(item =>
item.id === product.id
? { ...item, quantity: item.quantity + 1 }
: item
);
}
return [...prev, { ...product, quantity: 1 }];
});
}, []);
const removeItem = useCallback((id: string) => {
setItems(prev => prev.filter(item => item.id !== id));
}, []);
return (
<div>
<CartBadge count={totalItems} />
<CartItems items={items} onRemove={removeItem} />
<CartTotal price={totalPrice} />
</div>
);
};
3. Virtual DOM и Reconciliation
Virtual DOM — это не просто «оптимизация DOM». Это абстрация, которая позволяет React решать, какие именно DOM-операции минимально необходимы.
// React внутренне работает так:
// 1. Создаёт виртуальное дерево элементов (React Element tree)
// 2. При изменении состояния — создаёт новое виртуальное дерево
// 3. Сравнивает старое и новое дерево (diffing algorithm)
// 4. Вычисляет минимальный набор изменений (reconciliation)
// 5. Применяет изменения к реальному DOM (batching)
// Пример того, что React оптимизирует автоматически:
const Counter: React.FC = () => {
const [count, setCount] = useState(0);
const [name, setName] = useState('');
// При setCount — React обновит только текст в span
// При setName — React обновит только значение input
// Никаких ручных манипуляций с DOM
return (
<div>
<span>Count: {count}</span>
<input value={name} onChange={e => setName(e.target.value)} />
<button onClick={() => setCount(c => c + 1)}>Increment</button>
</div>
);
};
// Алгоритм reconciliation:
// - O(n) сложность вместо O(n^3) для общего случая
// - Предполагает, что элементы разных типов дают разные деревья
// - Использует key для определения перемещений элементов в списках
4. Композиция компонентов
// ✅ React — композиция вместо наследования
// Базовый компонент — переиспользуемый строительный блок
interface CardProps {
children: React.ReactNode;
className?: string;
onClick?: () => void;
}
const Card: React.FC<CardProps> = ({ children, className, onClick }) => (
<div className={`card ${className || ''}`} onClick={onClick}>
{children}
</div>
);
// Специализированные компоненты через композицию
interface UserCardProps {
user: User;
onEdit: () => void;
onDelete: () => void;
}
const UserCard: React.FC<UserCardProps> = ({ user, onEdit, onDelete }) => (
<Card className="user-card">
<Avatar src={user.avatar} name={user.name} />
<div className="user-info">
<h3>{user.name}</h3>
<p>{user.email}</p>
<Badge status={user.status} />
</div>
<CardActions>
<Button variant="primary" onClick={onEdit}>Edit</Button>
<Button variant="danger" onClick={onDelete}>Delete</Button>
</CardActions>
</Card>
);
// Сложные страницы собираются из простых компонентов
const UserDashboard: React.FC = () => {
const { users, isLoading, error } = useUsers();
if (isLoading) return <LoadingSpinner />;
if (error) return <ErrorMessage error={error} />;
return (
<PageLayout
header={<PageHeader title="Users" actions={<CreateUserButton />} />}
sidebar={<Navigation />}
>
<UserList users={users} />
<Pagination total={users.length} />
</PageLayout>
);
};
5. Экосистема и инструменты
React — это не только библиотека, а целая экосистема:
// Роутинг
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
const router = createBrowserRouter([
{
path: '/',
element: <Layout />,
errorElement: <ErrorBoundary />,
children: [
{ index: true, element: <HomePage /> },
{
path: 'users',
element: <UsersPage />,
loader: usersLoader, // данные загружаются до рендера
action: usersAction, // обработка форм
children: [
{ path: ':id', element: <UserDetail />, loader: userLoader },
{ path: 'new', element: <CreateUserPage />, action: createUserAction },
]
},
]
}
]);
// Серверный рендеринг с потоковой передачей
import { Suspense } from 'react';
const UserProfile: React.FC<{ userId: string }> = ({ userId }) => {
// Suspense позволяет показать fallback пока данные загружаются
return (
<Suspense fallback={<ProfileSkeleton />}>
<UserDetails userId={userId} />
<Suspense fallback={<ReviewsSkeleton />}>
<UserReviews userId={userId} />
</Suspense>
</Suspense>
);
};
Сравнение подходов
| Аспект | Vanilla JavaScript | React |
|---|---|---|
| Обновление DOM | Ручное, императивное | Автоматическое, декларативное |
| Состояние | Разбросано по переменным | Централизовано в useState/useReducer |
| Композиция | Копипаста, функции | Компоненты, JSX |
| Тестирование | Ручной поиск элементов | React Testing Library |
| Переиспользование | Функции, IIFE | Компоненты, хуки |
| Производительность | Зависит от разработчика | Virtual DOM, memo, автоматические оптимизации |
| Масштабирование | Сложно — код разрастается | Компонентная архитектура |
Когда React не нужен
- Простые лендинги с минимальной интерактивностью.
- Статические сайты (лучше Hugo, Astro, 11ty).
- Простые формы и валидация (достаточно Vanilla JS или Alpine.js).
- Проекты с жёсткими требованиями к размеру бандла (Preact, Solid).
- Когда нужен только один интерактивный элемент на странице (Web Components, Alpine.js).
Итог
React побеждает не потому что «модный», а потому что решает фундаментальную проблему: синхронизацию данных и UI в сложных приложениях. Декларативный подход, компонентная архитектура, Virtual DOM и богатая экосистема делают разработку сложных интерфейсов предсказуемой, тестируемой и масштабируемой.
Вопрос 8. Какие стейт-менеджеры использовал и зачем они нужны?
Таймкод: 00:14:50
Ответ собеседника: Правильный. Работал с Zustand (любимый — простой и в большинстве случаев достаточный) и Redux. Стейт-менеджеры нужны для управления глобальным состоянием приложения, чтобы избежать prop drilling — прокидывания пропсов через множество дочерних компонентов. Контекст также может использоваться, но стейт-менеджеры удобнее.
Правильный ответ:
Ответ собеседника хороший — он правильно определил основную проблему (prop drilling) и имеет практический опыт с двумя решениями. Разберём тему глубже: когда какой менеджер выбрать, какие у них архитектурные различия и какие паттерны применяются.
Проблема prop drilling — корень необходимости стейт-менеджеров
// ❌ Prop drilling — данные прокидываются через компоненты,
// которые их не используют, только для передачи дальше
// Глубокая иерархия: App → Layout → Sidebar → Nav → UserMenu → UserAvatar
// Нужно передать user и theme до UserAvatar
const App = () => {
const [user] = useState(currentUser);
const [theme, setTheme] = useState('dark');
return <Layout user={user} theme={theme} setTheme={setTheme} />;
};
const Layout = ({ user, theme, setTheme }) => {
// Layout не использует user, но обязан прокидывать
return (
<div>
<Header user={user} theme={theme} />
<Sidebar user={user} theme={theme} setTheme={setTheme} />
<Main />
</div>
);
};
const Sidebar = ({ user, theme, setTheme }) => {
// Sidebar тоже не использует user напрямую
return (
<aside>
<Navigation />
<UserMenu user={user} theme={theme} setTheme={setTheme} />
</aside>
);
};
const UserMenu = ({ user, theme, setTheme }) => {
// Наконец-то дошли до компонента, который реально использует user
return (
<div>
<UserAvatar user={user} />
<ThemeToggle theme={theme} setTheme={setTheme} />
</div>
);
};
// Проблемы:
// 1. Изменение интерфейса Layout требует изменения всех промежуточных компонентов
// 2. Тестирование: чтобы протестировать UserAvatar, нужно мокать всю цепочку
// 3. Ре-рендер: при изменении user ре-рендерятся все промежуточные компоненты
// 4. Переиспользование: Sidebar жёстко зависит от user в пропсах
Решение с Context — почему его недостаточно
// ⚠️ Context решает prop drilling, но создаёт проблему ре-рендеров
const UserContext = createContext(null);
const App = () => {
const [user, setUser] = useState(currentUser);
// Проблема: при ЛЮБОМ изменении value — ре-рендерятся ВСЕ потребители
// Даже если компонент использует только часть контекста
return (
<UserContext.Provider value={{ user, setUser }}>
<Layout />
</UserContext.Provider>
);
};
// Проблема ре-рендеров:
// При setUser — ре-рендерятся все компоненты, которые читают UserContext
// даже если они не используют поле, которое изменилось
Redux — предсказуемое состояние для сложных приложений
// ✅ Redux Toolkit — современный подход к Redux
import { configureStore, createSlice, createAsyncThunk } from '@reduxjs/toolkit';
import { useSelector, useDispatch } from 'react-redux';
// Асинхронные операции
export const fetchUsers = createAsyncThunk(
'users/fetchUsers',
async (_, { rejectWithValue }) => {
try {
const response = await api.get('/users');
return response.data;
} catch (error) {
return rejectWithValue(error.response.data);
}
}
);
export const updateUser = createAsyncThunk(
'users/updateUser',
async (userData: UpdateUserInput, { rejectWithValue }) => {
try {
const response = await api.put(`/users/${userData.id}`, userData);
return response.data;
} catch (error) {
return rejectWithValue(error.response.data);
}
}
);
// Slice — объединяет редьюсеры и экшены
const usersSlice = createSlice({
name: 'users',
initialState: {
items: [] as User[],
selectedId: null as string | null,
loading: false,
error: null as string | null,
},
reducers: {
selectUser: (state, action: PayloadAction<string>) => {
state.selectedId = action.payload;
},
clearError: (state) => {
state.error = null;
},
},
extraReducers: (builder) => {
builder
.addCase(fetchUsers.pending, (state) => {
state.loading = true;
state.error = null;
})
.addCase(fetchUsers.fulfilled, (state, action) => {
state.loading = false;
state.items = action.payload;
})
.addCase(fetchUsers.rejected, (state, action) => {
state.loading = false;
state.error = action.payload as string;
})
.addCase(updateUser.fulfilled, (state, action) => {
const index = state.items.findIndex(u => u.id === action.payload.id);
if (index !== -1) {
state.items[index] = action.payload;
}
});
},
});
export const { selectUser, clearError } = usersSlice.actions;
// Селекторы с мемоизацией
export const selectAllUsers = (state: RootState) => state.users.items;
export const selectSelectedUser = (state: RootState) =>
state.users.items.find(u => u.id === state.users.selectedId);
export const selectUsersLoading = (state: RootState) => state.users.loading;
// Фабрика селекторов (для параметризованных селекторов)
import { createSelector } from '@reduxjs/toolkit';
export const selectUserById = createSelector(
[selectAllUsers, (state: RootState, userId: string) => userId],
(users, userId) => users.find(u => u.id === userId)
);
// Store
export const store = configureStore({
reducer: {
users: usersSlice.reducer,
auth: authSlice.reducer,
cart: cartSlice.reducer,
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({
serializableCheck: {
ignoredActions: ['persist/PERSIST'],
},
}),
devTools: process.env.NODE_ENV !== 'production',
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
// Типизированные хуки
export const useAppDispatch = () => useDispatch<AppDispatch>();
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
// Использование в компонентах
const UserList: React.FC = () => {
const dispatch = useAppDispatch();
const users = useAppSelector(selectAllUsers);
const loading = useAppSelector(selectUsersLoading);
useEffect(() => {
dispatch(fetchUsers());
}, [dispatch]);
if (loading) return <LoadingSpinner />;
return (
<div>
{users.map(user => (
<UserCard key={user.id} userId={user.id} />
))}
</div>
);
};
// Компонент подписан только на конкретного пользователя
// Ре-рендер только при изменении этого пользователя
const UserCard: React.FC<{ userId: string }> = React.memo(({ userId }) => {
const user = useAppSelector(state => selectUserById(state, userId));
if (!user) return null;
return (
<div>
<h3>{user.name}</h3>
<p>{user.email}</p>
</div>
);
});
Когда Redux оправдан:
- Большое приложение с множеством связанных сущностей.
- Нужны middleware (логирование, аналитика, кэширование).
- Важна предсказуемость и воспроизводимость (time-travel debugging).
- Команда уже знает Redux и имеет устоявшиеся паттерны.
- Нужна сложная логика обновления состояния.
Zustand — минималистичный и эффективный
// ✅ Zustand — простой, без boilerplate, с отличной производительностью
import { create } from 'zustand';
import { devtools, persist, subscribeWithSelector } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';
interface AuthState {
user: User | null;
token: string | null;
isLoading: boolean;
error: string | null;
login: (email: string, password: string) => Promise<void>;
logout: () => void;
refreshToken: () => Promise<void>;
clearError: () => void;
}
const useAuthStore = create<AuthState>()(
devtools(
persist(
subscribeWithSelector(
immer((set, get) => ({
user: null,
token: null,
isLoading: false,
error: null,
login: async (email, password) => {
set((state) => {
state.isLoading = true;
state.error = null;
});
try {
const { user, token } = await authApi.login(email, password);
set((state) => {
state.user = user;
state.token = token;
state.isLoading = false;
});
} catch (error) {
set((state) => {
state.error = error.message;
state.isLoading = false;
});
throw error;
}
},
logout: () => {
set((state) => {
state.user = null;
state.token = null;
});
},
refreshToken: async () => {
const { token } = get();
if (!token) throw new Error('No token');
try {
const { token: newToken } = await authApi.refresh(token);
set((state) => { state.token = newToken; });
} catch {
get().logout();
throw new Error('Token refresh failed');
}
},
clearError: () => {
set((state) => { state.error = null; });
},
}))
),
{
name: 'auth-storage',
partialize: (state) => ({ token: state.token }),
}
),
{ name: 'AuthStore' }
)
);
// Подписка на конкретное поле — ре-рендер только при его изменении
const UserName = () => {
const name = useAuthStore((state) => state.user?.name);
return <span>{name}</span>;
};
// Подписка на несколько полей
const AuthStatus = () => {
const { user, isLoading } = useAuthStore(
(state) => ({ user: state.user, isLoading: state.isLoading })
);
if (isLoading) return <Spinner />;
return <span>{user ? `Hello, ${user.name}` : 'Not logged in'}</span>;
};
// Использование вне компонентов (в утилитах, интерцепторах)
const setupAxiosInterceptors = (axiosInstance: AxiosInstance) => {
axiosInstance.interceptors.request.use((config) => {
const token = useAuthStore.getState().token;
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
axiosInstance.interceptors.response.use(
(response) => response,
async (error) => {
if (error.response?.status === 401) {
try {
await useAuthStore.getState().refreshToken();
const token = useAuthStore.getState().token;
error.config.headers.Authorization = `Bearer ${token}`;
return axiosInstance.request(error.config);
} catch {
useAuthStore.getState().logout();
window.location.href = '/login';
}
}
return Promise.reject(error);
}
);
};
// Подписка на изменения (без ре-рендера компонента)
useAuthStore.subscribe(
(state) => state.token,
(token, prevToken) => {
if (token && !prevToken) {
analytics.track('user_logged_in');
}
if (!token && prevToken) {
analytics.track('user_logged_out');
}
}
);
Сравнение Zustand и Redux
| Критерий | Redux Toolkit | Zustand |
|---|---|---|
| Boilerplate | Средний (slice, actions) | Минимальный |
| Кривая обучения | Высокая (паттерны, middleware) | Низкая |
| DevTools | Отличные (time-travel) | Хорошие (через middleware) |
| Middleware | Богатая экосистема | Минимальные, но достаточные |
| Производительность | Хорошая | Отличная (меньше оверхеда) |
| TypeScript | Хорошая поддержка | Отличная поддержка |
| Размер бандла | ~11KB | ~3KB |
| Персистенция | redux-persist | Встроенный middleware |
| Подписка на поля | Через селекторы | Встроенная |
| Использование вне React | Сложно | Просто (getState/setState) |
Когда что выбрать
React.useState/useReducer достаточно для:
- Локального состояния компонента (форма, открытый дропдаун).
- Маленьких приложений без сложных связей между компонентами.
- Состояния, которое не нужно разделять между компонентами.
React Context подходит для:
- Темы, локализация, текущий язык.
- Данные, которые меняются редко и используются многими компонентами.
- Простые случаи без сложной логики обновления.
Zustand — оптимальный выбор для:
- Средних и больших приложений.
- Когда нужна простота без потери возможностей.
- Когда важна производительность и минимальный размер бандла.
- Когда нужно использовать состояние вне компонентов.
Redux — для сложных случаев:
- Большие команды с устоявшимися паттернами.
- Сложная логика обновления с множеством side effects.
- Нужны продвинутые middleware (saga, observable).
- Важна воспроизводимость состояния (undo/redo, time-travel).
Другие стейт-менеджеры
- Jotai — атомарный подход, каждый кусочек состояния независим. Отличная гранулярность ре-рендеров.
- Recoil — от Facebook, атомы и селекторы. Хорошая интеграция с Concurrent Mode.
- Valtio — прокси-подход, мутации вместо иммутабельности. Минимальный boilerplate.
- MobX — реактивный подход, автоматическое отслеживание зависимостей. Меньше бойлплейта, но менее предсказуемо.
- TanStack Query — не стейт-менеджер в классическом смысле, а серверный стейт-менеджер. Кэширование, инвалидация, оптимистичные обновления для данных с сервера.
// TanStack Query — для серверного состояния
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
const UserList: React.FC = () => {
const { data: users, isLoading, error } = useQuery({
queryKey: ['users'],
queryFn: () => api.get('/users').then(r => r.data),
staleTime: 5 * 60 * 1000, // 5 минут — данные считаются свежими
gcTime: 10 * 60 * 1000, // 10 минут — время хранения в кэше
});
if (isLoading) return <LoadingSpinner />;
if (error) return <ErrorMessage error={error} />;
return <div>{users.map(user => <UserCard key={user.id} user={user} />)}</div>;
};
const UpdateUser: React.FC<{ userId: string }> = ({ userId }) => {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: (data: UpdateUserInput) =>
api.put(`/users/${userId}`, data).then(r => r.data),
onMutate: async (newData) => {
// Оптимистичное обновление
await queryClient.cancelQueries({ queryKey: ['users'] });
const previousUsers = queryClient.getQueryData(['users']);
queryClient.setQueryData(['users'], (old: User[]) =>
old.map(u => u.id === userId ? { ...u, ...newData } : u)
);
return { previousUsers };
},
onError: (err, newData, context) => {
// Откат при ошибке
queryClient.setQueryData(['users'], context?.previousUsers);
},
onSettled: () => {
// Инвалидация после завершения
queryClient.invalidateQueries({ queryKey: ['users'] });
},
});
return (
<button onClick={() => mutation.mutate({ name: 'New Name' })}>
Update
</button>
);
};
Итог
Выбор стейт-менеджера зависит от размера приложения, команды и требований. Для большинства современных проектов Zustand или TanStack Query покрывают 90% потребностей. Redux остаётся актуальным для крупных проектов с высокими требованиями к предсказуемости и отладке. Главное правило — не внедрять стейт-менеджер пока он реально не нужен.
Вопрос 9. Зачем нужна мемоизация в React (useMemo и useCallback)?
Таймкод: 00:15:48
Ответ собеседника: Правильный. useMemo запоминает результат тяжёлого вычисления при тех же аргументах и возвращает кэшированный результат без повторного запуска функции. useCallback создаёт стабильную ссылку на функцию, чтобы она не пересоздавалась при каждом рендере. Оба хука служат для оптимизации — предотвращения дорогих вычислений и лишних перерисовок.
Правильный ответ:
Ответ собеседника верный и хорошо сформулирован. Разберём тему глубже — когда мемоизация действительно нужна, когда она избыточна, и какие подводные камни существуют.
Фундаментальная проблема: референциальное равенство в JavaScript
// Каждый рендер создаёт НОВЫЕ объекты и функции
// Даже если содержимое идентично — ссылки разные
const obj1 = { name: 'John', age: 30 };
const obj2 = { name: 'John', age: 30 };
console.log(obj1 === obj2); // false — разные ссылки
const fn1 = () => console.log('hello');
const fn2 = () => console.log('hello');
console.log(fn1 === fn2); // false — разные ссылки
// Это значит, что при каждом рендере компонента:
// - Все объекты в пропсах — "новые"
// - Все функции в пропсах — "новые"
// - React.memo, useEffect, useMemo с зависимостями — срабатывают
useMemo — мемоизация значений
// ✅ useMemo для дорогих вычислений
interface DataTableProps {
rows: DataRow[];
sortKey: string;
filter: string;
}
const DataTable: React.FC<DataTableProps> = ({ rows, sortKey, filter }) => {
// Без useMemo: фильтрация и сортировка выполняются при КАЖДОМ рендере
// Даже если rows, sortKey, filter не изменились
// (например, при изменении несвязанного состояния родителя)
// С useMemo: вычисление выполняется только при изменении зависимостей
const processedRows = useMemo(() => {
console.log('Processing rows...'); // Лог покажет, когда реально выполняется
let result = rows;
// Фильтрация
if (filter) {
const lowerFilter = filter.toLowerCase();
result = result.filter(row =>
row.name.toLowerCase().includes(lowerFilter) ||
row.email.toLowerCase().includes(lowerFilter)
);
}
// Сортировка
result = [...result].sort((a, b) => {
const aVal = a[sortKey];
const bVal = b[sortKey];
if (typeof aVal === 'string') {
return aVal.localeCompare(bVal);
}
return aVal - bVal;
});
return result;
}, [rows, sortKey, filter]); // Пересчёт только при изменении зависимостей
return (
<table>
<tbody>
{processedRows.map(row => (
<tr key={row.id}>
<td>{row.name}</td>
<td>{row.email}</td>
</tr>
))}
</tbody>
</table>
);
};
// ✅ useMemo для сохранения ссылки на объект
interface ChartProps {
data: DataPoint[];
config: ChartConfig;
}
const Chart: React.FC<ChartProps> = ({ data, config }) => {
// Проблема: config создаётся в родителе при каждом рендере
// → Chart получает "новый" config каждый раз
// → useEffect внутри Chart срабатывает каждый раз
// Решение: мемоизируем config в родителе
// (см. пример ниже в секции "Типичные ошибки")
useEffect(() => {
// Построение графика — дорогая операция
renderChart(data, config);
}, [data, config]);
return <canvas ref={canvasRef} />;
};
// ✅ useMemo для вычисления производных данных
const ShoppingCart: React.FC = () => {
const [items, setItems] = useState<CartItem[]>([]);
const [discountCode, setDiscountCode] = useState<string | null>(null);
// Вычисляемые значения с мемоизацией
const subtotal = useMemo(
() => items.reduce((sum, item) => sum + item.price * item.quantity, 0),
[items]
);
const discount = useMemo(() => {
if (!discountCode) return 0;
// Дорогой запрос к API для проверки купона
return calculateDiscount(subtotal, discountCode);
}, [subtotal, discountCode]);
const tax = useMemo(() => subtotal * 0.2, [subtotal]);
const total = useMemo(() => subtotal - discount + tax, [subtotal, discount, tax]);
return (
<div>
<span>Subtotal: ${subtotal.toFixed(2)}</span>
<span>Discount: -${discount.toFixed(2)}</span>
<span>Tax: ${tax.toFixed(2)}</span>
<span>Total: ${total.toFixed(2)}</span>
</div>
);
};
useCallback — мемоизация функций
// ✅ useCallback для стабильных ссылок на функции
const TodoList: React.FC = () => {
const [todos, setTodos] = useState<Todo[]>([]);
// Без useCallback: handleToggle создаётся заново при каждом рендере
// → TodoItem, обёрнутый в React.memo, всё равно ре-рендерится
// потому что получает "новую" функцию в пропсах
// С useCallback: стабильная ссылка, пока зависимости не изменились
const handleToggle = useCallback((id: string) => {
setTodos(prev =>
prev.map(todo =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
)
);
}, []); // Пустые зависимости — функция создаётся один раз
const handleDelete = useCallback((id: string) => {
setTodos(prev => prev.filter(todo => todo.id !== id));
}, []);
const handleEdit = useCallback((id: string, newText: string) => {
setTodos(prev =>
prev.map(todo =>
todo.id === id ? { ...todo, text: newText } : todo
)
);
}, []);
return (
<ul>
{todos.map(todo => (
<TodoItem
key={todo.id}
todo={todo}
onToggle={handleToggle}
onDelete={handleDelete}
onEdit={handleEdit}
/>
))}
</ul>
);
};
// React.memo работает благодаря стабильным ссылкам на функции
const TodoItem = React.memo<{
todo: Todo;
onToggle: (id: string) => void;
onDelete: (id: string) => void;
onEdit: (id: string, text: string) => void;
}>(({ todo, onToggle, onDelete, onEdit }) => {
console.log(`Rendering TodoItem: ${todo.id}`);
return (
<li>
<input
type="checkbox"
checked={todo.completed}
onChange={() => onToggle(todo.id)}
/>
<span>{todo.text}</span>
<button onClick={() => onDelete(todo.id)}>Delete</button>
</li>
);
});
Типичные ошибки с мемоизацией
// ❌ Ошибка 1: Мемоизация без React.memo
const Parent = () => {
const [count, setCount] = useState(0);
// useCallback бесполезен, если Child не обёрнут в React.memo
const handleClick = useCallback(() => {
console.log('clicked');
}, []);
return (
<div>
<button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
<Child onClick={handleClick} /> {/* Ре-рендерится при каждом setCount */}
</div>
);
};
// ✅ Правильно: React.memo + useCallback вместе
const Child = React.memo(({ onClick }: { onClick: () => void }) => {
console.log('Child render');
return <button onClick={onClick}>Click me</button>;
});
// ❌ Ошибка 2: Избыточная мемоизация
const SimpleComponent = ({ name }: { name: string }) => {
// Нет смысла мемоизировать простую конкатенацию строк
const greeting = useMemo(() => `Hello, ${name}!`, [name]);
// Проще и читаемее:
// const greeting = `Hello, ${name}!`;
// Нет смысла мемоизировать простой маппинг для рендера
const items = useMemo(() => data.map(item => item.name), [data]);
// Если data и так мемоизирован — повторная мемоизация избыточна
return <div>{greeting}</div>;
};
// ❌ Ошибка 3: Неправильные зависимости
const BadMemoization = ({ user }: { user: User }) => {
// Пропал user.email из зависимостей — баг!
const greeting = useMemo(
() => `Hello, ${user.name} (${user.email})`,
[user.name] // ❌ Забыли user.email
);
// Объект в зависимостях — всегда новая ссылка
const config = useMemo(() => ({ theme: 'dark', locale: 'en' }), []);
const result = useMemo(() => {
return processConfig(config);
}, [config]); // ❌ config — новый объект при каждом рендере родителя
return <div>{greeting}</div>;
};
// ✅ Правильно
const GoodMemoization = ({ user }: { user: User }) => {
const greeting = useMemo(
() => `Hello, ${user.name} (${user.email})`,
[user.name, user.email] // Все используемые поля в зависимостях
);
// Мемоизируем объект в родителе или используем примитивы
return <ChildComponent theme="dark" locale="en" />;
};
// ❌ Ошибка 4: useMemo для побочных эффектов
const BadSideEffect = ({ data }: { data: Data[] }) => {
// useMemo для побочных эффектов — антипаттерн
useMemo(() => {
analytics.track('data_processed', { count: data.length });
return data.filter(d => d.active);
}, [data]);
// ✅ Побочные эффекты — только в useEffect
const activeData = useMemo(() => data.filter(d => d.active), [data]);
useEffect(() => {
analytics.track('data_processed', { count: data.length });
}, [data.length]);
return <div>{activeData.length}</div>;
};
Когда мемоизация действительно нужна
// ✅ Случай 1: Дорогие вычисления
const ExpensiveComponent = ({ items }: { items: Item[] }) => {
// Алгоритм со сложностью O(n²) или выше
const sortedFiltered = useMemo(() => {
return items
.filter(item => item.category === 'active')
.sort((a, b) => b.priority - a.priority)
.slice(0, 100);
}, [items]);
return <ItemList items={sortedFiltered} />;
};
// ✅ Случай 2: Стабильные ссылки для дочерних компонентов с React.memo
const ParentWithMemoizedChild = () => {
const [count, setCount] = useState(0);
const stableCallback = useCallback(() => {
doSomething();
}, []);
const stableObject = useMemo(() => ({
theme: 'dark',
onAction: stableCallback,
}), [stableCallback]);
return (
<div>
<button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
<MemoizedChild config={stableObject} />
</div>
);
};
const MemoizedChild = React.memo(({ config }: { config: Config }) => {
// Ре-рендер только при изменении config
return <div>{config.theme}</div>;
});
// ✅ Случай 3: Зависимости в useEffect
const DataFetcher = ({ filters }: { filters: Filters }) => {
// Без мемоизации: новый объект filters при каждом рендере
// → useEffect срабатывает бесконечно
const stableFilters = useMemo(() => ({
status: filters.status,
dateFrom: filters.dateFrom,
dateTo: filters.dateTo,
}), [filters.status, filters.dateFrom, filters.dateTo]);
useEffect(() => {
fetchData(stableFilters);
}, [stableFilters]);
return <DataView />;
};
Когда мемоизация НЕ нужна
// ✅ Не нужна: простые вычисления
const SimpleGreeting = ({ name }: { name: string }) => {
// Простая конкатенация — быстрее, чем накладные расходы useMemo
const greeting = `Hello, ${name}!`;
return <div>{greeting}</div>;
};
// ✅ Не нужна: компонент без React.memo
const Parent = () => {
const [count, setCount] = useState(0);
// Бесполезно: Child не обёрнут в React.memo
const handleClick = useCallback(() => {}, []);
return (
<div>
<button onClick={() => setCount(c => c + 1)}>{count}</button>
<Child onClick={handleClick} />
</div>
);
};
// ✅ Не нужна: функция используется только в текущем компоненте
const LocalHandler = () => {
const [value, setValue] = useState('');
// useCallback не нужен — функция не передаётся в дочерние компоненты
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setValue(e.target.value);
};
return <input value={value} onChange={handleChange} />;
};
Альтернативы ручной мемоизации
// React 19: use() хук и автоматическая мемоизация
// React автоматически мемоизирует значения внутри компонента
// при использовании новых конкурентных фич
// useDeferredValue — автоматическая мемоизация с отложенным обновлением
const SearchResults = ({ query }: { query: string }) => {
// deferredQuery обновляется с задержкой
// Пока обновляется — показываем старые результаты
const deferredQuery = useDeferredValue(query);
const results = useMemo(() => {
return expensiveSearch(deferredQuery);
}, [deferredQuery]);
return (
<div style={{ opacity: query !== deferredQuery ? 0.5 : 1 }}>
{results.map(r => <ResultItem key={r.id} item={r} />)}
</div>
);
};
// React Compiler (экспериментальный) — автоматическая мемоизация
// Компилятор сам определяет, что нужно мемоизировать
// и генерирует оптимизированный код
// В будущем ручной useMemo/useCallback может стать не нужен
Итог
Мемоизация — это оптимизация, а не необходимость по умолчанию. Применяйте её когда:
- Вычисление действительно дорогое (O(n²) и выше, большие массивы).
- Стабильная ссылка критична для React.memo в дочерних компонентах.
- Объект/функция передаётся в зависимости useEffect и вызывает лишние срабатывания.
Не применяйте когда:
- Вычисление тривиально (конкатенация строк, простые арифметические операции).
- Компонент не обёрнут в React.memo и не передаётся в зависимости.
- Накладные расходы на мемоизацию превышают выгоду.
Профилируйте с помощью React DevTools Profiler перед оптимизацией — не оптимизируйте «на всякий случай».
Вопрос 10. Когда React решает, что должен произойти рендер компонента?
Таймкод: 00:17:56
Ответ собеседника: Правильный. Рендер происходит при изменении контекста (если компонент на него подписан), при изменении стейта, при изменении пропсов, а также при перерендере родительского компонента — даже если пропсы не изменились, дочерний компонент всё равно перерисовывается.
Правильный ответ:
Ответ собеседника верный и охватывает все основные триггеры рендера. Разберём тему глубже — как именно React принимает решение о рендере, что происходит внутри, и как это влияет на производительность.
Триггеры рендера в React
1. Изменение состояния (useState, useReducer)
// Каждый вызов setState с новым значением запускает рендер
const Counter = () => {
const [count, setCount] = useState(0);
console.log('Counter render'); // Лог покажет каждый рендер
// Рендер происходит
const handleIncrement = () => setCount(count + 1);
// Рендер НЕ происходит — значение не изменилось
const handleNoop = () => setCount(count); // React сравнивает через Object.is
return (
<div>
<span>{count}</span>
<button onClick={handleIncrement}>+1</button>
<button onClick={handleNoop}>No-op</button>
</div>
);
};
// useReducer — аналогично, dispatch запускает рендер
const reducer = (state: State, action: Action): State => {
switch (action.type) {
case 'increment':
return { ...state, count: state.count + 1 };
case 'reset':
return { ...state, count: 0 };
default:
return state; // Возврат того же state — рендер не произойдёт
}
};
const CounterWithReducer = () => {
const [state, dispatch] = useReducer(reducer, { count: 0 });
// dispatch с action, который возвращает тот же state — рендер не произойдёт
return <button onClick={() => dispatch({ type: 'noop' })}>+1</button>;
};
2. Изменение пропсов
// Родитель передаёт новые пропсы — дочерний компонент рендерится
const Parent = () => {
const [count, setCount] = useState(0);
return (
<div>
<button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
{/* Child получает count как пропс — рендерится при каждом изменении */}
<Child value={count} />
</div>
);
};
const Child = ({ value }: { value: number }) => {
console.log('Child render'); // Срабатывает при каждом изменении value
return <span>{value}</span>;
};
3. Ререндер родителя — даже если пропсы не изменились
// Это ключевое поведение React по умолчанию
const Parent = () => {
const [count, setCount] = useState(0);
return (
<div>
<button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
{/* StaticChild НЕ получает count, но ВСЁ РАВНО рендерится */}
<StaticChild />
</div>
);
};
const StaticChild = () => {
console.log('StaticChild render'); // Срабатывает при каждом рендере Parent!
return <div>I am static</div>;
};
// Это происходит потому что React не знает, зависит ли StaticChild
// от состояния родителя (контекст, глобальные переменные и т.д.)
// Поэтому перестраховывается и рендерит всё поддерево
4. Изменение контекста
// Компонент, подписанный на контекст, рендерится при изменении его значения
const ThemeContext = createContext<'light' | 'dark'>('light');
const ThemeProvider = ({ children }: { children: React.ReactNode }) => {
const [theme, setTheme] = useState<'light' | 'dark'>('light');
return (
<ThemeContext.Provider value={theme}>
<button onClick={() => setTheme(t => t === 'light' ? 'dark' : 'light')}>
Toggle
</button>
{children}
</ThemeContext.Provider>
);
};
const ThemedButton = () => {
const theme = useContext(ThemeContext);
console.log('ThemedButton render'); // Срабатывает при каждом изменении theme
return <button className={`btn-${theme}`}>Themed</button>;
};
// Проблема: ВСЕ потребители контекста рендерятся при ЛЮБОМ изменении value
// Даже если компонент использует только часть контекста
Что происходит внутри React при рендере
// Рендер в React — это вызов функции компонента
// React вызывает функцию, получает JSX (React Element tree),
// сравнивает с предыдущим деревом (reconciliation)
// и применяет минимальные изменения к DOM
const RenderCycle = () => {
const [count, setCount] = useState(0);
// 1. Вызов функции компонента (render phase)
// React вызывает RenderCycle() и получает:
// {
// type: 'div',
// props: {
// children: [
// { type: 'span', props: { children: 0 } },
// { type: 'button', props: { onClick: fn, children: '+' } }
// ]
// }
// }
// 2. Reconciliation — сравнение с предыдущим деревом
// React видит, что изменился только текст в span (0 → 1)
// 3. Commit phase — применение изменений к DOM
// React обновляет только текстовый узел в span
return (
<div>
<span>{count}</span> {/* Это обновится */}
<button onClick={() => setCount(c => c + 1)}>+</button>
</div>
);
};
Как предотвратить лишние рендеры
1. React.memo — мемоизация компонента
// React.memo предотвращает рендер, если пропсы не изменились
const MemoizedChild = React.memo(({ value }: { value: number }) => {
console.log('MemoizedChild render');
return <span>{value}</span>;
});
const Parent = () => {
const [count, setCount] = useState(0);
const [other, setOther] = useState(0);
return (
<div>
<button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
<button onClick={() => setOther(o => o + 1)}>Other: {other}</button>
{/* MemoizedChild НЕ рендерится при изменении other */}
<MemoizedChild value={count} />
</div>
);
};
// React.memo с кастомным сравнем
const CustomMemoChild = React.memo(
({ user, onSelect }: { user: User; onSelect: (id: string) => void }) => {
return <div onClick={() => onSelect(user.id)}>{user.name}</div>;
},
(prevProps, nextProps) => {
// Рендер только если изменились значимые поля
return prevProps.user.id === nextProps.user.id &&
prevProps.user.name === nextProps.user.name;
}
);
2. Разделение контекста — один контекст = одна ответственность
// ❌ Плохо: один контекст на всё — все потребители рендерятся при любом изменении
const AppContext = createContext({
theme: 'dark',
user: null,
locale: 'en',
});
// ✅ Хорошо: разделённые контексты
const ThemeContext = createContext<'light' | 'dark'>('light');
const UserContext = createContext<User | null>(null);
const LocaleContext = createContext('en');
// Теперь компонент, использующий только theme,
// НЕ рендерится при изменении user или locale
const ThemedButton = () => {
const theme = useContext(ThemeContext);
// Рендер только при изменении theme
return <button className={`btn-${theme}`}>Click</button>;
};
3. Подписка на часть контекста через селектор
// Создаём хук с селектором для гранулярных подписок
const useUserSelector = <T,>(selector: (user: User | null) => T): T => {
const user = useContext(UserContext);
return useMemo(() => selector(user), [user, selector]);
};
// Компонент подписан только на имя пользователя
const UserName = () => {
const name = useUserSelector(user => user?.name ?? 'Guest');
// Рендер только при изменении name, а не всего user
return <span>{name}</span>;
};
4. Поднятие состояния вниз (colocation)
// ❌ Плохо: состояние высоко — всё дерево рендерится
const Parent = () => {
const [search, setSearch] = useState('');
return (
<div>
<input value={search} onChange={e => setSearch(e.target.value)} />
<ExpensiveList /> {/* Рендерится при каждом вводе символа */}
<AnotherExpensive /> {/* Тоже рендерится */}
</div>
);
};
// ✅ Хорошо: состояние рядом с потребителем
const Parent = () => {
return (
<div>
<SearchInput /> {/* Состояние внутри — не влияет на братьев */}
<ExpensiveList />
<AnotherExpensive />
</div>
);
};
const SearchInput = () => {
const [search, setSearch] = useState('');
return <input value={search} onChange={e => setSearch(e.target.value)} />;
};
5. Использование children для изоляции
// children не рендерятся при изменении состояния родителя
const Parent = () => {
const [count, setCount] = useState(0);
return (
<div>
<button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
{/* StaticContent передаётся как children — не рендерится */}
<Layout>
<StaticContent />
</Layout>
</div>
);
};
const Layout = ({ children }: { children: React.ReactNode }) => {
// Layout может менять своё состояние — children не пострадают
return <div className="layout">{children}</div>;
};
Визуализация цикла рендера
setState / new props / context change
│
▼
┌─────────────────────┐
│ Render Phase │
│ (может быть │
│ прерван React) │
│ │
│ 1. Вызов функции │
│ компонента │
│ 2. Получение JSX │
│ 3. Создание Fiber │
│ узлов │
└────────┬────────────┘
│
▼
┌─────────────────────┐
│ Reconciliation │
│ (diffing) │
│ │
│ 1. Сравнение │
│ старого и │
│ нового дерева │
│ 2. Вычисление │
│ минимальных │
│ изменений │
└────────┬────────────┘
│
▼
┌─────────────────────┐
│ Commit Phase │
│ (синхронный, │
│ не прерываемый) │
│ │
│ 1. Применение │
│ изменений к DOM │
│ 2. Вызов │
│ useLayoutEffect │
│ 3. Вызов useEffect │
│ (после paint) │
└─────────────────────┘
Профилирование рендеров
// React DevTools Profiler — основной инструмент для анализа рендеров
// Программное профилирование через onRender callback
import { Profiler } from 'react';
const App = () => (
<Profiler
id="App"
onRender={(id, phase, actualDuration, baseDuration, startTime, commitTime) => {
console.log({
id, // идентификатор профилируемого компонента
phase, // "mount" или "update"
actualDuration, // фактическое время рендера
baseDuration, // время рендера без мемоизации
startTime, // время начала рендера
commitTime, // время завершения коммита
});
if (actualDuration > 16) {
console.warn(`Slow render detected: ${id} took ${actualDuration}ms`);
// 16ms = 60fps, больше — видимая задержка
}
}}
>
<ExpensiveComponent />
</Profiler>
);
Ключевые принципы
- React по умолчанию рендерит всё поддерево — это безопасное поведение, но может быть неэффективным.
- React.memo — оптимизация, а не необходимость — применяйте только когда профилирование показывает проблему.
- Разделение контекста — самый эффективный способ уменьшить количество рендеров в больших приложениях.
- Colocation — размещайте состояние как можно ближе к потребителям.
- Профилируйте перед оптимизацией — не оптимизируйте «на всякий случай», это усложняет код без пользы.
Вопрос 11. Как предотвратить перерендер дочернего компонента, если родитель перерендеривается, а пропсы не изменились?
Таймкод: 00:18:30
Ответ собеседника: Правильный. Использовать React.memo — высший порядок компонента (HOC), который оборачивает дочерний компонент. Можно также передать второй аргумент с кастомной логикой сравнения. Это аналог shouldComponentUpdate из классовых компонентов.
Правильный ответ:
Ответ собеседника верный и точный. React.memo — действительно основной инструмент для решения этой проблемы. Разберём тему глубже — различные подходы, их нюансы и когда каждый из них предпочтительнее.
React.memo — основной инструмент
// React.memo выполняет поверхностное сравнение пропсов
// Если пропсы не изменились — рендер пропускается
// Без React.memo: рендерится при каждом рендере родителя
const ChildWithoutMemo = ({ user }: { user: User }) => {
console.log('ChildWithoutMemo render');
return <div>{user.name}</div>;
};
// С React.memo: рендерится только при изменении пропсов
const ChildWithMemo = React.memo(({ user }: { user: User }) => {
console.log('ChildWithMemo render');
return <div>{user.name}</div>;
});
// Демонстрация разницы
const Parent = () => {
const [count, setCount] = useState(0);
const [user] = useState({ name: 'John', email: 'john@example.com' });
return (
<div>
<button onClick={() => setCount(c => c + 1)}>
Count: {count}
</button>
{/* Рендерится при каждом нажатии — count изменился */}
<ChildWithoutMemo user={user} />
{/* НЕ рендерится — user не изменился */}
<ChildWithMemo user={user} />
</div>
);
};
Поверхностное сравнение в React.memo — подводные камни
// React.memo использует Object.is для сравнения каждого пропса
// Это работает для примитивов, но не для объектов и функций
// ❌ Проблема: объекты и функции — новые ссылки при каждом рендере
const Parent = () => {
const [count, setCount] = useState(0);
// Новый объект при каждом рендере
const config = { theme: 'dark', locale: 'en' };
// Новая функция при каждом рендере
const handleClick = () => console.log('clicked');
return (
<div>
<button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
{/* React.memo НЕ поможет — config и handleClick — новые ссылки */}
<MemoizedChild config={config} onClick={handleClick} />
</div>
);
};
const MemoizedChild = React.memo(({ config, onClick }) => {
console.log('MemoizedChild render'); // Всегда срабатывает!
return <div>{config.theme}</div>;
});
// ✅ Решение: мемоизировать объекты и функции в родителе
const ParentFixed = () => {
const [count, setCount] = useState(0);
// Мемоизированный объект
const config = useMemo(() => ({ theme: 'dark', locale: 'en' }), []);
// Мемоизированная функция
const handleClick = useCallback(() => console.log('clicked'), []);
return (
<div>
<button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
{/* Теперь React.memo работает — ссылки стабильны */}
<MemoizedChild config={config} onClick={handleClick} />
</div>
);
};
Кастомная функция сравнения
// Второй аргумент React.memo — функция сравнения
// Возвращает true если пропсы равны (рендер пропустить)
// Возвращает false если пропсы изменились (рендерить)
interface UserCardProps {
user: User;
isSelected: boolean;
onSelect: (id: string) => void;
}
const UserCard = React.memo(
({ user, isSelected, onSelect }: UserCardProps) => {
console.log(`UserCard render: ${user.id}`);
return (
<div
className={isSelected ? 'selected' : ''}
onClick={() => onSelect(user.id)}
>
<span>{user.name}</span>
<span>{user.email}</span>
<span>{user.role}</span>
</div>
);
},
(prevProps, nextProps) => {
// Кастомная логика сравнения
// true = пропсы равны, рендер не нужен
// false = пропсы изменились, нужен рендер
return (
prevProps.user.id === nextProps.user.id &&
prevProps.user.name === nextProps.user.name &&
prevProps.user.email === nextProps.user.email &&
prevProps.isSelected === nextProps.isSelected &&
// onSelect сравниваем по ссылке — если мемоизирован, ок
prevProps.onSelect === nextProps.onSelect
);
}
);
// Пример с глубоким сравением для сложных объектов
const DeepCompareMemo = React.memo(
({ data }: { data: ComplexData }) => {
return <DataVisualizer data={data} />;
},
(prevProps, nextProps) => {
// Глубокое сравнение для вложенных объектов
// В реальном проекте используйте lodash.isEqual или fast-deep-equal
return deepEqual(prevProps.data, nextProps.data);
}
);
Альтернативные подходы к React.memo
1. Использование children для изоляции
// children не перерендериваются при изменении состояния родителя
// Это самый простой способ изоляции без React.memo
const Parent = () => {
const [count, setCount] = useState(0);
return (
<div>
<button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
{/* StaticContent — как children, не рендерится */}
<LayoutComponent>
<StaticContent />
</LayoutComponent>
</div>
);
};
const LayoutComponent = ({ children }: { children: React.ReactNode }) => {
// LayoutComponent может иметь своё состояние
// children не пострадают
return <div className="layout">{children}</div>;
};
2. Поднятие состояния вниз (colocation)
// ❌ Плохо: состояние высоко — всё дерево страдает
const Parent = () => {
const [search, setSearch] = useState('');
return (
<div>
<input value={search} onChange={e => setSearch(e.target.value)} />
<ExpensiveList /> {/* Рендерится при каждом вводе */}
<AnotherComponent /> {/* Тоже рендерится */}
</div>
);
};
// ✅ Хорошо: состояние рядом с потребителем
const Parent = () => {
return (
<div>
<SearchInput /> {/* Состояние внутри — изолировано */}
<ExpensiveList />
<AnotherComponent />
</div>
);
};
const SearchInput = () => {
const [search, setSearch] = useState('');
return <input value={search} onChange={e => setSearch(e.target.value)} />;
};
3. Разделение контекста
// ❌ Плохо: один контекст — все потребители рендерятся
const AppContext = createContext({
theme: 'dark',
user: null,
locale: 'en',
});
// ✅ Хорошо: разделённые контексты для гранулярных обновлений
const ThemeContext = createContext<'light' | 'dark'>('light');
const UserContext = createContext<User | null>(null);
const LocaleContext = createContext('en');
// Компонент подписан только на тему — не рендерится при изменении пользователя
const ThemedButton = () => {
const theme = useContext(ThemeContext);
return <button className={`btn-${theme}`}>Click</button>;
};
// Компонент подписан только на пользователя — не рендерится при изменении темы
const UserName = () => {
const user = useContext(UserContext);
return <span>{user?.name}</span>;
};
4. Использование селекторов с контекстом
// Создаём хук с селектором для гранулярных подписок
function useStoreSelector<T>(selector: (state: StoreState) => T): T {
const state = useContext(StoreContext);
return useMemo(() => selector(state), [state, selector]);
}
// Компонент подписан только на конкретное поле
const UserName = () => {
const name = useStoreSelector(state => state.user?.name ?? 'Guest');
// Рендер только при изменении name, а не всего state
return <span>{name}</span>;
};
const CartCount = () => {
const count = useStoreSelector(state => state.cart.items.length);
// Рендер только при изменении количества товаров
return <span>{count}</span>;
};
Сравнение подходов
// Подход 1: React.memo + useCallback/useMemo
// Когда: дочерний компонент получает объекты/функции в пропсах
// Плюсы: точный контроль, стандартный подход
// Минусы: нужно мемоизировать все нестабильные пропсы
const Approach1 = () => {
const config = useMemo(() => ({ theme: 'dark' }), []);
const handler = useCallback(() => {}, []);
return <MemoizedChild config={config} onAction={handler} />;
};
// Подход 2: children
// Когда: дочерний компонент не зависит от состояния родителя
// Плюсы: простота, не нужно React.memo
// Минусы: не всегда подходит по архитектуре
const Approach2 = () => {
return (
<Layout>
<StaticContent /> {/* Не рендерится при изменении состояния Layout */}
</Layout>
);
};
// Подход 3: Colocation
// Когда: состояние нужно только в одном месте
// Плюсы: естественная изоляция, простота
// Минусы: не подходит для глобального состояния
const Approach3 = () => {
return (
<div>
<SearchInput /> {/* Состояние внутри */}
<StaticList />
</div>
);
};
// Подход 4: Разделение контекста
// Когда: много компонентов подписаны на разные части состояния
// Плюсы: гранулярные обновления, масштабируемость
// Минусы: больше контекстов — сложнее структура
const Approach4 = () => {
return (
<ThemeContext.Provider value={theme}>
<UserContext.Provider value={user}>
<ThemedUserButton />
</UserContext.Provider>
</ThemeContext.Provider>
);
};
Когда React.memo не поможет
// 1. Компонент использует контекст
const ContextConsumer = React.memo(() => {
const theme = useContext(ThemeContext);
// Рендерится при изменении контекста, несмотря на React.memo
return <div>{theme}</div>;
});
// 2. Комponent использует children
const WithChildren = React.memo(({ children }: { children: React.ReactNode }) => {
// children — новые пропсы при каждом рендере родителя
return <div>{children}</div>;
});
// 3. Пропсы всегда новые (не мемоизированы)
const Parent = () => {
return (
<MemoizedChild
style={{ color: 'red' }} // Новый объект
onClick={() => {}} // Новая функция
data={[1, 2, 3]} // Новый массив
/>
);
};
Рекомендации по применению
- React.memo — применяйте для компонентов с дорогим рендером (большие списки, сложные вычисления, графики).
- children — используйте когда дочерний компонент статичен и не зависит от состояния родителя.
- Colocation — первый выбор при проектировании: размещайте состояние ближе к потребителям.
- Разделение контекста — когда у вас большой контекст с множеством независимых полей.
- Профилируйте — используйте React DevTools Profiler для выявления реальных узких мест перед оптимизацией.
Главное правило: не оптимизируйте «на всякий случай». React достаточно быстр в большинстве случаев. Оптимизация без профилирования часто усложняет код без реальной пользы.
Вопрос 12. Что произойдёт, если родитель передаёт callback в дочерний компонент без обёртки в useCallback, и как это предотвратить?
Таймкод: 00:19:19
Ответ собеседника: Правильный. Дочерний компонент будет перерисовываться, потому что при каждом рендере родителя создаётся новая ссылка на функцию. Для предотвращения необходимо обернуть callback в useCallback, чтобы сохранить стабильную ссылку между рендерами. Без мемоизации функций React.memo становится бесполезным.
Правильный ответ:
Ответ собеседника точный и полный. Это один из самых распространённых источников проблем с производительностью в React-приложениях. Разберём тему глубже — почему это происходит на уровне JavaScript, какие есть решения и какие нюансы нужно учитывать.
Корень проблемы: референциальное равенство в JavaScript
// В JavaScript функции — это объекты
// Каждое объявление создаёт новый объект в памяти
const fn1 = () => console.log('hello');
const fn2 = () => console.log('hello');
console.log(fn1 === fn2); // false — разные ссылки
console.log(fn1 === fn1); // true — та же ссылка
// То же самое с объектами и массивами
const obj1 = { name: 'John' };
const obj2 = { name: 'John' };
console.log(obj1 === obj2); // false — разные ссылки
// React использует Object.is для сравнения пропсов
// Для функций и объектов это сравнение по ссылке
Проблема на практике
// ❌ ПРОБЛЕМА: React.memo бесполезен без useCallback
const Parent = () => {
const [count, setCount] = useState(0);
const [text, setText] = useState('');
// Новая функция при каждом рендере — новая ссылка
const handleClick = () => {
console.log('clicked', count);
};
// Новая функция при каждом рендере — новая ссылка
const handleChange = (value: string) => {
setText(value);
};
return (
<div>
<button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
{/* ExpensiveChild рендерится при каждом изменении count,
даже если получает только handleChange, который не зависит от count */}
<ExpensiveChild onClick={handleClick} onChange={handleChange} />
</div>
);
};
const ExpensiveChild = React.memo(({ onClick, onChange }: {
onClick: () => void;
onChange: (value: string) => void;
}) => {
console.log('ExpensiveChild render'); // Срабатывает при КАЖДОМ рендере Parent!
return (
<div>
<button onClick={onClick}>Action</button>
<input onChange={e => onChange(e.target.value)} />
</div>
);
});
// Почему это происходит:
// 1. Parent рендерится (count изменился)
// 2. handleClick — новая функция (новая ссылка)
// 3. handleChange — новая функция (новая ссылка)
// 4. React.memo сравнивает пропсы: onClick (было) !== onClick (стало) → true
// 5. Рендер ExpensiveChild не пропускается
Решение с useCallback
// ✅ РЕШЕНИЕ: useCallback сохраняет стабильную ссылку
const Parent = () => {
const [count, setCount] = useState(0);
const [text, setText] = useState('');
// Стабильная ссылка — функция пересоздаётся только при изменении count
const handleClick = useCallback(() => {
console.log('clicked', count);
}, [count]); // count в зависимостях — он используется внутри
// Стабильная ссылка — функция создаётся один раз
const handleChange = useCallback((value: string) => {
setText(value);
}, []); // Пустые зависимости — setText стабилен
return (
<div>
<button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
{/* ExpensiveChild НЕ рендерится при изменении count,
потому что handleChange имеет стабильную ссылку */}
<ExpensiveChild onClick={handleClick} onChange={handleChange} />
</div>
);
};
Важный нюанс: зависимости useCallback
// ❌ ОШИБКА: Забыли зависимость — замыкание на устаревшем значении
const BadCallback = () => {
const [count, setCount] = useState(0);
// count не в зависимостях — замыкание всегда на count = 0
const handleClick = useCallback(() => {
console.log(count); // Всегда выводит 0!
setCount(count + 1); // Всегда устанавливает 1!
}, []); // ❌ Забыли count в зависимостях
return <button onClick={handleClick}>Count: {count}</button>;
};
// ✅ ПРАВИЛЬНО: Все используемые переменные в зависимостях
const GoodCallback = () => {
const [count, setCount] = useState(0);
const handleClick = useCallback(() => {
console.log(count); // Актуальное значение
setCount(count + 1); // Корректное обновление
}, [count]); // ✅ count в зависимостях
return <button onClick={handleClick}>Count: {count}</button>;
};
// ✅ ЕЩЁ ЛУЧШЕ: Функциональное обновление — не нужна зависимость от count
const BestCallback = () => {
const [count, setCount] = useState(0);
// setCount с функцией — не зависит от текущего count
const handleClick = useCallback(() => {
setCount(prev => prev + 1); // Функциональное обновление
}, []); // ✅ Пустые зависимости — setCount стабилен
return <button onClick={handleClick}>Count: {count}</button>;
};
Когда useCallback с функциональным обновлением — лучший выбор
const Counter = () => {
const [count, setCount] = useState(0);
// Все эти функции имеют стабильные ссылки на всё время жизни компонента
const increment = useCallback(() => setCount(c => c + 1), []);
const decrement = useCallback(() => setCount(c => c - 1), []);
const reset = useCallback(() => setCount(0), []);
const double = useCallback(() => setCount(c => c * 2), []);
return (
<div>
<span>{count}</span>
<button onClick={increment}>+1</button>
<button onClick={decrement}>-1</button>
<button onClick={reset}>Reset</button>
<button onClick={double}>Double</button>
{/* ChildComponent никогда не перерендерится из-за этих коллбэков */}
<ChildComponent onIncrement={increment} onDecrement={decrement} />
</div>
);
};
Полный пример: список с оптимизацией
interface Todo {
id: string;
text: string;
completed: boolean;
}
const TodoList: React.FC = () => {
const [todos, setTodos] = useState<Todo[]>([]);
const [filter, setFilter] = useState<'all' | 'active' | 'completed'>('all');
// ✅ Все коллбэки мемоизированы с правильными зависимостями
const handleToggle = useCallback((id: string) => {
setTodos(prev =>
prev.map(todo =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
)
);
}, []); // setTodos стабилен — пустые зависимости
const handleDelete = useCallback((id: string) => {
setTodos(prev => prev.filter(todo => todo.id !== id));
}, []);
const handleEdit = useCallback((id: string, newText: string) => {
setTodos(prev =>
prev.map(todo =>
todo.id === id ? { ...todo, text: newText } : todo
)
);
}, []);
const handleAdd = useCallback((text: string) => {
setTodos(prev => [...prev, {
id: crypto.randomUUID(),
text,
completed: false,
}]);
}, []);
// Отфильтрованный список тоже мемоизирован
const filteredTodos = useMemo(() => {
switch (filter) {
case 'active':
return todos.filter(t => !t.completed);
case 'completed':
return todos.filter(t => t.completed);
default:
return todos;
}
}, [todos, filter]);
return (
<div>
<FilterTabs current={filter} onChange={setFilter} />
<TodoInput onAdd={handleAdd} />
<ul>
{filteredTodos.map(todo => (
<TodoItem
key={todo.id}
todo={todo}
onToggle={handleToggle}
onDelete={handleDelete}
onEdit={handleEdit}
/>
))}
</ul>
</div>
);
};
// TodoItem обёрнут в React.memo — рендерится только при изменении пропсов
const TodoItem = React.memo<{
todo: Todo;
onToggle: (id: string) => void;
onDelete: (id: string) => void;
onEdit: (id: string, text: string) => void;
}>(({ todo, onToggle, onDelete, onEdit }) => {
console.log(`TodoItem render: ${todo.id}`);
return (
<li>
<input
type="checkbox"
checked={todo.completed}
onChange={() => onToggle(todo.id)}
/>
<span>{todo.text}</span>
<button onClick={() => onDelete(todo.id)}>Delete</button>
</li>
);
});
Альтернатива useCallback: передача диспатч-функции
// Вместо мемоизации коллбэков — передаём dispatch
// Это работает с useReducer
type Action =
| { type: 'toggle'; id: string }
| { type: 'delete'; id: string }
| { type: 'edit'; id: string; text: string }
| { type: 'add'; text: string };
const todoReducer = (state: Todo[], action: Action): Todo[] => {
switch (action.type) {
case 'toggle':
return state.map(todo =>
todo.id === action.id ? { ...todo, completed: !todo.completed } : todo
);
case 'delete':
return state.filter(todo => todo.id !== action.id);
case 'edit':
return state.map(todo =>
todo.id === action.id ? { ...todo, text: action.text } : todo
);
case 'add':
return [...state, {
id: crypto.randomUUID(),
text: action.text,
completed: false,
}];
default:
return state;
}
};
const TodoListWithReducer: React.FC = () => {
const [todos, dispatch] = useReducer(todoReducer, []);
// dispatch имеет стабильную ссылку — не нужен useCallback!
// Передаём dispatch напрямую — React.memo работает
return (
<ul>
{todos.map(todo => (
<TodoItemWithDispatch
key={todo.id}
todo={todo}
dispatch={dispatch} // dispatch стабилен!
/>
))}
</ul>
);
};
const TodoItemWithDispatch = React.memo<{
todo: Todo;
dispatch: React.Dispatch<Action>;
}>(({ todo, dispatch }) => {
// Компонент сам формирует action и вызывает dispatch
return (
<li>
<input
type="checkbox"
checked={todo.completed}
onChange={() => dispatch({ type: 'toggle', id: todo.id })}
/>
<span>{todo.text}</span>
<button onClick={() => dispatch({ type: 'delete', id: todo.id })}>
Delete
</button>
</li>
);
});
Сравнение подходов
// Подход 1: useCallback для каждого коллбэка
// Плюсы: явный контроль, понятные зависимости
// Минусы: много бойлплейта, нужно следить за зависимостями
const handleToggle = useCallback((id: string) => {
setTodos(prev => prev.map(t =>
t.id === id ? { ...t, completed: !t.completed } : t
));
}, []);
// Подход 2: useReducer + dispatch
// Пlus: dispatch стабилен, меньше кода, централизованная логика
// Минусы: сложнее для простых случаев, нужно определять actions
dispatch({ type: 'toggle', id });
// Подход 3: Передача setState напрямую
// Пlus: минимум кода
// Минусы: дочерний компонент знает о структуре состояния
const Parent = () => {
const [todos, setTodos] = useState<Todo[]>([]);
// setTodos стабилен — можно передавать напрямую
return <Child setTodos={setTodos} />;
};
// Подход 4: Использование кастомного хука
// Пlus: инкапсуляция логики, переиспользование
// Минусы: дополнительная абстракция
const useTodos = () => {
const [todos, setTodos] = useState<Todo[]>([]);
const toggle = useCallback((id: string) => {
setTodos(prev => prev.map(t =>
t.id === id ? { ...t, completed: !t.completed } : t
));
}, []);
const remove = useCallback((id: string) => {
setTodos(prev => prev.filter(t => t.id !== id));
}, []);
return { todos, toggle, remove };
};
Когда useCallback НЕ нужен
// 1. Функция не передаётся в дочерние компоненты
const LocalHandler = () => {
const [value, setValue] = useState('');
// useCallback не нужен — функция используется только здесь
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setValue(e.target.value);
};
return <input value={value} onChange={handleChange} />;
};
// 2. Дочерний компонент не обёрнут в React.memo
const ParentWithoutMemo = () => {
const [count, setCount] = useState(0);
// useCallback бесполезен — Child рендерится в любом случае
const handleClick = useCallback(() => {
setCount(c => c + 1);
}, []);
return <Child onClick={handleClick} />; // Child без React.memo
};
// 3. Функция передаётся только в зависимости useEffect
const EffectOnly = () => {
const [data, setData] = useState(null);
// Если функция нужна только в useEffect — useCallback избыточен
useEffect(() => {
const fetchData = async () => {
const result = await api.fetch();
setData(result);
};
fetchData();
}, []);
return <div>{data}</div>;
};
Итог
- Проблема: каждая функция в JavaScript — новый объект с новой ссылкой. При каждом рендере родителя создаются новые функции → React.memo видит новые пропсы → рендер не пропускается.
- Решение: useCallback сохраняет стабильную ссылку на функцию между рендерами, пока не изменятся зависимости.
- Лучшая практика: используйте функциональное обновление состояния (
setCount(c => c + 1)) — это позволяет использовать пустые зависимости в useCallback. - Альтернатива: useReducer с передачей dispatch — dispatch стабилен по умолчанию, не требует useCallback.
- Главное правило: не оборачивайте все функции в useCallback без разбора — только те, которые передаются в мемоизированные дочерние компоненты.
Вопрос 13. Как решить проблему рендеринга большого списка (1000 элементов), который вызывает лаги?
Таймкод: 00:20:35
Ответ собеседника: Правильный. Использовать виртуализацию списка (виртуальный список). Работает на примере библиотеки tanstack/virtual. Суть: есть область видимости (viewport) и estimated size — ожидаемая высота элемента списка. Подсчитывается количество элементов на основе массива данных. Отрисовываются только элементы в области видимости экрана (например, ~10 штук). При скролле размонтируются элементы, вышедшие за пределы видимости (сверху), и монтируются новые (снизу — 11, 12, 13 и т.д.). Также можно решить пагинацией на бэкенде.
Правильный ответ:
Ответ собеседника отличный — он точно описывает механизм виртуализации и упоминает конкретную библиотеку. Разберём тему глубже — почему большие списки тормозят, как именно работает виртуализация внутри, какие есть библиотеки и какие дополнительные оптимизации можно применить.
Почему большие списки тормозят
// ❌ Проблема: рендеринг 1000 элементов одновременно
const SlowList = ({ items }: { items: Item[] }) => {
return (
<div style={{ height: '600px', overflow: 'auto' }}>
{items.map(item => (
<ExpensiveItem key={item.id} item={item} />
))}
</div>
);
};
// Что происходит при рендере 1000 элементов:
// 1. React создаёт 1000 Fiber узлов в памяти
// 2. React вычисляет layout для 1000 элементов
// 3. Браузер создаёт 1000 DOM-узлов
// 4. Браузер вычисляет стили для 1000 элементов
// 5. Браузер выполняет layout (reflow) для 1000 элементов
// 6. Браузер отрисовывает (repaint) видимую часть
// Итого: ~1000 DOM-узлов, ~1000 вычислений стилей, ~1000 layout-вычислений
// При скролле: повторный layout + repaint для видимой области
// Проблемы:
// - Инициальный рендер: 200-500ms (заметная задержка)
// - Потребление памяти: ~50-100MB для 1000 сложных элементов
// - Скролл: 16ms на кадр (60fps) — любая задержка видна
// - Перерендер при изменении одного элемента: все 1000 элементов
Виртуализация — принцип работы
// Виртуализация: рендерим только видимые элементы
// + небольшой буфер сверху и снизу для плавного скролла
// Визуализация:
//
// ┌─────────────────────────┐
// │ Невидимая область │ ← Не рендерится
// │ (элементы 0-49) │
// ├─────────────────────────┤ ← scrollTop
// │ Буфер (элементы 50-54) │ ← Рендерится (подготовка)
// ├─────────────────────────┤
// │ │
// │ Видимая область │ ← Рендерится (~10-15 элементов)
// │ (элементы 55-69) │
// │ │
// ├─────────────────────────┤
// │ Буфер (элементы 70-74) │ ← Рендерится (подготовка)
// ├─────────────────────────┤ ← scrollTop + viewportHeight
// │ Невидимая область │ ← Не рендерится
// │ (элементы 75-999) │
// └─────────────────────────┘
// Итого: рендерится ~20-25 элементов вместо 1000
// Выигрыш: ~40-50x меньше DOM-узлов, ~40-50x меньше вычислений
Реализация с TanStack Virtual
import { useVirtualizer } from '@tanstack/react-virtual';
import { useRef } from 'react';
interface VirtualListProps {
items: Item[];
itemHeight: number; // Фиксированная высота элемента
overscan?: number; // Количество дополнительных элементов для рендера
}
const VirtualList: React.FC<VirtualListProps> = ({
items,
itemHeight,
overscan = 5,
}) => {
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => itemHeight,
overscan, // Буфер: 5 элементов сверху и снизу
// Для элементов переменной 高度:
// measureElement: (element) => element.getBoundingClientRect().height,
});
// Виртуальные элементы — только те, что нужно рендерить
const virtualItems = virtualizer.getVirtualItems();
return (
<div
ref={parentRef}
style={{
height: '600px',
overflow: 'auto',
position: 'relative',
}}
>
{/* Контейнер полной высоты для корректного скроллбара */}
<div
style={{
height: virtualizer.getTotalSize(),
width: '100%',
position: 'relative',
}}
>
{virtualItems.map(virtualItem => (
<div
key={virtualItem.key}
data-index={virtualItem.index}
ref={virtualizer.measureElement}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: virtualItem.size,
transform: `translateY(${virtualItem.start}px)`,
}}
>
<ListItem item={items[virtualItem.index]} />
</div>
))}
</div>
</div>
);
};
Виртуализация с переменной высотой элементов
// Когда элементы имеют разную высоту
// TanStack Virtual автоматически измеряет и кэширует размеры
const VariableHeightList: React.FC<{ items: VariableItem[] }> = ({ items }) => {
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: (index) => {
// Предполагаемая высота на основе типа контента
const item = items[index];
if (item.type === 'text') return 60;
if (item.type === 'image') return 200;
if (item.type === 'video') return 300;
return 80;
},
overscan: 10,
});
const virtualItems = virtualizer.getVirtualItems();
return (
<div ref={parentRef} style={{ height: '600px', overflow: 'auto' }}>
<div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
{virtualItems.map(virtualItem => (
<div
key={virtualItem.key}
data-index={virtualItem.index}
ref={virtualizer.measureElement}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualItem.start}px)`,
}}
>
{items[virtualItem.index].type === 'text' && (
<TextItem content={items[virtualItem.index].content} />
)}
{items[virtualItem.index].type === 'image' && (
<ImageItem src={items[virtualItem.index].src} />
)}
{items[virtualItem.index].type === 'video' && (
<VideoItem src={items[virtualItem.index].src} />
)}
</div>
))}
</div>
</div>
);
};
react-window — классическая библиотека
import { FixedSizeList, VariableSizeList } from 'react-window';
import { memo } from 'react';
interface ItemData {
items: Item[];
onSelect: (id: string) => void;
}
// Мемоизированный компонент строки
const Row = memo(({ index, style, data }: { index: number; style: React.CSSProperties; data: ItemData }) => {
const item = data.items[index];
return (
<div style={style} onClick={() => data.onSelect(item.id)}>
<div className="item-content">
<span className="item-name">{item.name}</span>
<span className="item-description">{item.description}</span>
</div>
</div>
);
});
// Список с фиксированной высотой
const FixedHeightList: React.FC<{ items: Item[] }> = ({ items }) => {
const handleSelect = useCallback((id: string) => {
console.log('Selected:', id);
}, []);
const itemData = useMemo(() => ({
items,
onSelect: handleSelect,
}), [items, handleSelect]);
return (
<FixedSizeList
height={600}
width="100%"
itemCount={items.length}
itemSize={80}
itemData={itemData}
overscanCount={5}
>
{Row}
</FixedSizeList>
);
};
react-virtuoso — продвинутая виртуализация
import { Virtuoso } from 'react-virtuoso';
// react-virtuoso автоматически определяет размеры элементов
// и поддерживает бесконечную прокрутку
const VirtuosoList: React.FC<{ items: Item[] }> = ({ items }) => {
return (
<Virtuoso
style={{ height: '600px' }}
data={items}
itemContent={(index, item) => (
<div style={{ padding: '10px', borderBottom: '1px solid #eee' }}>
<h3>{item.name}</h3>
<p>{item.description}</p>
</div>
)}
overscan={200} // Дополнительный рендер за пределами viewport (в пикселях)
/>
);
};
// Бесконечная прокрутка с подгрузкой
const InfiniteList: React.FC = () => {
const [items, setItems] = useState<Item[]>([]);
const [loading, setLoading] = useState(false);
const [hasMore, setHasMore] = useState(true);
const loadMore = useCallback(async () => {
if (loading || !hasMore) return;
setLoading(true);
const newItems = await fetchItems(items.length, 20);
setItems(prev => [...prev, ...newItems]);
setHasMore(newItems.length === 20);
setLoading(false);
}, [items.length, loading, hasMore]);
return (
<Virtuoso
style={{ height: '600px' }}
data={items}
endReached={loadMore}
itemContent={(index, item) => (
<div className="item">
<h3>{item.name}</h3>
<p>{item.description}</p>
</div>
)}
components={{
Footer: () => loading ? <div>Loading...</div> : null,
}}
/>
);
};
Сравнение библиотек виртуализации
// react-window
// Плюсы: лёгкая (~3KB), стабильная, хорошо протестирована
// Минусы: нет переменной высоты из коробки, нет бесконечной прокрутки
// Размер: ~3KB gzip
// Когда: простые списки с фиксированной высотой
// react-virtuoso
// Плюсы: автоматическое определение размеров, бесконечная прокрутка,
// grouped items, sticky headers, переменная высота
// Минусы: больше размер, сложнее API
// Размер: ~8KB gzip
// Когда: сложные списки с переменной высотой, бесконечная прокрутка
// @tanstack/react-virtual
// Плюсы: фреймворк-агностичный (React, Vue, Solid, Svelte),
// переменная высота, горизонтальная виртуализация,
// grid-виртуализация, кастомные скролл-контейнеры
// Минусы: требует ручной настройки для сложных случаев
// Размер: ~4KB gzip
// Когда: максимальная гибкость, нестандартные кейсы
// Сравнительная таблица:
//
// Функция │ react-window │ react-virtuoso │ tanstack/virtual
// ─────────────────────┼──────────────┼────────────────┼─────────────────
// Фиксированная высота │ ✅ │ ✅ │ ✅
// Переменная высота │ ⚠️ │ ✅ │ ✅
// Горизонтальная │ ✅ │ ✅ │ ✅
// Grid │ ✅ │ ❌ │ ✅
// Бесконечная прокрутка│ ❌ │ ✅ │ ⚠️
// Sticky headers │ ❌ │ ✅ │ ⚠️
// Размер (gzip) │ ~3KB │ ~8KB │ ~4KB
Дополнительные оптимизации для больших списков
// 1. Мемоизация элементов списка
const MemoizedListItem = React.memo(
({ item, onSelect }: { item: Item; onSelect: (id: string) => void }) => {
return (
<div onClick={() => onSelect(item.id)}>
<h3>{item.name}</h3>
<p>{item.description}</p>
</div>
);
},
(prev, next) => prev.item.id === next.item.id && prev.item === next.item
);
// 2. Стабильные коллбэки
const StableList: React.FC<{ items: Item[] }> = ({ items }) => {
// useCallback предотвращает ре-рендер при перерендере родителя
const handleSelect = useCallback((id: string) => {
console.log('Selected:', id);
}, []);
return (
<Virtuoso
data={items}
itemContent={(index, item) => (
<MemoizedListItem item={item} onSelect={handleSelect} />
)}
/>
);
};
// 3. Дебаунс для поиска в списке
const SearchableList: React.FC<{ items: Item[] }> = ({ items }) => {
const [search, setSearch] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');
useEffect(() => {
const timer = setTimeout(() => setDebouncedSearch(search), 300);
return () => clearTimeout(timer);
}, [search]);
const filteredItems = useMemo(() => {
if (!debouncedSearch) return items;
return items.filter(item =>
item.name.toLowerCase().includes(debouncedSearch.toLowerCase())
);
}, [items, debouncedSearch]);
return (
<div>
<input
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Search..."
/>
<Virtuoso
data={filteredItems}
itemContent={(index, item) => <ListItem item={item} />}
/>
</div>
);
};
// 4. Ленивая загрузка изображений
const LazyImageItem: React.FC<{ item: Item }> = ({ item }) => {
const [loaded, setLoaded] = useState(false);
return (
<div>
{!loaded && <div className="skeleton" style={{ height: 200 }} />}
<img
src={item.imageUrl}
alt={item.name}
loading="lazy"
onLoad={() => setLoaded(true)}
style={{ display: loaded ? 'block' : 'none' }}
/>
</div>
);
};
// 5. Пагинация на бэкенде для очень больших списков
const PaginatedList: React.FC = () => {
const [page, setPage] = useState(1);
const [allItems, setAllItems] = useState<Item[]>([]);
const [hasMore, setHasMore] = useState(true);
useEffect(() => {
fetchItems(page).then(newItems => {
setAllItems(prev => [...prev, ...newItems]);
setHasMore(newItems.length === 20);
});
}, [page]);
return (
<div>
<Virtuoso
data={allItems}
endReached={() => hasMore => setPage(p => p + 1)}
itemContent={(index, item) => <ListItem item={item} />}
components={{
Footer: () => hasMore ? <div>Loading more...</div> : null,
}}
/>
</div>
);
};
Итог
- Виртуализация — основной инструмент для больших списков. Рендерим только видимые элементы + небольшой буфер.
- Выбор библиотеки: react-window для простых случаев, react-virtuoso для сложных (переменная высота, бесконечная прокрутка), tanstack/virtual для максимальной гибкости.
- Дополнительные оптимизации: React.memo для элементов, useCallback для коллбэков, дебаунс для поиска, ленивая загрузка изображений.
- Пагинация на бэкенде — когда список может содержать десятки тысяч элементов, виртуализация с бесконечной подгрузкой предпочтительнее загрузки всех данных сразу.
- Профилируйте — используйте React DevTools Profiler для измерения реального улучшения после внедрения виртуализации.
Вопрос 14. Как организуешь работу с бэкендом на фронтенде: какие подходы и инструменты используешь?
Таймкод: 00:23:15
Ответ собеседника: Правильный. Хранит URL бэкенда в environment-переменных. Использует подход с папкой services — отдельно от FSD. Пишет функции с fetch для получения данных по конкретным API-поинтам с возможностью прокидывания параметров. Функции возвращают промисы. Также использует TanStack Query (React Query), который предоставляет флаги загрузки, спиннеры, лоадинг, стратегии кэширования, мутации и запросы. Работает через Next.js с React Query.
Правильный ответ:
Ответ собеседника хороший — он описывает рабочий подход с использованием современных инструментов. Разберём тему глубже — как правильно организовать слой работы с API, какие паттерны применять и как обрабатывать ошибки.
Архитектура слоя работы с API
1. Базовая конфигурация HTTP-клиента
// api/client.ts — базовый HTTP-клиент
interface RequestConfig extends RequestInit {
params?: Record<string, string | number | boolean>;
timeout?: number;
}
class ApiClient {
private baseUrl: string;
private defaultHeaders: HeadersInit;
private defaultTimeout: number;
constructor(config: {
baseUrl: string;
headers?: HeadersInit;
timeout?: number;
}) {
this.baseUrl = config.baseUrl.replace(/\/$/, '');
this.defaultHeaders = {
'Content-Type': 'application/json',
...config.headers,
};
this.defaultTimeout = config.timeout || 10000;
}
private buildUrl(endpoint: string, params?: Record<string, string | number | boolean>): string {
const url = new URL(`${this.baseUrl}${endpoint}`);
if (params) {
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
url.searchParams.append(key, String(value));
}
});
}
return url.toString();
}
private async request<T>(
endpoint: string,
config: RequestConfig = {}
): Promise<T> {
const { params, timeout = this.defaultTimeout, ...fetchConfig } = config;
const url = this.buildUrl(endpoint, params);
// AbortController для таймаута
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
try {
const response = await fetch(url, {
...fetchConfig,
headers: {
...this.defaultHeaders,
...fetchConfig.headers,
},
signal: controller.signal,
});
clearTimeout(timeoutId);
// Обработка ошибок HTTP
if (!response.ok) {
const errorBody = await response.json().catch(() => null);
throw new ApiError(
response.status,
errorBody?.message || response.statusText,
errorBody?.code,
errorBody
);
}
// Пустой ответ (204 No Content)
if (response.status === 204) {
return undefined as T;
}
return response.json();
} catch (error) {
clearTimeout(timeoutId);
if (error instanceof ApiError) {
throw error;
}
if (error instanceof DOMException && error.name === 'AbortError') {
throw new ApiError(408, 'Request timeout', 'TIMEOUT');
}
// Сетевые ошибки
throw new ApiError(0, 'Network error', 'NETWORK_ERROR', { originalError: error });
}
}
get<T>(endpoint: string, config?: RequestConfig): Promise<T> {
return this.request<T>(endpoint, { ...config, method: 'GET' });
}
post<T>(endpoint: string, data?: unknown, config?: RequestConfig): Promise<T> {
return this.request<T>(endpoint, {
...config,
method: 'POST',
body: data ? JSON.stringify(data) : undefined,
});
}
put<T>(endpoint: string, data?: unknown, config?: RequestConfig): Promise<T> {
return this.request<T>(endpoint, {
...config,
method: 'PUT',
body: data ? JSON.stringify(data) : undefined,
});
}
patch<T>(endpoint: string, data?: unknown, config?: RequestConfig): Promise<T> {
return this.request<T>(endpoint, {
...config,
method: 'PATCH',
body: data ? JSON.stringify(data) : undefined,
});
}
delete<T>(endpoint: string, config?: RequestConfig): Promise<T> {
return this.request<T>(endpoint, { ...config, method: 'DELETE' });
}
}
// Типизированный класс ошибки API
class ApiError extends Error {
constructor(
public status: number,
message: string,
public code?: string,
public data?: unknown
) {
super(message);
this.name = 'ApiError';
}
isNotFound(): boolean {
return this.status === 404;
}
isUnauthorized(): boolean {
return this.status === 401;
}
isForbidden(): boolean {
return this.status === 403;
}
isValidationError(): boolean {
return this.status === 422;
}
isServerError(): boolean {
return this.status >= 500;
}
isNetworkError(): boolean {
return this.status === 0;
}
}
// Создаём экземпляр клиента
export const apiClient = new ApiClient({
baseUrl: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080',
timeout: 15000,
});
export { ApiError };
2. Слой сервисов — типобезопасные API-методы
// api/services/user.service.ts
import { apiClient } from '../client';
// Типы данных
export interface User {
id: string;
email: string;
name: string;
avatar: string | null;
role: 'user' | 'admin' | 'moderator';
createdAt: string;
updatedAt: string;
}
export interface CreateUserInput {
email: string;
name: string;
password: string;
}
export interface UpdateUserInput {
name?: string;
avatar?: string;
}
export interface UsersListResponse {
items: User[];
total: number;
page: number;
pageSize: number;
totalPages: number;
}
export interface GetUsersParams {
page?: number;
pageSize?: number;
search?: string;
role?: string;
sortBy?: 'name' | 'email' | 'createdAt';
sortOrder?: 'asc' | 'desc';
}
// Сервис для работы с пользователями
export const userService = {
async getUsers(params?: GetUsersParams): Promise<UsersListResponse> {
return apiClient.get<UsersListResponse>('/api/users', { params });
},
async getUserById(id: string): Promise<User> {
return apiClient.get<User>(`/api/users/${id}`);
},
async getUserByEmail(email: string): Promise<User> {
return apiClient.get<User>(`/api/users/email/${encodeURIComponent(email)}`);
},
async createUser(input: CreateUserInput): Promise<User> {
return apiClient.post<User>('/api/users', input);
},
async updateUser(id: string, input: UpdateUserInput): Promise<User> {
return apiClient.patch<User>(`/api/users/${id}`, input);
},
async deleteUser(id: string): Promise<void> {
return apiClient.delete(`/api/users/${id}`);
},
async uploadAvatar(id: string, file: File): Promise<{ avatarUrl: string }> {
const formData = new FormData();
formData.append('avatar', file);
return apiClient.post(`/api/users/${id}/avatar`, formData, {
headers: {
// Убираем Content-Type — браузер сам установит с boundary
'Content-Type': undefined as unknown as string,
},
});
},
};
// api/services/auth.service.ts
export interface AuthTokens {
accessToken: string;
refreshToken: string;
expiresIn: number;
}
export interface LoginInput {
email: string;
password: string;
}
export interface RegisterInput {
email: string;
name: string;
password: string;
}
export const authService = {
async login(input: LoginInput): Promise<AuthTokens> {
return apiClient.post<AuthTokens>('/api/auth/login', input);
},
async register(input: RegisterInput): Promise<AuthTokens> {
return apiClient.post<AuthTokens>('/api/auth/register', input);
},
async refreshToken(refreshToken: string): Promise<AuthTokens> {
return apiClient.post<AuthTokens>('/api/auth/refresh', { refreshToken });
},
async logout(): Promise<void> {
return apiClient.post('/api/auth/logout');
},
async forgotPassword(email: string): Promise<void> {
return apiClient.post('/api/auth/forgot-password', { email });
},
async resetPassword(token: string, newPassword: string): Promise<void> {
return apiClient.post('/api/auth/reset-password', { token, newPassword });
},
};
3. Интеграция с TanStack Query
// api/hooks/useUsers.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { userService, GetUsersParams, CreateUserInput, UpdateUserInput } from '../services/user.service';
// Ключи запросов — централизованное управление
export const userKeys = {
all: ['users'] as const,
lists: () => [...userKeys.all, 'list'] as const,
list: (params: GetUsersParams) => [...userKeys.lists(), params] as const,
details: () => [...userKeys.all, 'detail'] as const,
detail: (id: string) => [...userKeys.details(), id] as const,
};
// Хук для получения списка пользователей
export const useUsers = (params: GetUsersParams = {}) => {
return useQuery({
queryKey: userKeys.list(params),
queryFn: () => userService.getUsers(params),
staleTime: 5 * 60 * 1000, // 5 минут — данные считаются свежими
gcTime: 10 * 60 * 1000, // 10 минут — время хранения в кэше
placeholderData: (previousData) => previousData, // Показываем старые данные при фетче
});
};
// Хук для получения одного пользователя
export const useUser = (id: string) => {
return useQuery({
queryKey: userKeys.detail(id),
queryFn: () => userService.getUserById(id),
enabled: !!id, // Запрос только если есть id
staleTime: 2 * 60 * 1000,
});
};
// Хук для создания пользователя
export const useCreateUser = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: CreateUserInput) => userService.createUser(input),
// Оптимистичное обновление
onMutate: async (newUser) => {
// Отменяем текущие запросы
await queryClient.cancelQueries({ queryKey: userKeys.lists() });
// Сохраняем предыдущие данные для отката
const previousLists = queryClient.getQueriesData<UsersListResponse>({
queryKey: userKeys.lists(),
});
// Оптимистично добавляем нового пользователя
queryClient.setQueriesData<UsersListResponse>(
{ queryKey: userKeys.lists() },
(old) => {
if (!old) return old;
const optimisticUser: User = {
id: `temp-${Date.now()}`,
email: newUser.email,
name: newUser.name,
avatar: null,
role: 'user',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
return {
...old,
items: [optimisticUser, ...old.items],
total: old.total + 1,
};
}
);
return { previousLists };
},
// Откат при ошибке
onError: (error, newUser, context) => {
if (context?.previousLists) {
context.previousLists.forEach(([key, data]) => {
queryClient.setQueryData(key, data);
});
}
},
// Инвалидация после успеха
onSettled: () => {
queryClient.invalidateQueries({ queryKey: userKeys.lists() });
},
onSuccess: (data) => {
// Кэшируем созданного пользователя
queryClient.setQueryData(userKeys.detail(data.id), data);
},
});
};
// Хук для обновления пользователя
export const useUpdateUser = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, input }: { id: string; input: UpdateUserInput }) =>
userService.updateUser(id, input),
onSuccess: (updatedUser) => {
// Обновляем кэш конкретного пользователя
queryClient.setQueryData(userKeys.detail(updatedUser.id), updatedUser);
// Инвалидация списков
queryClient.invalidateQueries({ queryKey: userKeys.lists() });
},
});
};
// Хук для удаления пользователя
export const useDeleteUser = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => userService.deleteUser(id),
onSuccess: (_, deletedId) => {
// Удаляем из кэша
queryClient.removeQueries({ queryKey: userKeys.detail(deletedId) });
// Инвалидация списков
queryClient.invalidateQueries({ queryKey: userKeys.lists() });
},
});
};
4. Использование в компонентах
// components/UserList.tsx
import { useState } from 'react';
import { useUsers, useDeleteUser } from '@/api/hooks/useUsers';
const UserList: React.FC = () => {
const [page, setPage] = useState(1);
const [search, setSearch] = useState('');
const { data, isLoading, isError, error, isFetching } = useUsers({
page,
pageSize: 20,
search: search || undefined,
});
const deleteUser = useDeleteUser();
if (isLoading) {
return <UserListSkeleton count={20} />;
}
if (isError) {
return <ErrorMessage error={error} />;
}
return (
<div>
{/* Индикатор фонового обновления */}
{isFetching && !isLoading && (
<div className="update-indicator">Updating...</div>
)}
<SearchInput value={search} onChange={setSearch} />
<div className="user-list">
{data.items.map(user => (
<UserCard
key={user.id}
user={user}
onDelete={() => deleteUser.mutate(user.id)}
isDeleting={deleteUser.isPending}
/>
))}
</div>
<Pagination
currentPage={page}
totalPages={data.totalPages}
onPageChange={setPage}
/>
</div>
);
};
// components/UserProfile.tsx
import { useUser, useUpdateUser } from '@/api/hooks/useUsers';
const UserProfile: React.FC<{ userId: string }> = ({ userId }) => {
const { data: user, isLoading } = useUser(userId);
const updateUser = useUpdateUser();
if (isLoading) return <ProfileSkeleton />;
if (!user) return <NotFound />;
const handleUpdateName = async (newName: string) => {
try {
await updateUser.mutateAsync({ id: userId, input: { name: newName } });
// Успех — кэш обновился автоматически
} catch (error) {
// Обработка ошибки — кэш откатился автоматически
showToast('Failed to update profile');
}
};
return (
<div>
<h1>{user.name}</h1>
<p>{user.email}</p>
<EditableName value={user.name} onSave={handleUpdateName} />
</div>
);
};
5. Глобальная обработка ошибок и аутентификация
// api/interceptors.ts
import { QueryClient } from '@tanstack/react-query';
import { authService } from './services/auth.service';
import { ApiError } from './client';
// Глобальный обработчик ошибок для TanStack Query
export const queryErrorHandler = (error: unknown) => {
if (error instanceof ApiError) {
switch (error.status) {
case 401:
// Перенаправление на логин
window.location.href = '/login';
break;
case 403:
showToast('You do not have permission to perform this action');
break;
case 422:
// Ошибки валидации — обрабатываются в мутациях
break;
case 429:
showToast('Too many requests. Please try again later.');
break;
default:
showToast(error.message || 'An error occurred');
}
}
};
// Настройка QueryClient с глобальными обработчиками
export const createQueryClient = () =>
new QueryClient({
defaultOptions: {
queries: {
retry: (failureCount, error) => {
// Не ретраим 401, 403, 404
if (error instanceof ApiError) {
if (error.status === 401 || error.status === 403 || error.status === 404) {
return false;
}
}
// Ретрайим сетевые ошибки и 500 до 3 раз
return failureCount < 3;
},
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
refetchOnWindowFocus: false,
staleTime: 5 * 60 * 1000,
},
mutations: {
retry: false,
},
},
});
// Интерцептор для автоматического обновления токена
export const setupAuthInterceptor = (queryClient: QueryClient) => {
// Перехватываем 401 и пытаемся обновить токен
const originalFetch = window.fetch;
window.fetch = async (...args) => {
const response = await originalFetch(...args);
if (response.status === 401) {
try {
const refreshToken = getRefreshToken();
const { accessToken, refreshToken: newRefreshToken } = await authService.refreshToken(refreshToken);
setAccessToken(accessToken);
setRefreshToken(newRefreshToken);
// Повторяем оригинальный запрос с новым токеном
const [url, config] = args;
return originalFetch(url, {
...config,
headers: {
...config?.headers,
Authorization: `Bearer ${accessToken}`,
},
});
} catch {
// Не удалось обновить токен — разлогиниваем
clearTokens();
queryClient.clear();
window.location.href = '/login';
return response;
}
}
return response;
};
};
6. Структура проекта
src/
├── api/
│ ├── client.ts # Базовый HTTP-клиент
│ ├── interceptors.ts # Интерцепторы (auth, error handling)
│ ├── types.ts # Общие типы API (pagination, errors)
│ ├── services/
│ │ ├── user.service.ts # API-методы для пользователей
│ │ ├── auth.service.ts # API-методы для аутентификации
│ │ ├── post.service.ts # API-методы для постов
│ │ └── index.ts # Re-export всех сервисов
│ └── hooks/
│ ├── useUsers.ts # Хуки для работы с пользователями
│ ├── useAuth.ts # Хуки для аутентификации
│ ├── usePosts.ts # Хуки для работы с постами
│ └── index.ts # Re-export всех хуков
Ключевые принципы
- Централизованный HTTP-клиент — одна точка для настройки base URL, заголовков, таймаутов, обработки ошибок.
- Сервисный слой — типобезопасные функции для каждого API-эндпоинта, отдельно от UI-логики.
- TanStack Query — кэширование, автоматическая инвалидация, оптимистичные обновления, фоновые рефетчи.
- Централизованные query keys — ключи запросов в одном месте для удобной инвалидации.
- Глобальная обработка ошибок — единая логика для 401, 403, 404, 422, 500, сетевых ошибок.
- Автоматическая ротация токенов — интерцептор для обновления access-токена при 401.
Вопрос 15. Как работают серверные и клиентские компоненты в Next.js и как синхронизировать серверные данные с клиентскими при реализации бесконечной ленты?
Таймкод: 00:25:27
Ответ собеседника: Неполный. Серверные компоненты рендерятся на сервере и отдают готовый HTML, клиентские — рендерятся на клиенте. В современных Next.js можно сделать запрос прямо внутри серверного компонента и передать данные пропсами вниз. Для синхронизации серверных и клиентских данных при бесконечном скролле — знает о существовании параметра initialData в TanStack Query, но признал, что почти с ним не работал и затруднился дать полный ответ.
Правильный ответ:
Ответ собеседника частично верный, но не раскрывает ключевые аспекты: различия в возможностях серверных и клиентских компонентов, механизм их взаимодействия, и главное — полную архитектуру синхронизации данных для бесконечной ленты. Разберём всё подробно.
Серверные vs Клиентские компоненты в Next.js App Router
// СЕРВЕРНЫЙ КОМПОНЕНТ (по умолчанию в App Router)
// Файл: app/posts/page.tsx
// Это серверный компонент — он выполняется только на сервере
// Может быть async, может делать запросы к БД напрямую
import { db } from '@/lib/db'; // Прямой доступ к БД!
import { PostsList } from './PostsList'; // Клиентский компонент
// Серверный компонент может быть async
export default async function PostsPage({
searchParams,
}: {
searchParams: { page?: string };
}) {
const page = parseInt(searchParams.page || '1', 10);
const pageSize = 20;
// Прямой запрос к базе данных — без API-слоя!
const posts = await db.post.findMany({
skip: (page - 1) * pageSize,
take: pageSize,
orderBy: { createdAt: 'desc' },
include: { author: true },
});
const total = await db.post.count();
const totalPages = Math.ceil(total / pageSize);
return (
<div>
<h1>Posts</h1>
{/* Серверные данные передаются как initialData в клиентский компонент */}
<PostsList
initialPosts={posts}
initialPage={page}
totalPages={totalPages}
/>
</div>
);
}
// Что происходит:
// 1. Сервер выполняет async функцию, получает данные из БД
// 2. React рендерит компонент на сервере в RSC (React Server Components) payload
// 3. Отправляет клиенту: HTML для немедленного отображения + RSC payload
// 4. Клиент получает готовый HTML — мгновенный First Contentful Paint
// 5. Клиентский компонент hydrate-ится и становится интерактивным
// КЛИЕНТСКИЙ КОМПОНЕНТ
// Файл: app/posts/PostsList.tsx
'use client'; // Директива — этот компонент выполняется на клиенте
import { useState, useCallback } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { Virtuoso } from 'react-virtuoso';
import { fetchPosts } from '@/api/services/post.service';
interface PostsListProps {
initialPosts: Post[];
initialPage: number;
totalPages: number;
}
export function PostsList({ initialPosts, initialPage, totalPages }: PostsListProps) {
const [currentPage, setCurrentPage] = useState(initialPage);
// initialData — данные с сервера, placeholderData — для плавного перехода
const { data, isLoading, isFetching, hasNextPage, fetchNextPage } = useQuery({
queryKey: ['posts', 'infinite'],
queryFn: ({ pageParam = 1 }) => fetchPosts({ page: pageParam, pageSize: 20 }),
initialData: {
pages: [{ items: initialPosts, totalPages }],
pageParams: [1],
},
getNextPageParam: (lastPage, allPages) => {
const nextPage = allPages.length + 1;
return nextPage <= lastPage.totalPages ? nextPage : undefined;
},
staleTime: 60 * 1000, // 1 минута — данные считаются свежими
});
// Все посты из всех загруженных страниц
const allPosts = data?.pages.flatMap(page => page.items) ?? [];
// Загрузка следующей страницы при достижении конца списка
const handleEndReached = useCallback(() => {
if (hasNextPage && !isFetching) {
fetchNextPage();
}
}, [hasNextPage, isFetching, fetchNextPage]);
return (
<div>
{isFetching && !isLoading && (
<div className="update-indicator">Loading more...</div>
)}
<Virtuoso
data={allPosts}
endReached={handleEndReached}
itemContent={(index, post) => (
<PostCard key={post.id} post={post} />
)}
overscan={200}
components={{
Footer: () => {
if (!hasNextPage) {
return <div className="end-message">No more posts</div>;
}
if (isFetching) {
return <div className="loading-spinner">Loading...</div>;
}
return null;
},
}}
/>
</div>
);
}
Различия между серверными и клиентскими компонентами
// ═══════════════════════════════════════════════════════════════
// СЕРВЕРНЫЙ КОМПОНЕНТ
// ═══════════════════════════════════════════════════════════════
// ✅ Может быть async
export default async function ServerComponent() {
// ✅ Прямой доступ к БД
const users = await db.user.findMany();
// ✅ Прямой доступ к файловой системе
const content = await fs.readFile('README.md', 'utf-8');
// ✅ Доступ к секретам и API-ключам (не попадают в клиентский бандл)
const apiKey = process.env.SECRET_API_KEY;
// ✅ Использование серверных библиотек (bcrypt, sharp, pg, etc.)
// ❌ Не может использовать useState, useEffect, useContext
// ❌ Не может использовать браузерные API (window, document, localStorage)
// ❌ Не может использовать обработчики событий (onClick, onChange)
// ❌ Не может использовать React hooks
return <div>{users.map(u => <p key={u.id}>{u.name}</p>)}</div>;
}
// ═══════════════════════════════════════════════════════════════
// КЛИЕНТСКИЙ КОМПОНЕНТ
// ═══════════════════════════════════════════════════════════════
'use client';
import { useState, useEffect } from 'react';
export default function ClientComponent() {
// ✅ Может использовать React state
const [count, setCount] = useState(0);
// ✅ Может использовать эффекты
useEffect(() => {
console.log('Component mounted');
}, []);
// ✅ Может использовать обработчики событий
const handleClick = () => setCount(c => c + 1);
// ✅ Может использовать браузерные API
useEffect(() => {
const saved = localStorage.getItem('count');
if (saved) setCount(parseInt(saved, 10));
}, []);
// ❌ Не может быть async
// ❌ Не имеет прямого доступа к БД
// ❌ Не имеет доступа к серверным секретам
return <button onClick={handleClick}>Count: {count}</button>;
}
Архитектура бесконечной ленты с SSR + CSR
┌─────────────────────────────────────────────────────────┐
│ ПЕРВИЧНЫЙ ЗАПРОС │
│ │
│ ┌──────────┐ SSR ┌──────────────────────────┐ │
│ │ Browser │ ────────→ │ Next.js Server │ │
│ │ │ │ │ │
│ │ │ │ 1. Запрос к БД │ │
│ │ │ │ 2. Рендер Server Comp. │ │
│ │ │ │ 3. Отправка HTML + RSC │ │
│ │ │ ←──────── │ payload │ │
│ └──────────┘ └──────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ 1. Мгновенный показ HTML (FCP) │ │
│ │ 2. Hydration клиентских компонентов │ │
│ │ 3. TanStack Query получает initialData из SSR │ │
│ └─────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ КЛИЕНТСКАЯ НАВИГАЦИЯ │
│ │
│ ┌──────────┐ CSR ┌──────────────────────────┐ │
│ │ Browser │ ────────→ │ Next.js API Route │ │
│ │ │ │ или внешний API │ │
│ │ TanStack│ │ │ │
│ │ Query │ ←──────── │ 1. Запрос к БД │ │
│ │ │ │ 2. Возврат JSON │ │
│ └──────────┘ └──────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ 1. TanStack Query обновляет кэш │ │
│ │ 2. Компонент ре-рендерит с новыми данными │ │
│ │ 3. Оптимистичные обновления при мутациях │ │
│ └─────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
Полная реализация бесконечной ленты
// app/posts/page.tsx — Серверный компонент
import { Suspense } from 'react';
import { PostsList } from './PostsList';
import { PostsSkeleton } from './PostsSkeleton';
import { getPosts } from '@/lib/posts'; // Серверная функция
export const metadata = {
title: 'Posts',
description: 'Latest posts feed',
};
export default async function PostsPage({
searchParams,
}: {
searchParams: Promise<{ page?: string }>;
}) {
const { page: pageParam } = await searchParams;
const page = parseInt(pageParam || '1', 10);
// Загружаем первую страницу на сервере
// Это попадёт в initialData клиентского компонента
const initialData = await getPosts({ page: 1, pageSize: 20 });
return (
<main className="container">
<h1>Posts Feed</h1>
{/* Suspense для показа skeleton во время загрузки */}
<Suspense fallback={<PostsSkeleton count={20} />}>
<PostsList
initialData={initialData}
initialPage={1}
/>
</Suspense>
</main>
);
}
// lib/posts.ts — Серверные функции для работы с данными
import { cache } from 'react';
import { db } from './db';
// cache() — дедупликация запросов в рамках одного рендера
export const getPosts = cache(async ({ page, pageSize }: { page: number; pageSize: number }) => {
const skip = (page - 1) * pageSize;
const [posts, total] = await Promise.all([
db.post.findMany({
skip,
take: pageSize,
orderBy: { createdAt: 'desc' },
include: {
author: { select: { id: true, name: true, avatar: true } },
_count: { select: { comments: true, likes: true } },
},
}),
db.post.count(),
]);
return {
items: posts,
total,
page,
pageSize,
totalPages: Math.ceil(total / pageSize),
};
});
export const getPostById = cache(async (id: string) => {
return db.post.findUnique({
where: { id },
include: {
author: true,
comments: { include: { author: true } },
},
});
});
// app/posts/PostsList.tsx — Клиентский компонент с бесконечной лентой
'use client';
import { useCallback, useRef } from 'react';
import { useInfiniteQuery } from '@tanstack/react-query';
import { Virtuoso } from 'react-virtuoso';
import { fetchPosts } from '@/api/services/post.service';
import { PostCard } from './PostCard';
import { PostCardSkeleton } from './PostCardSkeleton';
interface PostsListProps {
initialData: {
items: Post[];
total: number;
page: number;
pageSize: number;
totalPages: number;
};
initialPage: number;
}
export function PostsList({ initialData, initialPage }: PostsListProps) {
// useInfiniteQuery — специальный хук для бесконечной прокрутки
const {
data,
isLoading,
isFetching,
isFetchingNextPage,
hasNextPage,
fetchNextPage,
error,
} = useInfiniteQuery({
queryKey: ['posts', 'infinite'],
// Функция загрузки страницы
queryFn: async ({ pageParam }) => {
return fetchPosts({ page: pageParam, pageSize: 20 });
},
// Начальная страница
initialPageParam: 1,
// Как получить параметр следующей страницы
getNextPageParam: (lastPage) => {
if (lastPage.page >= lastPage.totalPages) {
return undefined; // Следующей страницы нет
}
return lastPage.page + 1;
},
// Данные с сервера — предзаполненный кэш первой страницы
initialData: {
pages: [initialData],
pageParams: [initialPage],
},
// Данные с сервера считаются свежими 30 секунд
staleTime: 30 * 1000,
});
// Собираем все посты из всех загруженных страниц в один плоский массив
const allPosts = data?.pages.flatMap(page => page.items) ?? [];
// Обработчик достижения конца списка — загружаем следующую страницу
const handleEndReached = useCallback(() => {
if (hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
// Состояние ошибки
if (error) {
return (
<div className="error-container">
<p>Failed to load posts</p>
<button onClick={() => fetchNextPage()}>Retry</button>
</div>
);
}
// Начальная загрузка (только при полном рефреше без initialData)
if (isLoading) {
return <PostsSkeleton count={20} />;
}
return (
<div className="posts-container">
{/* Индикатор фонового обновления */}
{isFetching && !isFetchingNextPage && (
<div className="bg-update-indicator">
<span className="spinner" /> Updating...
</div>
)}
{/* Виртуализированный список */}
<Virtuoso
data={allPosts}
endReached={handleEndReached}
overscan={300} // Дополнительный рендер 300px за пределами viewport
itemContent={(index, post) => (
<PostCard
key={post.id}
post={post}
// Первые 20 постов уже в кэше — мгновенный показ
// Остальные подгружаются при скролле
/>
)}
components={{
// Футер — индизагрузки или сообщение об окончании
Footer: () => {
if (isFetchingNextPage) {
return (
<div className="loading-more">
<PostCardSkeleton />
<PostCardSkeleton />
</div>
);
}
if (!hasNextPage && allPosts.length > 0) {
return (
<div className="end-message">
You've reached the end! 🎉
</div>
);
}
return null;
},
}}
/>
</div>
);
}
// api/services/post.service.ts — API-методы для клиентских запросов
import { apiClient } from '../client';
export interface Post {
id: string;
title: string;
content: string;
author: {
id: string;
name: string;
avatar: string | null;
};
_count: {
comments: number;
likes: number;
};
createdAt: string;
}
export interface PostsResponse {
items: Post[];
total: number;
page: number;
pageSize: number;
totalPages: number;
}
export interface FetchPostsParams {
page: number;
pageSize: number;
search?: string;
tag?: string;
}
export const fetchPosts = async (params: FetchPostsParams): Promise<PostsResponse> => {
return apiClient.get<PostsResponse>('/api/posts', { params });
};
export const fetchPostById = async (id: string): Promise<Post> => {
return apiClient.get<Post>(`/api/posts/${id}`);
};
export const createPost = async (input: CreatePostInput): Promise<Post> => {
return apiClient.post<Post>('/api/posts', input);
};
export const likePost = async (postId: string): Promise<{ likesCount: number }> => {
return apiClient.post(`/api/posts/${postId}/like`);
};
Ключевые моменты синхронизации
// 1. initialData — мост между сервером и клиентом
// Сервер рендерит первую страницу → передаёт как initialData
// TanStack Query использует эти данные как начальную страницу кэша
// При скролле — загружает следующие страницы через API
// 2. staleTime — как долго данные считаются свежими
// Если staleTime не истёк — TanStack Query не делает рефетч
// Это важно: при навигации назад данные с сервера ещё актуальны
// 3. placeholderData — показ старых данных при рефетче
// При изменении queryKey (например, поиск) — показываем старые данные
// пока загружаются новые
// 4. Инвалидация при мутациях
// Создание/редактирование/удаление поста → инвалидация кэша
// TanStack Query автоматически рефетчит данные
export const useCreatePost = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: createPost,
onSuccess: () => {
// Инвалидация бесконечного списка
// Это вызовет рефетч первой страницы
queryClient.invalidateQueries({
queryKey: ['posts', 'infinite'],
});
},
});
};
// 5. Оптимистичное обновление при лайке
export const useLikePost = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: likePost,
onMutate: async (postId) => {
// Отменяем текущие запросы
await queryClient.cancelQueries({ queryKey: ['posts', 'infinite'] });
// Сохраняем предыдущие данные
const previousData = queryClient.getQueryData(['posts', 'infinite']);
// Оптимистично обновляем кэш
queryClient.setQueryData(['posts', 'infinite'], (old: any) => {
if (!old) return old;
return {
...old,
pages: old.pages.map((page: PostsResponse) => ({
...page,
items: page.items.map((post: Post) =>
post.id === postId
? { ...post, _count: { ...post._count, likes: post._count.likes + 1 } }
: post
),
})),
};
});
return { previousData };
},
onError: (error, postId, context) => {
// Откат при ошибке
if (context?.previousData) {
queryClient.setQueryData(['posts', 'infinite'], context.previousData);
}
},
onSettled: () => {
// Финальная синхронизация с сервером
queryClient.invalidateQueries({ queryKey: ['posts', 'infinite'] });
},
});
};
Итог
- Серверные компоненты — выполняются на сервере, имеют доступ к БД, не увеличивают клиентский бандл, отправляют готовый HTML.
- Клиентские компоненты — выполняются на клиенте, обеспечивают интерактивность, используют React hooks и браузерные API.
- Синхронизация — сервер загружает первую страницу и передаёт как
initialDataв TanStack Query. Клиент использует эти данные как начальную страницу кэша и подгружает остальные при скролле черезuseInfiniteQuery. - Ключевые инструменты:
initialDataдля моста SSR→CSR,useInfiniteQueryдля бесконечной прокрутки,Virtuosoдля виртуализации, оптимистичные обновления для мгновенного UI.
Вопрос 16. Как оцениваешь свой уровень владения TypeScript по 10-балльной шкале и какие проблемы он решает?
Таймкод: 00:27:41
Ответ собеседника: Правильный. Оценивает уровень на 6-7 из 10 (семь с натяжкой). Главная проблема JavaScript — слабая динамическая типизация, возможность неявного приведения типов (например, сложение строки и числа даёт '22'). TypeScript решает эту проблему — число нельзя прибавить к строке, нет автоматического приведения типов. Также добавляет кастомные типы, type aliases, интерфейсы и другой функционал.
Правильный ответ:
Ответ собеседника верный в части проблем, которые решает TypeScript, но слишком поверхностный. Уровень 6-7 подразумевает знание продвинутых концепций, которые не были упомянуты. Разберём тему глубже — какие проблемы решает TypeScript на практике и какие продвинутые возможности он предоставляет.
Проблемы JavaScript, которые решает TypeScript
1. Ошибки типов во время выполнения → ошибки на этапе компиляции
// ❌ JavaScript — ошибка обнаружена только во время выполнения
function getUserName(user) {
return user.name.toUpperCase();
}
getUserName({ name: "John" }); // "JOHN" — работает
getUserName({ name: null }); // TypeError: Cannot read property 'toUpperCase' of null
getUserName({}); // TypeError: Cannot read property 'toUpperCase' of undefined
getUserName(null); // TypeError: Cannot read property 'name' of null
// Все эти ошибки обнаруживаются только в продакшене
// Пользователь видит красный экран → баг → откат деплоя
// ✅ TypeScript — ошибки обнаружены до запуска кода
interface User {
name: string;
}
function getUserName(user: User): string {
return user.name.toUpperCase();
}
getUserName({ name: "John" }); // ✅ Работает
getUserName({ name: null }); // ❌ Ошибка компиляции: Type 'null' is not assignable to type 'string'
getUserName({}); // ❌ Ошибка компиляции: Property 'name' is missing
getUserName(null); // ❌ Ошибка компиляции: Argument of type 'null' is not assignable
// Все ошибки обнаружены в IDE до запуска кода
// Никаких сюрпризов в продакшене
2. Неявное приведение типов
// ❌ JavaScript — неявное приведение типов создаёт баги
console.log(1 + "2"); // "12" — строка!
console.log("2" - 1); // 1 — число!
console.log([] + {}); // "[object Object]"
console.log({} + []); // 0 (в некоторых движках)
console.log(null == undefined); // true
console.log(null === undefined); // false
console.log(0 == "0"); // true
console.log(0 == []); // true
console.log("" == false); // true
// Эти особенности — источник бесчисленных багов
// Разработчик должен помнить все правила приведения типов
// ✅ TypeScript — явные типы, никаких сюрпризов
const num: number = 1;
const str: string = "2";
// num + str; // ❌ Ошибка компиляции: Operator '+' cannot be applied to types 'number' and 'string'
// Явное приведение типов — разработчик осознанно делает конвертацию
const result: string = String(num) + str; // "12" — явно и понятно
const parsed: number = parseInt(str, 10); // 2 — явно и понятно
3. Отсутствие контрактов между модулями
// ❌ JavaScript — нет контракта, легко сломать API
// user-service.js
function createUser(data) {
return db.insert({
name: data.name,
email: data.email,
age: data.age,
});
}
// Где-то в другом файле
createUser({
name: "John",
email: "john@example.com",
// age забыли — ошибка в рантайме
// или передали не тот тип
age: "twenty-five", // Строка вместо числа — ошибка в рантайме
});
// ✅ TypeScript — явный контракт
interface CreateUserInput {
name: string;
email: string;
age: number;
}
function createUser(data: CreateUserInput): Promise<User> {
return db.insert(data);
}
createUser({
name: "John",
email: "john@example.com",
// age: забыли — ❌ Ошибка компиляции: Property 'age' is missing
// age: "twenty-five" — ❌ Ошибка компиляции: Type 'string' is not assignable to type 'number'
});
Продвинутые возможности TypeScript
1. Union и Intersection типы
// Union тип — значение может быть одного из нескольких типов
type Status = 'pending' | 'success' | 'error';
type ID = string | number;
type Response = SuccessResponse | ErrorResponse;
// Использование union типов
function handleResponse(response: Response) {
if (response.status === 'success') {
// TypeScript знает, что здесь response — SuccessResponse
console.log(response.data);
} else {
// TypeScript знает, что здесь response — ErrorResponse
console.log(response.error);
}
}
// Intersection тип — объединение нескольких типов
type HasName = { name: string };
type HasAge = { age: number };
type HasEmail = { email: string };
type Person = HasName & HasAge & HasEmail;
// Person должен иметь ВСЕ поля из всех типов
const person: Person = {
name: "John",
age: 30,
email: "john@example.com",
};
2. Generics — типобезопасная переиспользуемость
// Без generics — теряем типобезопасность
function getFirst(arr: any[]): any {
return arr[0];
}
const first = getFirst([1, 2, 3]); // Тип — any, нет автодополнения
// С generics — сохраняем тип
function getFirst<T>(arr: T[]): T | undefined {
return arr[0];
}
const firstNum = getFirst([1, 2, 3]); // Тип — number
const firstStr = getFirst(["a", "b", "c"]); // Тип — string
// Generics в интерфейсах
interface ApiResponse<T> {
data: T;
status: number;
message: string;
}
interface PaginatedResponse<T> {
items: T[];
total: number;
page: number;
pageSize: number;
}
// Использование
type UsersResponse = ApiResponse<PaginatedResponse<User>>;
type PostResponse = ApiResponse<Post>;
// Generics с ограничениями
interface HasId {
id: string;
}
function findById<T extends HasId>(items: T[], id: string): T | undefined {
return items.find(item => item.id === id);
}
// T должен иметь поле id — TypeScript проверит это на этапе компиляции
const users = [{ id: "1", name: "John" }, { id: "2", name: "Jane" }];
const user = findById(users, "1"); // Тип — { id: string; name: string } | undefined
3. Utility Types — трансформация типов
interface User {
id: string;
name: string;
email: string;
password: string;
createdAt: Date;
updatedAt: Date;
}
// Partial — все поля становятся опциональными
type UpdateUserInput = Partial<User>;
// Эквивалентно: { id?: string; name?: string; email?: string; ... }
// Pick — выбрать только указанные поля
type UserPublicInfo = Pick<User, 'id' | 'name' | 'email'>;
// { id: string; name: string; email: string; }
// Omit — исключить указанные поля
type CreateUserInput = Omit<User, 'id' | 'createdAt' | 'updatedAt'>;
// { name: string; email: string; password: string; }
// Required — все поля становятся обязательными
type CompleteUser = Required<Partial<User>>;
// Readonly — все поля становятся только для чтения
type ImmutableUser = Readonly<User>;
// Record — создать тип-словарь
type UserRoles = Record<string, 'admin' | 'user' | 'moderator'>;
const roles: UserRoles = {
"user-1": "admin",
"user-2": "user",
};
// ReturnType — извлечь тип возвращаемого значения функции
function createUser() {
return { id: "1", name: "John", email: "john@example.com" };
}
type NewUser = ReturnType<typeof createUser>;
// { id: string; name: string; email: string; }
// Parameters — извлечь типы параметров функции
function updateUser(id: string, data: Partial<User>) { /* ... */ }
type UpdateUserParams = Parameters<typeof updateUser>;
// [string, Partial<User>]
4. Conditional Types и Mapped Types
// Conditional Types — типы с условиями
type IsString<T> = T extends string ? true : false;
type A = IsString<string>; // true
type B = IsString<number>; // false
// Практический пример: извлечь тип из Promise
type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;
type Result1 = UnwrapPromise<Promise<string>>; // string
type Result2 = UnwrapPromise<number>; // number
// Mapped Types — трансформация полей типа
type Readonly<T> = {
readonly [P in keyof T]: T[P];
};
type Optional<T> = {
[P in keyof T]?: T[P];
};
type Nullable<T> = {
[P in keyof T]: T[P] | null;
};
// Практический пример: все поля nullable для формы
interface UserFormData {
name: string | null;
email: string | null;
age: number | null;
}
// Или через mapped type:
type NullableUser = Nullable<User>;
5. Template Literal Types
// Типы на основе строковых литералов
type EventName = 'click' | 'focus' | 'blur';
type EventHandlerName = `on${Capitalize<EventName>}`;
// 'onClick' | 'onFocus' | 'onBlur'
// Практический пример: API endpoints
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';
type Endpoint = '/users' | '/posts' | '/comments';
type ApiRoute = `${HttpMethod} ${Endpoint}`;
// 'GET /users' | 'POST /users' | 'PUT /users' | 'DELETE /users' | ...
// Извлечение параметров из URL
type ExtractRouteParams<T extends string> =
T extends `${string}:${infer Param}/${infer Rest}`
? Param | ExtractRouteParams<`/${Rest}`>
: T extends `${string}:${infer Param}`
? Param
: never;
type Params = ExtractRouteParams<'/users/:userId/posts/:postId'>;
// 'userId' | 'postId'
6. Discriminated Unions — типобезопасные состояния
// Паттерн для моделирования состояний приложения
type RequestState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: string };
// TypeScript автоматически сужает тип по полю status
function renderUser(state: RequestState<User>) {
switch (state.status) {
case 'idle':
return <div>Click to load</div>;
case 'loading':
return <Spinner />;
case 'success':
// TypeScript знает, что здесь есть state.data
return <div>{state.data.name}</div>;
case 'error':
// TypeScript знает, что здесь есть state.error
return <div className="error">{state.error}</div>;
}
}
// Пример с формами
type FormState =
| { status: 'editing'; values: FormValues; errors: Record<string, string> }
| { status: 'submitting'; values: FormValues }
| { status: 'submitted'; result: SubmitResult }
| { status: 'submitFailed'; values: FormValues; error: string };
7. Type Guards и Assertion Functions
// Type Guard — функция, сужающая тип
function isString(value: unknown): value is string {
return typeof value === 'string';
}
function processValue(value: string | number) {
if (isString(value)) {
// TypeScript знает, что здесь value — string
return value.toUpperCase();
}
// TypeScript знает, что здесь value — number
return value.toFixed(2);
}
// Assertion Function — выбрасывает ошибку, если тип не соответствует
function assertIsDefined<T>(value: T): asserts value is NonNullable<T> {
if (value === undefined || value === null) {
throw new Error('Value is not defined');
}
}
function processUser(user: User | null) {
assertIsDefined(user);
// TypeScript знает, что здесь user — User (не null)
console.log(user.name);
}
// Практический пример: проверка API-ответа
function isApiError(response: unknown): response is { error: string; code: number } {
return (
typeof response === 'object' &&
response !== null &&
'error' in response &&
'code' in response
);
}
async function fetchUser(id: string): Promise<User> {
const response = await fetch(`/api/users/${id}`);
const data = await response.json();
if (isApiError(data)) {
throw new Error(data.error);
}
return data; // TypeScript знает, что это User
}
Шкала уровней владения TypeScript
Уровень 1-3: Junior
- Базовые типы: string, number, boolean, array, object.
- Интерфейсы и type aliases.
- Простые generics (Array<T>, Promise<T>).
- Union типы.
Уровень 4-5: Junior+/Middle-
- Utility Types: Partial, Pick, Omit, Record.
- Type guards.
- Generics с ограничениями (extends).
- Mapped Types.
Уровень 6-7: Middle
- Conditional Types.
- Template Literal Types.
- Discriminated Unions.
- Assertion Functions.
- Понимание структурной типизации vs номинальной.
Уровень 8-9: Middle+/Senior
- Сложные conditional types с infer.
- Recursive types.
- Template Literal Types для маршрутов и событий.
- Интеграция с библиотеками (типизация сторонних пакетов).
- Понимание разницы между type и interface в контексте расширения.
Уровень 10: Expert
- Написание сложных типовых утилит.
- Понимание внутреннего устройства компилятора.
- Создание типобезопасных DSL.
- Вклад в DefinitelyTyped.
Итог
TypeScript решает фундаментальные проблемы JavaScript: обнаруживает ошибки на этапе компиляции, обеспечивает контракты между модулями, устраняет неявное приведение типов и предоставляет мощную систему типов для моделирования предметной области. Для уровня 6-7 важно уверенно владеть generics, utility types, type guards и discriminated unions — это инструменты, которые используются ежедневно в реальных проектах.
Вопрос 17. Что такое структурная типизация в TypeScript?
Таймкод: 00:29:39
Ответ собеседника: Правильный. TypeScript — язык со структурной типизацией, в отличие от номинальной типизации (например, C#, где нельзя иметь два интерфейса с одинаковым именем). В TypeScript можно иметь интерфейсы с одинаковыми именами — они объединятся. Сравнение идёт не по имени, а по структуре (полям). Если два интерфейса имеют одинаковые поля, объект может имплементировать оба. Сравнение структурное, а не номинальное.
Правильный ответ:
Ответ собеседника верный и хорошо объясняет концепцию. Разберём тему глубже — с практическими примерами, подводными камнями и способами эмуляции номинальной типизации, когда она нужна.
Структурная vs Номинальная типизация
// ═══════════════════════════════════════════════════════════════
// НОМИНАЛЬНАЯ ТИПИЗАЦИЯ (C#, Java, Kotlin)
// ═══════════════════════════════════════════════════════════════
// Два типа с одинаковой структурой, но разными именами — НЕ совместимы
// class UserId { value: string }
// class PostId { value: string }
//
// const userId: UserId = new PostId(); // ❌ Ошибка компиляции!
// Даже если структура одинаковая — имена разные, типы несовместимы
// ═══════════════════════════════════════════════════════════════
// СТРУКТУРНАЯ ТИПИЗАЦИЯ (TypeScript, Go, OCaml)
// ═══════════════════════════════════════════════════════════════
// Два типа с одинаковой структурой — совместимы, даже если имена разные
interface UserId {
value: string;
}
interface PostId {
value: string;
}
const userId: UserId = { value: "user-123" };
const postId: PostId = userId; // ✅ Работает! Структура одинаковая
// TypeScript не смотрит на имена типов
// Он смотрит на структуру: есть ли нужные поля с нужными типами
Практические примеры структурной типизации
// Пример 1: Неявная совместимость
interface User {
id: string;
name: string;
email: string;
}
interface UserDTO {
id: string;
name: string;
email: string;
}
// Поля одинаковые — типы совместимы
function displayUser(user: User) {
console.log(user.name);
}
const dto: UserDTO = { id: "1", name: "John", email: "john@example.com" };
displayUser(dto); // ✅ Работает! UserDTO структурно совместим с User
// Пример 2: Избыточные поля — не проблема
interface Named {
name: string;
}
function greet(entity: Named) {
console.log(`Hello, ${entity.name}!`);
}
const user = { id: "1", name: "John", email: "john@example.com" };
greet(user); // ✅ Работает! У объекта есть поле name
// Пример 3: Проблема с избыточными полями при литералах
interface Config {
host: string;
port: number;
}
function connect(config: Config) {
// ...
}
// ❌ Ошибка: литерал с лишними полями
connect({ host: "localhost", port: 8080, extra: "value" });
// ✅ Работает: переменная с лишними полями
const config = { host: "localhost", port: 8080, extra: "value" };
connect(config); // Лишние поля игнорируются при структурном сравнении
// Это называется "excess property checking" —
// TypeScript проверяет лишние поля только при передаче литералов напрямую
Подводные камни структурной типизации
// Проблема 1: Случайная совместимость несвязанных типов
interface UserId {
value: string;
}
interface PostId {
value: string;
}
function getUser(id: UserId): User {
return db.users.findById(id.value);
}
function getPost(id: PostId): Post {
return db.posts.findById(id.value);
}
const userId: UserId = { value: "user-123" };
// ❌ Логическая ошибка: передали UserId в функцию для Post
// TypeScript не ругается — структура одинаковая
getPost(userId);
// Проблема 2: Пустые интерфейсы совместимы со всем
interface Empty {}
function processEmpty(obj: Empty) {
// ...
}
processEmpty({ name: "John" }); // ✅ Работает
processEmpty({ id: 1, data: [] }); // ✅ Работает
processEmpty("anything"); // ✅ Работает (почти)
Эмуляция номинальной типизации (Nominal Typing)
// Способ 1: Branding pattern (рекомендуемый)
type UserId = string & { readonly __brand: 'UserId' };
type PostId = string & { readonly __brand: 'PostId' };
type CommentId = string & { readonly __brand: 'CommentId' };
// Фабрики для создания типизированных значений
function createUserId(value: string): UserId {
return value as UserId;
}
function createPostId(value: string): PostId {
return value as PostId;
}
function getUser(id: UserId): User {
return db.users.findById(id);
}
function getPost(id: PostId): Post {
return db.posts.findById(id);
}
// Использование
const userId = createUserId("user-123");
const postId = createPostId("post-456");
getUser(userId); // ✅ Работает
getPost(postId); // ✅ Работает
getPost(userId); // ❌ Ошибка компиляции: Argument of type 'UserId' is not assignable to parameter of type 'PostId'
getUser(postId); // ❌ Ошибка компиляции: Argument of type 'PostId' is not assignable to parameter of type 'UserId'
// Способ 2: Уникальный символ
declare const UserIdSymbol: unique symbol;
type UserId2 = string & { [UserIdSymbol]: typeof UserIdSymbol };
// Способ 3: Приватное поле в классе
class UserId3 {
private __nominal: void; // Приватное поле делает тип уникальным
constructor(public value: string) {}
}
class PostId3 {
private __nominal: void;
constructor(public value: string) {}
}
// UserId3 и PostId3 — разные типы, даже если структура одинаковая
Продвинутый пример: типобезопасный API-клиент
// Типобезопасный API-клиент с номинальной типизацией для ID
// Базовые ID-типы с брендированием
type Entity<T extends string> = string & { readonly __entity: T };
type UserId = Entity<'User'>;
type PostId = Entity<'Post'>;
type CommentId = Entity<'Comment'>;
// Фабрики
const createUserId = (id: string) => id as UserId;
const createPostId = (id: string) => id as PostId;
const createCommentId = (id: string) => id as CommentId;
// Интерфейсы сущностей
interface User {
id: UserId;
name: string;
email: string;
}
interface Post {
id: PostId;
title: string;
authorId: UserId;
}
interface Comment {
id: CommentId;
postId: PostId;
authorId: UserId;
text: string;
}
// Типобезопасный API-клиент
class ApiClient {
async getUser(id: UserId): Promise<User> {
return this.fetch(`/users/${id}`);
}
async getPost(id: PostId): Promise<Post> {
return this.fetch(`/posts/${id}`);
}
async getPostComments(postId: PostId): Promise<Comment[]> {
return this.fetch(`/posts/${postId}/comments`);
}
async createComment(postId: PostId, authorId: UserId, text: string): Promise<Comment> {
return this.fetch(`/posts/${postId}/comments`, {
method: 'POST',
body: JSON.stringify({ authorId, text }),
});
}
private async fetch<T>(url: string, init?: RequestInit): Promise<T> {
const response = await fetch(url, init);
return response.json();
}
}
// Использование — невозможно перепутать ID
const api = new ApiClient();
const userId = createUserId("user-123");
const postId = createPostId("post-456");
const user = await api.getUser(userId); // ✅
const post = await api.getPost(postId); // ✅
const comments = await api.getPostComments(postId); // ✅
// ❌ Ошибка компиляции — нельзя перепутать ID
await api.getUser(postId); // PostId не assignable к UserId
await api.getPost(userId); // UserId не assignable к PostId
await api.getPostComments(userId); // UserId не assignable к PostId
Объявление интерфейсов с одинаковыми именами (Declaration Merging)
// TypeScript позволяет объявлять интерфейсы с одинаковым именем
// Они объединяются (merge) в один интерфейс
interface User {
name: string;
}
interface User {
email: string;
}
interface User {
age: number;
}
// Результат: User имеет ВСЕ три поля
const user: User = {
name: "John",
email: "john@example.com",
age: 30,
};
// Это используется для расширения типов из библиотек
// Например, расширение Window:
declare global {
interface Window {
myCustomProperty: string;
}
}
// Или расширение Express Request:
declare namespace Express {
interface Request {
user?: { id: string; role: string };
}
}
Структурная типизация с функциями
// Функции сравниваются по сигнатуре
type EventHandler = (event: Event) => void;
type ClickHandler = (event: Event) => void;
const handleClick: ClickHandler = (event) => console.log('click');
const eventHandler: EventHandler = handleClick; // ✅ Совместимы
// Контравариантность параметров функций
interface Animal {
name: string;
}
interface Dog extends Animal {
breed: string;
}
type AnimalHandler = (animal: Animal) => void;
type DogHandler = (dog: Dog) => void;
const handleAnimal: AnimalHandler = (animal) => console.log(animal.name);
const handleDog: DogHandler = (dog) => console.log(dog.breed);
// Контравариантность: DogHandler может быть использован как AnimalHandler
const animalHandler: AnimalHandler = handleDog; // ❌ TypeScript запрещает (strictFunctionTypes)
// Ковариантность возвращаемого значения
type GetAnimal = () => Animal;
type GetDog = () => Dog;
const getAnimal: GetAnimal = () => ({ name: "Generic" });
const getDog: GetDog = () => ({ name: "Buddy", breed: "Labrador" });
const animalGetter: GetAnimal = getDog; // ✅ Dog extends Animal — безопасно
const dogGetter: GetDog = getAnimal; // ❌ Animal не обязательно Dog — небезопасно
Итог
- Структурная типизация — сравнение типов по структуре (полям), а не по имени. Два типа с одинаковой структурой совместимы.
- Номинальная типизация — сравнение по имени. Даже одинаковая структура не делает типы совместимыми.
- Плюсы структурной типизации: гибкость, duck typing, совместимость с JavaScript.
- Минусы: случайная совместимость несвязанных типов, нельзя различить типы с одинаковой структурой.
- Решение: branding pattern для эмуляции номинальной типизации, когда нужна строгая типобезопасность для ID и других примитивных типов.
- Declaration merging — возможность расширять интерфейсы с одинаковым именем, полезна для расширения типов библиотек.
Вопрос 18. Какие утилитарные типы TypeScript используешь в работе?
Таймкод: 00:30:57
Ответ собеседника: Неполный. Вопрос был задан, но ответ не был дан — диалог перешёл к следующей теме.
Правильный ответ:
Utility Types — это встроенные в TypeScript инструменты для трансформации типов. Они используются ежедневно в реальных проектах. Разберём все основные с практическими примерами.
Базовые Utility Types
// Исходный интерфейс для примеров
interface User {
id: string;
name: string;
email: string;
password: string;
role: 'admin' | 'user' | 'moderator';
createdAt: Date;
updatedAt: Date;
}
1. Partial<T> — все поля опциональные
// Создаёт тип, где все поля T становятся опциональными
type UpdateUserInput = Partial<User>;
// Эквивалентно:
// {
// id?: string;
// name?: string;
// email?: string;
// password?: string;
// role?: 'admin' | 'user' | 'moderator';
// createdAt?: Date;
// updatedAt?: Date;
// }
// Практическое использование: обновление сущности
async function updateUser(id: string, data: Partial<User>): Promise<User> {
// Можно передать только те поля, которые нужно обновить
return db.users.update(id, data);
}
// Вызов — можно передать любое подмножество полей
await updateUser("user-1", { name: "New Name" });
await updateUser("user-1", { email: "new@email.com", role: "admin" });
2. Required<T> — все поля обязательные
// Обратный Partial — все поля становятся обязательными
interface PartialUser {
id?: string;
name?: string;
email?: string;
}
type CompleteUser = Required<PartialUser>;
// {
// id: string; // Было опциональным — стало обязательным
// name: string;
// email: string;
// }
3. Pick<T, K> — выбрать указанные поля
// Создаёт тип, выбирая только указанные поля из T
type UserPublicInfo = Pick<User, 'id' | 'name' | 'email'>;
// {
// id: string;
// name: string;
// email: string;
// }
// Практическое использование: публичный профиль
function getPublicProfile(userId: string): Promise<UserPublicInfo> {
return db.users.findById(userId, ['id', 'name', 'email']);
}
// Тип для формы — только редактируемые поля
type UserEditableFields = Pick<User, 'name' | 'email'>;
4. Omit<T, K> — исключить указанные поля
// Создаёт тип, исключая указанные поля из T
type CreateUserInput = Omit<User, 'id' | 'createdAt' | 'updatedAt'>;
// {
// name: string;
// email: string;
// password: string;
// role: 'admin' | 'user' | 'moderator';
// }
// Практическое использование: создание сущности
async function createUser(data: CreateUserInput): Promise<User> {
return db.users.create({
...data,
id: generateId(),
createdAt: new Date(),
updatedAt: new Date(),
});
}
// Безопасный DTO — исключаем чувствительные поля
type UserSafeDTO = Omit<User, 'password'>;
5. Record<K, V> — словарь с ключами K и значениями V
// Создаёт тип-объект с ключами типа K и значениями типа V
type UserRoles = Record<string, 'admin' | 'user' | 'moderator'>;
const roles: UserRoles = {
"user-1": "admin",
"user-2": "user",
"user-3": "moderator",
};
// Практическое использование: локализация
type LocaleMessages = Record<string, string>;
const enMessages: LocaleMessages = {
greeting: "Hello",
farewell: "Goodbye",
welcome: "Welcome",
};
// Маппинг статусов
type StatusConfig = Record<'pending' | 'success' | 'error', {
color: string;
icon: string;
}>;
const statusConfig: StatusConfig = {
pending: { color: 'yellow', icon: 'clock' },
success: { color: 'green', icon: 'check' },
error: { color: 'red', icon: 'x' },
};
6. Readonly<T> — все поля только для чтения
// Создаёт тип, где все поля становятся readonly
type ImmutableUser = Readonly<User>;
const user: ImmutableUser = {
id: "1",
name: "John",
email: "john@example.com",
password: "secret",
role: "user",
createdAt: new Date(),
updatedAt: new Date(),
};
// user.name = "Jane"; // ❌ Ошибка: Cannot assign to 'name' because it is a read-only property
// Практическое использование: конфигурация
interface AppConfig {
apiUrl: string;
timeout: number;
retries: number;
}
const config: Readonly<AppConfig> = {
apiUrl: "https://api.example.com",
timeout: 5000,
retries: 3,
};
// config.timeout = 10000; // ❌ Ошибка — конфигурация неизменяема
Продвинутые Utility Types
7. ReturnType<T> — тип возвращаемого значения функции
// Извлекает тип возвращаемого значения функции
function createUser() {
return {
id: "1",
name: "John",
email: "john@example.com",
};
}
type NewUser = ReturnType<typeof createUser>;
// {
// id: string;
// name: string;
// email: string;
// }
// Практическое использование: типизация ответов API
async function fetchUsers() {
const response = await fetch('/api/users');
return response.json();
}
type UsersResponse = Awaited<ReturnType<typeof fetchUsers>>;
8. Parameters<T> — типы параметров функции
// Извлекает типы параметров функции как кортеж
function updateUser(id: string, data: Partial<User>) {
// ...
}
type UpdateUserParams = Parameters<typeof updateUser>;
// [string, Partial<User>]
// Практическое использование: типизация обёрток
async function withLogging<T extends (...args: any[]) => any>(
fn: T,
...args: Parameters<T>
): Promise<ReturnType<T>> {
console.log('Calling function with args:', args);
const result = await fn(...args);
console.log('Result:', result);
return result;
}
// Типы параметров выводятся автоматически
withLogging(updateUser, "user-1", { name: "New Name" }); // ✅
withLogging(updateUser, 123, { name: "New Name" }); // ❌ Ошибка — id должен быть string
9. NonNullable<T> — исключает null и undefined
// Убирает null и undefined из типа
type MaybeString = string | null | undefined;
type DefinitelyString = NonNullable<MaybeString>; // string
// Практическое использование: фильтрация nullable значений
function getDefinedValues<T>(values: T[]): NonNullable<T>[] {
return values.filter((value): value is NonNullable<T> => value != null);
}
const values: (string | null | undefined)[] = ["a", null, "b", undefined, "c"];
const defined = getDefinedValues(values); // string[] — только "a", "b", "c"
10. Awaited<T> — извлекает тип из Promise
// Рекурсивно извлекает тип из Promise
type PromiseType = Awaited<Promise<string>>; // string
type NestedPromise = Awaited<Promise<Promise<number>>>; // number
// Практическое использование: типизация async функций
async function fetchUser(): Promise<User> {
// ...
}
type UserFromFetch = Awaited<ReturnType<typeof fetchUser>>; // User
Комбинирование Utility Types
// Комбинация Partial и Pick
type UpdateUserProfile = Partial<Pick<User, 'name' | 'email'>>;
// {
// name?: string;
// email?: string;
// }
// Комбинация Omit и Pick
type UserCredentials = Pick<User, 'email' | 'password'>;
type UserPublicData = Omit<User, 'password'>;
// Комбинация Readonly и Partial
type ImmutableUpdate = Readonly<Partial<User>>;
// Все поля опциональные И readonly
// Практический пример: полная система типов для CRUD
interface Entity {
id: string;
createdAt: Date;
updatedAt: Date;
}
interface User extends Entity {
name: string;
email: string;
password: string;
role: 'admin' | 'user';
}
// Типы для операций CRUD
type CreateUserDTO = Omit<User, 'id' | 'createdAt' | 'updatedAt'>;
type UpdateUserDTO = Partial<CreateUserDTO>;
type UserResponseDTO = Omit<User, 'password'>;
type UserListResponse = {
items: UserResponseDTO[];
total: number;
page: number;
pageSize: number;
};
// Тип для поиска
type UserSearchParams = Partial<Pick<User, 'name' | 'email' | 'role'>> & {
page?: number;
pageSize?: number;
};
// Тип для формы
type UserFormData = Omit<User, 'id' | 'createdAt' | 'updatedAt' | 'role'> & {
confirmPassword: string;
};
Кастомные Utility Types
// DeepPartial — рекурсивный Partial для вложенных объекts
type DeepPartial<T> = {
[P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P];
};
interface NestedConfig {
database: {
host: string;
port: number;
credentials: {
username: string;
password: string;
};
};
}
type PartialConfig = DeepPartial<NestedConfig>;
// Все поля на всех уровнях вложенности опциональны
const config: PartialConfig = {
database: {
host: "localhost",
// port и credentials опциональны
},
};
// DeepReadonly — рекурсивный Readonly
type DeepReadonly<T> = {
readonly [P in keyof T]: T[P] extends object ? DeepReadonly<T[P]> : T[P];
};
// Nullable — все поля nullable
type Nullable<T> = {
[P in keyof T]: T[P] | null;
};
// Использование для форм, где все поля могут быть null
type UserFormNullable = Nullable<User>;
// ValueOf — тип значений объекта
type ValueOf<T> = T[keyof T];
type UserRoles = 'admin' | 'user' | 'moderator';
type RolePermissions = Record<UserRoles, string[]>;
type Permission = ValueOf<RolePermissions>; // string[]
// KeysOfValueType — ключи, значения которых имеют определённый тип
type KeysOfValueType<T, V> = {
[K in keyof T]: T[K] extends V ? K : never;
}[keyof T];
interface Mixed {
id: string;
name: string;
age: number;
isActive: boolean;
}
type StringKeys = KeysOfValueType<Mixed, string>; // "id" | "name"
type NumberKeys = KeysOfValueType<Mixed, number>; // "age"
Итог
Наиболее часто используемые Utility Types в повседневной работе:
- Partial — для DTO обновления, где все поля опциональны.
- Pick — для выбора подмножества полей (публичный профиль, форма).
- Omit — для исключения полей (безопасный DTO без пароля, входные данные без id).
- Record — для словарей, конфигураций, маппингов.
- Readonly — для неизменяемых конфигураций и состояний.
- ReturnType/Parameters — для типизации обёрток и утилит над функциями.
Комбинирование Utility Types позволяет создавать точные типы для любых сценариев: CRUD операции, формы, API-ответы, конфигурации.
Вопрос 18. Какие утилитарные типы TypeScript используешь в работе?
Таймкод: 00:31:01
Ответ собеседника: Правильный. Наиболее часто использует Partial, Omit, Pick. Также знает Required, Readonly, литеральные строковые типы вроде Uppercase, Lowercase, но с ними не работал. Упомянул Extract, но отметил, что он больше используется в основе Omit и Pick.
Правильный ответ:
Ответ собеседника хороший — он демонстрирует практический опыт с основными Utility Types и понимание их взаимосвязей. Дополним ответ полным обзором всех ключевых Utility Types с практическими примерами.
Наиболее частые в повседневной работе
interface User {
id: string;
name: string;
email: string;
password: string;
role: 'admin' | 'user' | 'moderator';
createdAt: Date;
updatedAt: Date;
}
// Partial — обновление сущностей
type UpdateUserInput = Partial<User>;
// Omit — безопасные DTO без чувствительных полей
type UserPublicDTO = Omit<User, 'password'>;
// Pick — выбор конкретных полей
type UserCredentials = Pick<User, 'email' | 'password'>;
Extract и Exclude — работа с Union типами
// Собеседник правильно отметил связь с Pick/Omit.
// Разберём подробнее — это важная пара.
// Exclude<T, U> — исключает из T типы, которые есть в U
type Status = 'pending' | 'active' | 'completed' | 'cancelled';
type ActiveStatus = Exclude<Status, 'cancelled'>;
// 'pending' | 'active' | 'completed'
// Extract<T, U> — извлекает из T только типы, которые есть в U
type FinishedStatus = Extract<Status, 'completed' | 'cancelled'>;
// 'completed' | 'cancelled'
// Практическое использование: фильтрация событий
type EventType = 'click' | 'focus' | 'blur' | 'keydown' | 'keyup';
type MouseEventType = Extract<EventType, 'click'>;
type KeyboardEventType = Extract<EventType, 'keydown' | 'keyup'>;
type FocusEventType = Exclude<EventType, 'click' | 'keydown' | 'keyup'>;
// С Pick/Omit для объектов:
// Pick<T, K> = { [P in Extract<keyof T, K>]: T[P] }
// Omit<T, K> = Pick<T, Exclude<keyof T, K>>
Record — типобезопасные словари
type UserRoles = Record<string, 'admin' | 'user' | 'moderator'>;
// Конфигурация статусов
type StatusConfig = Record<'pending' | 'success' | 'error', {
color: string;
icon: string;
}>;
const statusConfig: StatusConfig = {
pending: { color: 'yellow', icon: 'clock' },
success: { color: 'green', icon: 'check' },
error: { color: 'red', icon: 'x' },
};
ReturnType и Parameters — типизация функций
// ReturnType — тип возвращаемого значения
function createUser() {
return { id: "1", name: "John", email: "john@example.com" };
}
type NewUser = ReturnType<typeof createUser>;
// Parameters — типы параметров
function updateUser(id: string, data: Partial<User>) { /* ... */ }
type UpdateUserParams = Parameters<typeof createUser>;
String manipulation types
// Uppercase, Lowercase, Capitalize, Uncapitalize
type HttpMethod = 'get' | 'post' | 'put' | 'delete';
type UpperMethod = Uppercase<HttpMethod>; // 'GET' | 'POST' | 'PUT' | 'DELETE'
type EventName = 'click' | 'focus' | 'blur';
type HandlerName = `on${Capitalize<EventName>}>; // 'onClick' | 'onFocus' | 'onBlur'
// Практическое использование: генерация API endpoints
type Entity = 'user' | 'post' | 'comment';
type GetEndpoint = `get${Capitalize<Entity>}s`; // 'getUsers' | 'getPosts' | 'getComments'
Итог
Ответ собеседника демонстрирует хороший практический уровень. Основные Utility Types для ежедневной работы:
- Partial, Omit, Pick — основа типобезопасных DTO и форм.
- Exclude/Extract — работа с union типами, основа для Pick/Omit.
- Record — типобезопасные словари и конфигурации.
- ReturnType/Parameters — типизация обёрток и утилит.
- String manipulation — генерация типов из строк, менее частая, но мощная возможность.
Вопрос 19. В чём разница между Omit и Exclude в TypeScript?
Таймкод: 00:31:59
Ответ собеседника: Неполный. Omit удаляет конкретные поля из типа (можно выбрать одно или несколько полей). Exclude работает с union-типами — исключает из первого типа все типы, которые совместимы со вторым. Кандидат сначала неправильно описал Exclude (сказал, что он удаляет совместимые свойства из двух типов), но после обсуждения и проверки в коде пришёл к правильному пониманию: Exclude работает именно с union-типами, а не с полями объектных типов.
Правильный ответ:
Ответ собеседника после уточнения стал правильным. Разберём разницу между Omit и Exclude подробнее — это одна из самых частых путаниц в TypeScript.
Ключевое различие
┌─────────────────────────────────────────────────────────────┐
│ Omit<T, K> │
│ │
│ Работает с: объектными типами (interface, type) │
│ Исключает: ключи (поля) из объекта │
│ Результат: объектный тип без указанных полей │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ Exclude<T, U> │
│ │
│ Работает с: union типами (A | B | C) │
│ Исключает: элементы union из первого типа │
│ Результат: union тип без указанных элементов │
└─────────────────────────────────────────────────────────────┘
Omit — работа с объектными типами
interface User {
id: string;
name: string;
email: string;
password: string;
createdAt: Date;
}
// Omit исключает указанные КЛЮЧИ из объектного типа
type PublicUser = Omit<User, 'password' | 'createdAt'>;
// {
// id: string;
// name: string;
// email: string;
// }
// Пример использования
function displayUser(user: PublicUser) {
console.log(user.name); // ✅
console.log(user.email); // ✅
// console.log(user.password); // ❌ Property 'password' does not exist
}
Exclude — работа с union типами
// Exclude исключает элементы из union типа
type Status = 'pending' | 'active' | 'completed' | 'cancelled';
// Убираем 'cancelled' из union типа
type ActiveStatus = Exclude<Status, 'cancelled'>;
// 'pending' | 'active' | 'completed'
// Убираем несколько элементов
type FinalStatus = Exclude<Status, 'pending' | 'active'>;
// 'completed' | 'cancelled'
// Пример использования
function isActive(status: ActiveStatus): boolean {
switch (status) {
case 'pending': return true;
case 'active': return true;
case 'completed': return false;
// case 'cancelled': // ❌ Type '"cancelled"' is not assignable
}
}
Практические примеры использования
// ═══════════════════════════════════════════════════════════
// Omit: безопасные DTO
// ═══════════════════════════════════════════════════════════
interface UserEntity {
id: string;
name: string;
email: string;
passwordHash: string;
refreshToken: string;
createdAt: Date;
updatedAt: Date;
}
// Публичный DTO — исключаем чувствительные поля
type UserPublicDTO = Omit<UserEntity, 'passwordHash' | 'refreshToken'>;
// Входные данные для создания — исключаем автогенерируемые поля
type CreateUserDTO = Omit<UserEntity, 'id' | 'createdAt' | 'updatedAt'>;
// Входные данные для обновления — все поля опциональные, кроме id
type UpdateUserDTO = Partial<Omit<UserEntity, 'id'>> & Pick<UserEntity, 'id'>;
// ═══════════════════════════════════════════════════════════
// Exclude: фильтрация union типов
// ═══════════════════════════════════════════════════════════
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
// Только безопасные методы (без side effects)
type SafeMethod = Exclude<HttpMethod, 'POST' | 'PUT' | 'DELETE' | 'PATCH'>;
// 'GET'
// Только методы, которые изменяют данные
type MutatingMethod = Exclude<HttpMethod, 'GET'>;
// 'POST' | 'PUT' | 'DELETE' | 'PATCH'
// Типы событий
type EventType = 'click' | 'focus' | 'blur' | 'keydown' | 'keyup' | 'mousemove';
// Только события клавиатуры
type KeyboardEvent = Exclude<EventType, 'click' | 'focus' | 'blur' | 'mousemove'>;
// 'keydown' | 'keyup'
// Только события мыши
type MouseEvent = Exclude<EventType, 'keydown' | 'keyup' | 'focus' | 'blur'>;
// 'click' | 'mousemove'
Связь между Omit и Exclude
// Omit можно выразить через Pick и Exclude:
type MyOmit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>;
// Разбор:
// 1. keyof T — получаем все ключи объекта: 'id' | 'name' | 'email' | 'password'
// 2. Exclude<keyof T, K> — убираем указанные ключи: 'id' | 'name' | 'email'
// 3. Pick<T, ...> — выбираем оставшиеся ключи из T
interface User {
id: string;
name: string;
email: string;
password: string;
}
// Пошагово:
// keyof User = 'id' | 'name' | 'email' | 'password'
// Exclude<keyof User, 'password'> = 'id' | 'name' | 'email'
// Pick<User, 'id' | 'name' | 'email'> = { id: string; name: string; email: string }
type PublicUser = MyOmit<User, 'password'>;
// { id: string; name: string; email: string }
Сводная таблица
┌─────────────┬──────────────────────┬──────────────────────┐
│ │ Omit │ Exclude │
├─────────────┼──────────────────────┼──────────────────────┤
│ Входной тип │ Объектный тип │ Union тип │
│ │ (interface, type) │ (A | B | C) │
├─────────────┼──────────────────────┼──────────────────────┤
│ Что │ Ключи объекта │ Элементы union │
│ исключает │ ('name', 'email') │ ('a', 'b') │
├─────────────┼──────────────────────┼──────────────────────┤
│ Результат │ Объектный тип │ Union тип │
│ │ без полей │ без элементов │
├─────────────┼──────────────────────┼──────────────────────┤
│ Пример │ Omit<User, 'pass'> │ Exclude<Status, │
│ │ │ 'cancelled'> │
├─────────────┼──────────────────────┼──────────────────────┤
│ Применение │ DTO, формы, API │ Фильтрация статусов, │
│ │ │ событий, методов │
└─────────────┴──────────────────────┴──────────────────────┘
Итог
- Omit<T, K> — работает с объектными типами, исключает указанные ключи (поля). Используется для создания DTO, форм, API-контрактов.
- Exclude<T, U> — работает с union типами, исключает элементы из первого типа, которые совместимы со вторым. Используется для фильтрации статусов, событий, методов.
- Связь: Omit внутри использует Exclude для фильтрации ключей:
Omit<T, K> = Pick<T, Exclude<keyof T, K>>.
Вопрос 20. Что такое Union и Intersection типы в TypeScript?
Таймкод: 00:37:52
Ответ собеседника: Правильный. Union-типы (объединения) — это «либо один тип, либо другой» (оператор |). Берёт поля либо из одного типа, либо из другого. Intersection работает как наследование в интерфейсах — объединяет все свойства и методы нескольких типов в один (оператор &). Тип, использующий intersection, получает все свойства и методы всех составляющих типов.
Правильный ответ:
Ответ собеседника верный и хорошо передаёт суть. Дополним ответ подробностями о разнице в доступе к полям, практическими паттернами и распространёнными ошибками.
Union типы (A | B)
// Union — значение может быть ЛИБО одним типом, ЛИБО другим
type Status = 'pending' | 'success' | 'error';
type ID = string | number;
type Response = SuccessResponse | ErrorResponse;
// ═══════════════════════════════════════════════════════════
// Ключевой момент: безопасный доступ к полям Union типа
// ═══════════════════════════════════════════════════════════
interface Cat {
name: string;
purr(): void;
meow(): void;
}
interface Dog {
name: string;
bark(): void;
wagTail(): void;
}
type Pet = Cat | Dog;
// ❌ Нельзя вызвать метод, который есть только в одном типе
function interact(pet: Pet) {
console.log(pet.name); // ✅ name есть в обоих типах
// pet.meow(); // ❌ Ошибка: может быть Dog, у которого нет meow
// pet.bark(); // ❌ Ошибка: может быть Cat, у которого нет bark
}
// ✅ Нужна проверка типа (type narrowing)
function interactSafely(pet: Pet) {
console.log(pet.name); // ✅ name есть в обоих
if ('meow' in pet) {
// TypeScript знает: здесь pet — Cat
pet.meow(); // ✅
pet.purr(); // ✅
} else {
// TypeScript знает: здесь pet — Dog
pet.bark(); // ✅
pet.wagTail(); // ✅
}
}
Intersection типы (A & B)
// Intersection — значение должно соответствовать ВСЕМ типам одновременно
type Named = { name: string };
type Aged = { age: number };
type Emailed = { email: string };
type Person = Named & Aged & Emailed;
// Person должен иметь ВСЕ поля из всех типов
const person: Person = {
name: "John", // Из Named
age: 30, // Из Aged
email: "john@example.com", // Из Emailed
};
// ═══════════════════════════════════════════════════════════
// Практическое использование: композиция поведения
// ═══════════════════════════════════════════════════════════
interface Serializable {
serialize(): string;
}
interface Deserializable<T> {
deserialize(data: string): T;
}
interface Validatable {
validate(): boolean;
}
// Комбинируем поведение через intersection
type UserModel = User & Serializable & Deserializable<User> & Validatable;
// Теперь UserModel имеет все методы:
const user: UserModel = {
id: "1",
name: "John",
email: "john@example.com",
serialize() { return JSON.stringify(this); },
deserialize(data: string) { return JSON.parse(data) as User; },
validate() { return this.name.length > 0 && this.email.includes('@'); },
};
Различие в доступе к полям
interface A {
a: string;
shared: number;
}
interface B {
b: number;
shared: boolean;
}
// Union: доступны только общие поля (без проверки типа)
type UnionAB = A | B;
const union: UnionAB = { a: "hello", shared: 42 }; // ✅ Это A
const union2: UnionAB = { b: 123, shared: true }; // ✅ Это B
function processUnion(item: UnionAB) {
// item.shared — number | boolean (union типов полей)
// item.a — недоступно без проверки
// item.b — недоступно без проверки
if ('a' in item) {
item.a; // ✅ string
item.shared; // ✅ number
} else {
item.b; // ✅ number
item.shared; // ✅ boolean
}
}
// Intersection: доступны ВСЕ поля
type IntersectionAB = A & B;
const intersection: IntersectionAB = {
a: "hello",
b: 123,
// shared: ??? // number & boolean = never (невозможно!)
};
function processIntersection(item: IntersectionAB) {
item.a; // ✅ string
item.b; // ✅ number
item.shared; // ✅ number & boolean = never (конфликт типов)
}
Паттерн: Discriminated Union
// Самый частый и полезный паттерн с Union типами
type Shape =
| { kind: 'circle'; radius: number }
| { kind: 'rectangle'; width: number; height: number }
| { kind: 'triangle'; base: number; height: number };
// TypeScript автоматически сужает тип по полю kind
function getArea(shape: Shape): number {
switch (shape.kind) {
case 'circle':
return Math.PI * shape.radius ** 2;
case 'rectangle':
return shape.width * shape.height;
case 'triangle':
return (shape.base * shape.height) / 2;
}
}
Сводная таблица
┌──────────────┬────────────────────────┬────────────────────────┐
│ │ Union (|) │ Intersection (&) │
├──────────────┼────────────────────────┼────────────────────────┤
│ Значение │ Один из типов │ Все типы одновременно │
├──────────────┼────────────────────────┼────────────────────────┤
│ Доступ к │ Только общие поля │ Все поля всех типов │
│ полям │ (без narrowing) │ │
├──────────────┼────────────────────────┼────────────────────────┤
│ Аналогия │ ИЛИ (OR) │ И (AND) │
├──────────────┼────────────────────────┼────────────────────────┤
│ Применение │ Статусы, события, │ Композиция поведения, │
│ │ ответы API │ mixins, расширение │
└──────────────┴────────────────────────┴────────────────────────┘
Итог
- Union (A | B) — значение может быть одним из типов. Без type narrowing доступны только общие поля. Используется для статусов, событий, ответов API.
- Intersection (A & B) — значение должно соответствовать всем типам одновременно. Доступны все поля всех типов. Используется для композиции поведения (mixins).
- Ключевая ошибка: путать доступ к полям — в Union нужен narrowing, в Intersection поля с конфликтом типов становятся
never.
Вопрос 21. Какие антипаттерны в TypeScript ты знаешь?
Таймкод: 00:39:05
Ответ собеседника: Правильный. Два главных антипаттерна — any и type assertion (as). Они ломают логику TypeScript вместо подсказок — разработчик утверждает, что знает что делает. Также упомянул @ts-ignore, необработанную исчерпывающую проверку в discriminated unions, использование enums (из-за попадания в рантайм), и типизацию ради типизации — создание огромных типов там, где TypeScript может вывести тип автоматически.
Правильный ответ:
Ответ собеседника отличный — он перечисляет все ключевые антипаттерны. Разберём каждый подробнее с примерами проблем и правильными альтернативами.
1. Использование any
// ❌ Антипаттерн: any отключает всю типобезопасность
function processData(data: any) {
return data.name.toUpperCase(); // Никакой проверки
// data может быть чем угодно — ошибка в рантайме
}
processData({ name: "John" }); // "JOHN"
processData({ name: null }); // TypeError в рантайме!
processData("string"); // TypeError в рантайме!
// ✅ Альтернатива: использовать unknown + type narrowing
function processDataSafe(data: unknown) {
if (typeof data === 'object' && data !== null && 'name' in data) {
const obj = data as { name: string };
if (typeof obj.name === 'string') {
return obj.name.toUpperCase();
}
}
throw new Error('Invalid data format');
}
// ✅ Ещё лучше: использовать конкретный тип
interface DataWithName {
name: string;
}
function processDataTyped(data: DataWithName) {
return data.name.toUpperCase(); // Безопасно
}
2. Type Assertion (as) вместо типобезопасного кода
// ❌ Антипаттерн: утверждение типа без проверки
function handleResponse(response: unknown) {
const user = response as User; // Утверждаем, что это User
console.log(user.name); // Может упасть в рантайме!
}
// ✅ Альтернатива: type guard
function isUser(value: unknown): value is User {
return (
typeof value === 'object' &&
value !== null &&
'id' in value &&
'name' in value &&
'email' in value &&
typeof (value as User).id === 'string' &&
typeof (value as User).name === 'string' &&
typeof (value as User).email === 'string'
);
}
function handleResponseSafe(response: unknown) {
if (isUser(response)) {
console.log(response.name); // Безопасно — TypeScript знает, что это User
} else {
console.error('Invalid response format');
}
}
3. @ts-ignore и @ts-expect-error
// ❌ Антипаттерн: подавление ошибок компиляции
// @ts-ignore
const result = someFunction(); // Почему ошибка? Никто не знает.
// ❌ Злоупотребление @ts-expect-error
// @ts-expect-error: временная мера
const data = response as any; // "Временная мера" остаётся навсегда
// ✅ Альтернатива: исправить корневую причину
// Если ошибка легитимна — использовать правильный тип
// Если ошибка в библиотеке — написать .d.ts файл
// Если временно — оставить TODO с объяснением
4. Необработанная исчерпывающая проверка (exhaustive check)
// ❌ Антипаттерн: забыли обработать все варианты
type Status = 'pending' | 'success' | 'error';
function getStatusMessage(status: Status): string {
switch (status) {
case 'pending': return 'Загрузка...';
case 'success': return 'Успешно!';
// case 'error' забыли — вернёт undefined в рантайме
}
}
// ✅ Альтернатива: exhaustive check через never
function getStatusMessageSafe(status: Status): string {
switch (status) {
case 'pending': return 'Загрузка...';
case 'success': return 'Успешно!';
case 'error': return 'Ошибка!';
default:
// Если добавить новый статус в тип, TypeScript выдаст ошибку здесь
const _exhaustive: never = status;
throw new Error(`Unhandled status: ${_exhaustive}`);
}
}
5. Enums вместо union типов
// ❌ Антипаттерн: enum генерирует код в рантайме
enum Status {
Pending = 'PENDING',
Success = 'SUCCESS',
Error = 'ERROR',
}
// Компилируется в:
// var Status;
// (function (Status) {
// Status["Pending"] = "PENDING";
// Status["Success"] = "SUCCESS";
// Status["Error"] = "ERROR";
// })(Status || (Status = {}));
// ✅ Альтернатива: const enum или union тип
type StatusUnion = 'PENDING' | 'SUCCESS' | 'ERROR';
// Или const enum (не генерирует объект в рантайме)
const enum StatusConst {
Pending = 'PENDING',
Success = 'SUCCESS',
Error = 'ERROR',
}
6. Типизация ради типизации (избыточные типы)
// ❌ Антипаттерн: создание типов, которые TypeScript может вывести
const name: string = "John"; // string выводится автоматически
const numbers: number[] = [1, 2, 3]; // number[] выводится автоматически
const user: User = { // User выводится из return type
id: "1",
name: "John",
email: "john@example.com",
};
function getUserName(user: User): string { // string выводится из return
return user.name;
}
// ✅ Альтернатива: доверить вывод типов TypeScript
const name = "John"; // TypeScript выводит string
const numbers = [1, 2, 3]; // TypeScript выводит number[]
const user = { // TypeScript выводит тип объекта
id: "1",
name: "John",
email: "john@example.com",
};
function getUserName(user: User) { // TypeScript выводит string
return user.name;
}
7. Использование ! (non-null assertion)
// ❌ Антипаттерн: утверждение, что значение не null без проверки
function getFirstElement(arr: string[]): string {
return arr[0]!; // Массив может быть пустым!
}
getFirstElement([]); // undefined в рантайме
// ✅ Альтернатива: проверка или optional chaining
function getFirstElementSafe(arr: string[]): string | undefined {
return arr[0]; // TypeScript правильно выводит string | undefined
}
// Или с дефолтным значением
function getFirstElementOrDefault(arr: string[], defaultValue: string): string {
return arr[0] ?? defaultValue;
}
8. Игнорирование strict режима
// ❌ Антипаттерн: отключение strict в tsconfig.json
// {
// "compilerOptions": {
// "strict": false, // Отключает все strict-проверки
// "noImplicitAny": false,
// "strictNullChecks": false
// }
// }
// ✅ Альтернатива: включить strict и исправлять ошибки
// {
// "compilerOptions": {
// "strict": true // Включает все strict-проверки
// }
// }
Сводная таблица антипаттернов
┌─────────────────────┬──────────────────────────────────┐
│ Антипаттерн │ Проблема │
├─────────────────────┼──────────────────────────────────┤
│ any │ Отключает типобезопасность │
│ as (type assertion) │ Утверждение без проверки │
│ @ts-ignore │ Подавление ошибок │
│ non-null assertion │ Игнорирование null/undefined │
│ enum │ Код в рантайме, больше бандла │
│ Избыточные типы │ Шум, сложность поддержки │
│ Без strict режима │ Много скрытых ошибок │
└─────────────────────┴──────────────────────────────────┘
Итог
Ответ собеседника покрывает все основные антипаттерны. Главный принцип — TypeScript должен помогать находить ошибки на этапе компиляции, а не переносить их в рантайм. any, as и @ts-ignore — это способы сказать TypeScript «доверься мне», но чаще всего это приводит к багам в продакшене. Правильный подход — использовать unknown, type guards, exhaustive checks и доверять выводу типов.
Вопрос 21. Какие антипаттерны в TypeScript ты знаешь?
Таймкод: 00:39:05
Ответ собеседника: Правильный. Два главных антипаттерна — any и type assertion (as). Они ломают логику TypeScript вместо подсказок — разработчик утверждает, что знает что делает. Также упомянул @ts-ignore, необработанную исчерпывающую проверку в discriminated unions, использование enums (из-за попадания в рантайм), и типизацию ради типизации — создание огромных типов там, где TypeScript может вывести тип автоматически.
Правильный ответ:
Ответ собеседника отличный — он перечисляет все ключевые антипаттерны. Разберём каждый подробнее с примерами проблем и правильными альтернативами.
1. Использование any
// ❌ Антипаттерн: any отключает всю типобезопасность
function processData(data: any) {
return data.name.toUpperCase(); // Никакой проверки
}
processData({ name: null }); // TypeError в рантайме!
processData("string"); // TypeError в рантайме!
// ✅ Альтернатива: unknown + type narrowing
function processDataSafe(data: unknown) {
if (typeof data === 'object' && data !== null && 'name' in data) {
const obj = data as { name: string };
if (typeof obj.name === 'string') {
return obj.name.toUpperCase();
}
}
throw new Error('Invalid data format');
}
2. Type Assertion (as) без проверки
// ❌ Антипаттерн: утверждение типа без проверки
function handleResponse(response: unknown) {
const user = response as User; // Утверждаем, что это User
console.log(user.name); // Может упасть в рантайме!
}
// ✅ Альтернатива: type guard
function isUser(value: unknown): value is User {
return (
typeof value === 'object' &&
value !== null &&
'id' in value &&
'name' in value &&
'email' in value &&
typeof (value as User).id === 'string' &&
typeof (value as User).name === 'string' &&
typeof (value as User).email === 'string'
);
}
function handleResponseSafe(response: unknown) {
if (isUser(response)) {
console.log(response.name); // Безопасно
}
}
3. @ts-ignore и @ts-expect-error
// ❌ Подавление ошибок без понимания причины
// @ts-ignore
const result = someFunction();
// ✅ Лучше: исправить корневую причину или использовать @ts-expect-error
// @ts-expect-error: тип из библиотеки неверный, issue #1234
const result = someFunction();
4. Необработанная исчерпывающая проверка
// ❌ Забыли обработать все варианты
type Status = 'pending' | 'success' | 'error';
function getMessage(status: Status): string {
switch (status) {
case 'pending': return 'Загрузка...';
case 'success': return 'Успешно!';
// case 'error' забыли
}
}
// ✅ Exhaustive check через never
function getMessageSafe(status: Status): string {
switch (status) {
case 'pending': return 'Загрузка...';
case 'success': return 'Успешно!';
case 'error': return 'Ошибка!';
default:
const _exhaustive: never = status;
throw new Error(`Unhandled status: ${_exhaustive}`);
}
}
5. Enums вместо union типов
// ❌ Enum генерирует код в рантайме
enum Status {
Pending = 'PENDING',
Success = 'SUCCESS',
Error = 'ERROR',
}
// ✅ Union тип — нет кода в рантайме
type StatusUnion = 'PENDING' | 'SUCCESS' | 'ERROR';
// Или const enum
const enum StatusConst {
Pending = 'PENDING',
Success = 'SUCCESS',
Error = 'ERROR',
}
6. Избыточная типизация
// ❌ Типизация там, где TypeScript выводит тип
const name: string = "John";
const numbers: number[] = [1, 2, 3];
// ✅ Доверить вывод типов
const name = "John"; // string
const numbers = [1, 2, 3]; // number[]
7. Non-null assertion (!)
// ❌ Утверждение без проверки
function getFirst(arr: string[]): string {
return arr[0]!; // Массив может быть пустым!
}
// ✅ Проверка или optional chaining
function getFirstSafe(arr: string[]): string | undefined {
return arr[0];
}
Итог
Главный принцип — TypeScript должен помогать находить ошибки на этапе компиляции. any, as и @ts-ignore — это способы сказать «доверься мне», но чаще всего это приводит к багам в продакшене. Правильный подход — использовать unknown, type guards, exhaustive checks и доверять выводу типов.
Вопрос 22. Чем unknown отличается от any и как правильно работать с unknown?
Таймкод: 00:40:52
Ответ собеседника: Правильный. unknown безопаснее any — не даёт доступ ко всем методам и свойствам без проверки. Для работы с unknown необходимо сузить тип (type narrowing) до ожидаемого через type guards. Основные способы: typeof, проверка свойств объекта (in), instanceof, сравнения с null/undefined, switch-конструкции. После сужения типа становятся доступны все методы соответствующего типа.
Правильный ответ:
Ответ собеседника верный. Дополним его подробным сравнением any и unknown, а также практическими паттернами работы с unknown.
Ключевое отличие: any vs unknown
┌─────────────────────────────────────────────────────────────┐
│ any │
│ │
│ • Отключает ВСЕ проверки типов │
│ • Можно вызвать любой метод │
│ • Можно присвоить куда угодно │
│ • TypeScript не помогает │
│ • Ошибки только в рантайме │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ unknown │
│ │
│ • Сохраняет типобезопасность │
│ • Нельзя вызвать никаких методов без проверки │
│ • Нельзя присвоить без приведения типа │
│ • TypeScript заставляет проверить тип │
│ • Ошибки на этапе компиляции │
└─────────────────────────────────────────────────────────────┘
Практическое сравнение
// ═══════════════════════════════════════════════════════════
// any — опасно, ошибки только в рантайме
// ═══════════════════════════════════════════════════════════
function processWithAny(data: any) {
// TypeScript не проверяет НИЧЕГО
console.log(data.name.toUpperCase()); // ✅ Компилируется
console.log(data.foo.bar.baz); // ✅ Компилируется
data(); // ✅ Компиляция
// Всё это упадёт в рантайме, если data не соответствует ожиданиям
}
processWithAny({ name: null }); // TypeError: Cannot read property 'toUpperCase' of null
processWithAny("string"); // TypeError: data.name is undefined
processWithAny(42); // TypeError: data is not a function
// ═══════════════════════════════════════════════════════════
// unknown — безопасно, ошибки на этапе компиляции
// ═══════════════════════════════════════════════════════════
function processWithUnknown(data: unknown) {
// TypeScript ЗАСТАВЛЯЕТ проверить тип перед использованием
// console.log(data.name.toUpperCase()); // ❌ Ошибка: Object is of type 'unknown'
// console.log(data.foo.bar.baz); // ❌ Ошибка: Object is of type 'unknown'
// data(); // ❌ Ошибка: Object is of type 'unknown'
// Нужно явно сузить тип
if (typeof data === 'string') {
console.log(data.toUpperCase()); // ✅ TypeScript знает: здесь data — string
}
if (typeof data === 'object' && data !== null && 'name' in data) {
const obj = data as { name: unknown };
if (typeof obj.name === 'string') {
console.log(obj.name.toUpperCase()); // ✅ Полная проверка
}
}
}
Способы type narrowing для unknown
// ═══════════════════════════════════════════════════════════
// 1. typeof — проверка примитивных типов
// ═══════════════════════════════════════════════════════════
function handleValue(value: unknown) {
if (typeof value === 'string') {
console.log(value.toUpperCase()); // string
} else if (typeof value === 'number') {
console.log(value.toFixed(2)); // number
} else if (typeof value === 'boolean') {
console.log(value ? 'yes' : 'no'); // boolean
}
}
// ═══════════════════════════════════════════════════════════
// 2. in — проверка наличия свойства
// ═══════════════════════════════════════════════════════════
interface User {
id: string;
name: string;
email: string;
}
function processObject(obj: unknown) {
if (typeof obj === 'object' && obj !== null) {
if ('id' in obj && 'name' in obj) {
// obj имеет хотя бы id и name
console.log((obj as { id: unknown; name: unknown }).id);
}
if ('email' in obj) {
// obj имеет email
console.log((obj as { email: unknown }).email);
}
}
}
// ═══════════════════════════════════════════════════════════
// 3. instanceof — проверка класса
// ═══════════════════════════════════════════════════════════
class ApiError extends Error {
constructor(public statusCode: number, message: string) {
super(message);
}
}
function handleError(error: unknown) {
if (error instanceof ApiError) {
console.log(`API Error ${error.statusCode}: ${error.message}`);
} else if (error instanceof Error) {
console.log(`Error: ${error.message}`);
} else {
console.log('Unknown error:', error);
}
}
// ═══════════════════════════════════════════════════════════
// 4. Type guard функция
// ═══════════════════════════════════════════════════════════
function isUser(value: unknown): value is User {
return (
typeof value === 'object' &&
value !== null &&
'id' in value &&
'name' in value &&
'email' in value &&
typeof (value as User).id === 'string' &&
typeof (value as User).name === 'string' &&
typeof (value as User).email === 'string'
);
}
function processUser(data: unknown) {
if (isUser(data)) {
console.log(data.name); // TypeScript знает: здесь data — User
console.log(data.email); // Безопасно
} else {
throw new Error('Invalid user data');
}
}
// ═══════════════════════════════════════════════════════════
// 5. Discriminated union + switch
// ═══════════════════════════════════════════════════════════
type ApiResponse =
| { status: 'success'; data: User[] }
| { status: 'error'; error: string }
| { status: 'loading' };
function handleResponse(response: unknown) {
if (
typeof response === 'object' &&
response !== null &&
'status' in response &&
typeof (response as ApiResponse).status === 'string'
) {
const typed = response as ApiResponse;
switch (typed.status) {
case 'success':
typed.data.forEach(user => console.log(user.name)); // User[]
break;
case 'error':
console.error(typed.error); // string
break;
case 'loading':
console.log('Loading...');
break;
}
}
}
Практический пример: безопасный JSON.parse
// ❌ Опасный парсинг
function parseUserUnsafe(json: string): User {
return JSON.parse(json) as User; // Нет гарантии, что это User!
}
// ✅ Безопасный парсинг с валидацией
function parseUserSafe(json: string): User {
const parsed: unknown = JSON.parse(json);
if (!isUser(parsed)) {
throw new Error('Invalid user data');
}
return parsed; // TypeScript знает: это User
}
// Или с результатом
function parseUserResult(json: string): { success: true; data: User } | { success: false; error: string } {
try {
const parsed: unknown = JSON.parse(json);
if (isUser(parsed)) {
return { success: true, data: parsed };
}
return { success: false, error: 'Invalid user format' };
} catch (e) {
return { success: false, error: 'Invalid JSON' };
}
}
Когда использовать unknown вместо any
// Используйте unknown, когда:
// 1. Принимаете данные извне (API, localStorage, пользовательский ввод)
// 2. Пишете утилитные функции с неизвестным входом
// 3. Хотите сохранить типобезопасность
// ❌ Плохо
function saveToLocalStorage(key: string, value: any) {
localStorage.setItem(key, JSON.stringify(value));
}
function loadFromLocalStorage(key: string): any {
const data = localStorage.getItem(key);
return data ? JSON.parse(data) : null;
}
// ✅ Хорошо
function saveToLocalStorage<T>(key: string, value: T): void {
localStorage.setItem(key, JSON.stringify(value));
}
function loadFromLocalStorage<T>(key: string, validator: (data: unknown) => data is T): T | null {
const data = localStorage.getItem(key);
if (!data) return null;
const parsed: unknown = JSON.parse(data);
return validator(parsed) ? parsed : null;
}
// Использование
const user = loadFromLocalStorage('user', isUser);
if (user) {
console.log(user.name); // Безопасно
}
Итог
- any — «доверься мне, я знаю, что делаю». Отключает все проверки. Ошибки только в рантайме.
- unknown — «я не знаю, что это, но буду работать безопасно». Заставляет проверить тип перед использованием. Ошибки на этапе компиляции.
- Правило: Всегда используйте unknown вместо any, когда тип заранее неизвестен. Это особенно важно для данных извне: API-ответы, localStorage, пользовательский ввод, JSON.parse.
Вопрос 23. Что такое дженерики в TypeScript и для чего они используются?
Таймкод: 00:42:55
Ответ собеседника: Правильный. Дженерики позволяют создавать универсальные типы, когда заранее неизвестно, какой тип будет использован. Привёл пример с интерфейсом пагинации, где поле items могло содержать массив пользователей или продуктов — дженерик позволил универсализировать тип. Признал, что это его слабая тема — не упомянул constraints (ограничения) и условные конструкции в дженериках.
Правильный ответ:
Ответ собеседника хороший — он правильно описал суть дженериков и привёл практический пример. Дополним ответ ограничениями (constraints), условными типами и продвинутыми паттернами.
Базовые дженерики
// ═══════════════════════════════════════════════════════════
// Простая дженерик-функция
// ═══════════════════════════════════════════════════════════
function identity<T>(value: T): T {
return value;
}
const str = identity("hello"); // string
const num = identity(42); // number
const arr = identity([1, 2, 3]); // number[]
// ═══════════════════════════════════════════════════════════
// Дженерик-интерфейс (пример собеседника — пагинация)
// ═══════════════════════════════════════════════════════════
interface PaginatedResponse<T> {
items: T[];
total: number;
page: number;
pageSize: number;
totalPages: number;
}
interface User {
id: string;
name: string;
email: string;
}
interface Product {
id: string;
title: string;
price: number;
}
// Универсальный тип для любой сущности
const userResponse: PaginatedResponse<User> = {
items: [{ id: "1", name: "John", email: "john@example.com" }],
total: 1,
page: 1,
pageSize: 10,
totalPages: 1,
};
const productResponse: PaginatedResponse<Product> = {
items: [{ id: "1", title: "Laptop", price: 999 }],
total: 1,
page: 1,
pageSize: 10,
totalPages: 1,
};
// Универсальная функция для получения пагинированных данных
async function fetchPaginated<T>(
endpoint: string,
page: number,
pageSize: number
): Promise<PaginatedResponse<T>> {
const response = await fetch(`${endpoint}?page=${page}&pageSize=${pageSize}`);
return response.json();
}
// Тип выводится автоматически
const users = await fetchPaginated<User>("/api/users", 1, 10); // PaginatedResponse<User>
const products = await fetchPaginated<Product>("/api/products", 1, 10); // PaginatedResponse<Product>
Ограничения (Constraints)
// ═══════════════════════════════════════════════════════════
// extends — ограничение типа параметра
// ═══════════════════════════════════════════════════════════
// ❌ Без ограничений — можно передать что угодно
function getPropertyBad<T>(obj: T, key: string): any {
// obj[key] — ошибка: нет гарантии, что key существует в T
return obj[key];
}
// ✅ С ограничением — key должен быть ключом obj
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const user = { id: "1", name: "John", email: "john@example.com" };
const name = getProperty(user, "name"); // string
const id = getProperty(user, "id"); // string
// const bad = getProperty(user, "phone"); // ❌ Ошибка: "phone" не является ключом User
// ═══════════════════════════════════════════════════════════
// Ограничение на наличие определённых полей
// ═══════════════════════════════════════════════════════════
interface HasId {
id: string;
}
// T должен иметь поле id
function findById<T extends HasId>(items: T[], id: string): T | undefined {
return items.find(item => item.id === id);
}
const users = [
{ id: "1", name: "John", email: "john@example.com" },
{ id: "2", name: "Jane", email: "jane@example.com" },
];
const user = findById(users, "1"); // Работает — User имеет id
// ═══════════════════════════════════════════════════════════
// Ограничение на классы
// ═══════════════════════════════════════════════════════════
interface Constructable<T> {
new (...args: any[]): T;
}
// T должен быть классом, который можно создать через new
function createInstance<T>(ctor: Constructable<T>, ...args: any[]): T {
return new ctor(...args);
}
class UserEntity {
constructor(public name: string, public email: string) {}
}
const userInstance = createInstance(UserEntity, "John", "john@example.com");
Условные типы (Conditional Types)
// ═══════════════════════════════════════════════════════════
// Базовый синтаксис: T extends U ? X : Y
// ═══════════════════════════════════════════════════════════
// Если T — массив, возвращаем элемент массива, иначе возвращаем T
type ElementType<T> = T extends (infer U)[] ? U : T;
type A = ElementType<string[]>; // string
type B = ElementType<number[]>; // number
type C = ElementType<string>; // string
// ═══════════════════════════════════════════════════════════
// Практический пример: ApiResponse<T>
// ═══════════════════════════════════════════════════════════
type ApiResponse<T> = T extends Array<infer U>
? { items: U[]; total: number }
: { data: T };
// Для массива
type UserList = ApiResponse<User[]>;
// { items: User[]; total: number }
// Для одиночного объекта
type SingleUser = ApiResponse<User>;
// { data: User }
// ═══════════════════════════════════════════════════════════
// Условные типы для фильтрации
// ═══════════════════════════════════════════════════════════
// Убираем из union типа null и undefined
type NonNullable<T> = T extends null | undefined ? never : T;
type A = NonNullable<string | null | undefined>; // string
// Извлекаем только строковые ключи
type StringKeys<T> = {
[K in keyof T]: T[K] extends string ? K : never;
}[keyof T];
interface User {
id: string;
name: string;
age: number;
email: string;
}
type UserStringKeys = StringKeys<User>; // "id" | "name" | "email"
Продвинутые паттерны
// ═══════════════════════════════════════════════════════════
// Дженерик-класс: Repository pattern
// ═══════════════════════════════════════════════════════════
interface Entity {
id: string;
}
class Repository<T extends Entity> {
private items: Map<string, T> = new Map();
create(item: T): T {
this.items.set(item.id, item);
return item;
}
findById(id: string): T | undefined {
return this.items.get(id);
}
findAll(): T[] {
return Array.from(this.items.values());
}
update(id: string, data: Partial<Omit<T, 'id'>>): T | undefined {
const existing = this.items.get(id);
if (!existing) return undefined;
const updated = { ...existing, ...data };
this.items.set(id, updated);
return updated;
}
delete(id: string): boolean {
return this.items.delete(id);
}
}
// Использование
interface User extends Entity {
id: string;
name: string;
email: string;
}
interface Product extends Entity {
id: string;
title: string;
price: number;
}
const userRepo = new Repository<User>();
const productRepo = new Repository<Product>();
userRepo.create({ id: "1", name: "John", email: "john@example.com" });
productRepo.create({ id: "1", title: "Laptop", price: 999 });
// ═══════════════════════════════════════════════════════════
// Дженерик с дефолтным типом
// ═══════════════════════════════════════════════════════════
interface ApiResponse<T = unknown> {
data: T;
status: number;
message: string;
}
// Без указания типа — unknown
const response1: ApiResponse = {
data: { anything: true },
status: 200,
message: "OK",
};
// С указанием типа — User
const response2: ApiResponse<User> = {
data: { id: "1", name: "John", email: "john@example.com" },
status: 200,
message: "OK",
};
// ═══════════════════════════════════════════════════════════
// Mapped Types + дженерики
// ═══════════════════════════════════════════════════════════
// Делаем все поля опциональными
type Partial<T> = {
[P in keyof T]?: T[P];
};
// Делаем все поля readonly
type Readonly<T> = {
readonly [P in keyof T]: T[P];
};
// Выбираем только указанные поля
type Pick<T, K extends keyof T> = {
[P in K]: T[P];
};
// Исключаем указанные поля
type Omit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>;
// ═══════════════════════════════════════════════════════════
// infer — вывод типа внутри условного типа
// ═══════════════════════════════════════════════════════════
// Извлекаем тип из Promise
type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;
type A = UnwrapPromise<Promise<string>>; // string
type B = UnwrapPromise<number>; // number
// Извлекаем тип из массива
type UnwrapArray<T> = T extends (infer U)[] ? U : T;
type C = UnwrapArray<string[]>; // string
type D = UnwrapArray<number>; // number
// Извлекаем тип возвращаемого значения функции
type ReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
function getUser() {
return { id: "1", name: "John" };
}
type UserReturn = ReturnType<typeof getUser>; // { id: string; name: string }
Итог
- Базовые дженерики — создание универсальных функций, интерфейсов, классов.
- Constraints (extends) — ограничение типов параметров:
T extends HasId,K extends keyof T. - Условные типы —
T extends U ? X : Yдля создания типов, зависящих от входного типа. - infer — вывод типа внутри условного типа:
T extends Promise<infer U> ? U : T. - Практическое применение: репозитории, API-ответы, утилитные функции, type-safe ORM.
Вопрос 23. Что такое дженерики в TypeScript и для чего они используются?
Таймкод: 00:42:55
Ответ собеседника: Правильный. Дженерики позволяют создавать универсальные типы, когда заранее неизвестно, какой тип будет использован. Привёл пример с интерфейсом пагинации, где поле items могло содержать массив пользователей или продуктов — дженерик позволил универсализировать тип. Признал, что это его слабая тема — не упомянул constraints (ограничения) и условные конструкции в дженериках.
Правильный ответ:
Ответ собеседника хороший — он правильно описал суть дженериков и привёл практический пример. Дополним ответ ограничениями (constraints), условными типами и продвинутыми паттернами.
Базовые дженерики
// ═══════════════════════════════════════════════════════════
// Простая дженерик-функция
// ═══════════════════════════════════════════════════════════
function identity<T>(value: T): T {
return value;
}
const str = identity("hello"); // string
const num = identity(42); // number
// ═══════════════════════════════════════════════════════════
// Дженерик-интерфейс: пагинация
// ═══════════════════════════════════════════════════════════
interface PaginatedResponse<T> {
items: T[];
total: number;
page: number;
pageSize: number;
totalPages: number;
}
interface User {
id: string;
name: string;
email: string;
}
interface Product {
id: string;
title: string;
price: number;
}
// Универсальный тип для любой сущности
const userResponse: PaginatedResponse<User> = {
items: [{ id: "1", name: "John", email: "john@example.com" }],
total: 1,
page: 1,
pageSize: 10,
totalPages: 1,
};
const productResponse: PaginatedResponse<Product> = {
items: [{ id: "1", title: "Laptop", price: 999 }],
total: 1,
page: 1,
pageSize: 10,
totalPages: 1,
};
// Универсальная функция для получения пагинированных данных
async function fetchPaginated<T>(
endpoint: string,
page: number,
pageSize: number
): Promise<PaginatedResponse<T>> {
const response = await fetch(`${endpoint}?page=${page}&pageSize=${pageSize}`);
return response.json();
}
// Тип выводится автоматически
const users = await fetchPaginated<User>("/api/users", 1, 10);
const products = await fetchPaginated<Product>("/api/products", 1, 10);
Ограничения (Constraints)
// ═══════════════════════════════════════════════════════════
// extends — ограничение типа параметра
// ═══════════════════════════════════════════════════════════
// ❌ Без ограничений — можно передать что угодно
function getPropertyBad<T>(obj: T, key: string): any {
return obj[key]; // Ошибка: нет гарантии, что key существует в T
}
// ✅ С ограничением — key должен быть ключом obj
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const user = { id: "1", name: "John", email: "john@example.com" };
const name = getProperty(user, "name"); // string
const id = getProperty(user, "id"); // string
// const bad = getProperty(user, "phone"); // ❌ Ошибка: "phone" не является ключом User
// ═══════════════════════════════════════════════════════════
// Ограничение на наличие определённых полей
// ═══════════════════════════════════════════════════════════
interface HasId {
id: string;
}
// T должен иметь поле id
function findById<T extends HasId>(items: T[], id: string): T | undefined {
return items.find(item => item.id === id);
}
const users = [
{ id: "1", name: "John", email: "john@example.com" },
{ id: "2", name: "Jane", email: "jane@example.com" },
];
const user = findById(users, "1"); // Работает — User имеет id
// ═══════════════════════════════════════════════════════════
// Ограничение на классы
// ═══════════════════════════════════════════════════════════
interface Constructable<T> {
new (...args: any[]): T;
}
// T должен быть классом, который можно создать через new
function createInstance<T>(ctor: Constructable<T>, ...args: any[]): T {
return new ctor(...args);
}
class UserEntity {
constructor(public name: string, public email: string) {}
}
const userInstance = createInstance(UserEntity, "John", "john@example.com");
Условные типы (Conditional Types)
// ═══════════════════════════════════════════════════════════
// Базовый синтаксис: T extends U ? X : Y
// ═══════════════════════════════════════════════════════════
// Если T — массив, возвращаем элемент массива, иначе возвращаем T
type ElementType<T> = T extends (infer U)[] ? U : T;
type A = ElementType<string[]>; // string
type B = ElementType<number[]>; // number
type C = ElementType<string>; // string
// ═══════════════════════════════════════════════════════════
// Практический пример: ApiResponse<T>
// ═══════════════════════════════════════════════════════════
type ApiResponse<T> = T extends Array<infer U>
? { items: U[]; total: number }
: { data: T };
// Для массива
type UserList = ApiResponse<User[]>;
// { items: User[]; total: number }
// Для одиночного объекта
type SingleUser = ApiResponse<User>;
// { data: User }
// ═══════════════════════════════════════════════════════════
// Условные типы для фильтрации
// ═══════════════════════════════════════════════════════════
// Убираем из union типа null и undefined
type NonNullable<T> = T extends null | undefined ? never : T;
type A = NonNullable<string | null | undefined>; // string
// Извлекаем только строковые ключи
type StringKeys<T> = {
[K in keyof T]: T[K] extends string ? K : never;
}[keyof T];
interface User {
id: string;
name: string;
age: number;
email: string;
}
type UserStringKeys = StringKeys<User>; // "id" | "name" | "email"
Продвинутые паттерны
// ═══════════════════════════════════════════════════════════
// Дженерик-класс: Repository pattern
// ═══════════════════════════════════════════════════════════
interface Entity {
id: string;
}
class Repository<T extends Entity> {
private items: Map<string, T> = new Map();
create(item: T): T {
this.items.set(item.id, item);
return item;
}
findById(id: string): T | undefined {
return this.items.get(id);
}
findAll(): T[] {
return Array.from(this.items.values());
}
update(id: string, data: Partial<Omit<T, 'id'>>): T | undefined {
const existing = this.items.get(id);
if (!existing) return undefined;
const updated = { ...existing, ...data };
this.items.set(id, updated);
return updated;
}
delete(id: string): boolean {
return this.items.delete(id);
}
}
// Использование
interface User extends Entity {
id: string;
name: string;
email: string;
}
interface Product extends Entity {
id: string;
title: string;
price: number;
}
const userRepo = new Repository<User>();
const productRepo = new Repository<Product>();
userRepo.create({ id: "1", name: "John", email: "john@example.com" });
productRepo.create({ id: "1", title: "Laptop", price: 999 });
// ═══════════════════════════════════════════════════════════
// Дженерик с дефолтным типом
// ═══════════════════════════════════════════════════════════
interface ApiResponse<T = unknown> {
data: T;
status: number;
message: string;
}
// Без указания типа — unknown
const response1: ApiResponse = {
data: { anything: true },
status: 200,
message: "OK",
};
// С указанием типа — User
const response2: ApiResponse<User> = {
data: { id: "1", name: "John", email: "john@example.com" },
status: 200,
message: "OK",
};
// ═══════════════════════════════════════════════════════════
// Mapped Types + дженерики
// ═══════════════════════════════════════════════════════════
// Делаем все поля опциональными
type Partial<T> = {
[P in keyof T]?: T[P];
};
// Делаем все поля readonly
type Readonly<T> = {
readonly [P in keyof T]: T[P];
};
// Выбираем только указанные поля
type Pick<T, K extends keyof T> = {
[P in K]: T[P];
};
// Исключаем указанные поля
type Omit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>;
// ═══════════════════════════════════════════════════════════
// infer — вывод типа внутри условного типа
// ═══════════════════════════════════════════════════════════
// Извлекаем тип из Promise
type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;
type A = UnwrapPromise<Promise<string>>; // string
type B = UnwrapPromise<number>; // number
// Извлекаем тип из массива
type UnwrapArray<T> = T extends (infer U)[] ? U : T;
type C = UnwrapArray<string[]>; // string
type D = UnwrapArray<number>; // number
// Извлекаем тип возвращаемого значения функции
type ReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
function getUser() {
return { id: "1", name: "John" };
}
type UserReturn = ReturnType<typeof getUser>; // { id: string; name: string }
Итог
- Базовые дженерики — создание универсальных функций, интерфейсов, классов.
- Constraints (extends) — ограничение типов параметров:
T extends HasId,K extends keyof T. - Условные типы —
T extends U ? X : Yдля создания типов, зависящих от входного типа. - infer — вывод типа внутри условного типа:
T extends Promise<infer U> ? U : T. - Практическое применение: репозитории, API-ответы, утилитные функции, type-safe ORM.
Вопрос 24. Чем функциональное обновление состояния отличается от прямого в React?
Таймкод: 00:47:59
Ответ собеседника: Правильный. Count будет равен 1, потому что оба вызова setCount используют одно и то же значение count из замыкания (батчинг). Для корректного инкремента нужно использовать функциональное обновление: setCount(prev => prev + 1).
Правильный ответ:
Ответ собеседника полностью верный. Это один из ключевых концептов React, который стоит понимать глубже. Разберём подробнее.
Проблема: замыкание и батчинг
// ═══════════════════════════════════════════════════════════
// ❌ Неправильный подход
// ═══════════════════════════════════════════════════════════
function Counter() {
const [count, setCount] = useState(0);
const handleClick = () => {
// Оба вызова видят count = 0 из замыкания
setCount(count + 1); // setCount(0 + 1) → запланировано: count = 1
setCount(count + 1); // setCount(0 + 1) → запланировано: count = 1
// Итог: count = 1, а не 2!
};
return <button onClick={handleClick}>Count: {count}</button>;
}
// ═══════════════════════════════════════════════════════════
// ✅ Правильный подход: функциональное обновление
// ═══════════════════════════════════════════════════════════
function Counter() {
const [count, setCount] = useState(0);
const handleClick = () => {
// Каждый вызов получает актуальное предыдущее значение
setCount(prev => prev + 1); // prev = 0 → запланировано: count = 1
setCount(prev => prev + 1); // prev = 1 → запланировано: count = 2
// Итог: count = 2 ✓
};
return <button onClick={handleClick}>Count: {count}</button>;
}
Как это работает внутри React
Прямое обновление (setCount(count + 1)):
┌─────────────────────────────────────────────────┐
│ Render 1: count = 0 │
│ handleClick замыкается на count = 0 │
│ setCount(0 + 1) → планируем count = 1 │
│ setCount(0 + 1) → планируем count = 1 │
│ (второй вызов перезаписывает первый) │
│ Render 2: count = 1 │
└─────────────────────────────────────────────────┘
Функциональное обновление (setCount(prev => prev + 1)):
┌─────────────────────────────────────────────────┐
│ Render 1: count = 0 │
│ handleClick замыкается на count = 0 │
│ setCount(prev => prev + 1) │
│ → функция добавлена в очередь │
│ setCount(prev => prev + 1) │
│ → функция добавлена в очередь │
│ │
│ React обрабатывает очередь: │
│ prev = 0 → return 0 + 1 = 1 │
│ prev = 1 → return 1 + 1 = 2 │
│ Render 2: count = 2 │
└─────────────────────────────────────────────────┘
Когда обязательно использовать функциональное обновление
// ═══════════════════════════════════════════════════════════
// 1. Множественные обновления в одном обработчике
// ═══════════════════════════════════════════════════════════
function TodoList() {
const [todos, setTodos] = useState([]);
const addTwoTodos = () => {
// ❌ Неправильно — второй вызов перезапишет первый
setTodos([...todos, { id: Date.now(), text: "Todo 1" }]);
setTodos([...todos, { id: Date.now() + 1, text: "Todo 2" }]);
// ✅ Правильно — каждое обновление видит актуальный массив
setTodos(prev => [...prev, { id: Date.now(), text: "Todo 1" }]);
setTodos(prev => [...prev, { id: Date.now() + 1, text: "Todo 2" }]);
};
}
// ═══════════════════════════════════════════════════════════
// 2. Обновление в асинхронном коде (setTimeout, Promise)
// ═══════════════════════════════════════════════════════════
function AsyncCounter() {
const [count, setCount] = useState(0);
// ❌ Неправильно — замыкание на старом значении
const handleBadAsync = () => {
setTimeout(() => {
setCount(count + 1); // count может быть устаревшим
}, 1000);
};
// ✅ Правильно — всегда получаем актуальное значение
const handleGoodAsync = () => {
setTimeout(() => {
setCount(prev => prev + 1); // prev всегда актуален
}, 1000);
};
}
// ═══════════════════════════════════════════════════════════
// 3. Обновление в useEffect с зависимостями
// ═══════════════════════════════════════════════════════════
function PollingComponent() {
const [data, setData] = useState(null);
const [retryCount, setRetryCount] = useState(0);
useEffect(() => {
const interval = setInterval(() => {
fetchData().then(result => {
setData(result);
// ✅ Функциональное обновление не требует
// retryCount в зависимостях useEffect
setRetryCount(prev => prev + 1);
});
}, 5000);
return () => clearInterval(interval);
}, []); // Пустые зависимости — нет предупреждений ESLint
}
// ═══════════════════════════════════════════════════════════
// 4. Обновление объектов и массивов
// ═══════════════════════════════════════════════════════════
function UserProfile() {
const [user, setUser] = useState({
name: "John",
preferences: { theme: "dark", language: "en" }
});
// ❌ Неправильно — можно потерять другие поля
const updateTheme = (theme) => {
setUser({
...user,
preferences: { theme } // Потеряем language!
});
};
// ✅ Правильно — сохраняем все вложенные поля
const updateThemeCorrect = (theme) => {
setUser(prev => ({
...prev,
preferences: {
...prev.preferences,
theme
}
}));
};
// ✅ Или используем библиотеку immer для иммутабельных обновлений
// import produce from 'immer';
// setUser(produce(user, draft => {
// draft.preferences.theme = theme;
// }));
}
Батчинг в React 18+
// ═══════════════════════════════════════════════════════════
// React 18 автоматически батчит ВСЕ обновления состояния,
// даже вне обработчиков событий
// ═══════════════════════════════════════════════════════════
function BatchedUpdates() {
const [count, setCount] = useState(0);
const [flag, setFlag] = useState(false);
// React 17 (внутри обработчика — батч, снаружи — нет):
const handleClickOld = () => {
// Батч: один ре-рендер
setCount(c => c + 1);
setFlag(f => !f);
};
// React 18+ (везде — батч):
const handleClickNew = () => {
// Батч: один ре-рендер
setCount(c => c + 1);
setFlag(f => !f);
};
// Вне обработчика — тоже батч в React 18+
setTimeout(() => {
setCount(c => c + 1);
setFlag(f => !f);
// React 17: два ре-рендера
// React 18: один ре-рендер (автоматический батчинг)
}, 1000);
// Если нужно принудительно синхронно обновить (редко):
// import { flushSync } from 'react-dom';
// flushSync(() => setCount(c => c + 1));
// DOM обновлён синхронно
}
// ═══════════════════════════════════════════════════════════
// Принудительный сброс батча через flushSync
// ═══════════════════════════════════════════════════════════
import { flushSync } from 'react-dom';
function ForceSyncUpdate() {
const [count, setCount] = useState(0);
const handleClick = () => {
// Принудительный синхронный рендер
flushSync(() => {
setCount(c => c + 1);
});
// Здесь DOM уже обновлён, можно читать из DOM
console.log(document.getElementById('count').textContent); // "1"
};
}
Сравнение подходов
┌─────────────────────┬──────────────────────┬──────────────────────┐
│ Ситуация │ Прямое обновление │ Функциональное │
├─────────────────────┼──────────────────────┼──────────────────────┤
│ Один setCount │ ✅ Работает │ ✅ Работает │
│ Два setCount │ ❌ Теряет обновления │ ✅ Корректно │
│ В setTimeout │ ❌ Устаревшее знач. │ ✅ Актуальное знач. │
│ В useEffect deps=[] │ ❌ Нужна зависимость │ ✅ Без зависимости │
│ Сложные объекты │ ⚠️ Риск потери полей │ ✅ Безопасно │
└─────────────────────┴──────────────────────┴──────────────────────┘
Правило: если новое состояние зависит от предыдущего — всегда используйте функциональное обновление setState(prev => ...). Это безопаснее, не требует добавления в зависимости useEffect, и корректно работает при батчинге.
Вопрос 25. Какие проблемы возникают при использовании useEffect с обработчиком события и пустым массивом зависимостей?
Таймкод: 00:48:44
Ответ собеседника: Правильный. Проблема в том, что handleBefore объявлена вне useEffect и может меняться, но не указана в массиве зависимостей. Нужно добавить handleBefore в массив зависимостей, чтобы при изменении функции эффект пересоздавался с актуальной версией обработчика. Все используемые зависимости должны быть переданы в массив, за редким исключением.
Правильный ответ:
Ответ собеседника верный. Это одна из самых распространённых ошибок при работе с useEffect. Разберём все грани этой проблемы и способы решения.
Проблема: stale closure (устаревшее замыкание)
// ═══════════════════════════════════════════════════════════
// ❌ Проблемный код
// ═══════════════════════════════════════════════════════════
function Component({ userId }) {
const [data, setData] = useState(null);
// handleBefore создаётся при каждом рендере
const handleBefore = () => {
// Замыкается на data из момента создания эффекта
console.log("Current data:", data); // Всегда null!
};
// Пустой массив зависимостей — эффект запускается один раз
useEffect(() => {
window.addEventListener("beforeunload", handleBefore);
return () => window.removeEventListener("beforeunload", handleBefore);
}, []); // handleBefore не в зависимостях!
useEffect(() => {
fetchData(userId).then(setData);
}, [userId]);
return <div>{data ? data.name : "Loading..."}</div>;
}
// Что происходит:
// 1. Первый рендер: data = null, handleBefore замыкается на null
// 2. Эффект запускается, подписывается на handleBefore (с data = null)
// 3. Данные загрузились: data = { name: "John" }
// 4. Второй рендер: handleBefore пересоздаётся, но эффект НЕ перезапускается
// 5. При закрытии вкладки: handleBefore видит data = null (устаревшее значение!)
Решение 1: Добавить зависимость и использовать useCallback
// ═══════════════════════════════════════════════════════════
// ✅ Решение с useCallback
// ═══════════════════════════════════════════════════════════
function Component({ userId }) {
const [data, setData] = useState(null);
// useCallback сохраняет ссылку стабильной, пока data не изменится
const handleBefore = useCallback(() => {
console.log("Current data:", data);
}, [data]); // Пересоздаётся только при изменении data
useEffect(() => {
window.addEventListener("beforeunload", handleBefore);
return () => window.removeEventListener("beforeunload", handleBefore);
}, [handleBefore]); // Эффект перезапускается при изменении handleBefore
useEffect(() => {
fetchData(userId).then(setData);
}, [userId]);
return <div>{data ? data.name : "Loading..."}</div>;
}
Решение 2: useRef для хранения актуального значения
// ═══════════════════════════════════════════════════════════
// ✅ Решение с useRef (без пересоздания эффекта)
// ═══════════════════════════════════════════════════════════
function Component({ userId }) {
const [data, setData] = useState(null);
const dataRef = useRef(data);
// Синхронизируем ref с актуальным значением
useEffect(() => {
dataRef.current = data;
}, [data]);
// Эффект запускается один раз, но всегда читает актуальное значение
useEffect(() => {
const handleBefore = () => {
console.log("Current data:", dataRef.current); // Всегда актуально!
};
window.addEventListener("beforeunload", handleBefore);
return () => window.removeEventListener("beforeunload", handleBefore);
}, []); // Пустой массив — эффект не пересоздаётся
useEffect(() => {
fetchData(userId).then(setData);
}, [userId]);
return <div>{data ? data.name : "Loading..."}</div>;
}
Решение 3: Объявление функции внутри useEffect
// ═══════════════════════════════════════════════════════════
// ✅ Решение с функцией внутри useEffect
// ═══════════════════════════════════════════════════════════
function Component({ userId }) {
const [data, setData] = useState(null);
useEffect(() => {
const handleBefore = () => {
console.log("Current data:", data); // Замыкается на data из этого рендера
};
window.addEventListener("beforeunload", handleBefore);
return () => window.removeEventListener("beforeunload", handleBefore);
}, [data]); // Эффект перезапускается при изменении data
useEffect(() => {
fetchData(userId).then(setData);
}, [userId]);
return <div>{data ? data.name : "Loading..."}</div>;
}
Решение 4: Кастомный хук useEvent (React 19+)
// ═══════════════════════════════════════════════════════════
// ✅ React 19: useEvent (экспериментальный API)
// ═══════════════════════════════════════════════════════════
import { useEvent } from 'react';
function Component({ userId }) {
const [data, setData] = useState(null);
// useEvent создаёт стабильную ссылку, но всегда вызывает актуальную функцию
const handleBefore = useEvent(() => {
console.log("Current data:", data); // Всегда актуально!
});
useEffect(() => {
window.addEventListener("beforeunload", handleBefore);
return () => window.removeEventListener("beforeunload", handleBefore);
}, []); // handleBefore стабильна, можно не добавлять в зависимости
useEffect(() => {
fetchData(userId).then(setData);
}, [userId]);
return <div>{data ? data.name : "Loading..."}</div>;
}
Сравнение решений
┌─────────────────────┬────────────────────┬─────────────────────┬──────────────┐
│ Решение │ Пересоздаёт эффект │ Стабильная ссылка │ Сложность │
├─────────────────────┼────────────────────┼─────────────────────┼──────────────┤
│ useCallback + deps │ Да │ Да (с зависимостями)│ Средняя │
│ useRef │ Нет │ Да │ Высокая │
│ Функция внутри │ Да │ Нет │ Низкая │
│ useEvent (React 19) │ Нет │ Да │ Низкая │
└─────────────────────┴────────────────────┴─────────────────────┴──────────────┘
Правила для массива зависимостей useEffect
// ═══════════════════════════════════════════════════════════
// Правило 1: ВСЕ значения из замыкания должны быть в зависимостях
// ═══════════════════════════════════════════════════════════
function Component({ userId }) {
const [filter, setFilter] = useState("all");
useEffect(() => {
// userId и filter — из замыкания, должны быть в зависимостях
fetchUsers(userId, filter).then(setUsers);
}, [userId, filter]); // ✅ Все зависимости указаны
}
// ═══════════════════════════════════════════════════════════
// Правило 2: Функции из замыкания — используйте useCallback
// ═══════════════════════════════════════════════════════════
function Component() {
const [items, setItems] = useState([]);
const handleAdd = useCallback((item) => {
setItems(prev => [...prev, item]);
}, []); // Стаильная ссылка
useEffect(() => {
eventBus.on("add-item", handleAdd);
return () => eventBus.off("add-item", handleAdd);
}, [handleAdd]); // ✅ handleBefore стабильна благодаря useCallback
}
// ═══════════════════════════════════════════════════════════
// Правило 3: setState и dispatch — стабильны, можно не добавлять
// ═══════════════════════════════════════════════════════════
function Component() {
const [count, setCount] = useState(0);
const [state, dispatch] = useReducer(reducer, initialState);
useEffect(() => {
// setCount и dispatch стабильны — можно не добавлять в зависимости
const interval = setInterval(() => {
setCount(c => c + 1);
dispatch({ type: "tick" });
}, 1000);
return () => clearInterval(interval);
}, []); // ✅ setCount и dispatch стабильны
}
// ═══════════════════════════════════════════════════════════
// Правило 4: Объекты и массивы — создавайте вне компонента или useMemo
// ═══════════════════════════════════════════════════════════
// ❌ Неправильно — новый объект при каждом рендере
function Component() {
useEffect(() => {
fetchData({ limit: 10, offset: 0 });
}, [{ limit: 10, offset: 0 }]); // Новый объект каждый раз — бесконечный цикл!
}
// ✅ Правильно — вынести константу
const DEFAULT_PARAMS = { limit: 10, offset: 0 };
function Component() {
useEffect(() => {
fetchData(DEFAULT_PARAMS);
}, [DEFAULT_PARAMS]); // Стабильная ссылка
}
// ✅ Или использовать useMemo
function Component({ userId }) {
const params = useMemo(() => ({ limit: 10, offset: 0, userId }), [userId]);
useEffect(() => {
fetchData(params);
}, [params]); // Меняется только при изменении userId
}
Итог
- Пустой массив зависимостей — эффект захватывает значения из первого рендера и никогда не обновляет замыкание.
- Все внешние значения используемые внутри
useEffectдолжны быть в массиве зависимостей. - Функции — оборачивайте в
useCallbackили используйтеuseRefдля хранения актуального значения. - setState и dispatch — стабильны по ссылке, можно не добавлять в зависимости.
- ESLint-правило
react-hooks/exhaustive-depsпомогает найти пропущенные зависимости — не игнорируйте его.
Вопрос 26. Как найти пересечение двух массивов (общие элементы без дубликатов)?
Таймкод: 00:50:22
Ответ собеседника: Неполный. Кандидат сначала предложил наивный подход — двойной цикл сравнения элементов (O(n²)). После подсказки про хэш-карту (Map) начал реализовывать оптимальное решение: первый проход по первому массиву — заполнение Map значениями, второй проход по второму массиву — проверка наличия элемента в Map и добавление в result с проверкой через result.includes() для исключения дубликатов. Допустил синтаксическую ошибку (неверное имя переменной r1 вместо result) и логическую ошибку (проверял result.includes(x) вместо !result.includes(x)). После исправления обеих ошибок решение заработало корректно. Интервьюер также показал более элегантное решение через Set.
Правильный ответ:
Это классическая задача на знание структур данных. Разберём все подходы от наивного до оптимального.
Подход 1: Наивный (двойной цикл) — O(n × m)
// ═══════════════════════════════════════════════════════════
// ❌ Наивный подход — O(n × m)
// ═══════════════════════════════════════════════════════════
function intersectionNaive(arr1, arr2) {
const result = [];
for (let i = 0; i < arr1.length; i++) {
for (let j = 0; j < arr2.length; j++) {
if (arr1[i] === arr2[j]) {
// Проверяем, нет ли уже этого элемента в result
let alreadyExists = false;
for (let k = 0; k < result.length; k++) {
if (result[k] === arr1[i]) {
alreadyExists = true;
break;
}
}
if (!alreadyExists) {
result.push(arr1[i]);
}
}
}
}
return result;
}
// Пример
console.log(intersectionNaive([1, 2, 2, 3, 4], [2, 2, 3, 5]));
// [2, 3]
// Сложность: O(n × m × min(n,m)) — три вложенных цикла
Подход 2: Хэш-карта (Map) — O(n + m)
// ═══════════════════════════════════════════════════════════
// ✅ Оптимальный подход с Map — O(n + m)
// ═══════════════════════════════════════════════════════════
function intersectionWithMap(arr1, arr2) {
const map = new Map();
const result = [];
// Первый проход: заполняем Map элементами первого массива
for (const item of arr1) {
map.set(item, true);
}
// Второй проход: проверяем элементы второго массива
for (const item of arr2) {
if (map.has(item)) {
result.push(item);
map.delete(item); // Удаляем, чтобы избежать дубликатов
}
}
return result;
}
// Пример
console.log(intersectionWithMap([1, 2, 2, 3, 4], [2, 2, 3, 5]));
// [2, 3]
// Сложность: O(n + m) — два линейных прохода
// Память: O(min(n, m)) — для хранения Map
Подход 3: Set — самый элегантный
// ═══════════════════════════════════════════════════════════
// ✅ Самое элегантное решение через Set
// ═══════════════════════════════════════════════════════════
function intersectionWithSet(arr1, arr2) {
const set1 = new Set(arr1);
const set2 = new Set(arr2);
return [...set1].filter(item => set2.has(item));
}
// Или ещё короче
function intersectionShort(arr1, arr2) {
const set2 = new Set(arr2);
return [...new Set(arr1)].filter(item => set2.has(item));
}
// Пример
console.log(intersectionWithSet([1, 2, 2, 3, 4], [2, 2, 3, 5]));
// [2, 3]
// Сложность: O(n + m)
// Память: O(n + m)
Подход 4: Для отсортированных массивов — O(n log n + m log m)
// ═══════════════════════════════════════════════════════════
// ✅ Два указателя для отсортированных массивов
// ═══════════════════════════════════════════════════════════
function intersectionSorted(arr1, arr2) {
// Предварительная сортировка (если массивы не отсортированы)
arr1.sort((a, b) => a - b);
arr2.sort((a, b) => a - b);
const result = [];
let i = 0;
let j = 0;
while (i < arr1.length && j < arr2.length) {
if (arr1[i] === arr2[j]) {
// Избегаем дубликатов в результате
if (result[result.length - 1] !== arr1[i]) {
result.push(arr1[i]);
}
i++;
j++;
} else if (arr1[i] < arr2[j]) {
i++;
} else {
j++;
}
}
return result;
}
// Пример
console.log(intersectionSorted([1, 2, 2, 3, 4], [2, 2, 3, 5]));
// [2, 3]
// Сложность: O(n log n + m log m) — из-за сортировки
// Если массивы уже отсортированы: O(n + m)
// Память: O(1) дополнительной (без учёта результата)
Сравнение всех подходов
┌─────────────────────┬────────────────┬──────────────┬────────────────────────┐
│ Подход │ Время │ Память │ Когда использовать │
├─────────────────────┼────────────────┼──────────────┼────────────────────────┤
│ Двойной цикл │ O(n × m × k) │ O(min(n,m)) │ Никогда на практике │
│ Map │ O(n + m) │ O(n) │ Универсальный │
│ Set │ O(n + m) │ O(n + m) │ Самый читаемый код │
│ Два указателя │ O(n log n + │ O(1) │ Отсортированные массивы│
│ │ m log m) │ │ или ограниченная память│
└─────────────────────┴────────────────┴──────────────┴────────────────────────┘
Реализация на Go (для Golang-разработчика)
package main
import "fmt"
// Intersection находит пересечение двух слайсов без дубликатов
func Intersection(arr1, arr2 []int) []int {
// Используем map как множество для первого массива
set := make(map[int]bool)
for _, v := range arr1 {
set[v] = true
}
// Собираем результат, удаляя из map для исключения дубликатов
result := make([]int, 0)
for _, v := range arr2 {
if set[v] {
result = append(result, v)
delete(set, v) // Удаляем, чтобы не добавить повторно
}
}
return result
}
func main() {
arr1 := []int{1, 2, 2, 3, 4}
arr2 := []int{2, 2, 3, 5}
fmt.Println(Intersection(arr1, arr2)) // [2 3]
}
Типичные ошибки при решении
// ═══════════════════════════════════════════════════════════
// ❌ Ошибка 1: Забыли исключить дубликаты
// ═══════════════════════════════════════════════════════════
function intersectionBug1(arr1, arr2) {
const set1 = new Set(arr1);
const result = [];
for (const item of arr2) {
if (set1.has(item)) {
result.push(item); // 2 будет добавлена дважды!
}
}
return result;
}
console.log(intersectionBug1([1, 2, 2, 3], [2, 2, 3]));
// [2, 2, 3] — дубликаты не исключены!
// ═══════════════════════════════════════════════════════════
// ❌ Ошибка 2: Неправильное условие добавления
// ═══════════════════════════════════════════════════════════
function intersectionBug2(arr1, arr2) {
const map = new Map();
const result = [];
for (const item of arr1) {
map.set(item, true);
}
for (const item of arr2) {
if (map.has(item) && result.includes(item)) {
// ❌ Логическая ошибка: добавляем только если УЖЕ есть в result
result.push(item);
}
}
return result;
}
console.log(intersectionBug2([1, 2, 3], [2, 3, 4]));
// [] — пустой результат!
// ═══════════════════════════════════════════════════════════
// ❌ Ошибка 3: Проверка через result.includes() — O(n) на каждый элемент
// ═══════════════════════════════════════════════════════════
function intersectionBug3(arr1, arr2) {
const map = new Map();
const result = [];
for (const item of arr1) {
map.set(item, true);
}
for (const item of arr2) {
if (map.has(item) && !result.includes(item)) {
// ⚠️ result.includes() — O(n), итого O(n × m)
result.push(item);
}
}
return result;
}
// Лучше использовать другой Set для отслеживания добавленных элементов
function intersectionFixed(arr1, arr2) {
const set1 = new Set(arr1);
const added = new Set();
const result = [];
for (const item of arr2) {
if (set1.has(item) && !added.has(item)) {
result.push(item);
added.add(item); // O(1) вместо O(n)
}
}
return result;
}
Итог
- Лучшее решение по соотношению простоты и эффективности — через
Set:[...new Set(arr1)].filter(x => set2.has(x)). - Оптимальное по памяти — через
Mapс удалением элемента после добавления в результат. - Для отсортированных массивов — два указателя, O(1) дополнительной памяти.
- Избегайте
result.includes()внутри цикла — это превращает O(n + m) в O(n × m).
