Pull to refresh

Одна React-задача, демонстрирующая ключевые навыки на собеседовании

Level of difficultyEasy
Reading time7 min
Views21K
Фото из сериала Silicon Valley
Фото из сериала Silicon Valley

Ниже - пример того, как я обычно представляю (и детально разбираю) один из моих любимых вопросов по фронтенд-разработке на собеседовании. Он основан на моем опыте интервьюирования в крупных IT-компаниях. Этот вопрос посвящён созданию небольшого React-компонента, который асинхронно получает данные на основе пропса username. Он кажется простым, но на самом деле показывает много нюансов понимания кандидатом хуков React, сайд-эффектов, состояния гонки (race conditions) и компромиссов в дизайне. Приятного чтения!

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

Суть задачи на интервью

Перед вами один из возможных примеров использования компоненты <Profile>, который получает проп username и внутри делает запрос на некий API (например, fetchProfile(username)) – это некая абстракция: может быть GitHub, может быть корпоративный сервис, без разницы.

const App = () => {
  return (
    <Profile username="john_doe">
      {(user) => (user === null ? <Loading /> : <Badge info={user} />)}
    </Profile>
  );
};

Представьте, что вы разрабатываете библиотеку компонентов внутри большой компании, и этой библиотекой будут пользоваться другие команды (вполне реальный сценарий в больших IT-компаниях). Задача: написать реализацию компонента Profile, чтобы им удобно и ожидаемо могли пользоваться в самых разных контекстах.

import React, { useState, useEffect, useRef } from 'react';
import fetchProfile from 'somewhere'; 
// Это фиктивная функция, которая возвращает Promise,
// резолвящийся в объект пользователя

function Profile() {
  // Допишите здесь логику
}

Важный дисклеймер:

  • user === null ? <Loading /> : <Badge info={user} /> - это упрощённая проверка. В реальном мире сервер может вернуть null в ответ, и нам придётся делать дополнительную логику, чтобы корректно обрабатывать “нет данных” vs. “данные ещё загружаются”. Однако мы намеренно оставим такой код, чтобы посмотреть, заметит ли кандидат потенциальную проблему и предложит ли более надёжное решение (например, isLoading флаг).

  • Наш fetchProfile не даёт возможности вызвать abort(). Это сделано специально, чтобы проверить, знает ли кандидат про аборт запросов (AbortController) и как он будет рассуждать, если такой возможности нет.

Начинаем решение

Чаще всего кандидаты сначала пишут что-нибудь простое, используя функциональные компоненты и хуки:

import React, { useState, useEffect } from 'react';
import fetchProfile from 'profileApi'; // воображаемый модуль

const Profile = ({ username, children }) => {
  const [user, setUser] = useState(null);

  useEffect(() => {
    fetchProfile(username).then(setUser);
  }, []);

  return children(user);
};

Как ни странно, но на этом этапе у многих возникают сложности с пониманием того, что в children может быть функция, и её можно просто вызвать: children(user), даже не оборачивая во всякие <div>...</div> или <></>. Почему-то кандидатам с ними спокойнее.

Однако уже тут видно несколько типичных моментов:

  • Отсутствие зависимостей в useEffect.Часто люди забывают добавить username в массив зависимостей. Это значит, что если username поменяется, запрос на новый профиль не произойдёт.

  • Необработанные ошибки. А что если fetchProfile завершится ошибкой или вернёт null?

Уточняем детали

В интервью я обязательно спрашиваю: "А что, если проп username может динамически меняться? Например, пользователь кликает по списку пользователей?" Тогда кандидат обычно исправляет код, добавляя username в зависимости эффекта:

useEffect(() => {
  fetchProfile(username).then(setUser);
}, [username]);

Теперь, если username меняется, мы делаем новый запрос. Так понятнее. Но…

Race condition (гонка состояний)

Дальше я описываю сценарий: представьте, что в вашем приложении две панели. Слева - список пользователей, справа - <Profile username={currentUsername} />. Пользователь начинает быстро кликать то по одному, то по другому пользователю.

  • Запрос A уходит для username = 'alice'.

  • Тут же пользователь кликает на username = 'bob', отправляется запрос B.

  • Запрос B возвращается быстрее, мы записываем в state данные bob.

  • Потом запрос A (более медленный) тоже возвращается, и внезапно перезаписывает state данными пользователя Alice!

"Может быть тут какая либо проблема?". К счастью в основном ответ да - при таком кейсе у нас может отображаться неправильная информация. На экране написано “bob”, а по факту в компоненте данные “alice”.

Разбор типичных решений

Приведу несколько реальных подходов, которые я видел от кандидатов. Самые экзотические - типа очереди запросов - опустим :)

Локальная переменная вне компонента

Иногда пытаются сделать что-то вроде:

let lastUsernameFetched = null;
function Profile({ username, children }) {
  const [user, setUser] = useState(null);
  lastUsernameFetched = username;

  useEffect(() => {
    fetchProfileManaged(username).then((profile) => {
      if (lastUsernameFetched !== username) {
        setUser(profile);
      }
    });
  }, [username]);

  return children(user);
}

