Открытое интервью: Junior Frontend-разработчик
Сегодня мы разберём живое собеседование с кандидатом на позицию фронтенд-разработчика, который продемонстрировал практический опыт работы с React и Next.js, но испытывал трудности с фундаментальными концепциями JavaScript, такими как промисы и замыкания. В ходе интервью были рассмотрены задачи на лайв-кодинг, обсуждены ключевые хуки React и выявлены пробелы в понимании базовых механизмов языка, что в итоге позволило дать кандидату конкретные рекомендации по развитию.
Вопрос 1. Расскажите о себе и о проекте, которым вы больше всего гордитесь.
Таймкод: 00:07:14
Ответ собеседника: Правильный. Фронтенд-разработчик с примерно годом коммерческого опыта. Гордится проектом актёрского агентства, который переписал с нуля: перешёл с jQuery/Svelte на Next.js (React), добавил новый функционал, реализовал кастомную CMS через Google Таблицы по требованию заказчика. Проект был в плохом состоянии после нескольких разработчиков, работавших в разных парадигмах. Всё удалось запустить, несмотря на сложную ситуацию с тремя конфликтующими заказчиками.
Правильный ответ:
Важное замечание: кандидат позиционирует себя как фронтенд-разработчик, а интервью проводится на позицию Go-разработчика. Это стоит учитывать при дальнейших вопросах.
Структура хорошего ответа на вопрос «Расскажите о себе»:
Вводный вопрос интервью — это возможность представить себя как специалиста, релевантного вакансии. Ответ должен включать:
1. Краткая самопрезентация (30–60 секунд)
- Имя, общий стаж, основной стек технологий.
- Ключевые компетенции: например, «backend-разработка на Go, микросервисная архитектура, распределённые системы».
2. Проект, которым горжусь — структура STAR (Situation, Task, Action, Result)
- Situation (ситуация): Контекст — масштаб проекта, команда, бизнес-задача.
- Task (задача): Что именно нужно было решить. Желательно подчеркнуть техническую сложность: производительность, надёжность, масштабируемость.
- Action (действия): Конкретные технические решения — какие паттерны Go использовались (goroutine, channel, context, graceful shutdown), какие библиотеки и фреймворки (gin, echo, gRPC, protobuf), как была выстроена архитектура.
- Result (результат): Измеримые метрики — снижение latency на X%, увеличение throughput, сокращение инцидентов, ускорение деплоя.
3. Пример хорошего ответа для Go-разработчика:
> «Backend-разработчик с 4 годами опыта на Go. Горжусь проектом — высоконагруженный сервис обработки событий для платформы электронной коммерции. Сервис обрабатывал 10K+ событий в секунду. Задача — снизить latency p99 с 200ms до 50ms и обеспечить graceful degradation при пиковых нагрузках. Реализовал pipeline на goroutine с backpressure через buffered channels, внедрил circuit breaker для внешних зависимостей, написал кастомный rate limiter на token bucket алгоритме. Результат: p99 latency упал до 35ms, инциденты отказов внешних сервисов снизились на 90%».
4. Что стоит подчеркнуть для Go-позиции:
- Опыт работы с конкурентностью (goroutine, sync, atomic).
- Опыт профилирования и оптимизации (pprof, trace, benchstat).
- Работа с базами данных (PostgreSQL, Redis, Kafka).
- Опыт деплоя (Docker, Kubernetes, CI/CD).
Ответ кандидата демонстрирует хорошие soft skills и умение работать в сложных условиях, но технически он описывает фронтенд-проект, что не соответствует профилю Go-вакансии.
Вопрос 2. Какие технологии были выбраны для переписывания проекта и почему именно они?
Таймкод: 00:09:01
Ответ собеседника: Правильный. Изначально проект представлял собой «Франкенштейн»: начинали на Next.js, потом пришёл разработчик, который перевёл всё на Svelte. Решено было вернуться к React/Next.js. Большие компоненты были разбиты на маленькие, использовался контекст для передачи данных вместо менеджера состояния, так как это было бы over-engineering для данного проекта.
Правильный ответ:
Ответ кандидата корректный в контексте фронтенд-разработки, но для позиции Go-разработчика важно продемонстрировать понимание выбора технологий на бэкенде.
Структура хорошего ответа для Go-разработчика:
1. Обоснование выбора технологий должно включать:
- Бизнес-требования: какие задачи решает система, какие нагрузки ожидаются.
- Технические характеристики: производительность, масштабируемость, надёжность.
- Экосистема и зрелость: наличие библиотек, качество документации, активность сообщества.
- Командный фактор: знакомство команды со стеком, порог входа.
- Операционные расходы: стоимость инфраструктуры, сложность деплоя и мониторинга.
2. Пример выбора технологий для backend на Go:
Фреймворк: Chi или Echo вместо стандартного net/http
Для микросервиса с REST API выбираем легковесный роутер, а не тяжёлый фреймворм вроде Buffalo. Chi даёт middleware, параметризованные маршруты и совместимость с net/http, что упрощает тестирование и интеграцию.
r := chi.NewRouter()
r.Use(middleware.Logger, middleware.Recoverer)
r.Get("/users/{id}", getUserHandler)
База данных: PostgreSQL + pgx
PostgreSQL выбран для транзакционных данных благодаря ACID, поддержке JSONB и мощному индексированию. Драйвер pgx вместо database/sql напрямую — за лучшую производительность и поддержку нативных типов PostgreSQL.
pool, err := pgxpool.New(context.Background(), connStr)
// Используем prepared statements через pgx
row := pool.QueryRow(ctx, "SELECT name FROM users WHERE id=$1", userID)
Очередь сообщений: NATS JetStream или Kafka
Для event-driven архитектуры выбор зависит от требований к порядку сообщений и throughput. NATS JetStream проще в эксплуатации, Kafka — для гарантированного порядка и высокой пропускной способности.
Контейнеризация и оркестрация: Docker + Kubernetes
Kubernetes обеспечивает автоскейлинг, self-healing и rolling updates. Go-бинарники идеальны для контейнеров — один статический файл, минимальный образ (distroless или scratch).
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY . .
RUN CGO_ENABLED=0 go build -o service ./cmd/server
FROM gcr.io/distroless/static-debian12
COPY --from=builder /app/service /service
ENTRYPOINT ["/service"]
3. Антипаттерны, которых стоит избегать:
- Over-engineering: не нужен Kubernetes для сервиса с 100 RPS.
- Слепое следование трендам: новый фреймворк не всегда лучше проверенного.
- Игнорирование экосистемы: выбор технологии без библиотек для логирования, метрик, трейсинга создаст технический долг.
4. Ключевой принцип:
Выбор технологий — это всегда компромисс. Хороший разработчик объясняет не только что выбрал, но и какие альтернативы рассматривал, почему отклонил их и какие ограничения принял.
Вопрос 3. Почему было решено переписать проект на React/Next.js, в чём недостатки предыдущего подхода с jQuery и Svelte?
Таймкод: 00:10:38
Ответ собеседника: Правильный. Была сильно извращённая логика передачи данных между компонентами — скрипты работали независимо, компоненты не получали данные друг от друга. Происходило прямое манипулирование DOM-нодами, что теряет одно из основных преимуществ React. Профилирование в DevTools показало массу лишних перерисовок — скрипт перерисовывал всю страницу. Решено было использовать React-подход с контекстом вместо менеджера состояния, так как это было бы over-engineering для данного проекта.
Правильный ответ:
Ответ кандидата корректный и технически грамотный в контексте фронтенда. Для позиции Go-разработчика аналогичный вопрос мог бы звучать так: «Почему был выбран Go для переписывания сервиса?» или «Какие недостатки предыдущего стека привели к выбору Go?»
Структура хорошего ответа для Go-разработчика:
1. Типичные причины перехода на Go:
Производительность и эффективность ресурсов
Предыдущий сервис на Python/Django или Node.js потреблял слишком много памяти при обработке большого количества параллельных соединений. Go даёт легковесные goroutine (стек ~2KB против ~1MB на поток в Java или ~8MB на поток ОС), что позволяет обрабатывать десятки тысяч одновременных подключений в одном процессе.
// Обработка 10K соединений — каждая в своей goroutine
for conn := range listener {
go handleConnection(conn) // лёгковесный, не OS-поток
}
Предсказуемая производительность (отсутствие GC-пауз как в JVM)
Go имеет низкую латенцию сборщика мусора, что критично для сервисов реального времени. В отличие от JVM с остановками мира (stop-the-world) при major GC, Go использует конкурентный трицветный маркер.
Статическая типизация и компиляция
Переход с динамически типизированного языка (Python, Ruby, JavaScript) даёт:
- Раннее обнаружение ошибок на этапе компиляции.
- Один бинарный файл для деплоя — без зависимостей runtime.
- Лучшую поддержку IDE и рефакторинга.
2. Недостатки предыдущих подходов:
Node.js / Python — проблема с CPU-bound задачами
Node.js имеет однопоточный event loop. Тяжёлые вычисления блокируют обработку всех запросов. Go решает это через планировщик goroutine, который автоматически распределяет работу по OS-потокам.
// CPU-bound задача не блокирует другие запросы
func processImage(img []byte) []byte {
go func() {
// Тяжёлая обработка в отдельной goroutine
result := heavyComputation(img)
resultChan <- result
}()
}
Java — тяжёлый runtime и медленный старт
JVM требует сотни мегабайт памяти и секунды на запуск. Go-бинарник стартует за миллисекунды и потребляет мегабайты памяти — идеально для serverless и контейнеров.
3. Когда Go — не лучший выбор:
Честный разработчик признаёт ограничения Go:
- Быстрая прототипирование — Python/JavaScript быстрее в разработке.
- Data science / ML — экосистема Go значительно беднее.
- GUI-приложения — нет зрелых фреймворков.
4. Ключевой вывод:
Переход на Go оправдан, когда критичны: высокая конкурентность, низкая латенция, эффективное потребление ресурсов и простота деплоя. Решение должно быть основано на измерениях (профилирование, бенчмарки), а не на предпочтениях.
Вопрос 4. Почему был выбран именно Next.js, а не обычный React, в чём техническая сложность для поисковых роботов при работе с SPA?
Таймкод: 00:12:48
Ответ собеседника: Правильный. Заказчику крайне важна хорошая SEO-оптимизация. Поисковые роботы плохо обрабатывают динамический контент SPA-приложений на React, так как у них нет готовой HTML-страницы — она генерируется динамически на клиенте. Next.js решает эту проблему благодаря серверному рендерингу (SSR): HTML-страница уже сгенерирована на сервере и может быть распарсена поисковым роботом.
Правильный ответ:
Ответ кандидата точный и полный. Для позиции Go-разработчика аналогичный вопрос мог бы касаться выбора архитектурного подхода для backend-сервиса — например, почему был выбран именно REST API, а не gRPC, или почему микросервисы вместо монолита.
Структура хорошего ответа для Go-разработчика на тему выбора архитектурного подхода:
1. Проблема «чистого» HTTP-сервера на net/http
Стандартный net/http в Go даёт базовый роутер без middleware, параметризованных маршрутов и встроенной валидации. Для production-сервиса этого недостаточно — приходится писать boilerplate для логирования, аутентификации, обработки ошибок.
2. Почему выбран конкретный подход (пример: gRPC вместо REST):
Проблема REST для межсервисного взаимодействия:
- Нет строгого контракта — ошибки обнаруживаются только в runtime.
- Избыточность данных (JSON) — больше трафика, медленная сериализация.
- Нет нативной поддержки двунаправленного потока (streaming).
Преимущества gRPC:
- Строгий контракт через Protocol Buffers — ошибки ловятся на этапе генерации кода.
- Бинарная сериализация — в 3–10 раз быстрее JSON, меньше размер сообщений.
- HTTP/2 — multiplexing, server push, эффективное использование соединений.
- Поддержка четырёх паттернов: unary, server streaming, client streaming, bidirectional streaming.
// user.proto
service UserService {
rpc GetUser(GetUserRequest) returns (User);
rpc ListUsers(ListUsersRequest) returns (stream User);
rpc UpdateUsers(stream UpdateUserRequest) returns (UpdateSummary);
}
// Генерация кода: protoc --go_out=. --go-grpc_out=. user.proto
type UserServer struct {
pb.UnimplementedUserServiceServer
}
func (s *UserServer) GetUser(ctx context.Context, req *pb.GetUserRequest) (*pb.User, error) {
user, err := s.repo.FindByID(ctx, req.Id)
if err != nil {
return nil, status.Errorf(codes.NotFound, "user not found: %v", err)
}
return &pb.User{Id: user.ID, Name: user.Name}, nil
}
3. Когда gRPC — не лучший выбор:
- Публичный API для внешних клиентов — браузеры имеют ограниченную поддержку gRPC (нужен gRPC-Web прокси).
- Простые CRUD-операции без строгих требований к производительности.
- Команда без опыта работы с Protocol Buffers.
4. Ключевой принцип выбора:
Выбор технологии определяется не модой, а конкретными требованиями:
- Кто потребляет API (браузер, мобильное приложение, внутренние сервисы)?
- Какие требования к latency и throughput?
- Есть ли необходимость в streaming?
- Какова зрелость команды в данной технологии?
Хороший разработчик всегда называет альтернативы и объясняет, почему они были отклонены в пользу выбранного решения.
Вопрос 5. Как устроен механизм гидратации в Next.js — что происходит, когда HTML с сервера приходит на фронтенд и становится React-приложением?
Таймкод: 00:13:35
Ответ собеседника: Неполный. Кандидат слышал о понятии гидратации, но смог ответить только «в общих чертах» и не смог объяснить механизм подробнее. Ответ был неполным и не раскрыл технических деталей процесса гидратации.
Правильный ответ:
Что такое гидратация (Hydration):
Гидратация — это процесс «оживления» статического HTML, полученного с сервера, превращение его в интерактивное React-приложение на клиенте. React привязывает обработчики событий к существующим DOM-узлам, не пересоздавая их заново.
Пошаговый механизм:
1. Серверный этап (SSR):
Сервер выполняет React-компоненты и генерирует полный HTML через ReactDOMServer.renderToString().
// Сервер рендерит компонент в HTML
const html = ReactDOMServer.renderToString(<App />);
// Результат: <div id="root"><h1>Hello</h1><button>Click</button></div>
Next.js автоматически оборачивает это в полную HTML-страницу и добавляет скрипт с клиентским бандлом.
2. Отправка клиенту:
Браузер получает готовый HTML и отображает его сразу — пользователь видит контент до загрузки JavaScript. Это критично для FCP (First Contentful Paint) и SEO.
3. Загрузка JavaScript-бандла:
Браузер загружает bundle.js, который содержит React-код приложения.
4. Этап гидратации:
На клиенте вызывается ReactDOM.hydrate() вместо ReactDOM.render():
// Клиентский код
import { hydrateRoot } from 'react-dom/client';
import App from './App';
const root = hydrateRoot(document.getElementById('root'), <App />);
React не пересоздаёт DOM. Вместо этого он:
- Обходит существующий DOM-дерево.
- Сверяет виртуальное дерево (Virtual DOM) с реальным DOM.
- Привязывает обработчики событий к существующим узлам.
- Создаёт внутренние структуры React (fiber tree) для последующих обновлений.
5. Приложение становится интерактивным:
После гидратации все onClick, onChange и другие обработчики работают. Приложение ведёт себя как обычное SPA.
Проблемы и подводные камни:
Hydration mismatch (рассинхронизация):
Если HTML с сервера не совпадает с тем, что ожидает React на клиенте, возникает ошибка:
Warning: Text content did not match. Server: "Hello" Client: "World"
Причины: использование Date.now(), Math.random(), браузерных API (window, document) во время рендеринга. Решение — использовать useEffect для клиентской логики.
Стоимость гидратации:
Гидратация блокирует main thread. Для больших страниц это увеличивает TTI (Time to Interactive). Решения:
- Selective Hydration (React 18): приоритетная гидратации видимых компонентов.
- Streaming SSR: HTML отправляется частями через
renderToPipeableStream. - React Server Components: часть компонентов никогда не отправляется на клиент.
Аналогия в backend-разработке на Go:
Для Go-разработчика гидратацию можно сравнить с процессом graceful restart сервера: новый процесс загружает состояние (как React загружает Virtual DOM), сверяет его с текущим (как React сверяет с HTML), и начинает обрабатывать запросы (как React начинает обрабатывать события). Ключевая идея — не пересоздавать с нуля, а подключиться к существующему состоянию.
Вопрос 6. Как работала CMS через Google Таблицы — каким образом контент из Google Таблиц попадал на frontend, был ли промежуточный сервер?
Таймкод: 00:14:40
Ответ собеседника: Правильный. Задача заключалась в том, чтобы контент со страниц (текст в HTML) мог менять человек без знания кода. Заказчик настоял на Google Таблицах. Использовался markdown для форматирования текста. Серверная часть Next.js (serverless функции/API routes) обращалась к Google Таблицам при загрузке страницы, получала данные, сохраняла в кэш (JSON-файл с текстом по ключам). Данные прокидывались через пропсы. Промежуточного отдельного сервера не было — всё работало на serverless-функциях Next.js, деплоенных на Vercel.
Правильный ответ:
Ответ кандидата технически полный и демонстрирует понимание архитектуры serverless-приложений. Для позиции Go-разработчика аналогичный вопрос мог бы касаться построения собственного headless CMS или интеграции с внешними системами управления контентом.
Структура хорошего ответа для Go-разработчика на тему CMS/контент-пайплайна:
1. Архитектура контент-пайплайна на Go:
Типичная схема: внешний источник данных → промежуточный сервис → кэш → API для клиентов.
2. Реализация на Go:
Сервис синхронизации контента:
package main
import (
"context"
"encoding/json"
"log"
"time"
"github.com/go-redis/redis/v8"
)
type ContentService struct {
source ContentSource // интерфейс для Google Sheets, Airtable, Strapi и т.д.
cache *redis.Client
ttl time.Duration
}
type ContentSource interface {
FetchAll(ctx context.Context) (map[string]string, error)
}
func (s *ContentService) Sync(ctx context.Context) error {
data, err := s.source.FetchAll(ctx)
if err != nil {
return err
}
pipe := s.cache.Pipeline()
for key, value := range data {
pipe.Set(ctx, "content:"+key, value, s.ttl)
}
_, err = pipe.Exec(ctx)
return err
}
func (s *ContentService) GetContent(ctx context.Context, key string) (string, error) {
val, err := s.cache.Get(ctx, "content:"+key).Result()
if err == redis.Nil {
// Cache miss — синхронизация и повторная попытка
if syncErr := s.Sync(ctx); syncErr != nil {
return "", syncErr
}
return s.cache.Get(ctx, "content:"+key).Result()
}
return val, err
}
Периодическая синхронизация:
func (s *ContentService) StartSyncLoop(ctx context.Context, interval time.Duration) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
if err := s.Sync(ctx); err != nil {
log.Printf("sync error: %v", err)
}
case <-ctx.Done():
return
}
}
}
3. Ключевые решения:
Кэширование в Redis:
- Избегаем запросов к внешнему API на каждый клиентский запрос.
- TTL контролирует свежесть данных.
- Pipeline снижает round-trips до Redis.
Graceful degradation:
- Если Google Sheets API недоступен, сервис продолжает отдавать данные из кэша.
- Метрики и алерты на случай долгой недоступности источника.
Webhook вместо polling:
- Для мгновенного обновления можно настроить Google Apps Script, который отправляет webhook при изменении таблицы.
- Go-сервис принимает webhook и инвалидирует кэш.
func (s *ContentService) HandleWebhook(w http.ResponseWriter, r *http.Request) {
s.cache.FlushDB(r.Context())
go s.Sync(r.Context())
w.WriteHeader(http.StatusAccepted)
}
4. Сравнение подходов:
| Подход | Плюсы | Минусы |
|---|---|---|
| Serverless (Vercel) | Простота деплоя, автоскейлинг | Cold start, ограничения по времени выполнения |
| Go-сервис + Redis | Полный контроль, предсказуемая производительность | Нужно управлять инфраструктурой |
| Готовый headless CMS (Strapi, Contentful) | Богатый API, UI для редакторов | Стоимость, vendor lock-in |
5. Ключевой вывод:
Выбор архитектуры CMS зависит от требований к свежести данных, нагрузке и ресурсам команды. Go-сервис с Redis-кэшем — оптимальный выбор для высоконагруженных систем, где важны предсказуемая латенция и контроль над инфраструктурой.
Вопрос 7. Написать функцию Delay, которая возвращает промис с задержкой на указанное количество миллисекунд.
Таймкод: 00:18:33
Ответ собеседника: Неполный. Кандидат начал двигаться в правильном направлении, создал промис, но не смог вспомнить синтаксис конструктора Promise и не смог самостоятельно завершить реализацию функции. После подсказки интервьюера код был дописан с помощью наводящих вопросов. Задача в итоге решена, но с существенной помощью.
Правильный ответ:
Это классическая задача на знание асинхронного программирования. Для позиции Go-разработчика аналогом будет задача на работу с каналами и контекстами. Рассмотрим оба варианта.
1. JavaScript-реализация (как было задано):
function delay(ms) {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
// Использование
await delay(1000); // пауза 1 секунда
console.log('Done');
2. Go-реализация (аналогичная задача для Go-разработчика):
Задача: написать функцию Delay, которая блокирует выполнение на указанное время, но может быть отменена через контекст.
package main
import (
"context"
"fmt"
"time"
)
// Delay блокирует выполнение на указанную длительность.
// Возвращает ошибку, если контекст был отменён до истечения таймера.
func Delay(ctx context.Context, d time.Duration) error {
select {
case <-time.After(d):
return nil
case <-ctx.Done():
return ctx.Err()
}
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
fmt.Println("Start")
if err := Delay(ctx, 5*time.Second); err != nil {
fmt.Printf("Cancelled: %v\n", err) // context deadline exceeded
} else {
fmt.Println("Done")
}
}
3. Продвинутый вариант с каналом (аналог Promise):
// Delay возвращает канал, который закроется через указанное время.
// Аналог Promise, который resolve через ms миллисекунд.
func Delay(d time.Duration) <-chan struct{} {
ch := make(chan struct{})
go func() {
time.Sleep(d)
close(ch)
}()
return ch
}
// Использование
<-Delay(1 * time.Second) // ждём закрытия канала
fmt.Println("Done")
4. Важные нюансы в Go:
Отменяемый delay через context:
func Delay(ctx context.Context, d time.Duration) error {
timer := time.NewTimer(d)
defer timer.Stop()
select {
case <-timer.C:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
Использование time.After vs time.NewTimer:
// time.After — утечка таймера до его срабатывания (нельзя остановить раньше)
select {
case <-time.After(d): // таймер в любом случае отработает
}
// time.NewTimer — можно остановить, освободив ресурсы
timer := time.NewTimer(d)
select {
case <-timer.C:
case <-ctx.Done():
timer.Stop() // освобождаем ресурс
}
5. Ключевой вывод:
Задача проверяет базовое понимание асинхронности. В JavaScript — это Promise и event loop. В Go — это каналы, контексты и горутины. Для Go-разработчика критически важно понимать, что time.After создаёт утечку таймера, если он не сработал, и уметь использовать context для отмены длительных операций.
Вопрос 8. Реализовать функцию createCancelablePromise, которая оборачивает промис и добавляет метод cancel для отмены (перевода промиса в состояние rejected).
Таймкод: 00:34:57
Ответ собеседника: Неполный. Кандидат испытывал затруднения с пониманием условия задачи и архитектурой решения. Не смог самостоятельно определить, что функция принимает callback с resolve/reject, а не готовый промис. Реализация не была завершена — кандидат запутался в сигнатуре функции и не смог корректно написать метод cancel. Задача не решена самостоятельно.
Правильный ответ:
JavaScript-реализация:
function createCancelablePromise(executor) {
let rejectFn;
let isCancelled = false;
const promise = new Promise((resolve, reject) => {
rejectFn = reject;
executor(
(value) => {
if (!isCancelled) {
resolve(value);
}
},
(reason) => {
if (!isCancelled) {
reject(reason);
}
}
);
});
promise.cancel = (reason = 'Cancelled') => {
if (!isCancelled) {
isCancelled = true;
rejectFn(new Error(reason));
}
};
return promise;
}
// Использование
const p = createCancelablePromise((resolve) => {
setTimeout(() => resolve('Done'), 5000);
});
setTimeout(() => p.cancel(), 1000);
p.catch((err) => console.log(err.message)); // "Error: Cancelled"
Ключевые моменты реализации:
executor— это callback, который принимаетresolveиreject(стандартная сигнатура конструктора Promise).- Сохраняем ссылку на
rejectиз замыкания, чтобы вызвать его изcancel. - Флаг
isCancelledпредотвращает повторный вызов reject/resolve после отмены.
Go-аналог с использованием context:
В Go отмена асинхронных операций реализуется через context.Context — это идиоматический подход.
package main
import (
"context"
"errors"
"fmt"
"time"
)
// CancelableOperation оборачивает функцию с поддержкой отмены.
// Аналог createCancelablePromise в JavaScript.
type CancelableOperation[T any] struct {
ctx context.Context
cancel context.CancelFunc
result <-chan T
err <-chan error
}
func NewCancelableOperation[T any](
fn func(ctx context.Context) (T, error),
) *CancelableOperation[T] {
ctx, cancel := context.WithCancel(context.Background())
resultCh := make(chan T, 1)
errCh := make(chan error, 1)
go func() {
result, err := fn(ctx)
if err != nil {
errCh <- err
return
}
resultCh <- result
}()
return &CancelableOperation[T]{
ctx: ctx,
cancel: cancel,
result: resultCh,
err: errCh,
}
}
func (op *CancelableOperation[T]) Cancel() {
op.cancel()
}
func (op *CancelableOperation[T]) Wait() (T, error) {
select {
case result := <-op.result:
return result, nil
case err := <-op.err:
var zero T
return zero, err
}
}
// Использование
func main() {
op := NewCancelableOperation(func(ctx context.Context) (string, error) {
select {
case <-time.After(5 * time.Second):
return "Done", nil
case <-ctx.Done():
return "", ctx.Err()
}
})
// Отменяем через 1 секунду
time.AfterFunc(1*time.Second, op.Cancel)
result, err := op.Wait()
if err != nil {
fmt.Printf("Cancelled: %v\n", err) // context canceled
} else {
fmt.Println(result)
}
}
Более простой вариант с каналом:
func createCancelableTask(doWork func() error) (cancel func(), wait func() error) {
ctx, cancel := context.WithCancel(context.Background())
errCh := make(chan error, 1)
go func() {
select {
case errCh <- doWork():
case <-ctx.Done():
errCh <- ctx.Err()
}
}()
return cancel, func() error { return <-errCh }
}
// Использование
cancel, wait := createCancelableTask(func() error {
time.Sleep(5 * time.Second)
return nil
})
go func() {
time.Sleep(1 * time.Second)
cancel()
}()
err := wait() // context canceled
Сравнение подходов:
| Аспект | JavaScript | Go |
|---|---|---|
| Механизм отмены | Ручной reject через замыкание | context.WithCancel |
| Состояние | Флаг isCancelled | ctx.Done() канал |
| Идиоматичность | Нестандартный паттерн | Стандартный подход в Go |
| Гарантии | Зависит от реализации | Встроено в язык |
Ключевой вывод:
Задача проверяет понимание замыканий и управления состоянием асинхронных операций. В Go аналогичная функциональность достигается через context — это фундаментальный механизм, который должен знать каждый Go-разработчик. Умение отменять длительные операции через контекст — обязательный навык для production-кода.
Вопрос 9. Какие основные хуки React вы знаете и для чего они используются (useState, useEffect, useMemo, useCallback)?
Таймкод: 00:50:00
Ответ собеседника: Неполный. Кандидат назвал useState для хранения состояния, useEffect для изменения DOM-узлов (что не совсем точно — useEffect для побочных эффектов), useMemo для мемоизации вычисленных данных. Упомянул useCallback для мемоизации колбэков, но не смог объяснить разницу между useMemo и useCallback и их практическое применение. Знание хуков поверхностное.
Правильный ответ:
Основные хуки React:
1. useState — управление состоянием
Хук для добавления локального состояния в функциональный компонент. Возвращает массив из текущего значения и функции для его обновления.
const [count, setCount] = useState(0);
const [user, setUser] = useState({ name: '', email: '' });
// Ленивая инициализация — вычисление начального значения только при первом рендере
const [data, setData] = useState(() => expensiveComputation());
2. useEffect — побочные эффекты
Выполняет побочные эффекты после рендера. Заменяет жизненный цикл классовых компонентов (componentDidMount, componentDidUpdate, componentWillUnmount).
// Выполняется после каждого рендера
useEffect(() => {
document.title = `Count: ${count}`;
});
// Выполняется только при монтировании (пустой массив зависимостей)
useEffect(() => {
fetchData();
}, []);
// Выполняется при изменении зависимостей + cleanup при размонтировании
useEffect(() => {
const subscription = eventBus.subscribe(handler);
return () => subscription.unsubscribe(); // cleanup
}, [handler]);
Важно: useEffect НЕ предназначен для изменения DOM напрямую — это побочный эффект, который выполняется после того, как React обновил DOM.
3. useMemo — мемоизация вычислений
Кэширует результат вычисления и пересчитывает его только при изменении зависимостей. Используется для оптимизации дорогих вычислений.
const sortedItems = useMemo(() => {
return items.sort((a, b) => a.price - b.price);
}, [items]);
4. useCallback — мемоизация функций
Кэширует саму функцию (ссылку), а не результат вычисления. Используется для предотвращения лишних рендеров дочерних компонентов.
const handleClick = useCallback(() => {
setCount(c => c + 1);
}, []);
// Без useCallback — новая ссылка на функцию при каждом рендере
// С useCallback — та же ссылка, пока зависимости не изменились
Разница между useMemo и useCallback:
| Хук | Что мемоизирует | Когда использовать |
|---|---|---|
| useMemo | Результат вычисления (значение) | Дорогие вычисления, фильтрация списков |
| useCallback | Ссылку на функцию | Передача колбэков в оптимизированные дочерние компоненты |
// useMemo возвращает значение
const total = useMemo(() => items.reduce((sum, i) => sum + i.price, 0), [items]);
// useCallback возвращает функцию (эквивалент useMemo для функций)
const handleSubmit = useCallback((data) => {
api.save(data);
}, []);
// Это то же самое, что:
// const handleSubmit = useMemo(() => (data) => api.save(data), []);
Дополнительные важные хуки:
useRef — хранение мутабельного значения без ре-рендера:
const inputRef = useRef(null);
const renderCount = useRef(0);
renderCount.current += 1; // не вызывает ре-рендер
useReducer — сложное состояние:
const [state, dispatch] = useReducer(reducer, initialState);
// Аналогично Redux — удобно для сложной логики обновления состояния
Аналогия для Go-разработчика:
Для Go-разработчика хуки React можно сравнить с паттернами управления состоянием:
useState— как переменная с getter/setter.useEffect— какinit()иcleanup()в пакете, илиdeferс замыканием.useMemo— как кэш с инвалидацией по ключам (аналогsync.Onceс зависимостями).useCallback— как сохранение ссылки на функцию для предотвращения аллокаций.
Ключевой вывод:
Понимание хуков критично для фронтенд-разработки. Для Go-разработчика важно понимать, что управление состоянием и побочными эффектами — универсальная концепция, которая существует в любом языке и фреймворке, просто реализуется через разные механизмы (хуки, каналы, контексты, middleware).
Вопрос 10. Для чего используется useRef, какие данные можно в нём хранить и в чём отличие от useState?
Таймкод: 00:55:01
Ответ собеседника: Правильный. Кандидат правильно ответил, что useRef используется для получения прямого доступа к DOM-узлам. После наводящих вопросов интервьюера выяснилось, что в useRef можно хранить любые данные (числа, строки, объекты, массивы, функции), а не только ссылки на DOM-узлы. Ключевое отличие от useState: изменение значения в useRef не приводит к перерисовке компонента, а useState запускает цикл перерисовки. Кандидат признал, что не знал об этом сценарии использования.
Правильный ответ:
useRef — полное описание:
1. Два основных сценария использования:
Доступ к DOM-элементам:
function TextInput() {
const inputRef = useRef(null);
const focusInput = () => {
inputRef.current.focus(); // прямой доступ к DOM-узлу
};
return (
<>
<input ref={inputRef} type="text" />
<button onClick={focusInput}>Focus</button>
</>
);
}
Хранение мутабельного значения без ре-рендера:
function Timer() {
const [count, setCount] = useState(0);
const intervalRef = useRef(null);
const renderCount = useRef(0);
// Считаем количество рендеров без вызова новых рендеров
renderCount.current += 1;
console.log(`Rendered ${renderCount.current} times`);
const start = () => {
intervalRef.current = setInterval(() => {
setCount(c => c + 1);
}, 1000);
};
const stop = () => {
clearInterval(intervalRef.current);
};
useEffect(() => {
return () => clearInterval(intervalRef.current); // cleanup
}, []);
return <div>{count}</div>;
}
2. Что можно хранить в useRef:
Любые данные: числа, строки, объекты, массивы, функции, DOM-узлы, таймеры, подписки.
const ref = useRef({
count: 0,
timer: null,
data: [],
handler: () => {},
element: null
});
3. Ключевые отличия от useState:
| Характеристика | useRef | useState |
|---|---|---|
| Триггерит ре-рендер | Нет | Да |
| Доступ к значению | .current | Напрямую |
| Когда обновляется | Синхронно | Асинхронно (батчинг) |
| Сохраняется между рендерами | Да | Да |
| Назначение | Хранение мутабельных данных, DOM-доступ | Управление состоянием UI |
4. Типичные паттерны использования useRef:
Предыдущее значение пропса или состояния:
function usePrevious(value) {
const ref = useRef();
useEffect(() => {
ref.current = value;
});
return ref.current; // значение из предыдущего рендера
}
Флаг для предотвращения двойного вызова (StrictMode):
const initialized = useRef(false);
useEffect(() => {
if (initialized.current) return;
initialized.current = true;
fetchData();
}, []);
Аналогия для Go-разработчика:
useRef в React — это аналог переменной в замыкании Go-горутины: значение сохраняется между вызовами, но изменение не запускает никакого «цикла обновления». В отличие от канала или sync.Cond, которые уведомляют подписчиков об изменениях.
// Go-аналог useRef: значение в замыкании горутины
func counter() func() int {
count := 0 // как ref.current
return func() int {
count++
return count
}
}
Ключевой вывод:
useRef — это контейнер для мутабельных данных, которые живут на протяжении всего жизненного цикла компонента, но не влияют на его рендеринг. Это мощный инструмент для хранения таймеров, предыдущих значений, флагов и DOM-ссылок без лишних ре-рендеров.
Вопрос 11. Реализовать кастомный хук useCallbackInterval, который вызывает переданный колбэк с заданным интервалом и позволяет остановить выполнение.
Таймкод: 01:01:35
Ответ собеседника: Неполный. Кандидат начал реализацию хука, правильно деструктурировал параметры (callback, interval), использовал setInterval. Однако не смог самостоятельно определить необходимость использования useEffect для управления жизненным циклом интервала и cleanup-функции для очистки. Задача не завершена — кандидат не реализовал функцию остановки интервала и не добавил зависимости в useEffect. Реализация требует существенной помощи.
Правильный ответ:
JavaScript-реализация кастомного хука:
function useCallbackInterval(callback, interval) {
const savedCallback = useRef(callback);
const [isRunning, setIsRunning] = useState(true);
// Сохраняем актуальный колбэк при каждом рендере
useEffect(() => {
savedCallback.current = callback;
}, [callback]);
// Управление жизненным циклом интервала
useEffect(() => {
if (!isRunning || interval === null) {
return;
}
const id = setInterval(() => savedCallback.current(), interval);
// Cleanup при размонтировании или изменении зависимостей
return () => clearInterval(id);
}, [isRunning, interval]);
const stop = useCallback(() => setIsRunning(false), []);
const start = useCallback(() => setIsRunning(true), []);
return { stop, start, isRunning };
}
// Использование
function Timer() {
const [count, setCount] = useState(0);
const { stop, isRunning } = useCallbackInterval(() => {
setCount(c => c + 1);
}, 1000);
return (
<div>
<p>{count}</p>
<button onClick={stop} disabled={!isRunning}>
Stop
</button>
</div>
);
}
Ключевые моменты реализации:
useRefдля хранения актуального колбэка — замыкание всегда вызывает свежую версию.useEffectс cleanup (clearInterval) — предотвращает утечку таймеров.- Возврат
stopиstart— управление извне.
Go-аналог с использованием context и ticker:
package main
import (
"context"
"fmt"
"time"
)
// CallbackInterval вызывает fn с заданным интервалом до отмены контекста.
type CallbackInterval struct {
ctx context.Context
cancel context.CancelFunc
done chan struct{}
}
func NewCallbackInterval(ctx context.Context, fn func(), interval time.Duration) *CallbackInterval {
ctx, cancel := context.WithCancel(ctx)
done := make(chan struct{})
go func() {
defer close(done)
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
fn()
case <-ctx.Done():
return
}
}
}()
return &CallbackInterval{
ctx: ctx,
cancel: cancel,
done: done,
}
}
func (ci *CallbackInterval) Stop() {
ci.cancel()
<-ci.done // ждём завершения горутины
}
func (ci *CallbackInterval) IsRunning() bool {
select {
case <-ci.ctx.Done():
return false
default:
return true
}
}
// Использование
func main() {
count := 0
interval := NewCallbackInterval(context.Background(), func() {
count++
fmt.Println("Tick:", count)
}, time.Second)
time.Sleep(3500 * time.Millisecond)
interval.Stop()
fmt.Println("Stopped. Final count:", count)
}
Более простой вариант с каналом:
func startInterval(fn func(), interval time.Duration) (stop func()) {
ctx, cancel := context.WithCancel(context.Background())
go func() {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
fn()
case <-ctx.Done():
return
}
}
}()
return func() { cancel() }
}
// Использование
stop := startInterval(func() {
fmt.Println("tick")
}, time.Second)
time.Sleep(3 * time.Second)
stop()
Сравнение подходов:
| Аспект | React (JavaScript) | Go |
|---|---|---|
| Механизм интервала | setInterval | time.Ticker |
| Механизм остановки | useState + useEffect cleanup | context.WithCancel |
| Утечка ресурсов | Забытый clearInterval | Забытый cancel + goroutine leak |
| Гарантия остановки | Cleanup в useEffect | defer ticker.Stop() + ctx.Done() |
Ключевой вывод:
Задача проверяет понимание жизненного цикла асинхронных операций и предотвращения утечек ресурсов. В React — это cleanup в useEffect. В Go — это context.WithCancel и defer ticker.Stop(). Оба подхода требуют явного освобождения ресурсов, иначе возникают утечки (таймеров в JS, горутин в Go).
Вопрос 12. Где нельзя использовать хуки React и почему (циклы, условия, обычные функции)?
Таймкод: 01:08:02
Ответ собеседника: Правильный. Кандидат правильно ответил, что хуки нельзя использовать в циклах и условиях, потому что количество вызовов хуков должно быть фиксировано в течение жизни компонента. Если количество итераций цикла неизвестно заранее, реакт упадёт. Также хуки можно использовать только в компонентах и других кастомных хуках, но не в обычных функциях.
Правильный ответ:
Правила хуков (Rules of Hooks) — полное объяснение:
1. Нельзы вызывать хуки внутри циклов, условий или вложенных функций.
React внутренне хранит состояние хуков в связном списке (linked list) в порядке вызова. На каждом рендере React проходит по этому списку и сопоставляет каждый вызов хука с его записью по порядковому номеру.
// НЕПРАВИЛЬНО — условный вызов хука
function Component({ show }) {
if (show) {
const [value, setValue] = useState(''); // ❌ Нарушение правила
}
return <div>{value}</div>;
}
// НЕПРАВИЛЬНО — хук в цикле
function Component({ items }) {
items.forEach(item => {
useEffect(() => { // ❌ Нарушение правила
console.log(item);
});
});
}
Что происходит внутри React:
Рендер 1: useState('') → позиция 0, useEffect → позиция 1
Рендер 2: useEffect → позиция 0 (ожидалось useState!) → 💥 Ошибка
React не может определить, какой хук соответствует какой записи, если порядок или количество вызовов меняется между рендерами.
2. Хуки можно вызывать только на верхнем уровне функционального компонента или кастомного хука.
// ПРАВИЛЬНО — компонент
function MyComponent() {
const [state, setState] = useState(0); // ✅ Верхний уровень
return <div>{state}</div>;
}
// ПРАВИЛЬНО — кастомный хук (имя начинается с "use")
function useCustomHook() {
const [data, setData] = useState(null); // ✅ Верхний уровень хука
return data;
}
// НЕПРАВИЛЬНО — обычная функция
function regularFunction() {
const [value, setValue] = useState(0); // ❌ Не компонент и не хук
}
3. Почему именно такой дизайн:
React выбрал подход с порядковым хранением хуков вместо словаря (Map) по нескольким причинам:
- Простота реализации: связный список — минимальная накладная стоимость.
- Производительность: не нужны хеш-таблицы или строковые ключи.
- Статический анализ: ESLint-правило
react-hooks/rules-of-hooksможет проверить корректность на этапе компиляции.
4. Аналогия для Go-разработчика:
Правила хуков похожи на ограничения в Go:
- Инициализация в init(): порядок вызова
init()в пакетах детерминирован, как и порядок хуков. - defer в циклах: так же проблематичен, как хуки в циклах — порядок выполнения становится непредсказуемым.
// Аналогия: defer в цикле — накапливается, порядок LIFO
for i := 0; i < 3; i++ {
defer fmt.Println(i) // Выведет: 2, 1, 0
}
5. Обход ограничений (легальные способы):
Вместо условного хука — условная логика внутри хука:
// ВМЕСТО условного хука:
// if (condition) useEffect(...)
// ИСПОЛЬЗУЙТЕ условие внутри хука:
useEffect(() => {
if (condition) {
// логика
}
}, [condition]);
Вместо хука в цикле — отдельный компонент:
// ВМЕСТО хука в цикле:
// items.forEach(item => useEffect(...))
// ИСПОЛЬЗУЙТЕ отдельный компонент:
function Item({ item }) {
useEffect(() => {
console.log(item);
}, [item]);
return <div>{item.name}</div>;
}
function List({ items }) {
return items.map(item => <Item key={item.id} item={item} />);
}
Ключевой вывод:
Правила хуков — не произвольное ограничение, а следствие внутренней архитектуры React. Понимание того, почему эти правила существуют (связный список состояний), демонстрирует глубокое понимание фреймворка, а не поверхностное запоминание ограничений.
Вопрос 13. В чём разница между setTimeout и setInterval?
Таймкод: 01:10:54
Ответ собеседника: Правильный. Кандидат правильно объяснил: setTimeout вызывает колбэк один раз через указанное время, а setInterval вызывает колбэк многократно через указанный интервал. setInterval не учитывает реальное время выполнения задач и может накладываться, поэтому для точного контроля рекомендуется использовать рекурсивный setTimeout вместо setInterval.
Правильный ответ:
Полное сравнение setTimeout и setInterval:
1. Базовое различие:
| Характеристика | setTimeout | setInterval |
|---|---|---|
| Количество вызовов | Один раз | Многократно |
| Сигнатура | setTimeout(fn, delay) | setInterval(fn, delay) |
| Отменка | clearTimeout(id) | clearInterval(id) |
| Возвращает | ID таймера | ID таймера |
// setTimeout — однократный вызов
const timeoutId = setTimeout(() => {
console.log('Выполнится один раз через 1 секунду');
}, 1000);
// setInterval — многократный вызов
const intervalId = setInterval(() => {
console.log('Выполняется каждую секунду');
}, 1000);
// Отменка
clearTimeout(timeoutId);
clearInterval(intervalId);
2. Проблема setInterval — наложение вызовов:
Если колбэк выполняется дольше интервала, setInterval не ждёт завершения и ставит следующий вызов в очередь.
// Проблема: колбэк выполняется 1500ms, интервал 1000ms
setInterval(() => {
heavyTask(); // занимает 1500ms
}, 1000);
// Результат: задачи накладываются друг на друга
3. Решение — рекурсивный setTimeout:
function recursiveTimeout() {
heavyTask().then(() => {
// Следующий вызов только после завершения текущего
setTimeout(recursiveTimeout, 1000);
});
}
recursiveTimeout();
4. Проблема точности:
Оба метода не гарантируют точное время выполнения. Минимум задержка — 4ms для вложенных вызовов (HTML spec), и event loop может задержать выполнение.
// Минимум 4ms для вложенных setTimeout
setTimeout(() => {
setTimeout(() => {
// Минимум 4ms, даже если указано 0
}, 0);
}, 0);
5. Go-аналог:
В Go аналогом является time.AfterFunc (однократный) и time.NewTicker (многократный):
package main
import (
"fmt"
"time"
)
func main() {
// setTimeout-аналог
time.AfterFunc(1*time.Second, func() {
fmt.Println("Однократный вызов")
})
// setInterval-аналог
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()
go func() {
for range ticker.C {
fmt.Println("Периодический вызов")
}
}()
// Рекурсивный setTimeout-аналог
var recursive func()
recursive = func() {
heavyTask()
time.AfterFunc(1*time.Second, recursive)
}
recursive()
time.Sleep(5 * time.Second)
}
func heavyTask() {
time.Sleep(100 * time.Millisecond)
}
6. Ключевые отличия в Go:
В Go time.Ticker имеет ту же проблему наложения, что и setInterval. Решение — использовать time.AfterFunc рекурсивно или time.Sleep в горутине после завершения задачи.
// Правильный подход в Go — ждём завершения задачи
go func() {
for {
start := time.Now()
doWork()
elapsed := time.Since(start)
wait := interval - elapsed
if wait > 0 {
time.Sleep(wait)
}
}
}()
Ключевой вывод:
Выбор между однократным и периодическим выполнением зависит от задачи. Для критичных к времени операций предпочтителен рекурсивный подход (setTimeout в JS, time.AfterFunc в Go), который гарантирует интервал между завершением текущей задачи и началом следующей.
Вопрос 14. Чем отличается стажёр от джуниора, и на что обращают внимание при найме джунов в компании?
Таймкод: 01:31:59
Ответ собеседника: Правильный. Интервьюер объяснил разницу: стажёр — это человек без коммерческого опыта, который имеет полное право на ошибку, за него отвечает руководитель. Джуниор — это уже боевая единица, которая может нести ответственность за задачи. Стажировки — лучший способ для людей без опыта быстро вырасти (Яндекс, Сбер, МТС, интеграторы). Компания нанимает мидлов и выше с рынка, а стажёров и джунов выращивает через бесплатные школы.
Правильный ответ:
Разница между стажёром и джуниором:
1. Стажёр (Intern):
- Нет коммерческого опыта или минимальный (пет-проекты, учебные задачи).
- Работает под постоянным наставничеством (mentor проверяет каждый PR).
- Имеет право на ошибку — за качество отвечает руководитель стажировки.
- Задачи: мелкие баги, документация, простые фичи с чётким ТЗ.
- Оценка: обучаемость, мотивация, базовые знания.
2. Джуниор (Junior Developer):
- Есть коммерческий опыт (обычно 0.5–2 года).
- Может самостоятельно решать задачи средней сложности.
- Несёт ответственность за свои задачи (дедлайны, качество кода).
- Задачи: фичи средней сложности, баги, участие в code review.
- Оценка: технические навыки, самостоятельность, качество кода.
3. Критерии найма джуниора — на что обращают внимание:
Технические навыки:
- Базовое владение стеком (для Go: синтаксис, конкурентность, стандартная библиотека).
- Понимание основ алгоритмов и структур данных.
- Умение работать с Git (branching, merging, pull requests).
- Базовые знания SQL и работы с базами данных.
Мягкие навыки (soft skills):
- Обучаемость — способность быстро усваивать новую информацию.
- Коммуникация — умение задавать вопросы, объяснять проблемы.
- Самостоятельность — попытка решить задачу перед обращением за помощью.
- Ответственность — выполнение обязательств, соблюдение дедлайнов.
Красные флаги при найме:
- Не может объяснить код, который написал.
- Не задаёт вопросы, когда не понимает задачу.
- Не знает основ языка, на котором позиционирует себя.
- Нет примеров кода (GitHub, пет-проекты).
4. Как компании выращивают джунов:
- Бесплатные школы/курсы (Яндекс.Практикум, Сбер, МТС) — отбор лучших выпускников.
- Стажировки с трудоустройством — 3–6 месяцев обучения + практика.
- Mentorship программы — выделенный наставник на первые 6–12 месяцев.
- Постепенное усложнение задач — от багов к фичам, от фич к архитектурным решениям.
5. Ключевой вывод:
Разница между стажёром и джуниором — не только в опыте, но и в уровне ответственности. Стажёр учится и имеет право на ошибку, джуниор уже должен быть самостоятельным. При найме джуна компании смотрят не только на текущие навыки, но и на потенциал роста — обучаемость, мотивацию и способность работать в команде.
