Задачи с кодом

Задачи на собеседовании frontend: 7 классических с решениями

Обновлено 7 мин чтения
Содержание
  1. Коротко
  2. Как дают задачи и что в них оценивают
  3. 1. debounce
  4. 2. throttle
  5. 3. Promise.all
  6. 4. deepEqual
  7. 5. EventEmitter
  8. 6. flatten
  9. 7. useDebounce на React
  10. Как готовиться к задачам
  11. Частые ошибки
  12. Чек-лист
  13. Вопросы из базы
  14. Как тренировать
  15. Частые вопросы
Коротко

Задачи на собеседовании frontend почти всегда из одного набора: debounce, throttle, Promise.all, deepEqual, EventEmitter, flatten и простой хук на React. Их дают не ради самого кода, а чтобы увидеть замыкания, асинхронность и рекурсию в деле. Ниже у каждой задачи рабочее решение, что в нём проверяют и какой вопрос прозвучит следом.

Как дают задачи и что в них оценивают

На frontend-собеседовании задачу обычно дают в общем редакторе или в песочнице и просят решать вслух. Время на одну задачу от 15 до 40 минут. Интервьюеру важен не только итоговый код. Он смотрит, как вы уточняете условие, с чего начинаете и как проверяете себя.

ЗадачаЧто проверяютТипичное продолжение
debounceЗамыкание, таймеры, this и аргументыДобавить cancel и вызов в начале паузы
throttleРаботу со временем, крайние вызовыНе потерять последний вызов
Promise.allПромисы, порядок результатов, ошибкиНаписать allSettled или ограничить параллельность
deepEqualРекурсию, типы, краевые случаиЦиклические ссылки, Date, Map
EventEmitterКлассы, хранение подписок, отпискуМетод once, отписка во время emit
flattenРекурсию и глубинуРешить без рекурсии, через стек
useDebounceЭффекты React и их очисткуОтменить запрос, который уже ушёл

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

1. debounce

Условие. Напишите функцию debounce(fn, ms). Она возвращает обёртку, которая вызывает fn только после того, как вызовы прекратились на ms миллисекунд. Пример из жизни: поиск, который не шлёт запрос на каждую букву.

function debounce(fn, ms) {
  let timer;
  return function (...args) {
    clearTimeout(timer);
    timer = setTimeout(() => fn.apply(this, args), ms);
  };
}

const search = debounce((q) => console.log('поиск:', q), 100);
search('р');
search('ре');
search('реа');
setTimeout(() => search('react'), 200);
Вывод
поиск: реа
поиск: react

Что проверяют. Таймер хранится в замыкании, а не в глобальной переменной. Стрелочная функция внутри setTimeout сохраняет this обёртки, поэтому метод объекта не потеряет контекст. Аргументы берутся из последнего вызова.

Уточнения, которые стоит задать. Вызывать в конце паузы или в начале? Нужен ли метод cancel? Что вернуть из обёртки? Полная версия с cancel и flush есть в задаче debounce с отменой.

2. throttle

Условие. throttle(fn, ms) вызывает fn не чаще раза в ms миллисекунд. Первый вызов проходит сразу, а последний вызов в окне не теряется: он выполнится в конце окна. Так обрабатывают прокрутку и изменение размера окна.

function throttle(fn, ms) {
  let last = 0;
  let timer = null;
  let lastArgs;
  return function (...args) {
    lastArgs = args;
    const wait = ms - (Date.now() - last);
    if (wait <= 0) {
      clearTimeout(timer);
      timer = null;
      last = Date.now();
      fn.apply(this, args);
    } else if (!timer) {
      timer = setTimeout(() => {
        last = Date.now();
        timer = null;
        fn.apply(this, lastArgs);
      }, wait);
    }
  };
}

const onScroll = throttle((n) => console.log('scroll', n), 100);
onScroll(1);
onScroll(2);
onScroll(3);
setTimeout(() => onScroll(4), 150);
Вывод
scroll 1
scroll 3
scroll 4

Что проверяют. Отличаете ли вы throttle от debounce. debounce ждёт тишины, throttle держит ровный темп. Частая ошибка: забыть последний вызов, и тогда интерфейс показывает устаревшее положение прокрутки.

Вариант с настройками leading и trailing разобран в задаче throttle с краями.

3. Promise.all

Условие. Реализуйте свой Promise.all. На вход массив промисов и обычных значений. На выходе промис с массивом результатов в исходном порядке. Первая ошибка сразу отклоняет общий промис.

function promiseAll(items) {
  return new Promise((resolve, reject) => {
    const list = Array.from(items);
    const results = new Array(list.length);
    let done = 0;
    if (list.length === 0) return resolve(results);
    list.forEach((item, i) => {
      Promise.resolve(item).then((value) => {
        results[i] = value;
        done += 1;
        if (done === list.length) resolve(results);
      }, reject);
    });
  });
}