По сути, мы храним состояние (lastUsernameFetched) на уровне модуля. Но что, если на странице несколько экземпляров <Profile>? Придётся как-то разделять их по идентификаторам. Это далеко не лучшее решение…

Использование useRef для отслеживания текущего username

Иногда кандидаты придумывают хранить текущий username в useRef, чтобы при получении результата сравнивать, совпадает ли он со значением пропса. Кандидат начинает спрашивать про структуру ответа, и в этом месте мы обычно вводим предположение, что username в объекте профиля всё-таки есть. В результате вижу такое решение:

const Profile = ({ username, children }) => {
  const [user, setUser] = useState(null);
  const usernameRef = useRef(username);

  useEffect(() => {
    fetchProfile(username).then((profile) => {
      if (usernameRef.current === profile?.username) {
        setUser(profile);
      }
    });
  }, [username]);

  return children(user);
};

Почему-то часто встречал заблуждение, что useRef(username) всегда будет передавать в usernameRef актуальное значение пропса 🤷‍♂️ (хотя на самом деле это лишь начальное значение). После выяснения этого обстоятельства встречаются исправления в виде:

...
useEffect(() => {
  usernameRef.current = username;
}, [username]);
...

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

const Profile = ({ username, children }) => {
  const [user, setUser] = useState(null);
  const usernameRef = useRef(username);

  useEffect(() => {
    usernameRef.current = username;
    fetchProfile(username).then((profile) => {
      if (usernameRef.current === profile?.username) {
        setUser(profile);
      }
    });
  }, [username]);

  return children(user);
};

Отлично, идем дальше.

А если у нас в приложении две страницы, и пользователь уходит со страницы с <Profile> раньше, чем придёт ответ от fetchProfile будет ли тут какая-либо проблема?

"Да, будет", ведь компонент может быть размонтирован, а асинхронный вызов вернётся. Возникает сценарий, когда React ругается - “Can’t perform a React state update on an unmounted component…”.

Тогда нередко вижу такой решение:

...
useEffect(() => {
  return () => {
    usernameRef.current = null;
  }
}, []);
...

Это, как правило, вовсе не гарантирует, что setUser никогда не будет вызван (мало ли, если не хороший сервер вернёт null).

Идеальное решение

Часто самый простой подход (при отсутствии AbortController) - завести внутри useEffect переменную-флаг:

const Profile = ({ username, children }) => {
  const [user, setUser] = useState(null);

  useEffect(() => {
    let isLive = true;
    setUser(null);
    fetchProfile(username)
      .then((profile) => {
        if (isLive) {
          setUser(profile);
        }
      })
      .catch((err) => {
        // Здесь можно обсудить дополнительные аспекты обработки ошибок.
        // Если интересно, какие именно - пишите вопросы к статье :)
      });

    return () => {
      isLive = false;
    };
  }, [username]);

  return children(user);
};
  • Пока isLive = true, состояние обновляется при поступлении ответа

  • Если компонент размонтировался или username изменился (а значит, эффект сработает заново), переменная isLive сбрасывается в false. В результате старый запрос, вернувшийся с задержкой, не изменит состояние.

  • Таким образом, удаётся избежать гонки при обновлении состояния и предупредить возникновение ошибки в React при вызове setState на размонтированном компоненте.

  • Добавление блока catch наглядно показывает возможность обработки ошибок от сервера или сети. При необходимости можно обсудить способы уведомления пользователя и логирования таких ошибок.

Примечание: для упрощения здесь не рассматривается сценарий, когда username или children могут оказаться "пустыми" (например, null, undefined или пустая строка), а также ситуация, когда children не является функцией. Однако здорово, если кандидат обратит внимание и на эти нюансы.

Почему мне нравится этот вопрос

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

Он проверяет базовые знания React: хуки, сайд-эффекты, “cleanup” при размонтировании, изменение пропсов со временем - всё это ключевые концепции во фронтенд-разработке на React.

Он выявляет важные крайние случаи:

  • Проп username может меняться, пока запрос ещё выполняется.

  • При уходе со страницы до завершения запроса может случиться попытка обновить state размонтированного компонента.

  • Сервер может вернуть null или ошибку.

  • Может возникнуть состояние гонки при быстрых переключениях пользователя.

Его можно масштабировать. Джуны могут представить простую рабочую версию, а для синьоров я могу задать дополнительные вопросы про оптимизацию, отмену запросов, работу с несколькими запросами одновременно.

Итог

Моя цель в подобных React-вопросах - не просто услышать готовое решение, а понять, как человек рассуждает:

  • Задаёт ли он уточняющие вопросы: “Что если username меняется?”, “Что если у нас много быстрых кликов?”, “Нужна ли отмена запроса?”

  • Понимает ли он асинхронные эффекты и их подводные камни?

  • Учитывает ли он необходимость освободить ресурсы при размонтировании компонента?

  • Думает ли о загрузке / ошибках / логировании - ведь сервер может вернуть null, ошибку, или просто долго висеть.

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

Удачи на ваших будущих собеседованиях!

Tags:
Hubs:
+25
Comments47

Articles