Перейти к основному содержимому

Собеседование Frontend - самое душное интервью

· 54 мин. чтения

Сегодня мы разберём техническое собеседование кандидата на позицию фронтенд-разработчика, в ходе которого он продемонстрировал практический опыт внедрения код-ревью, архитектуры FSD и самописной UI-библиотеки в монолитных проектах. Несмотря на некоторые пробелы в базовых знаниях JavaScript и сложности с чёткой декомпозицией задач, кандидат показал сильный энтузиазм, способность к анализу производительности и успешно решил алгоритмическую задачу на последовательность Трибоначчи, что в итоге произвело на интервьюера положительное впечатление.

Вопрос 1. Какие три главных достижения (фичи) вы внедрили в команду на последнем месте работы?

Таймкод: 00:01:44

Ответ собеседника: Правильный. Кандидат назвал три достижения: внедрение code review с правилом ограничения размера компонентов до 150 строк, внедрение кодстайла, а также инициировал переход на архитектуру FSD (Feature-Sliced Design) — три сервиса были переписаны на эту архитектуру при поддержке лида и команды.

Правильный ответ:

Ответ кандидата является полным и структурированным. Он чётко выделил три конкретных достижения, каждое из которых имеет измеримый результат и демонстрирует инициативность.

Разбор каждого достижения с точки зрения его ценности:

1. Code review с ограничением размера компонентов (150 строк)

Это интересный подход к улучшению качества кода через code review. Ограничение в 150 строк — это не жёсткий линтер-правило, а скорее культурный контракт внутри команды. Идея в том, что если компонент (функция, файл, структура) превышает этот порог, это сигнал к рефакторингу. В Golang это особенно актуально, потому что язык поощряет многословность (явная обработка ошибок, отсутствие сокращений), и файлы могут разрастаться быстро.

Практическая польза такого правила:

  • Компоненты становятся проще для ревью (ревьюер может удержать в голове логику)
  • Уменьшается когнитивная нагрузка при онбординге новых разработчиков
  • Вынуждает декомпозицию, что ведёт к лучшей тестируемости
  • Снижает количество багов, так как маленькие функции легче верифицировать

2. Внедрение кодстайла

В Golang это особенно важная тема, потому что в языке уже есть встроенные инструменты форматирования (gofmt, goimports), но кодстайл выходит далеко за пределы форматирования. Хороший кодстайл в Go-проекте обычно покрывает:

  • Соглашения по именованию (пакетов, интерфейсов, ошибок)
  • Структуру проекта (layout, например, Standard Go Project Layout)
  • Правила обработки ошибок (когда использовать fmt.Errorf с %w, когда кастомные типы ошибок)
  • Использование контекстов (context.Context как первый аргумент)
  • Ограничения на цикломатическую сложность
  • Правила написания тестов (table-driven tests, именование тестовых случаев)

Инструменты автоматизации: golangci-lint с кастомным конфигом, go vet, staticcheck, прехуки (pre-commit hooks) для автоматической проверки.

3. Переход на Feature-Sliced Design (FSD)

FSD — это архитектурная методология, которая организует код по бизнес-фичам, а не по техническим слоям. В контексте Go-сервисов это означает переход от классической структуры internal/handlers, internal/services, internal/repositories к структуре, где каждая фича — это самодостаточный модуль со всеми необходимыми слоями.

Пример структуры FSD в Go-проекте:

internal/
features/
user/
api/ # HTTP/gRPC хендлеры
service/ # Бизнес-логика
repository/ # Работа с БД
model/ # Модели данных
dto/ # Транспортные объекты
order/
api/
service/
repository/
model/
dto/
payment/
api/
service/
repository/
model/
dto/

Преимущества FSD для Go-проектов:

  • Изоляция фич друг от друга — изменение в одной фиче не затрагивает другие
  • Упрощение командной работы — разные команды могут владеть разными фичами
  • Лёгкость удаления функциональности — удаляем одну папку
  • Масштабируемость — новые фичи добавляются как новые модули

Что усилило бы ответ кандидата:

  • Конкретные метрики влияния (например: «после внедрения code review с ограничением в 150 строк среднее время ревью сократилось на 40%»)
  • Описание процесса внедрения и сопротивления команды
  • Упоминание инструментов, которые использовались для автоматизации проверок

В целом ответ демонстрирует системное мышление, инициативность и понимание инженерной культуры — это именно то, что ожидают услышать на такой вопрос.

Вопрос 2. FSD внедрялся в микрофронтенды или в монолитную архитектуру?

Таймкод: 00:03:37

Ответ собеседника: Правильный. FSD внедрялся в монолитную архитектуру. Все проекты кандидата были монолитными.

Правильный ответ:

Ответ корректный и важный для понимания контекста. FSD (Feature-Sliced Design) изначально был разработан для frontend-приложений, но его принципы прекрасно ложатся на организацию кода в монолитных бэкенд-сервисах.

Почему FSD особенно ценен именно для монолита:

В монолитной архитектуре главная боль — это связанность кода. Без чёткой структуры монолит быстро превращается в «big ball of mud», где изменение одной фичи непредсказуемо влияет на другие. FSD решает эту проблему, создавая чёткие границы между фичами даже внутри единого процесса.

Отличие от микросервисной архитектуры:

В микросервисах границы между функциональностью обеспечиваются на инфраструктурном уровне — каждый сервис это отдельный процесс с собственным кодом, деплоем и жизненным циклом. В монолите таких естественных границ нет, поэтому их нужно создавать на уровне кода — и именно это делает FSD.

Практическая реализация FSD в Go-монолите:

Принцип изоляции модулей:

// internal/features/user/service/service.go
package service

import (
"context"
"myapp/internal/features/user/model"
"myapp/internal/features/user/repository"
)

type UserService struct {
repo *repository.UserRepository
}

func NewUserService(repo *repository.UserRepository) *UserService {
return &UserService{repo: repo}
}

func (s *UserService) CreateUser(ctx context.Context, dto model.CreateUserDTO) (*model.User, error) {
// Бизнес-логика создания пользователя
user := &model.User{
Email: dto.Email,
Name: dto.Name,
}

if err := s.repo.Save(ctx, user); err != nil {
return nil, fmt.Errorf("failed to save user: %w", err)
}

return user, nil
}

Принцип публичного API фичи:

Каждая фича экспортирует только то, что нужно другим фичам — через чётко определённый интерфейс:

// internal/features/user/service/interface.go
package service

import (
"context"
"myapp/internal/features/user/model"
)

// UserServiceInterface — публичный контракт фичи "user"
type UserServiceInterface interface {
CreateUser(ctx context.Context, dto model.CreateUserDTO) (*model.User, error)
GetUser(ctx context.Context, id int64) (*model.User, error)
}

Принцип запрета на прямые импорты внутренних пакетов:

Другие фичи могут использовать только публичный интерфейс, а не обращаться напрямую к репозиториям или внутренним моделям другой фичи:

// internal/features/order/service/service.go
package service

import (
"context"
"myapp/internal/features/user/service" // импортируем только интерфейс
"myapp/internal/features/order/model"
"myapp/internal/features/order/repository"
)

type OrderService struct {
orderRepo *repository.OrderRepository
userService service.UserServiceInterface // зависимость через интерфейс
}

func NewOrderService(
orderRepo *repository.OrderRepository,
userService service.UserServiceInterface,
) *OrderService {
return &OrderService{
orderRepo: orderRepo,
userService: userService,
}
}

Сборка монолита через композицию:

// cmd/app/main.go
func main() {
db := initDB()

// Инициализация фич
userRepo := repository.NewUserRepository(db)
userService := service.NewUserService(userRepo)

orderRepo := repository.NewOrderRepository(db)
orderService := service.NewOrderService(orderRepo, userService)

paymentRepo := repository.NewPaymentRepository(db)
paymentService := service.NewPaymentService(paymentRepo, orderService)

// Регистрация хендлеров
handler := httphandler.New(userService, orderService, paymentService)

server := &http.Server{
Addr: ":8080",
Handler: handler,
}

log.Fatal(server.ListenAndServe())
}

Преимущества такого подхода в монолите:

  • Каждая фича — самодостаточный модуль с чёткими границами
  • Легко найти весь код, относящийся к конкретной бизнес-фиче
  • Безопасный рефакторинг — меняем внутренности фичи, не ломая другие
  • Простой путь к выделению в микросервис — если фича изолирована, вынести её в отдельный сервис значительно проще
  • Упрощённое тестирование — можно тестировать каждую фичу изолированно, подменяя зависимости моками

Кандидат правильно указал, что FSD применялся к монолиту — это зрелый подход, который показывает понимание того, что архитектура кода внутри сервиса не менее важна, чем архитектура между сервисами.

Вопрос 3. Опишите реальную задачу продолжительностью около недели, которую вы реализовали за последние полгода: на какие этапы вы её разбили и сколько времени ушло на каждый?

Таймкод: 00:04:38

Ответ собеседника: Неполный. Кандидат описал задачу по изменению первого этапа бронирования в сервисе онлайн-бронирования недвижимости. Этапы с указанием времени: 1) Ознакомление с обновлённым ТЗ и разбор существующего кода — около дня; 2) Изменение порядка шагов бронирования (перестановка, удаление и добавление шагов через изменение констант) и доработка полей первого этапа — второй день; 3) Правка багов от тестировщика, созвоны с аналитиком, доработки по второму этапу — третий день; 4) Завершение всех доработок, передача на тест, демо команде с аналитиком и РМ — четвёртый день; 5) Исправление замечаний, выявленных на демо — пятый день (пятница). Однако кандидат изначально не дал структурированный ответ с таймингами, как было запрошено, и уточнил детали только после наводящих вопросов.

Правильный ответ:

Кандидат привёл конкретный пример задачи с разбивкой на этапы, что хорошо. Однако ответ изначально был неструктурированным — тайминги вспоминались после уточняющих вопросов, а не даны сразу. Это может указывать на недостаточно привычный подход к планированию и декомпозиции задач.

Разбор ответа кандидата:

Задача — изменение первого этапа бронирования в сервисе онлайн-бронирования недвижимости. Разбивка на 5 рабочних дней выглядит реалистично. Кандидат упомянул важные аспекты: работа с ТЗ, разбор существующего кода, итеративная работа с тестировщиком и аналитиком, демо команде. Это показывает понимание процесса разработки.

Что можно было бы улучшить в ответе:

  • Структурировать ответ сразу: задача → этапы → время → результат
  • Указать сложности, с которыми столкнулся, и как их решал
  • Упомянуть технические детали реализации
  • Описать, как именно была организована работа с зависимостями (аналитик, QA)

Пример эталонного ответа на этот вопрос:

Задача: Реализация интеграции с внешним платёжным провайдером для обработки возвратов в сервисе бронирования.

Этап 1. Анализ и проектирование (1 день)

Изучение API документации провайдера, анализ существующей архитектуры платежного модуля, проектирование схемы взаимодействия. Определение формата запросов и ответов, моделей данных, стратегии обработки ошибок.

Этап 2. Реализация клиента API (1.5 дня)

Написание HTTP-клиента для взаимодействия с API провайдера, включая авторизацию, сериализацию/десериализацию запросов, обработку ошибок и ретраи.

// internal/features/payment/gateway/refund/client.go
package refund

import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"time"
)

type Client struct {
baseURL string
apiKey string
httpClient *http.Client
}

func NewClient(baseURL, apiKey string) *Client {
return &Client{
baseURL: baseURL,
apiKey: apiKey,
httpClient: &http.Client{
Timeout: 30 * time.Second,
},
}
}