const wait = (ms, v) => new Promise((r) => setTimeout(() => r(v), ms));
promiseAll([wait(30, 'a'), 'b', wait(10, 'c')]).then((r) => console.log(r));
promiseAll([wait(10, 1), Promise.reject(new Error('упал'))]).catch((e) => console.log('ошибка:', e.message));
promiseAll([]).then((r) => console.log(r));
Вывод
[]
ошибка: упал
[ 'a', 'b', 'c' ]

Что проверяют. Результат кладут по индексу, а не через push: иначе порядок зависит от того, кто ответил быстрее. Счётчик done нужен, потому что длина массива с дырками не говорит, сколько промисов завершилось. Обычные значения оборачивают в Promise.resolve. Пустой массив сразу даёт пустой результат.

Обратите внимание на порядок вывода. Пустой массив и ошибка печатаются раньше, чем результат с таймерами, потому что они обрабатываются в микрозадачах, а таймеры ждут своей очереди. Это повод заговорить про event loop, если интервьюер спросит. Потренировать задачу можно в promiseAll.

4. deepEqual

Условие. Сравните два значения глубоко: объекты и массивы равны, если равны все их поля.

function deepEqual(a, b) {
  if (Object.is(a, b)) return true;
  if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) return false;
  if (Array.isArray(a) !== Array.isArray(b)) return false;
  const keysA = Object.keys(a);
  const keysB = Object.keys(b);
  if (keysA.length !== keysB.length) return false;
  return keysA.every((k) => Object.hasOwn(b, k) && deepEqual(a[k], b[k]));
}

console.log(deepEqual({ a: [1, { b: 2 }] }, { a: [1, { b: 2 }] }));
console.log(deepEqual({ a: 1 }, { a: 1, b: undefined }));
console.log(deepEqual([1, 2], { 0: 1, 1: 2 }));
console.log(deepEqual(NaN, NaN));
Вывод
true
false
false
true

Что проверяют. null тоже имеет тип object, его отсекают отдельно. Массив и объект с теми же ключами не равны. Поле со значением undefined и отсутствующее поле различаются. Object.is считает NaN равным NaN, а обычное сравнение нет.

Дальше спросят про циклические ссылки. Ответ: храните пары уже сравниваемых объектов в WeakMap и не заходите в них повторно. Похожая логика нужна в deepClone с циклами. Базовую версию сравнения можно сдать в задаче deepEqual.

5. EventEmitter

Условие. Сделайте класс с методами on, off, once и emit. Подписчики события вызываются в порядке подписки.

class EventEmitter {
  #handlers = new Map();

