Что нужно знать после того, как вы изучили основы

Развиваться как разработчик

Если вы читаете этот пост, вы, вероятно, знакомы с React и заинтересованы в дальнейшем развитии своих навыков разработки. Это достаточно просто, чтобы изучить основы React и достаточно эффективно создавать простые интерфейсы с использованием компонентов, свойств и простого управления состоянием. Но по мере продвижения вы начнете сталкиваться с более сложными проблемами, которые необходимо решить, и вполне может начаться потребность в более сложных концепциях для поддержания удовлетворительного уровня чистоты и ремонтопригодности вашего приложения.

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

Концепции

Понимание хуков

Крючки — важная часть головоломки, когда дело доходит до разработки приложений React, и если вы потратили много времени на создание всех компонентов, вы, вероятно, использовали хуки useState и useEffect — но понимаете ли вы, что делает хуки «хуками»? Точно так же, почему или когда вы написали бы свои собственные зацепки?

«Хук» в React технически является обычной функцией Javascript. Что делает хук, так это то, что он следует «правилам» хуков:

  1. Имя функции начинается с use
  2. Функция безоговорочно вызывается на верхнем уровне тела функционального компонента илина верхнем уровне тела другого хука

Вот и все. Если ваша функция удовлетворяет этим условиям, технически она считается ловушкой! Тем не менее, как правило, пользовательские хуки используют другие хуки внутри, чтобы упростить и повысить удобство использования логики, выраженной путем объединения нескольких хуков. Дополнительные сведения см. в официальной документации https://react.dev/warnings/invalid-hook-call-warning#breaking-rules-of-hooks.

Рассмотрим надуманный пример ниже, где хук useAppStatus инкапсулирует функциональность регулярного опроса конечной точки состояния, чтобы определить, «готово» ли приложение. Компоненты StartButton и StatusIndicator могут повторно использовать этот хук, чтобы значительно сократить повторяющийся код и значительно повысить читабельность.

const isStatusLoading = (status) => {
  return ["waiting", "loading", "error", "unknown"].includes(status);
};

const useAppStatus = () => {
  let [appStatus, setAppStatus] = useState("unknown");
  let [isLoading, setIsLoading] = useState(true);
  useEffect(() => {
    let intervalId = setInterval(() => {
      fetch("https://localhost:1234/app/status")
        .then((response) => response.json())
        .then((responseJson) => {          
          setAppStatus(responseJson.app_status);
          setIsLoading(isStatusLoading(responseJson.app_status));
        });
    }, 1000);

    return () => clearInterval(intervalId)
  });
  
  return {
    appStatus,
    isLoading
  }
}

const StatusIndicator = () => {
  let {appStatus, isLoading} = useAppStatus();

  return <h2>{isLoading ? `App is in ${appStatus} state`: "App is ready!"}<h2>
}


const StartButton = () => {
  let {appStatus, isLoading} = useAppStatus();
  return <button disabled={isLoading}>{ isLoading ? "Loading..." : "Start" }</button>
}

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

Дополнительная литература

useMemo & useCallback

Благодаря вашему лучшему пониманию хуков, мы можем углубиться в пару немного менее часто используемых встроенных хуков, которые предоставляет React: useMemo и useCallback. Хотя эти хуки довольно распространены, вы, возможно, не копались в них, поскольку они используются исключительно для повышения производительности и не являются строго необходимыми для функциональности.

Для функции useMemo это помогает понять паттерн программирования запоминания. Проще говоря, шаблон мемоизации кэширует результаты ресурсоемких операций, чтобы их можно было использовать повторно без пересчета. Рассмотрим следующие реализации последовательности Фибоначчи:

// not memoized - return Fibonacci sequence element n
const fibonacci = (n) => {
  if (n < 2) return 1;
  return fibonacci(n-1) + fibonacci(n-2);
}


// memoized - stores results of fibonacci calculations in memoMap from closure so they can be retrieved in subsequent calls
const fibonacciMemo = (() => {
  let memoMap = {};
  let fib = (n) => {
    if (n < 2) return 1;
    if (memoMap[n]) {
      // console.log(`pulling memoized value for element ${e}`);
      return memoMap[n];
    }
    // console.log(`calculating value for element ${e}`);
    let result = fib(n-1) + fib(n-2);
    memoMap[n] = result;
    return result;
  }
  return fib; 
})();

const benchmark = () => {
  let elementToCalculate = 40;
  console.time('non-memo');
  fibonacci(elementToCalculate);
  console.timeEnd('non-memo');

  console.time('memo');
  fibonacciMemo(elementToCalculate);
  console.timeEnd('memo');
}