type RefundRequest struct {
PaymentID string `json:"payment_id"`
Amount int64 `json:"amount"` // в копейках
Reason string `json:"reason"`
}

type RefundResponse struct {
RefundID string `json:"refund_id"`
Status string `json:"status"`
CreatedAt string `json:"created_at"`
}

func (c *Client) CreateRefund(ctx context.Context, req RefundRequest) (*RefundResponse, error) {
body, err := json.Marshal(req)
if err != nil {
return nil, fmt.Errorf("marshal refund request: %w", err)
}

httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost,
c.baseURL+"/api/v1/refunds", bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}

httpReq.Header.Set("Authorization", "Bearer "+c.apiKey)
httpReq.Header.Set("Content-Type", "application/json")

resp, err := c.httpClient.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("execute request: %w", err)
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}

var refundResp RefundResponse
if err := json.NewDecoder(resp.Body).Decode(&refundResp); err != nil {
return nil, fmt.Errorf("decode response: %w", err)
}

return &refundResp, nil
}

Этап 3. Реализация бизнес-логики (1.5 дня)

Создание сервисного слоя, который оркестрирует процесс возврата: валидация, проверка статуса платежа, вызов клиента API, обновление статуса в базе данных, публикация события.

// internal/features/payment/service/refund.go
package service

import (
"context"
"fmt"
"myapp/internal/features/payment/gateway/refund"
"myapp/internal/features/payment/model"
"myapp/internal/features/payment/repository"
)

type RefundService struct {
refundClient *refund.Client
paymentRepo *repository.PaymentRepository
eventBus EventPublisher
}

func NewRefundService(
refundClient *refund.Client,
paymentRepo *repository.PaymentRepository,
eventBus EventPublisher,
) *RefundService {
return &RefundService{
refundClient: refundClient,
paymentRepo: paymentRepo,
eventBus: eventBus,
}
}

func (s *RefundService) ProcessRefund(ctx context.Context, paymentID string, amount int64, reason string) error {
// 1. Получаем платёж из БД
payment, err := s.paymentRepo.GetByID(ctx, paymentID)
if err != nil {
return fmt.Errorf("get payment: %w", err)
}

// 2. Валидация: платёж должен быть в статусе "completed"
if payment.Status != model.PaymentStatusCompleted {
return fmt.Errorf("payment is not in completed status, current: %s", payment.Status)
}

// 3. Проверка суммы возврата
if amount > payment.Amount {
return fmt.Errorf("refund amount exceeds payment amount")
}

// 4. Вызов провайдера
refundReq := refund.RefundRequest{
PaymentID: payment.ExternalID,
Amount: amount,
Reason: reason,
}

refundResp, err := s.refundClient.CreateRefund(ctx, refundReq)
if err != nil {
return fmt.Errorf("create refund via provider: %w", err)
}

// 5. Обновление статуса в БД
payment.RefundID = refundResp.RefundID
payment.Status = model.PaymentStatusRefunding

if err := s.paymentRepo.Update(ctx, payment); err != nil {
return fmt.Errorf("update payment status: %w", err)
}

// 6. Публикация события
s.eventBus.Publish(ctx, model.RefundInitiatedEvent{
PaymentID: paymentID,
RefundID: refundResp.RefundID,
Amount: amount,
})

return nil
}

Этап 4. Написание тестов (1 день)

Unit-тесты для сервисного слоя с моками, интеграционные тесты для клиента API.

// internal/features/payment/service/refund_test.go
package service

import (
"context"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"

"myapp/internal/features/payment/gateway/refund"
"myapp/internal/features/payment/model"
)

type MockRefundClient struct {
mock.Mock
}

func (m *MockRefundClient) CreateRefund(ctx context.Context, req refund.RefundRequest) (*refund.RefundResponse, error) {
args := m.Called(ctx, req)
return args.Get(0).(*refund.RefundResponse), args.Error(1)
}

func TestRefundService_ProcessRefund(t *testing.T) {
tests := []struct {
name string
payment *model.Payment
refundReq refund.RefundRequest
mockSetup func(client *MockRefundClient, repo *MockPaymentRepo)
expectError bool
}{
{
name: "successful refund",
payment: &model.Payment{
ID: "pay_123",
ExternalID: "ext_456",
Amount: 10000,
Status: model.PaymentStatusCompleted,
},
refundReq: refund.RefundRequest{
PaymentID: "ext_456",
Amount: 5000,
Reason: "customer request",
},
mockSetup: func(client *MockRefundClient, repo *MockPaymentRepo) {
client.On("CreateRefund", mock.Anything, mock.Anything).
Return(&refund.RefundResponse{
RefundID: "ref_789",
Status: "pending",
}, nil)
repo.On("Update", mock.Anything, mock.Anything).Return(nil)
},
expectError: false,
},
{
name: "payment not completed",
payment: &model.Payment{
ID: "pay_123",
Amount: 10000,
Status: model.PaymentStatusPending,
},
expectError: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockClient := new(MockRefundClient)
mockRepo := new(MockPaymentRepo)

if tt.mockSetup != nil {
tt.mockSetup(mockClient, mockRepo)
}

svc := NewRefundService(mockClient, mockRepo, nil)
err := svc.ProcessRefund(context.Background(), tt.payment.ID, tt.refundReq.Amount, tt.refundReq.Reason)

if tt.expectError {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
})
}
}

Этап 5. Ревью, деплой, мониторинг (1 день)

Code review, исправление замечаний, деплой на staging, проверка работоспособности, настройка алертов и мониторинга.

Итого: 5 рабочих дней.

Ключевые моменты, которые стоит отразить в ответе:

  • Декомпозиция задачи на логические этапы с оценкой времени
  • Упоминание технических деталей реализации
  • Описание подхода к тестированию
  • Указание сложностей и их решения
  • Результат и влияние на продукт

Ответ кандидата содержит правильную структуру, но ему не хватает технической глубины и проактивности в подаче информации.

Вопрос 4. Как была реализована мультиступенчатая форма — какие инструменты использовались для форм и валидации, был ли переиспользуемый компонент формы или отдельные компоненты для каждого шага?

Таймкод: 00:08:38

Ответ собеседника: Правильный. Кандидат использовал переиспользуемые компоненты инпутов из собственной дизайн-системы (UI Kit): Password input, Email input, Code input и другие. Каждый этап формы был уникальным по набору полей, переиспользовался только общий враппер с заголовком. Валидация была полностью самописной — без использования библиотек типа React Hook Form или Yup. Валидация проверяла заполненность обязательных полей (со звёздочкой) и соответствие длины введённых данных. Мотивация самописной реализации — уменьшение размера бандла и независимость от сторонних библиотек. За дизайн-систему отвечала отдельная команда: один лид дизайна и два разработчика.

Правильный ответ:

Ответ кандидата демонстрирует зрелый подход к архитектуре фронтенд-приложения. Разберём каждый аспект подробнее.

Переиспользуемые компоненты инпутов из дизайн-системы:

Создание собственной дизайн-системы (UI Kit) — это правильное решение для продукта с множеством форм. Типичные компоненты инпута в такой системе:

  • PasswordInput — с переключением видимости пароля, индикатором сложности
  • EmailInput — с базовой валидацией формата email
  • CodeInput — для ввода OTP/кодов подтверждения (обычно 4-6 отдельных ячеек)
  • PhoneInput — с маской ввода и выбором страны
  • TextInput, NumberInput, DateInput — базовые примитивы
  • SelectInput, CheckboxInput, RadioInput — для выбора из вариантов

Преимущества собственной дизайн-системы:

  • Единообразие UX во всём приложении
  • Централизованное управление состояниями (focused, error, disabled, loading)
  • Доступность (a11y) реализуется один раз на уровне компонента
  • Локализация и темизация управляются централизованно

Архитектура мультиступенчатой формы:

Кандидат описал подход, где каждый шаг — это отдельный компонент с уникальным набором полей, а переиспользуется только общий враппер. Это разумный компромисс между гибкостью и переиспользованием.

Структура типичной мультиступенчатой формы:

MultiStepForm (контейнер)
├── StepWrapper (переиспользуемый враппер)
│ ├── StepIndicator (прогресс-бар шагов)
│ ├── StepContent (контент текущего шага)
│ └── NavigationButtons (кнопки «Назад» / «Далее»)
├── Step1 — PersonalInfo
├── Step2 — PropertyDetails
├── Step3 — PaymentInfo
└── Step4 — Confirmation

Самописная валидация — обоснованный выбор:

Мотивация кандидата — уменьшение размера бандла и независимость от сторонних библиотек — вполне обоснована. React Hook Form весит ~12 кБ (gzip), Yup — ~16 кБ (gzip). Для продукта, где критична скорость загрузки (особенно мобильные пользователи), это может быть значимым фактором.

Пример реализации самописной валидации в Go-стиле (для понимания логики на бэкенде):

Хотя вопрос про фронтенд, важно понимать, что валидация на бэкенде должна дублироваться:

// internal/features/booking/model/validation.go
package model

import (
"fmt"
"strings"
"unicode/utf8"
)

type ValidationError struct {
Field string `json:"field"`
Message string `json:"message"`
}

func (e ValidationError) Error() string {
return fmt.Sprintf("%s: %s", e.Field, e.Message)
}

type ValidationErrors []ValidationError

func (errs ValidationErrors) Error() string {
var messages []string
for _, err := range errs {
messages = append(messages, err.Error())
}
return strings.Join(messages, "; ")
}

// PersonalInfoStep — данные первого шага бронирования
type PersonalInfoStep struct {
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Email string `json:"email"`
Phone string `json:"phone"`
}

func (s *PersonalInfoStep) Validate() error {
var errs ValidationErrors

// Валидация обязательных полей
if strings.TrimSpace(s.FirstName) == "" {
errs = append(errs, ValidationError{
Field: "first_name",
Message: "Имя обязательно для заполнения",
})
}

if strings.TrimSpace(s.LastName) == "" {
errs = append(errs, ValidationError{
Field: "last_name",
Message: "Фамилия обязательна для заполнения",
})
}

// Валидация длины
if utf8.RuneCountInString(s.FirstName) < 2 {
errs = append(errs, ValidationError{
Field: "first_name",
Message: "Имя должно содержать минимум 2 символа",
})
}

if utf8.RuneCountInString(s.FirstName) > 50 {
errs = append(errs, ValidationError{
Field: "first_name",
Message: "Имя не может превышать 50 символов",
})
}

// Валидация email
if !isValidEmail(s.Email) {
errs = append(errs, ValidationError{
Field: "email",
Message: "Некорректный формат email",
})
}

// Валидация телефона
if !isValidPhone(s.Phone) {
errs = append(errs, ValidationError{
Field: "phone",
Message: "Некорректный формат телефона",
})
}

if len(errs) > 0 {
return errs
}

return nil
}

func isValidEmail(email string) bool {
// Упрощённая проверка — в проде лучше использовать библиотеку
// или отправку подтверждения на email
return strings.Contains(email, "@") && strings.Contains(email, ".")
}

func isValidPhone(phone string) bool {
// Упрощённая проверка — в проде лучше использовать libphonenumber
cleaned := strings.ReplaceAll(phone, " ", "")
cleaned = strings.ReplaceAll(cleaned, "-", "")
cleaned = strings.ReplaceAll(cleaned, "(", "")
cleaned = strings.ReplaceAll(cleaned, ")", "")
cleaned = strings.ReplaceAll(cleaned, "+", "")
return len(cleaned) >= 10 && len(cleaned) <= 15
}

Использование валидации в хендлере:

// internal/features/booking/api/handler.go
func (h *BookingHandler) SubmitPersonalInfo(w http.ResponseWriter, r *http.Request) {
var step model.PersonalInfoStep

if err := json.NewDecoder(r.Body).Decode(&step); err != nil {
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}

// Валидация
if err := step.Validate(); err != nil {
if validationErrs, ok := err.(model.ValidationErrors); ok {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnprocessableEntity)
json.NewEncoder(w).Encode(map[string]interface{}{
"errors": validationErrs,
})
return
}

http.Error(w, "Validation failed", http.StatusInternalServerError)
return
}

// Сохранение данных шага
if err := h.bookingService.SavePersonalInfo(r.Context(), &step); err != nil {
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}

w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{
"status": "success",
"next_step": "property_details",
})
}

Организация команды дизайн-системы:

Кандидат упомянул, что за дизайн-систему отвечала отдельная команда (лид дизайна + 2 разработчика). Это правильная практика — дизайн-система требует постоянной поддержки, документации, версионирования и коммуникации с потребителями.

Что усилило бы ответ кандидата:

  • Упоминание конкретных правил валидации (регулярные выражения, длина полей)
  • Описание механизма сохранения прогресса между шагами (localStorage, серверное состояние)
  • Упоминание обработки ошибок и пользовательских сообщений
  • Описание подхода к доступности (a11y) форм

В целом ответ показывает понимание архитектурных решений и обоснованность выбора инструментов.

Вопрос 5. Как вы подойдёте к задаче оптимизации скорости загрузки страницы во внутренней системе? Какие шаги предпримете и что проверите?

Таймкод: 00:18:28

Ответ собеседника: Правильный. Кандидат описал комплексный подход: 1) Получить метрики (Lighthouse) и данные о том, что именно тормозит; 2) Определить тип проекта — если legacy на чистом HTML, рассмотреть переписывание на React/Next.js с SSR; 3) Если монолит слишком большой — рассмотреть разбиение на микрофронтенды; 4) For React/Next.js проектов: React.memo, useCallback, lazy loading, Suspense, SSR, Next Image, анализ бандла; 5) Оптимизация изображений — сжатие, lazy loading, srcset, использование SVG; 6) Замена require на динамические импорты; 7) Проверка скорости бэкенда; 8) Пагинация или виртуализация для больших объёмов данных; 9) Порционная отдача бандла только используемых элементов. Кандидат также упомянул, что бэкенд-оптимизация — это зона ответственности бэкенд-разработчиков, но на неё тоже стоит обратить внимание.

Правильный ответ:

Кандидат дал развёрнутый и структурированный ответ, охватывающий большинство ключевых аспектов оптимизации. Разберём каждый пункт и дополним важными деталями.

Шаг 1. Измерение и профилирование (Measure First)

Прежде чем оптимизировать, нужно понять, что именно тормозит. Кандидат правильно упомянул Lighthouse.

Инструменты измерения:

  • Lighthouse — комплексный аудит: FCP, LCP, TBT, CLS, TTI
  • WebPageTest — детальный анализ загрузки с разных локаций и устройств
  • Chrome DevTools → Performance — профилирование рендеринга, выявление long tasks
  • Chrome DevTools → Network — анализ размера и времени загрузки ресурсов
  • Bundle Analyzer — визуализация размера бандла (source-map-explorer, webpack-bundle-analyzer)

Ключевые метрики Core Web Vitals:

  • LCP (Largest Contentful Paint) — время загрузки самого большого видимого элемента. Цель: < 2.5 с
  • FID (First Input Delay) — время до первого взаимодействия пользователя. Цель: < 100 мс
  • CLS (Cumulative Layout Shift) — визуальная стабильность. Цель: < 0.1

Шаг 2. Анализ архитектуры проекта

Кандидат правильно разделил подходы в зависимости от типа проекта. Это зрелый подход — нет универсального решения.

Для legacy-проектов:

Если проект на чистом HTML/jQuery, оптимизация может включать:

  • Минификация и сжатие CSS/JS (Terser, cssnano)
  • Включение gzip/brotli на сервере
  • Настройка кэширования (Cache-Control, ETag)
  • Ленивая загрузка изображений через loading="lazy"

Для современных SPA/Next.js проектов:

Кандидат упомянул правильные инструменты. Дополним:

// React.memo — предотвращает ре-рендер, если props не изменились
const ExpensiveComponent = React.memo(({ data, onAction }) => {
return <div>{/* тяжёлый рендер */}</div>;
});

// useCallback — стабилизирует ссылку на функцию
const handleSubmit = useCallback((formData) => {
dispatch(submitAction(formData));
}, [dispatch]);

// React.lazy + Suspense — код-сплиттинг на уровне компонентов
const HeavyChart = React.lazy(() => import('./HeavyChart'));

function Dashboard() {
return (
<Suspense fallback={<Skeleton />}>
<HeavyChart />
</Suspense>
);
}

Шаг 3. Оптимизация бэкенда (Go-специфика)

Кандидат упомянул, что бэкенд-оптимизация — зона ответственности бэкенд-команды. Для Go-разработчика это важная тема, поэтому дополним:

Профилирование Go-сервиса:

// Включение pprof для профилирования
import _ "net/http/pprof"

func go func() {
log.Println(http.ListenAndServe("localhost:6060", nil))
}()

Инструменты pprof:

  • go tool pprof http://localhost:6060/debug/pprof/profile — CPU профиль
  • go tool pprof http://localhost:6060/debug/pprof/heap — профиль памяти
  • go tool pprof -http=:8080 profile.pb.gz — визуализация в браузере

Оптимизация запросов к базе данных:

// Плохо: N+1 запросов
func GetOrdersWithUsers(ctx context.Context, db *sql.DB) ([]OrderDTO, error) {
orders, err := getOrders(ctx, db)
if err != nil {
return nil, err
}

for i, order := range orders {
user, err := getUserByID(ctx, db, order.UserID) // N запросов!
if err != nil {
return nil, err
}
orders[i].User = user
}

return orders, nil
}

// Хорошо: один JOIN запрос
func GetOrdersWithUsers(ctx context.Context, db *sql.DB) ([]OrderDTO, error) {
query := `
SELECT o.id, o.amount, o.status,
u.id, u.name, u.email
FROM orders o
JOIN users u ON o.user_id = u.id
WHERE o.created_at > $1
ORDER BY o.created_at DESC
LIMIT $2
`

rows, err := db.QueryContext(ctx, query, time.Now().AddDate(0, -1, 0), 100)
if err != nil {
return nil, fmt.Errorf("query orders: %w", err)
}
defer rows.Close()

var orders []OrderDTO
for rows.Next() {
var dto OrderDTO
err := rows.Scan(
&dto.OrderID, &dto.Amount, &dto.Status,
&dto.UserID, &dto.UserName, &dto.UserEmail,
)
if err != nil {
return nil, fmt.Errorf("scan order: %w", err)
}
orders = append(orders, dto)
}

return orders, nil
}

Кэширование на бэкенде:

// Простое кэширование с TTL
type CacheEntry struct {
Value interface{}
ExpiresAt time.Time
}

type MemoryCache struct {
mu sync.RWMutex
entries map[string]CacheEntry
ttl time.Duration
}

func NewMemoryCache(ttl time.Duration) *MemoryCache {
cache := &MemoryCache{
entries: make(map[string]CacheEntry),
ttl: ttl,
}

// Периодическая очистка просроченных записей
go cache.cleanup()

return cache
}

func (c *MemoryCache) Get(key string) (interface{}, bool) {
c.mu.RLock()
defer c.mu.RUnlock()

entry, exists := c.entries[key]
if !exists || time.Now().After(entry.ExpiresAt) {
return nil, false
}

return entry.Value, true
}

func (c *MemoryCache) Set(key string, value interface{}) {
c.mu.Lock()
defer c.mu.Unlock()

c.entries[key] = CacheEntry{
Value: value,
ExpiresAt: time.Now().Add(c.ttl),
}
}

func (c *MemoryCache) cleanup() {
ticker := time.NewTicker(time.Minute)
for range ticker.C {
c.mu.Lock()
now := time.Now()
for key, entry := range c.entries {
if now.After(entry.ExpiresAt) {
delete(c.entries, key)
}
}
c.mu.Unlock()
}
}

Шаг 4. Оптимизация передачи данных

Кандидат упомянул пагинацию и виртуализацию. Дополним техническими деталями:

Пагинация на бэкенде (cursor-based вместо offset):

// Плохо: OFFSET становится медленным на больших таблицах
// SELECT * FROM orders ORDER BY created_at DESC LIMIT 20 OFFSET 100000

// Хорошо: cursor-based пагинация
func GetOrders(ctx context.Context, db *sql.DB, cursor *time.Time, limit int) ([]Order, *time.Time, error) {
query := `
SELECT id, amount, status, created_at
FROM orders
WHERE ($1::timestamp IS NULL OR created_at < $1)
ORDER BY created_at DESC
LIMIT $2
`

rows, err := db.QueryContext(ctx, query, cursor, limit+1)
if err != nil {
return nil, nil, fmt.Errorf("query orders: %w", err)
}
defer rows.Close()

var orders []Order
var lastCreatedAt time.Time

for rows.Next() {
var order Order
if err := rows.Scan(&order.ID, &order.Amount, &order.Status, &order.CreatedAt); err != nil {
return nil, nil, fmt.Errorf("scan order: %w", err)
}
orders = append(orders, order)
lastCreatedAt = order.CreatedAt
}

// Если получили больше, чем limit — есть следующая страница
var nextCursor *time.Time
if len(orders) > limit {
nextCursor = &lastCreatedAt
orders = orders[:limit]
}

return orders, nextCursor, nil
}

Сжатие ответов:

// Включение gzip на сервере
import "github.com/nytimes/gziphandler"

func main() {
mux := http.NewServeMux()
mux.HandleFunc("/api/orders", ordersHandler)

// Оборачиваем handler в gzip
gzippedMux := gziphandler.GzipHandler(mux)

server := &http.Server{
Addr: ":8080",
Handler: gzippedMux,
}

log.Fatal(server.ListenAndServe())
}

Шаг 5. Оптимизация доставки статики

  • CDN — раздача статики с ближайшего к пользователю сервера
  • HTTP/2 или HTTP/3 — мультиплексирование запросов, server push
  • Preload/Prefetch — подсказки браузеру для приоритетной загрузки
  • Service Worker — кэширование ресурсов на клиенте для офлайн-работы

Чек-лист полной оптимизации:

  1. Измерить текущие метрики (Lighthouse, WebPageTest)
  2. Проанализировать бандл (Bundle Analyzer)
  3. Оптимизировать изображения (WebP, lazy loading, srcset)
  4. Включить код-сплиттинг (динамические импорты)
  5. Оптимизировать рендеринг (React.memo, useMemo, useCallback)
  6. Проверить бэкенд (профилирование, кэширование, индексы)
  7. Включить сжатие (gzip/brotli)
  8. Настроить кэширование (Cache-Control, CDN)
  9. Внедрить пагинацию/виртуализацию для больших списков
  10. Повторно измерить метрики и сравнить

Ответ кандидата покрывает большинство пунктов этого чек-листа, что говорит о хорошем понимании темы.

Вопрос 6. Назовите все способы создания глубокой копии объекта в JavaScript. Какие из них действительно дадут глубокую копию?

Таймкод: 00:27:09

Ответ собеседника: Неполный. Кандидат перечислил: самописная функция глубокого копирования, Object.assign, JSON.parse(JSON.stringify()), structuredClone, spread-оператор, а также функции из Lodash. При уточнении кандидат верно указал, что глубокую копию дают JSON.parse(JSON.stringify()), structuredClone и самописная функция, а Object.assign и spread — только поверхностную. Однако кандидат не сразу чётко разделил все способы и путался в процессе ответа.