  on(event, fn) {
    if (!this.#handlers.has(event)) this.#handlers.set(event, new Set());
    this.#handlers.get(event).add(fn);
    return () => this.off(event, fn);
  }

  off(event, fn) {
    this.#handlers.get(event)?.delete(fn);
  }

  once(event, fn) {
    const off = this.on(event, (...args) => {
      off();
      fn(...args);
    });
    return off;
  }

  emit(event, ...args) {
    for (const fn of [...(this.#handlers.get(event) ?? [])]) fn(...args);
  }
}

const bus = new EventEmitter();
const stop = bus.on('msg', (t) => console.log('a:', t));
bus.once('msg', (t) => console.log('b:', t));
bus.emit('msg', 1);
stop();
bus.emit('msg', 2);
console.log('конец');
Вывод
a: 1
b: 1
конец

Что проверяют. emit перебирает копию списка. Без копии once отписывается прямо во время обхода, и следующий подписчик может пропуститься. on возвращает функцию отписки: так удобнее в React-эффектах. Set не даст подписать одну и ту же функцию дважды, об этом стоит сказать вслух, если интервьюер ждёт повторов.

Задача целиком с тестами: EventEmitter.

6. flatten

Условие. Разверните вложенный массив на заданную глубину. Без глубины разворачивайте полностью.

function flatten(arr, depth = Infinity) {
  const out = [];
  for (const x of arr) {
    if (Array.isArray(x) && depth > 0) out.push(...flatten(x, depth - 1));
    else out.push(x);
  }
  return out;
}

console.log(flatten([1, [2, [3, [4]]], 5]));
console.log(flatten([1, [2, [3, [4]]]], 1));
Вывод
[ 1, 2, 3, 4, 5 ]
[ 1, 2, [ 3, [ 4 ] ] ]

Что проверяют. Рекурсию и параметр глубины. Встроенный arr.flat(depth) знать нужно, но писать его просят именно руками. Частое продолжение: решить без рекурсии через стек, чтобы очень глубокий массив не переполнил стек вызовов. Версии с глубиной: flattenDepth и flattenDeep.

7. useDebounce на React

Условие. Напишите хук, который возвращает значение с задержкой: поле поиска меняется сразу, а запрос уходит, когда пользователь перестал печатать.

function useDebounce<T>(value: T, ms: number): T {
  const [debounced, setDebounced] = useState(value);
  useEffect(() => {
    const id = setTimeout(() => setDebounced(value), ms);
    return () => clearTimeout(id);
  }, [value, ms]);
  return debounced;
}

function Search() {
  const [query, setQuery] = useState('');
  const q = useDebounce(query, 300);
  useEffect(() => {
    if (q) loadResults(q);
  }, [q]);
  return <input value={query} onChange={(e) => setQuery(e.target.value)} />;
}

Что проверяют. Очистку эффекта: при каждом новом значении старый таймер отменяется, поэтому срабатывает только последний. Список зависимостей полный. Следующий вопрос обычно про гонку ответов: запрос на «rea» может вернуться позже запроса на «react». Ответ: отменять старый запрос через AbortController в очистке эффекта или игнорировать устаревший ответ. Хук целиком: useDebounce.

Как готовиться к задачам

  1. Напишите каждую задачу из списка сами, без подсказок, и прогоните тесты. Чтение готового решения почти ничего не даёт.
  2. Через два-три дня напишите ещё раз с чистого листа. Если застряли, значит, решение было заучено, а не понято.
  3. Решайте вслух и с таймером: 20 минут на задачу. На собеседовании молчание пугает интервьюера сильнее, чем ошибка.
  4. После каждой задачи ответьте себе на вопрос «что спросят дальше» и допишите это продолжение.
Если задача не идёт, начните с простой рабочей версии и скажите это вслух. Работающий наивный вариант ценится больше, чем красивая идея без кода.

Частые ошибки

  • Сразу писать код, не уточнив условие: вызов в начале или в конце, что делать с ошибкой, какой порядок результатов
  • Хранить таймер или результаты в глобальной переменной, а не в замыкании
  • В Promise.all складывать результаты через push и терять порядок
  • Забыть про null, NaN и пустой массив
  • Молчать, пока пишете. Интервьюер не видит ход мысли и не может подсказать
  • Не проверить код на своём же примере перед тем, как сказать «готово»

Чек-лист

  • debounce и throttle пишутся за 10 минут и вы можете объяснить разницу одной фразой
  • Promise.all, а следом allSettled и any, без подглядывания
  • deepEqual с объяснением, почему null и NaN требуют отдельной проверки
  • EventEmitter с once и отпиской во время emit
  • flatten рекурсией и через стек
  • Один кастомный хук React с очисткой эффекта

Вопросы из базы Техпрогона

Частые вопросы по теме с ответом и разбором. Откройте любой и попробуйте ответить сами.

Как тренировать в Техпрогоне

  • Все задачи из статьи есть в каталоге JavaScript с автопроверкой: решение запускается на тестах прямо в браузере.
  • Лестница стресса: сначала задача «Наедине» с подсказками, потом «Вслух» с записью и «Под давлением» с таймером и уточняющими вопросами.
  • Билеты с зачётом по JavaScript: 20 вопросов на замыкания, промисы и event loop, зачёт при двух ошибках и меньше.
  • Бесплатно доступны 3 задачи с кодом в день и мок «Наедине». Остальные ступени и задачи без лимита в тарифе «Рывок».

Частые вопросы

Какие задачи чаще всего дают на собеседовании frontend?
Чаще всего debounce, throttle, свой Promise.all, глубокое сравнение или клонирование объектов, EventEmitter и разворачивание массива. На React добавляют кастомный хук или компонент с эффектом. Алгоритмические задачи тоже бывают, но реже и попроще, чем на backend.
Можно ли пользоваться документацией во время задачи?
Зависит от компании. Спросите в начале, это нормальный вопрос. Даже если можно, подглядывать в сигнатуру setTimeout допустимо, а искать готовое решение нет.
Что делать, если не помню, как решается задача?
Начните с простой рабочей версии и скажите, что потом улучшите. Проговаривайте, что уже понятно, а что нет. Интервьюер часто подсказывает, если видит ход мысли.
Нужно ли писать на TypeScript?
Если вакансия на TypeScript, могут попросить типы. Обычно хватает JavaScript, а типы добавляют по просьбе. Уточните в начале задачи.
Сколько задач нужно решить перед собеседованием?
Важнее не число, а то, что вы можете написать каждую задачу из этого списка с нуля через несколько дней. Обычно это 15-30 задач с повтором слабых.

Билеты с зачётом вместо чтения статей

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

Читайте также