benchmark();

Если вы скопируете и вставите приведенный выше код в свою консоль Javascript, вы увидите разницу между производительностью двух функций при вычислении элемента 40, и вы сможете раскомментировать операторы console.log в запомненной версии, чтобы увидеть в реальном времени, сколько элементов могут вытягиваться из memoMap, а не вычисляться вручную. На моем компьютере запомненная функция выполняется менее чем за одну десятую миллисекунды, а не запомненная версия занимает почти целую секунду.

Хук useMemo ведет себя аналогично: он возвращает результаты переданной ему функции и не выполняет эту функцию повторно, пока не будет изменена одна из явно объявленных зависимостей. См. пример ниже:

const FibonacciDisplay = () => {
  let [fibElement, setFibElement] = useState(1);
  let [message, setMessage] = useState("fibonacci is cool!");
  

  const fibonacci = (n) => {
    if (n < 2) return 1;
    return fibonacci(n-1) + fibonacci(n-2);
  }

  let fibValue = useMemo(() => {
    return fibonacci(fibElement);
  }, [fibElement]);

  return <div>
    <h2>{ message }</h2>
    <h3>Fibonacci element {fibElement} is {fibValue}</h3>
    <span>{message}</span>
    <button onClick={() => setFibElement(fibElement+1)}>Increase</button>
    <text onChange={(e) => setMessage(e.target.value)} defaultValue={message}/>
  </div> 
}

В первом блоке кода, демонстрирующем мемоизацию, мы обнаружили, насколько ресурсоемким может быть вычисление элемента последовательности Фибоначчи. В приведенном выше примере мы используем это, чтобы продемонстрировать долго работающую функцию, которую мы хотим запускать только тогда, когда нам это абсолютно необходимо. В примере используются два отдельных поля useState, которые могут обновляться независимо. Чтобы избежать ненужных пересчетов значения последовательности Фибоначчи при обновлении message, мы используем useMemo. Это делает очевидным, что мы хотим пересчитывать это значение только при изменении значения fibElement, экономя ценные вычислительные ресурсы.

Хук useCallback очень похож на useMemo, но используется для определения функций. Когда у вас есть let x = useMemo(() => 10, []), переменная x будет равна 10, однако, когда у вас есть let x = useCallback(() => 10, []), переменная x будет равна функции () => 10. Вы можете думать о useCallback как о специализированной реализации useMemo с таким определением:

function useCallback(fn, dependencies) {
  return useMemo(() => fn, dependencies);
}

Хук useCallback предотвращает воссоздание определения функции между рендерингами, пока переданные зависимости не изменились. Хотя определения функций не являются ресурсоемкими операциями, может быть важно поддерживать одно и то же определение функции при использовании компонента, который был заключен в memo, что предотвращает повторный рендеринг компонента, когда его реквизиты не изменились с момента предыдущего рендеринга. . Поскольку определение функции каждый раз приводит к технически другому объекту, это вызовет проблемы, если эта функция будет передана в качестве реквизита компоненту, обернутому memo, — функция будет считаться другим значением каждый раз и эффективно обходить кэширование memo.

Дополнительная литература

Компоненты высшего порядка

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

const SensitiveMessageDisplay = ({message}) => {
  let { userHasAccess } = useVerifiedAccess();

  if (userHasAccess) {
    return <div>
      <h2>{message}</h2>
    </div>
  }

  return <span>You don't have proper access</span>
}


const SensitiveButton = ({action}) => {
  let { userHasAccess } = useVerifiedAccess();

  if (userHasAccess) {
    return <button onClick={action}>Click me!</button>
  }

  return <span>You don't have proper access</span>
}

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

const MessageDisplay = ({message}) => {
  return <div>
    <h2>{message}</h2>
  </div>
}

const Button = ({action}) => {
  return <button onClick={action}>Click me!</button>
}

const withAccessVerification = (WrappedComponent) => {
  return (props) => {
    let { userHasAccess } = useVerifiedAccess();
    if (userHasAccess) {
      return <WrappedComponent {...props} />;
    }
    return <span>You don't have proper access</span>;
  }
}

const SensitiveMessageDisplay = withAccessVerification(MessageDisplay);
const SensitiveButton = withAccessVerification(Button);