Правильный ответ:

Кандидат перечислил большинство способов и верно разделил их на поверхностные и глубокие при уточнении. Однако ответ был неструктурированным с первого раза, что указывает на недостаточно глубокое понимание нюансов. Разберём все способы подробно.

Способы поверхностного копирования (Shallow Copy):

Эти методы копируют только свойства первого уровня. Вложенные объекты и массивы передаются по ссылке.

1. Spread-оператор (...) для объектов

const original = { name: "John", address: { city: "Moscow" } };
const copy = { ...original };

copy.name = "Jane"; // Не влияет на original
copy.address.city = "SPb"; // ВЛИЯЕТ на original!

console.log(original.address.city); // "SPb" — изменилось!

2. Spread-оператор для массивов

const original = [1, { nested: true }, [3, 4]];
const copy = [...original];

copy[0] = 999; // Не влияет на original
copy[1].nested = false; // ВЛИЯЕТ на original!
copy[2].push(5); // ВЛИЯЕТ на original!

3. Object.assign()

const original = { a: 1, b: { c: 2 } };
const copy = Object.assign({}, original);

copy.a = 100; // Не влияет на original
copy.b.c = 200; // ВЛИЯЕТ на original!

console.log(original.b.c); // 200

4. Array.prototype.slice() для массивов

const original = [1, { nested: true }];
const copy = original.slice();

copy[0] = 999; // Не влияет
copy[1].nested = false; // ВЛИЯЕТ на original!

5. Array.from()

const original = [1, { nested: true }];
const copy = Array.from(original);

copy[0] = 999; // Не влияет
copy[1].nested = false; // ВЛИЯЕТ на original!

6. Object.create() — создаёт новый объект с прототипом исходного

const original = { a: 1, b: { c: 2 } };
const copy = Object.create(
Object.getPrototypeOf(original),
Object.getOwnPropertyDescriptors(original)
);

// Это тоже shallow copy для вложенных объектов
copy.b.c = 200;
console.log(original.b.c); // 200

Способы глубокого копирования (Deep Copy):

1. JSON.parse(JSON.stringify())

Самый простой и широко используемый способ, но с серьёзными ограничениями:

const original = {
name: "John",
age: 30,
address: { city: "Moscow", zip: "123456" },
hobbies: ["coding", "reading"],
created: new Date("2024-01-01"),
pattern: /test/g,
fn: function() { return 42; },
undef: undefined,
nil: nil,
sym: Symbol("test"),
map: new Map([["key", "value"]]),
set: new Set([1, 2, 3])
};

const copy = JSON.parse(JSON.stringify(original));

// Результат:
// {
// name: "John", ✓ скопировано
// age: 30, ✓ скопировано
// address: { city: "Moscow", zip: "123456" }, ✓ глубокая копия
// hobbies: ["coding", "reading"], ✓ глубокая копия
// created: "2024-01-01T00:00:00.000Z", ✗ Date → строка
// pattern: {}, ✗ RegExp → пустой объект
// fn: undefined, ✗ функция потеряна
// undef: undefined, ✗ undefined потерян
// nil: null, ✓ null сохраняется
// sym: undefined, ✗ Symbol потерян
// map: {}, ✗ Map → пустой объект
// set: {} ✗ Set → пустой объект
// }

Что теряется при JSON-копировании:

  • undefined — удаляется из объектов, заменяется на null в массивах
  • Функции — удаляются
  • Symbol — удаляются
  • Date — конвертируется в строку ISO
  • RegExp — становится пустым объектом {}
  • Map, Set, WeakMap, WeakSet — становятся {}
  • Infinity, NaN — становятся null
  • Циклические ссылки — выбрасывают ошибку TypeError: Converting circular structure to JSON
// Циклическая ссылка — ошибка
const obj = { a: 1 };
obj.self = obj;
JSON.parse(JSON.stringify(obj)); // TypeError!

2. structuredClone() — нативный метод (с 2022 года)

Это самый правильный современный способ глубокого копирования:

const original = {
name: "John",
created: new Date("2024-01-01"),
pattern: /test/g,
map: new Map([["key", "value"]]),
set: new Set([1, 2, 3]),
arrayBuffer: new ArrayBuffer(8),
error: new Error("test"),
};

const copy = structuredClone(original);

console.log(copy.created instanceof Date); // true
console.log(copy.pattern instanceof RegExp); // true
console.log(copy.map instanceof Map); // true
console.log(copy.set instanceof Set); // true

// Глубокая копия — изменения не влияют на оригинал
copy.map.set("key", "changed");
console.log(original.map.get("key")); // "value"

Что поддерживает structuredClone:

  • Все примитивы (string, number, boolean, null, undefined, bigint)
  • Date, RegExp, Map, Set
  • ArrayBuffer, Blob, File, ImageData
  • Обычные объекты и массивы
  • Циклические ссылки (!)

Что НЕ поддерживает structuredClone:

  • Функции — выбрасывает DataCloneError
  • DOM-узлы — выбрасывает DataCloneError
  • Свойства из цепочки прототипов
  • Некоторые дескрипторы свойств (getters/setters)
// Циклические ссылки работают
const obj = { a: 1 };
obj.self = obj;
const copy = structuredClone(obj); // Работает!
console.log(copy.self === copy); // true
console.log(copy.self === obj); // false

// Функции — ошибка
structuredClone({ fn: () => {} }); // DataCloneError

3. Самописная рекурсивная функция

Полный контроль над процессом копирования:

function deepClone(obj, hash = new WeakMap()) {
// Примитивы и null
if (obj === null || typeof obj !== "object") {
return obj;
}

// Циклические ссылки
if (hash.has(obj)) {
return hash.get(obj);
}

// Date
if (obj instanceof Date) {
return new Date(obj.getTime());
}

// RegExp
if (obj instanceof RegExp) {
return new RegExp(obj.source, obj.flags);
}

// Map
if (obj instanceof Map) {
const mapCopy = new Map();
hash.set(obj, mapCopy);
obj.forEach((value, key) => {
mapCopy.set(deepClone(key, hash), deepClone(value, hash));
});
return mapCopy;
}

// Set
if (obj instanceof Set) {
const setCopy = new Set();
hash.set(obj, setCopy);
obj.forEach((value) => {
setCopy.add(deepClone(value, hash));
});
return setCopy;
}

// Array или Object
const clone = Array.isArray(obj) ? [] : Object.create(Object.getPrototypeOf(obj));
hash.set(obj, clone);

// Копируем Symbol-ключи тоже
const keys = [...Object.keys(obj), ...Object.getOwnPropertySymbols(obj)];

for (const key of keys) {
clone[key] = deepClone(obj[key], hash);
}

return clone;
}

4. Lodash _.cloneDeep()

Самая надёжная библиотека для глубокого копирования:

import cloneDeep from "lodash/cloneDeep";

const original = {
name: "John",
created: new Date(),
pattern: /test/g,
map: new Map([["key", "value"]]),
set: new Set([1, 2, 3]),
fn: function() { return this.name; },
};

const copy = cloneDeep(original);

console.log(copy.created instanceof Date); // true
console.log(copy.pattern instanceof RegExp); // true
console.log(copy.map instanceof Map); // true
console.log(copy.fn()); // "John" — функции копируются

5. Через MessageChannel (хак для structuredClone без нативного API)

function structuredClonePolyfill(obj) {
return new Promise((resolve) => {
const { port1, port2 } = new MessageChannel();
port2.onmessage = (ev) => resolve(ev.data);
port1.postMessage(obj);
});
}

// Использование
const cloned = await structuredClonePolyfill({ a: 1, b: { c: 2 } });

Сводная таблица:

МетодГлубокаяЦиклические ссылкиDate/RegExpMap/SetФункцииНативный
Spread ...НетСсылкаСсылкаСсылкаДа
Object.assignНетСсылкаСсылкаСсылкаДа
JSON.parse/stringifyДаОшибкаСтрока/&#123;&#125;&#123;&#125;ПотеряныДа
structuredCloneДаДаДаДаОшибкаДа (2022+)
Lodash cloneDeepДаДаДаДаДаНет
СамописнаяДа*Да*Да*Да*Да*Да

*Зависит от реализации

Рекомендация:

Для современных проектов — structuredClone() является предпочтительным способом глубокого копирования. Если нужно копировать функции или использовать в старых браузерах — lodash/cloneDeep. JSON.parse(JSON.stringify()) стоит использовать только для простых объектов без специальных типов данных.

Вопрос 7. Какие методы массива не мутируют исходный массив?

Таймкод: 00:29:25

Ответ собеседника: Неправильный. Кандидат затруднился с ответом. Перепутал forEach и pop, заявив, что forEach мутирует, а pop — нет (наоборот). При подсказке про Map кандидат сначала сказал, что он мутирует, затем поправил себя. Про reduce не смог вспомнить, мутирует он или нет. Про sort честно признался, что не помнит. Продемонстрировал слабое знание мутирующих и немутирующих методов массива.

Правильный ответ:

Это базовый вопрос по JavaScript, и знание мутирующих/немутирующих методов критически важно для предсказуемой работы с данными, особенно в React и других фреймворках, где иммутабельность — ключевой принцип.

Немутирующие методы (возвращают новый массив/значение, не изменяя исходный):

1. map() — преобразование каждого элемента

const numbers = [1, 2, 3, 4, 5];
const doubled = numbers.map(n => n * 2);

console.log(doubled); // [2, 4, 6, 8, 10]
console.log(numbers); // [1, 2, 3, 4, 5] — не изменился

2. filter() — фильтрация элементов

const numbers = [1, 2, 3, 4, 5];
const evens = numbers.filter(n => n % 2 === 0);

console.log(evens); // [2, 4]
console.log(numbers); // [1, 2, 3, 4, 5] — не изменился

3. slice() — извлечение части массива

const arr = [1, 2, 3, 4, 5];
const part = arr.slice(1, 3);

console.log(part); // [2, 3]
console.log(arr); // [1, 2, 3, 4, 5] — не изменился

4. concat() — объединение массивов

const arr1 = [1, 2];
const arr2 = [3, 4];
const merged = arr1.concat(arr2);

console.log(merged); // [1, 2, 3, 4]
console.log(arr1); // [1, 2] — не изменился
console.log(arr2); // [3, 4] — не изменился

5. reduce() — свёртка в одно значение

const numbers = [1, 2, 3, 4, 5];
const sum = numbers.reduce((acc, n) => acc + n, 0);

console.log(sum); // 15
console.log(numbers); // [1, 2, 3, 4, 5] — не изменился

6. reduceRight() — свёртка справа налево

const numbers = [1, 2, 3];
const result = numbers.reduceRight((acc, n) => acc + n, "");

console.log(result); // "321"
console.log(numbers); // [1, 2, 3] — не изменился

7. flat() — выравнивание вложенных массивов

const nested = [1, [2, [3, [4]]]];
const flat1 = nested.flat(1);
const flatAll = nested.flat(Infinity);

console.log(flat1); // [1, 2, [3, [4]]]
console.log(flatAll); // [1, 2, 3, 4]
console.log(nested); // [1, [2, [3, [4]]]] — не изменился

8. flatMap() — map + flat(1)

const sentences = ["Hello world", "Good morning"];
const words = sentences.flatMap(s => s.split(" "));

console.log(words); // ["Hello", "world", "Good", "morning"]
console.log(sentences); // ["Hello world", "Good morning"] — не изменился

9. indexOf() — поиск индекса элемента

const arr = [10, 20, 30, 40];
const idx = arr.indexOf(30);

console.log(idx); // 2
console.log(arr); // [10, 20, 30, 40] — не изменился

10. lastIndexOf() — поиск индекса с конца

const arr = [1, 2, 3, 2, 1];
const idx = arr.lastIndexOf(2);

console.log(idx); // 3
console.log(arr); // [1, 2, 3, 2, 1] — не изменился

11. includes() — проверка наличия элемента

const arr = [1, 2, 3];
const hasTwo = arr.includes(2);

console.log(hasTwo); // true
console.log(arr); // [1, 2, 3] — не изменился

12. find() — поиск первого совпадения

const users = [
{ id: 1, name: "Alice" },
{ id: 2, name: "Bob" },
{ id: 3, name: "Charlie" }
];
const user = users.find(u => u.id === 2);

console.log(user); // { id: 2, name: "Bob" }
console.log(users); // массив не изменился

13. findIndex() — поиск индекса первого совпадения

const users = [
{ id: 1, name: "Alice" },
{ id: 2, name: "Bob" }
];
const idx = users.findIndex(u => u.name === "Bob");

console.log(idx); // 1
console.log(users); // массив не изменился

14. findLast() — поиск последнего совпадения (ES2023)

const arr = [1, 2, 3, 2, 1];
const last = arr.findLast(n => n === 2);

console.log(last); // 2 (последняя)
console.log(arr); // [1, 2, 3, 2, 1] — не изменился

15. findLastIndex() — поиск индекса последнего совпадения (ES2023)

const arr = [1, 2, 3, 2, 1];
const idx = arr.findLastIndex(n => n === 2);

console.log(idx); // 3
console.log(arr); // [1, 2, 3, 2, 1] — не изменился

16. join() — объединение в строку

const arr = ["a", "b", "c"];
const str = arr.join("-");

console.log(str); // "a-b-c"
console.log(arr); // ["a", "b", "c"] — не изменился

17. toString() — преобразование в строку

const arr = [1, 2, 3];
const str = arr.toString();

console.log(str); // "1,2,3"
console.log(arr); // [1, 2, 3] — не изменился

18. some() — проверка «хотя бы один»

const numbers = [1, 2, 3, 4, 5];
const hasEven = numbers.some(n => n % 2 === 0);

console.log(hasEven); // true
console.log(numbers); // [1, 2, 3, 4, 5] — не изменился

19. every() — проверка «все»

const numbers = [2, 4, 6, 8];
const allEven = numbers.every(n => n % 2 === 0);

console.log(allEven); // true
console.log(numbers); // [2, 4, 6, 8] — не изменился

20. forEach() — итерация (не мутирует сам по себе)

const arr = [1, 2, 3];
arr.forEach(n => console.log(n));

console.log(arr); // [1, 2, 3] — не изменился

// Важно: forEach сам по себе не мутирует массив,
// но внутри callback можно мутировать элементы по ссылке
const users = [{ name: "Alice" }, { name: "Bob" }];
users.forEach(u => u.name = u.name.toUpperCase());

console.log(users); // [{ name: "ALICE" }, { name: "BOB" }] — объекты изменились!

21. at() — доступ по индексу (включая отрицательные)

const arr = [10, 20, 30, 40];
const last = arr.at(-1);

console.log(last); // 40
console.log(arr); // [10, 20, 30, 40] — не изменился

22. toSorted() — сортировка без мутации (ES2023)

const arr = [3, 1, 4, 1, 5];
const sorted = arr.toSorted();

console.log(sorted); // [1, 1, 3, 4, 5]
console.log(arr); // [3, 1, 4, 1, 5] — не изменился!

23. toReversed() — реверс без мутации (ES2023)

const arr = [1, 2, 3, 4, 5];
const reversed = arr.toReversed();

console.log(reversed); // [5, 4, 3, 2, 1]
console.log(arr); // [1, 2, 3, 4, 5] — не изменился!

24. toSpliced() — splice без мутации (ES2023)

const arr = [1, 2, 3, 4, 5];
const result = arr.toSpliced(1, 2, 10, 20);

console.log(result); // [1, 10, 20, 4, 5]
console.log(arr); // [1, 2, 3, 4, 5] — не изменился!

25. with() — замена элемента по индексу без мутации (ES2023)

const arr = [1, 2, 3, 4, 5];
const result = arr.with(2, 99);

console.log(result); // [1, 2, 99, 4, 5]
console.log(arr); // [1, 2, 3, 4, 5] — не изменился!

Мутирующие методы (изменяют исходный массив):

1. push() — добавление в конец

const arr = [1, 2, 3];
arr.push(4);

console.log(arr); // [1, 2, 3, 4] — изменился!

2. pop() — удаление с конца

const arr = [1, 2, 3];
const removed = arr.pop();

console.log(removed); // 3
console.log(arr); // [1, 2] — изменился!

3. shift() — удаление с начала

const arr = [1, 2, 3];
const removed = arr.shift();

console.log(removed); // 1
console.log(arr); // [2, 3] — изменился!

4. unshift() — добавление в начало

const arr = [1, 2, 3];
arr.unshift(0);

console.log(arr); // [0, 1, 2, 3] — изменился!

5. splice() — удаление/вставка элементов

const arr = [1, 2, 3, 4, 5];
const removed = arr.splice(1, 2, 10, 20);

console.log(removed); // [2, 3]
console.log(arr); // [1, 10, 20, 4, 5] — изменился!

6. sort() — сортировка

const arr = [3, 1, 4, 1, 5];
arr.sort((a, b) => a - b);

console.log(arr); // [1, 1, 3, 4, 5] — изменился!

7. reverse() — реверс

const arr = [1, 2, 3, 4, 5];
arr.reverse();

console.log(arr); // [5, 4, 3, 2, 1] — изменился!

8. fill() — заполнение значением

const arr = [1, 2, 3, 4, 5];
arr.fill(0, 1, 3);

console.log(arr); // [1, 0, 0, 4, 5] — изменился!

9. copyWithin() — копирование внутри массива

const arr = [1, 2, 3, 4, 5];
arr.copyWithin(0, 3, 5);

console.log(arr); // [4, 5, 3, 4, 5] — изменился!

Сводная таблица:

НемутирующиеМутирующие
mappush
filterpop
sliceshift
concatunshift
reducesplice
reduceRightsort
flatreverse
flatMapfill
indexOfcopyWithin
lastIndexOf
includes
find
findIndex
findLast
findLastIndex
join
toString
some
every
forEach*
at
toSorted (ES2023)
toReversed (ES2023)
toSpliced (ES2023)
with (ES2023)

*forEach не мутирует массив сам по себе, но может мутировать элементы-объекты внутри callback

Почему это важно в React:

// Неправильно: мутируем состояние напрямую
const [items, setItems] = useState([1, 2, 3]);

function addItem(item) {
items.push(item); // Мутация! React не увидит изменение
setItems(items); // Та же ссылка — ре-рендера не будет
}

// Правильно: создаём новый массив
function addItem(item) {
setItems([...items, item]); // Новый массив — React вызовет ре-рендер
}

// Неправильно: сортируем на месте
function sortItems() {
items.sort((a, b) => a - b); // Мутация!
setItems(items);
}

// Правильно: создаём отсортированную копию
function sortItems() {
setItems([...items].sort((a, b) => a - b)); // Новый массив
}

// Или с ES2023:
function sortItems() {
setItems(items.toSorted((a, b) => a - b)); // Нативный немутирующий метод
}

Кандидат продемонстрировал пробел в фундаментальных знаниях JavaScript. Это серьёзный момент, так как понимание мутабельности — основа для работы с состоянием в любом фронтенд-фреймворке и для написания предсказуемого кода в целом.

Вопрос 8. В чём разница между HTTP-методами PUT и PATCH?

Таймкод: 00:31:00

Ответ собеседника: Правильный. Кандидат верно ответил: PUT используется для полной замены ресурса (нужно передать всю структуру), а PATCH — для частичного обновления ресурса (передаются только изменяемые поля).

Правильный ответ:

Кандидат дал корректный базовый ответ. Разберём тему глубже, включая нюансы, которые ожидает услышать опытный разработчик.

PUT — полная замена ресурса (Replace)

PUT предназначен для полной замены ресурса. Клиент отправляет полное представление ресурса, и сервер заменяет весь ресурс этим представлением.

PUT /api/users/123 HTTP/1.1
Content-Type: application/json

{
"id": 123,
"name": "John Smith",
"email": "john@example.com",
"phone": "+79001234567",
"address": "Moscow, Tverskaya 1",
"role": "admin",
"isActive": true
}

Если какой-то из этих полей не передать, сервер должен его удалить или установить значение по умолчанию. Это ключевое отличие от PATCH.

Свойства PUT:

  • Идемпотентен — повторный вызов с теми же данными даёт тот же результат
  • Заменяет весь ресурс целиком
  • Если ресурса не существует, может создать его (зависит от реализации сервера)

PATCH — частичное обновление (Partial Update)

PATCH предназначен для частичного изменения ресурса. Клиент отправляет только те поля, которые нужно изменить.

PATCH /api/users/123 HTTP/1.1
Content-Type: application/json

{
"email": "newemail@example.com"
}

После этого запроса все остальные поля пользователя остаются неизменными — обновляется только email.

Свойства PATCH:

  • Не гарантирует идемпотентность — зависит от применяемых операций
  • Обновляет только указанные поля
  • Не создаёт новый ресурс, если он не существует

Реализация на Go:

// internal/features/user/api/handler.go

// PUT — полная замена пользователя
func (h *UserHandler) UpdateUser(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
userID := vars["id"]

var dto UpdateUserDTO
if err := json.NewDecoder(r.Body).Decode(&dto); err != nil {
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}

// Валидация всех полей обязательна
if err := dto.Validate(); err != nil {
w.WriteHeader(http.StatusUnprocessableEntity)
json.NewEncoder(w).Encode(ErrorResponse{Error: err.Error()})
return
}

// Проверяем существование пользователя
existingUser, err := h.userService.GetByID(r.Context(), userID)
if err != nil {
http.Error(w, "User not found", http.StatusNotFound)
return
}

// Полная замена — обновляем ВСЕ поля
existingUser.Name = dto.Name
existingUser.Email = dto.Email
existingUser.Phone = dto.Phone
existingUser.Address = dto.Address
existingUser.Role = dto.Role
existingUser.IsActive = dto.IsActive

if err := h.userService.Update(r.Context(), existingUser); err != nil {
http.Error(w, "Update failed", http.StatusInternalServerError)
return
}

w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(existingUser)
}

// PATCH — частичное обновление пользователя
func (h *UserHandler) PatchUser(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
userID := vars["id"]

var dto PatchUserDTO
if err := json.NewDecoder(r.Body).Decode(&dto); err != nil {
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}

// Получаем текущего пользователя
existingUser, err := h.userService.GetByID(r.Context(), userID)
if err != nil {
http.Error(w, "User not found", http.StatusNotFound)
return
}

// Обновляем только переданные поля (непустые)
if dto.Name != nil {
existingUser.Name = *dto.Name
}
if dto.Email != nil {
existingUser.Email = *dto.Email
}
if dto.Phone != nil {
existingUser.Phone = *dto.Phone
}
if dto.Address != nil {
existingUser.Address = *dto.Address
}
if dto.Role != nil {
existingUser.Role = *dto.Role
}
if dto.IsActive != nil {
existingUser.IsActive = *dto.IsActive
}

if err := h.userService.Update(r.Context(), existingUser); err != nil {
http.Error(w, "Update failed", http.StatusInternalServerError)
return
}

w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(existingUser)
}

DTO для PATCH с указателями (для различия «не передано» и «передано пустое»):

// internal/features/user/model/dto.go