При описанном выше подходе у вас есть те же функциональные возможности в ваших компонентах Sensitive, но у вас также есть версии компонентов, отличные от Sensitive, для использования в менее важных частях приложения. По мере роста вашего приложения и добавления дополнительных функций вы можете продолжать повторно использовать компонент более высокого порядка withAccessVerification, чтобы продолжать проверку подтвержденного доступа согласованным образом. Хотя они менее распространены, чем когда-то, вы все же, вероятно, столкнетесь с компонентами более высокого порядка при чтении старых кодовых баз, и они являются ценным инструментом, которым можно воспользоваться, когда представится подходящий случай.

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

const useAccessGuard = () => {
  let { accessMessage, setAccessMessage } = useState(<span>You have proper access!</span>);
  let { userHasAccess } = useVerifiedAccess();
  if (!userHasAccess) {
    setAccessMessage(<span>You don't have proper access</span>);
  }

  return { accessMessage, userHasAccess }
}

const SensitiveMessageDisplay = ({message}) => {
  let { userHasAccess, accessMessage } = useAccessGuard();
  return userHasAccess ? 
    <div><h2>{ message }</h2></div> :
    { accessMessage };
}

const SensitiveButtonDisplay = ({action}) => {
  let {userHasAccess, accessMessage} = useAccessGuard();

  return userHasAccess ? 
    <button onClick={action}>Click me!</button> :
    { accessMessage }
}

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

Дополнительная литература

Инверсия контроля

Инверсия управления — это шаблон проектирования программного обеспечения (или просто принцип, в зависимости от того, кого вы спросите), который предлагает создать по существу общий код, реализующий абстрактный процесс, а затем делегировать ответственность за детали клиенту, вызывающему код. Рассмотрим следующий надуманный пример:

const customCompare = (a, b, asString = false, asValue = false) => {
  if (asString) a = a.toString(); b = b.toString();
  if (asValue) a = a.valueOf(); b = b.valueOf();
  return a === b;
}

В приведенном выше коде мы реализуем функцию с именем customCompare, которая пытается сравнить два объекта, опционально преобразовывая их в строки или запуская над ними .toValue() перед преобразованием. В этом коде вызывающая сторона имеет контроль над способом сравнения объектов, но ограничена только конкретными реализациями, предоставляемыми функцией. Рассмотрим другой подход:

const customCompare = (a, b, transform = null) => {
    if (transform) {
      [a, b] = [transform(a), transform(b)];
    }
    return a === b;
}

Вуаля! Управление инвертировано. Теперь предоставляемая функция по-прежнему поддерживает свою базовую логику, сравнивая два объекта, но она делегирует ответственность за то, как объекты должны быть преобразованы, вызывающей стороне, что обеспечивает гораздо большую гибкость в том, где функция может использоваться. Понятно, что в этом примере от функции мало толку, но тот же принцип можно использовать в React для повышения гибкости и уменьшения связанности. См. приведенный ниже пример:

const useFriends = () => {
  let [friends, setFriends] = useState([]);
  useEffect(() => {
    fetch("https://localhost:1234/api/friends")
    .then((response) => response.json())
    .then((responseJson) => setState(responseJson.friends));
  });

  return { friends }
}

const FriendsListItem = ({ name, url }) => {
  return <a href={url}>{name}</a>
}

const FriendsList = () => {
  let { friends } = useFriends();
  let [hide, setHide] = useState(false);
  
  return <div>
      <button onClick={() => setHide(!hide)}>{hide ? "Show" : "Hide"}</button>
      {
        hide ? <span>List hidden</span>
        : friends.map((friend) => {
          return <FriendsListItem {...friend} />
        })
      }
    </div>;
}

const MainDisplay = () => {
  return <main role="main">
      <FriendsList />
    </main>
}

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

const HidableList = ({ children }) => {
  let [hide, setHide] = useState(false);
  
  return <div>
      <button onClick={() => setHide(!hide)}>{hide ? "Show" : "Hide"}</button>
      {
        hide ? <span>List hidden</span>
        : children
      }
    </div>;
}

const MainDisplay = () => {
  let { friends } = useFriends();
  
  return <main role="main">
      <HidableList>
        {
          friends.map((friend) => {
            return <FriendsListItem {...friend} />
          })
        }
      </HidableList>
    </main>;
}

В этой версии кода мы использовали свойство React children в качестве рычага для инвертирования управления, делегируя ответственность за то, какие элементы отображаются в этом списке, компоненту, который его использует. В процессе мы устраняем связь между компонентами FriendsList и FriendsListItem и в итоге получаем более общий компонент FriendsList, который мы можем переименовать в HidableList и использовать не только для FriendsListItems.

Есть много способов инвертировать управление в вашем приложении, в этом примере показан только один.

Дополнительная литература

Заключение

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

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