// UpdateUserDTO — для PUT (все поля обязательны)
type UpdateUserDTO struct {
Name string `json:"name" validate:"required"`
Email string `json:"email" validate:"required,email"`
Phone string `json:"phone" validate:"required"`
Address string `json:"address"`
Role string `json:"role" validate:"required"`
IsActive bool `json:"is_active"`
}

// PatchUserDTO — для PATCH (все поля опциональны через указатели)
type PatchUserDTO struct {
Name *string `json:"name"`
Email *string `json:"email"`
Phone *string `json:"phone"`
Address *string `json:"address"`
Role *string `json:"role"`
IsActive *bool `json:"is_active"`
}

Примеры запросов с curl:

# PUT — полная замена, нужно передать ВСЕ поля
curl -X PUT http://localhost:8080/api/users/123 \
-H "Content-Type: application/json" \
-d '{
"name": "John Smith",
"email": "john@example.com",
"phone": "+79001234567",
"address": "Moscow",
"role": "admin",
"is_active": true
}'

# PATCH — частичное обновление, только нужные поля
curl -X PATCH http://localhost:8080/api/users/123 \
-H "Content-Type: application/json" \
-d '{"email": "newemail@example.com"}'

# PATCH — обновление нескольких полей
curl -X PATCH http://localhost:8080/api/users/123 \
-H "Content-Type: application/json" \
-d '{"name": "Jane Doe", "is_active": false}'

JSON Patch (RFC 6902) — стандартизированный формат для PATCH:

Вместо простого JSON-объекта можно использовать стандарт JSON Patch:

PATCH /api/users/123 HTTP/1.1
Content-Type: application/json-patch+json

[
{ "op": "replace", "path": "/email", "value": "newemail@example.com" },
{ "op": "replace", "path": "/name", "value": "Jane Doe" },
{ "op": "remove", "path": "/phone" },
{ "op": "add", "path": "/tags", "value": ["premium", "verified"] }
]

Операции JSON Patch:

  • add — добавление значения
  • remove — удаление значения
  • replace — замена значения
  • move — перемещение значения
  • copy — копирование значения
  • test — проверка значения (для оптимистичной блокировки)

JSON Merge Patch (RFC 7386) — упрощённый формат:

PATCH /api/users/123 HTTP/1.1
Content-Type: application/merge-patch+json

{
"email": "newemail@example.com",
"phone": null
}

Здесь null означает удаление поля.

Сравнительная таблица:

ХарактеристикаPUTPATCH
СемантикаПолная заменаЧастичное обновление
ИдемпотентностьДаНе гарантируется
Передача данныхВсе поля ресурсаТолько изменяемые поля
Создание ресурсаМожет создатьНе создаёт
Content-Typeapplication/jsonapplication/json или application/json-patch+json
БезопасностьРиск потери данных (если забыть поле)Безопаснее для частичных обновлений

Когда использовать что:

  • PUT — когда клиент имеет полное представление о ресурсе и хочет заменить его целиком. Например, редактирование профиля с формой, где все поля заполнены.
  • PATCH — когда нужно изменить одно-два поля без передачи всего ресурса. Например, переключение toggle, изменение статуса, обновление одного поля в таблице.

Ответ кандидата верный, но для позиции выше среднего уровня ожидается знание про идемпотентность, JSON Patch, и умение объяснить практические различия в реализации API.

Вопрос 9. В чём разница между event.target и event.currentTarget?

Таймкод: 00:31:19

Ответ собеседника: Правильный. Кандидат верно ответил: event.target — это элемент, на котором произошло событие (фактический источник), а event.currentTarget — это элемент, на котором висит обработчик события.

Правильный ответ:

Кандидат дал точный базовый ответ. Разберём тему глубже с практическими примерами, которые показывают, почему это важно понимать.

Ключевая разница:

  • event.target — элемент, на котором фактически произошло событие (самый глубоко вложенный элемент)
  • event.currentTarget — элемент, к которому привязан обработчик события (меняется при всплытии)
  • this — в обычных функциях обработчиков равен event.currentTarget

Практический пример с вложенными элементами:

<div id="outer" class="outer">
<div id="middle" class="middle">
<button id="inner" class="inner">
Кликни меня
</button>
</div>
</div>
// Обработчик на внешнем div
document.getElementById("outer").addEventListener("click", function(event) {
console.log("target:", event.target.id); // "inner" — кнопка
console.log("currentTarget:", event.currentTarget.id); // "outer" — div с обработчиком
console.log("this:", this.id); // "outer" — совпадает с currentTarget
});

При клике на кнопку:

  • event.target<button id="inner"> — элемент, по которому кликнули
  • event.currentTarget<div id="outer"> — элемент, на котором висит обработчик
  • this<div id="outer"> — то же, что currentTarget

Делегирование событий — главный практический случай:

// Плохо: обработчик на каждой кнопке
document.querySelectorAll(".todo-item .delete-btn").forEach(btn => {
btn.addEventListener("click", handleDelete);
});

// Хорошо: один обработчик на родителе с делегированием
document.getElementById("todo-list").addEventListener("click", function(event) {
// event.target — элемент, по которому кликнули
// Может быть кнопкой, иконкой внутри кнопки, текстом...

const deleteBtn = event.target.closest(".delete-btn");
if (!deleteBtn) return; // Кликнули не на кнопку удаления

const todoItem = deleteBtn.closest(".todo-item");
const todoId = todoItem.dataset.id;

deleteTodo(todoId);
});

Почему currentTarget меняется при всплытии:

document.getElementById("outer").addEventListener("click", function(event) {
console.log("outer handler");
console.log(" target:", event.target.id); // "inner"
console.log(" currentTarget:", event.currentTarget.id); // "outer"
});

document.getElementById("middle").addEventListener("click", function(event) {
console.log("middle handler");
console.log(" target:", event.target.id); // "inner"
console.log(" currentTarget:", event.currentTarget.id); // "middle"
});

document.getElementById("inner").addEventListener("click", function(event) {
console.log("inner handler");
console.log(" target:", event.target.id); // "inner"
console.log(" currentTarget:", event.currentTarget.id); // "inner"
});

При клике на кнопку в консоли будет:

inner handler
target: inner
currentTarget: inner
middle handler
target: inner
currentTarget: middle
outer handler
target: inner
currentTarget: outer

event.target остаётся неизменным при всплытии, а event.currentTarget меняется на каждом уровне.

Стрелочные функции и this:

// Обычная функция: this === currentTarget
document.getElementById("outer").addEventListener("click", function(event) {
console.log(this === event.currentTarget); // true
});

// Стрелочная функция: this НЕ равен currentTarget
document.getElementById("outer").addEventListener("click", (event) => {
console.log(this === event.currentTarget); // false
// this здесь — лексический контекст (обычно window или undefined в strict mode)
// Поэтому в стрелочных функциях нужно использовать event.currentTarget явно
});

Практический пример: таблица с действиями

// Таблица с кнопками действий в каждой строке
document.getElementById("users-table").addEventListener("click", function(event) {
const target = event.target;
const currentTarget = event.currentTarget; // table#users-table

// Определяем, по чему кликнули
if (target.matches(".edit-btn")) {
const row = target.closest("tr");
const userId = row.dataset.userId;
openEditModal(userId);
} else if (target.matches(".delete-btn")) {
const row = target.closest("tr");
const userId = row.dataset.userId;
confirmDelete(userId);
} else if (target.matches(".view-btn")) {
const row = target.closest("tr");
const userId = row.dataset.userId;
navigateToUser(userId);
}
});

Пример: выпадающее меню

class DropdownMenu {
constructor(element) {
this.element = element;
this.trigger = element.querySelector(".dropdown-trigger");
this.menu = element.querySelector(".dropdown-menu");

this.trigger.addEventListener("click", this.toggle.bind(this));

// Закрытие при клике вне меню
document.addEventListener("click", this.handleOutsideClick.bind(this));
}

toggle(event) {
// event.target — элемент, по которому кликнули
// event.currentTarget — this.trigger (кнопка-триггер)
this.menu.classList.toggle("open");
}

handleOutsideClick(event) {
// Проверяем, был ли клик вне меню
// event.target — любой элемент на странице
// this.element — контейнер dropdown

if (!this.element.contains(event.target)) {
this.menu.classList.remove("open");
}
}
}

React-специфика:

В React синтетические события работают аналогично:

function TodoList({ todos, onDelete }) {
const handleClick = (event) => {
// event.target — элемент DOM, на котором произошло событие
// event.currentTarget — элемент, на котором висит обработник (ul)

const deleteBtn = event.target.closest("[data-action='delete']");
if (!deleteBtn) return;

const todoId = deleteBtn.dataset.id;
onDelete(todoId);
};

return (
<ul onClick={handleClick}>
{todos.map(todo => (
<li key={todo.id} className="todo-item">
<span>{todo.title}</span>
<button data-action="delete" data-id={todo.id}>
🗑️
</button>
</li>
))}
</ul>
);
}

Сводка:

СвойствоОписаниеМеняется при всплытии
event.targetЭлемент-источник событияНет
event.currentTargetЭлемент с обработчикомДа
this (обычная функция)Совпадает с currentTargetДа
this (стрелочная функция)Лексический контекстНет

Понимание разницы между target и currentTarget критически важно для корректной реализации делегирования событий — одного из ключевых паттернов оптимизации производительности при работе с большим количеством элементов.

Вопрос 10. В чём разница между приведением к строке через String() и .toString(), а также между Number() и унарным плюсом?

Таймкод: 00:31:42

Ответ собеседника: Неправильный. Кандидат не смог ответить ни на один из двух вопросов. По первому вопросу предположил, что результат будет одинаковым, но не знал про различия (String() работает с null и undefined, а .toString() выбросит ошибку). По второму вопросу также признался, что не помнит разницу между Number() и унарным плюсом.

Правильный ответ:

Это вопрос на знание нюансов приведения типов в JavaScript — фундаментальная тема, которая напрямую влияет на предсказуемость кода.

Часть 1: String() vs .toString()

String() — функция глобального объекта

String() работает с любым значением, включая null и undefined:

String(123); // "123"
String(true); // "true"
String(false); // "false"
String(null); // "null"
String(undefined); // "undefined"
String(NaN); // "NaN"
String(Infinity); // "Infinity"
String({}); // "[object Object]"
String([1, 2, 3]); // "1,2,3"
String(0); // "0"
String(-0); // "0"

// Вызов toString() на объектах с кастомной реализацией
String({ toString() { return "custom"; } }); // "custom"

String() внутренне работает так:

function String(value) {
if (value === null) return "null";
if (value === undefined) return "undefined";

// Для остальных значений вызывает toString()
return value.toString();
}

.toString() — метод объекта

.toString() вызывается на конкретном значении и не работает с null и undefined:

(123).toString(); // "123"
true.toString(); // "true"
false.toString(); // "false"
NaN.toString(); // "NaN"
Infinity.toString(); // "Infinity"
({}).toString(); // "[object Object]"
[1, 2, 3].toString(); // "1,2,3"

// Ошибки:
null.toString(); // TypeError: Cannot read properties of null
undefined.toString(); // TypeError: Cannot read properties of undefined

Особые случаи .toString():

// Числа с указанием системы счисления
(255).toString(16); // "ff"
(255).toString(2); // "11111111"
(255).toString(8); // "377"

// Строки — toString() возвращает копию
"hello".toString(); // "hello"

// Boolean
true.toString(); // "true"

// Symbol — работает, но нельзя привести к числу
Symbol("test").toString(); // "Symbol(test)"

Сравнительная таблица String() vs .toString():

ЗначениеString().toString()
null"null"TypeError
undefined"undefined"TypeError
123"123""123"
true"true""true"
NaN"NaN""NaN"
{}"[object Object]""[object Object]"
[1,2,3]"1,2,3""1,2,3"

Практический пример:

function formatValue(value) {
// Безопасное преобразование — используем String()
return String(value);
}

formatValue(null); // "null"
formatValue(undefined); // "undefined"
formatValue(42); // "42"

// Небезопасное — используем .toString()
function formatValueUnsafe(value) {
return value.toString(); // Упадёт на null/undefined
}

Часть 2: Number() vs унарный плюс (+)

Оба преобразуют значение к числу, но есть нюансы.

Number() — функция конструктор/преобразования

Number("123"); // 123
Number("123.45"); // 123.45
Number(""); // 0
Number(" "); // 0
Number("123abc"); // NaN
Number("abc"); // NaN
Number("0x1A"); // 26 (шестнадцатеричное)
Number("0o17"); // 15 (восьмеричное)
Number("0b101"); // 5 (двоичное)
Number(null); // 0
Number(undefined); // NaN
Number(true); // 1
Number(false); // 0
Number(NaN); // NaN
Number(Infinity); // Infinity
Number({}); // NaN
Number([1, 2]); // NaN
Number([5]); // 5

Унарный плюс (+)

+"123"; // 123
+"123.45"; // 123.45
+""; // 0
+" "; // 0
+"123abc"; // NaN
+"abc"; // NaN
+null; // 0
+undefined; // NaN
+true; // 1
+false; // 0
+NaN; // NaN
+Infinity; // Infinity
+{}; // NaN
+[1, 2]; // NaN
+[5]; // 5

Ключевые различия:

На первый взгляд результаты идентичны. Различия проявляются в редких случаях:

1. Работа с Date:

const now = new Date();

Number(now); // 1704067200000 (timestamp в миллисекундах)
+now; // 1704067200000 (то же самое)

// Оба вызывают valueOf() для Date

2. Объекты с кастомными valueOf/toString:

const obj = {
valueOf() { return 42; },
toString() { return "hello"; }
};

Number(obj); // 42 — вызывает valueOf()
+obj; // 42 — вызывает valueOf()

const obj2 = {
toString() { return "123"; }
// нет valueOf
};

Number(obj2); // 123 — вызывает toString(), затем парсит
+obj2; // 123 — то же самое

3. Цепочки преобразований:

// Унарный плюс имеет более высокий приоритет в выражениях
+"1" + +"2"; // 3 (число)
"1" + "2"; // "12" (строка)

// Можно использовать для быстрого преобразования в выражении
const total = +price * +quantity;

Различия с шестнадцатеричными строками:

Number("0x1A"); // 26
+"0x1A"; // NaN (!)

// Унарный плюс не парсит шестнадцатеричные префиксы
// Number() парсит

Number("0xFF"); // 255
+"0xFF"; // NaN

Различия с BigInt:

const big = 9007199254740991n;

Number(big); // 9007199254740992 (возможна потеря точности)
+big; // TypeError: Cannot convert a BigInt value to a number

Сравнительная таблица Number() vs +:

ЗначениеNumber()Унарный плюс (+)
"123"123123
"0x1A"26NaN
""00
null00
undefinedNaNNaN
true11
BigIntЧисло (с потерей)TypeError

Другие способы приведения к числу:

// parseInt — парсит целое число, останавливается на нецифровом символе
parseInt("123abc"); // 123
parseInt("abc123"); // NaN
parseInt("123.45"); // 123
parseInt("101", 2); // 5 (двоичная система)
parseInt("FF", 16); // 255 (шестнадцатеричная)

// parseFloat — парсит дробное число
parseFloat("123.45abc"); // 123.45
parseFloat("abc123.45"); // NaN
parseFloat("123.45.67"); // 123.45

// Math.floor, Math.ceil, Math.round
Math.floor("123.9"); // 123
Math.ceil("123.1"); // 124
Math.round("123.5"); // 124

// Побитовое ИЛИ с 0 — быстрое преобразование к целому числу
"123.9" | 0; // 123
"123abc" | 0; // 0 (!)

Практические рекомендации:

// Для безопасного преобразования строки к числу:
const num = Number(input); // Явное, читаемое

// Для быстрого преобразования в выражении:
const total = +price * +quantity;

// Для парсинга целых чисел из строк:
const count = parseInt(input, 10); // Всегда указывайте radix!

// Для проверки на число:
if (!isNaN(Number(input))) {
// input можно преобразовать к числу
}

Итого:

  • String() безопаснее .toString(), так как работает с null и undefined
  • Number() и унарный плюс почти идентичны, но Number() парсит шестнадцатеричные строки, а унарный плюс — нет
  • Для явного и читаемого кода предпочтительнее String() и Number()
  • Для быстрых преобразований в выражениях удобен унарный плюс

Вопрос 11. В чём разница между useEffect и useLayoutEffect?

Таймкод: 00:32:24

Ответ собеседника: Правильный. Кандидат верно ответил: useEffect — асинхронный хук, не блокирует отрисовку и выполняется после того, как браузер отрисовал страницу. useLayoutEffect — синхронный хук, срабатывает после изменений DOM, но до отрисовки браузером, и блокирует отрисовку. Кандидат также упомянул пример использования — определение темы пользователя для предотвращения мерцания.

Правильный ответ:

Кандидат дал точный ответ с хорошим практическим примером. Разберём тему подробнее.

Жизненный цикл рендеринга в React:

1. React вызывает компонент → получает JSX
2. React применяет изменения к DOM (реконсиляция)
3. Вызываются колбэки useLayoutEffect
4. Браузер отрисовывает (paint) изменения на экране
5. Вызываются колбэки useEffect

useEffect — асинхронный, не блокирует рендер

import { useEffect, useState } from "react";

function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);

useEffect(() => {
// Выполняется ПОСЛЕ отрисовки
// Пользователь уже видит экран загрузки
setLoading(true);

fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => {
setUser(data);
setLoading(false);
});
}, [userId]);

if (loading) return <Spinner />;

return (
<div>
<h1>{user.name}</h1>
<p>{user.email}</p>
</div>
);
}

Типичные сценарии для useEffect:

  • Загрузка данных с сервера
  • Подписка на события (WebSocket, EventSource)
  • Установка таймеров (setInterval, setTimeout)
  • Логирование и аналитика
  • Изменение document.title
  • Интеграция с не-React библиотеками (jQuery плагины, карты)

useLayoutEffect — синхронный, блокирует рендер

import { useLayoutEffect, useRef, useState } from "react";

function Tooltip({ children, content }) {
const tooltipRef = useRef(null);
const [position, setPosition] = useState({ x: 0, y: 0 });

useLayoutEffect(() => {
// Выполняется ДО отрисовки
// Пользователь НЕ видит промежуточное состояние
const rect = tooltipRef.current.getBoundingClientRect();

if (rect.right > window.innerWidth) {
setPosition({ x: window.innerWidth - rect.width - 10, y: rect.top });
}
}, [content]);

return (
<div ref={tooltipRef} style={{ left: position.x, top: position.y }}>
{content}
</div>
);
}

Типичные сценарии для useLayoutEffect:

  • Измерение размеров и позиции DOM-элементов
  • Позиционирование элементов (тултипы, модалки, дропдауны)
  • Предотвращение мерцания (flash of content)
  • Синхронные изменения DOM перед отрисовкой
  • Установка темы/стилей до первого рендера

Практический пример: мерцание темы

// Плохо: с useEffect — будет мерцание
function App() {
const [theme, setTheme] = useState("light");

useEffect(() => {
// Пользователь сначала видит светлую тему, потом тёмную
const savedTheme = localStorage.getItem("theme");
if (savedTheme) setTheme(savedTheme);
}, []);

return <div className={theme}>...</div>;
}

// Хорошо: с useLayoutEffect — без мерцания
function App() {
const [theme, setTheme] = useState("light");

useLayoutEffect(() => {
// Выполняется до отрисовки — тема установлена сразу
const savedTheme = localStorage.getItem("theme");
if (savedTheme) setTheme(savedTheme);
}, []);

return <div className={theme}>...</div>;
}

Ещё лучше: инициализация без хука (для темы)

// Оптимально: начальное состояние вычисляется сразу
function App() {
const [theme, setTheme] = useState(() => {
// Выполняется только при первом рендере
return localStorage.getItem("theme") || "light";
});

return <div className={theme}>...</div>;
}

Пример: измерение элемента

function AutoWidthInput({ value, onChange }) {
const spanRef = useRef(null);
const [width, setWidth] = useState(100);

useLayoutEffect(() => {
// Измеряем ширину текста и устанавливаем ширину input
// Если использовать useEffect — input сначала будет 100px, потом изменится
// С useLayoutEffect — ширина сразу правильная
const spanWidth = spanRef.current.offsetWidth;
setWidth(Math.max(100, spanWidth + 20));
}, [value]);

return (
<div style={{ position: "relative" }}>
<span
ref={spanRef}
style={{
visibility: "hidden",
position: "absolute",
whiteSpace: "pre",
font: "inherit",
}}
>
{value}
</span>
<input
type="text"
value={value}
onChange={onChange}
style={{ width }}
/>
</div>
);
}

Пример: позиционирование модального окна

function Modal({ isOpen, children, onClose }) {
const modalRef = useRef(null);

useLayoutEffect(() => {
if (!isOpen) return;

// Центрирование модалки перед отрисовкой
const modal = modalRef.current;
const rect = modal.getBoundingClientRect();

const top = (window.innerHeight - rect.height) / 2;
const left = (window.innerWidth - rect.width) / 2;

modal.style.top = `${Math.max(0, top)}px`;
modal.style.left = `${Math.max(0, left)}px`;
}, [isOpen]);

if (!isOpen) return null;

return (
<div ref={modalRef} className="modal">
{children}
<button onClick={onClose}>Закрыть</button>
</div>
);
}

SSR (Server-Side Rendering) нюанс:

useLayoutEffect не работает на сервере. При SSR вы получите предупреждение:

Warning: useLayoutEffect does nothing on the server...

Решение — хук-обёртка:

import { useEffect, useLayoutEffect } from "react";

const useIsomorphicLayoutEffect =
typeof window !== "undefined" ? useLayoutEffect : useEffect;

// Использование
function Component() {
useIsomorphicLayoutEffect(() => {
// Работает и на клиенте, и на сервере
}, []);
}

Сводная таблица:

ХарактеристикаuseEffectuseLayoutEffect
Когда выполняетсяПосле отрисовки браузеромПосле изменений DOM, до отрисовки
Блиокирует рендерНетДа
SSRРаботаетПредупреждение (не работает)
Частота использования~95% случаев~5% случаев
Аналог в классовых компонентахcomponentDidMount, componentDidUpdatecomponentDidUpdate (синхронная часть)

Правило:

Используйте useEffect по умолчанию. Переключайтесь на useLayoutEffect только когда:

  • Нужно измерить DOM-элемент и синхронно обновить состояние
  • Нужно предотвратить мерцание (flash of content)
  • Нужно позиционировать элемент до отрисовки

Ответ кандидата полностью корректный и демонстрирует понимание практического применения.

Вопрос 12. Как сделать реверс строки в JavaScript?

Таймкод: 00:33:44

Ответ собеседника: Правильный. Кандидат предложил два подхода: 1) Использовать встроенные методы: split('').reverse().join(''); 2) Написать свою функцию с циклом, где два указателя (левый и правый) двигаются навстречу друг другу, меняя символы местами. Кандидат подробно описал алгоритм ручной реализации.

Правильный ответ:

Кандидат дал правильный ответ, показав и знание стандартных методов, и понимание алгоритма. Разберём все способы подробнее.

Способ 1: split('').reverse().join('')

Самый распространённый и читаемый способ:

function reverseString(str) {
return str.split('').reverse().join('');
}

console.log(reverseString("hello")); // "olleh"
console.log(reverseString("JavaScript")); // "tpircSavaJ"
console.log(reverseString("12345")); // "54321"
console.log(reverseString("")); // ""
console.log(reverseString("a")); // "a"

Как это работает пошагово:

  1. split('') — разбивает строку на массив отдельных символов: "hello"["h", "e", "l", "l", "o"]
  2. reverse() — переворачивает массив: ["h", "e", "l", "l", "o"]["o", "l", "l", "e", "h"]
  3. join('') — объединяет массив обратно в строку: ["o", "l", "l", "e", "h"]"olleh"

Способ 2: Два указателя (in-place, как описал кандидат)

function reverseString(str) {
// Строки в JavaScript иммутабельны, поэтому работаем с массивом
const chars = str.split('');
let left = 0;
let right = chars.length - 1;

while (left < right) {
// Меняем символы местами
const temp = chars[left];
chars[left] = chars[right];
chars[right] = temp;

left++;
right--;
}

return chars.join('');
}

console.log(reverseString("hello")); // "olleh"
console.log(reverseString("abcd")); // "dcba"

Сложность: O(n) по времени, O(n) по памяти (из-за массива).

Способ 3: for цикл с конца

function reverseString(str) {
let result = '';

for (let i = str.length - 1; i >= 0; i--) {
result += str[i];
}

return result;
}

console.log(reverseString("hello")); // "olleh"

Способ 4: reduce

function reverseString(str) {
return str.split('').reduce((reversed, char) => char + reversed, '');
}

console.log(reverseString("hello")); // "olleh"

Как работает:

  • Начальное значение: ""
  • Шаг 1: "h" + "" = "h"
  • Шаг 2: "e" + "h" = "eh"
  • Шаг 3: "l" + "eh" = "leh"
  • Шаг 4: "l" + "leh" = "lleh"
  • Шаг 5: "o" + "lleh" = "olleh"

Способ 5: Рекурсия

function reverseString(str) {
// Базовый случай
if (str.length <= 1) return str;

// Рекурсивный случай: последний символ + реверс оставшейся строки
return str[str.length - 1] + reverseString(str.slice(0, -1));
}

console.log(reverseString("hello")); // "olleh"

Способ 6: Оператор расширения (spread)

function reverseString(str) {
return [...str].reverse().join('');
}

console.log(reverseString("hello")); // "olleh"

Spread-оператор корректно работает с символами Unicode (в отличие от split('')).

Способ 7: Array.from

function reverseString(str) {
return Array.from(str).reverse().join('');
}

console.log(reverseString("hello")); // "olleh"

Способ 8: Использование метода reverse напрямую (хак)

function reverseString(str) {
return Array.prototype.reverse.call([...str]).join('');
}

Проблема с Unicode:

Простые методы не всегда корректно работают с символами, состоящими из нескольких кодовых точек:

// Проблема с эмодзи и комбинированными символами
"👋🏽".split('').reverse().join(''); // Некорректно
"👨‍👩‍👧".split('').reverse().join(''); // Некорректно
"café".split('').reverse().join(''); // Может сломаться с é

// Решение: использовать Intl.Segmenter (современный API)
function reverseString(str) {
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
const segments = [...segmenter.segment(str)].map(s => s.segment);
return segments.reverse().join('');
}

console.log(reverseString("👋🏽 hello")); // "olleh 👋🏽"
console.log(reverseString("👨‍👩‍👧 family")); // "ylimaf 👨‍👩‍👧"

Сравнение производительности:

// Бенчмарк для длинной строки
const longString = "a".repeat(1000000);

// Самый быстрый: два указателя (без создания промежуточных строк)
// Самый медленный: reduce с конкатенацией строк (из-за создания новой строки на каждом шаге)

Реализация на Go (для сравнения):

package main

import (
"fmt"
"strings"
)

// Через два указателя
func reverseString(str string) string {
// Конвертируем в слайс рун для корректной работы с Unicode
runes := []rune(str)
left, right := 0, len(runes)-1

for left < right {
runes[left], runes[right] = runes[right], runes[left]
left++
right--
}

return string(runes)
}

// Через простую конкатенацию
func reverseStringSimple(str string) string {
runes := []rune(str)
var result strings.Builder

for i := len(runes) - 1; i >= 0; i-- {
result.WriteRune(runes[i])
}

return result.String()
}

func main() {
fmt.Println(reverseString("hello")) // olleh
fmt.Println(reverseString("привет")) // тевирп
fmt.Println(reverseString("👋🏽 hello")) // olleh 👋🏽
}

Сводная таблица способов:

СпособЧитаемостьПроизводительностьUnicode-safe
split/reverse/joinВысокаяСредняяНет
Два указателяСредняяВысокаяС рунами
reduceСредняяНизкаяНет
РекурсияВысокаяНизкаяНет
spread/reverse/joinВысокаяСредняяЧастично
Intl.SegmenterСредняяСредняяДа

Рекомендация:

Для большинства случаев — str.split('').reverse().join('') или [...str].reverse().join(''). Если нужна корректная работа с эмодзи и сложными символами — Intl.Segmenter. Если важна производительность на больших строках — два указателя.

Ответ кандидата полностью корректный и показывает как знание стандартных методов, так и понимание алгоритма.

Вопрос 13. Реализовать функцию для вычисления n-го числа последовательности Трибоначчи

Таймкод: 00:36:15

Ответ собеседника: Правильный. Кандидат реализовал задачу итеративным подходом: определил базовые случаи (n=0 → 0, n=1 или n=2 → 1), инициализировал три переменные (a=0, b=1, c=1), написал цикл for от i=3 до n, в котором вычисляет сумму трёх предыдущих значений, сдвигает переменные (a=b, b=c, c=sum) и возвращает c. Решение корректное и соответствует ожидаемому подходу.

Правильный ответ:

Кандидат выбрал оптимальный подход — итеративный с тремя переменными. Разберём все возможные решения и их характеристики.

Определение последовательности Трибоначчи:

T(0) = 0
T(1) = 1
T(2) = 1
T(n) = T(n-1) + T(n-2) + T(n-3) для n >= 3

Последовательность: 0, 1, 1, 2, 4, 7, 13, 24, 44, 81, 149, ...

Решение 1: Итеративное (оптимальное) — как у кандидата

function tribonacci(n) {
// Базовые случаи
if (n === 0) return 0;
if (n === 1 || n === 2) return 1;

let a = 0; // T(0)
let b = 1; // T(1)
let c = 1; // T(2)

for (let i = 3; i <= n; i++) {
const sum = a + b + c;
a = b;
b = c;
c = sum;
}

return c;
}

console.log(tribonacci(0)); // 0
console.log(tribonacci(1)); // 1
console.log(tribonacci(2)); // 1
console.log(tribonacci(3)); // 2
console.log(tribonacci(4)); // 4
console.log(tribonacci(5)); // 7
console.log(tribonacci(10)); // 149

Сложность:

  • Время: O(n)
  • Память: O(1)

Решение 2: Рекурсивное наивное

function tribonacciRecursive(n) {
if (n === 0) return 0;
if (n === 1 || n === 2) return 1;

return tribonacciRecursive(n - 1)
+ tribonacciRecursive(n - 2)
+ tribonacciRecursive(n - 3);
}

Сложность:

  • Время: O(3^n) — экспоненциальная, очень медленно
  • Память: O(n) — глубина стека рекурсии

Это решение не проходит по времени для больших n.

Решение 3: Рекурсия с мемоизацией

function tribonacciMemo(n, memo = {}) {
if (n === 0) return 0;
if (n === 1 || n === 2) return 1;

if (memo[n] !== undefined) {
return memo[n];
}

memo[n] = tribonacciMemo(n - 1, memo)
+ tribonacciMemo(n - 2, memo)
+ tribonacciMemo(n - 3, memo);

return memo[n];
}

Сложность:

  • Время: O(n)
  • Память: O(n)

Решение 4: Динамическое программирование с массивом

function tribonacciDP(n) {
if (n === 0) return 0;
if (n === 1 || n === 2) return 1;

const dp = new Array(n + 1);
dp[0] = 0;
dp[1] = 1;
dp[2] = 1;

for (let i = 3; i <= n; i++) {
dp[i] = dp[i - 1] + dp[i - 2] + dp[i - 3];
}

return dp[n];
}

Сложность:

  • Время: O(n)
  • Память: O(n)

Решение 5: Через генератор

function* tribonacciGenerator() {
let a = 0, b = 1, c = 1;

yield a;
yield b;
yield c;

while (true) {
const sum = a + b + c;
yield sum;
a = b;
b = c;
c = sum;
}
}

function tribonacci(n) {
const gen = tribonacciGenerator();
let result;

for (let i = 0; i <= n; i++) {
result = gen.next().value;
}

return result;
}

Реализация на Go:

package main

import "fmt"

// Итеративное решение
func tribonacci(n int) int {
if n == 0 {
return 0
}
if n == 1 || n == 2 {
return 1
}

a, b, c := 0, 1, 1

for i := 3; i <= n; i++ {
sum := a + b + c
a = b
b = c
c = sum
}

return c
}

// Рекурсия с мемоизацией
func tribonacciMemo(n int) int {
memo := make(map[int]int)
return tribonacciMemoHelper(n, memo)
}

func tribonacciMemoHelper(n int, memo map[int]int) int {
if n == 0 {
return 0
}
if n == 1 || n == 2 {
return 1
}

if val, exists := memo[n]; exists {
return val
}

memo[n] = tribonacciMemoHelper(n-1, memo) +
tribonacciMemoHelper(n-2, memo) +
tribonacciMemoHelper(n-3, memo)

return memo[n]
}

func main() {
for i := 0; i <= 10; i++ {
fmt.Printf("T(%d) = %d\n", i, tribonacci(i))
}
// T(0) = 0
// T(1) = 1
// T(2) = 1
// T(3) = 2
// T(4) = 4
// T(5) = 7
// T(6) = 13
// T(7) = 24
// T(8) = 44
// T(9) = 81
// T(10) = 149
}

Сравнение подходов:

ПодходВремяПамятьКогда использовать
ИтеративныйO(n)O(1)Всегда предпочтителен
Рекурсия наивнаяO(3^n)O(n)Только для обучения
Рекурсия + мемоO(n)O(n)Когда нужна рекурсивная логика
DP с массивомO(n)O(n)Когда нужны все промежуточные значения
ГенераторO(n)O(1)Когда нужна ленивая генерация

Обобщение: N-последовательность (N-bonacci)

function nBonacci(n, order) {
// order = 2 → Фибоначчи
// order = 3 → Трибоначчи
// order = 4 → Тетраначчи

if (n < order - 1) return 0;
if (n === order - 1) return 1;

const window = new Array(order).fill(0);
window[order - 1] = 1;

for (let i = order; i <= n; i++) {
const sum = window.reduce((a, b) => a + b, 0);
window.shift();
window.push(sum);
}

return window[window.length - 1];
}

console.log(nBonacci(10, 2)); // Фибоначчи: 55
console.log(nBonacci(10, 3)); // Трибоначчи: 149
console.log(nBonacci(10, 4)); // Тетраначчи: 29

Вывод:

Итеративное решение кандидата — оптимальное по всем параметрам. Оно использует O(1) дополнительной памяти и O(n) времени, что является лучшим возможным результатом для этой задачи. Кандидат продемонстрировал правильное понимание алгоритмов и умение выбирать эффективный подход.