Обзор. Шаблон контроллера представляет собой эволюцию шаблона контейнера. Это позволяет вам четко отделить вашу логику (например, использование ловушек, внутреннее состояние компонента и т. д.) от вашей презентации (т. е. HTML/JSX). Применяя шаблон контроллера, вы можете убедиться, что все ваши компоненты имеют одинаковую структуру, что упрощает обслуживание ваших компонентов в долгосрочной перспективе.

Введение

React дает вам большую свободу, когда дело доходит до структурирования ваших компонентов. В отличие от таких фреймворков, как Angular, вы не обязаны отделять свою бизнес-логику (и данные) от своего HTML. С одной стороны, эта свобода хороша тем, что вы можете работать так, как вамнравится. Хотите быстро набросать компонент? Просто создайте функцию, введите некоторые данные, верните немного JSX, и все готово. Хотя эта свобода хороша для быстрого начала создания начальных частей вашего приложения, в долгосрочной перспективе она может стать бременем: где заканчивается бизнес-логика и начинается презентационная часть моего компонента? Теперь есть десятки (вспомогательных) функций, но какая из них на самом деле должна вызываться при нажатии этой кнопки? Я новичок в этой кодовой базе, и все компоненты выглядят по-другому — с чего мне начать?

Шаблон контейнера

Чтобы четко отделить бизнес-логику (например, использование ловушек, внутреннее состояние компонента и т. д.) от представления (т. е. HTML/JSX), разработчики React традиционно использовали шаблоны, такие как Контейнерный шаблон. При использовании Container Pattern вы определяете два компонента: компонент Presentation и компонент Container. Компонент Presentation, как следует из названия, отвечает только за визуализацию пользовательского интерфейса. Этот компонент не имеет никакого внутреннего состояния, не вызывает никаких API и т. д. Компонент Container, с другой стороны, содержит вашу бизнес-логику. Этот компонент-контейнер передает все необходимые данные компоненту Презентация с помощью реквизитов. Таким образом, Контейнер не имеет отношения к пользовательскому интерфейсу, а вместо этого фокусируется только на обработке состояния, вызове API и т.п.

На пути к шаблону контроллера

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

В шаблоне контроллера вы создаете только один компонент. Этот компонент отвечает только за отрисовку пользовательского интерфейса. Бизнес-логика перемещена в пользовательский хук: Контроллер.

Стандартный код компонента

По своей сути компонент, построенный с использованием шаблона контроллера, выглядит так:

interface Props {
  // The Props of the component.
}
export const MyComponent: React.FC<Props> = (props) => {
  const controller = useController(props);

  return (
    // The JSX that shall be rendered.
  );
};

interface State {
  // Component state.
}
interface Controller {
  state: State;

  // Some functions, data, or the like, that shall be accessed in our JSX.
}
function useController(props: Props): Controller {
  const [state, setState] = React.useState<State>({
   // Initial state.
  });

  return {
    state: state,

    // Return what is required by the interface defined above.
  };
}

Согласитесь, довольно сложно понять, что происходит. Итак, давайте посмотрим на пример.

Пример

Хотите поэкспериментировать с показанным здесь кодом? Взгляните на образец приложения, особенно на main-page.tsx и main-page-without-controller.tsx.

Чтобы понять, как работает шаблон контроллера, давайте создадим небольшой пример компонента. Этот компонент предназначен для отображения случайной котировки, поступающей из API. Пользователь может нажать кнопку «Случайная котировка», после чего компонент выполнит выборку и отобразит новую случайную котировку. Вот как выглядит приложение:

Без какого-либо конкретного шаблона вы, вероятно, создали бы такой компонент:

export const FancyQuotes: React.FC = () => {
  const [currentQuote, setCurrentQuote] = React.useState('');
  const [isLoading, setIsLoading] = React.useState(false);

  React.useEffect(() => {
    void loadNewQuote();
  }, []);

  async function loadNewQuote(): Promise<void> {
    setIsLoading(true);

    const quote = await loadRandomQuote();

    setIsLoading(false);
    setCurrentQuote(quote);
  }

  return (
    <div>
      {isLoading && <LoadingIndicator />}

      {!isLoading && (
        <React.Fragment>
          <div>{currentQuote}</div>

          <button type="button" onClick={(): void => void loadNewQuote()}>
            Random Quote
          </button>
        </React.Fragment>
      )}
    </div>
  );
};

Давайте также добавим реквизиты, чтобы сделать это немного интереснее:

interface Props {
  onNewQuote: (newQuote: string) => void;
}
export const FancyQuotes: React.FC<Props> = (props) => {
  const [currentQuote, setCurrentQuote] = React.useState('');
  const [isLoading, setIsLoading] = React.useState(false);

  React.useEffect(() => {
    void loadNewQuote();
  }, []);

  React.useEffect(() => {
    if (currentQuote === '') {
      return;
    }

    props.onNewQuote(currentQuote);
  }, [currentQuote]);

  async function loadNewQuote(): Promise<void> {
    setIsLoading(true);

    const quote = await loadRandomQuote();

    setIsLoading(false);
    setCurrentQuote(quote);
  }

  return (
    <div>
      {isLoading && <LoadingIndicator />}

      {!isLoading && (
        <React.Fragment>
          <div>{currentQuote}</div>

          <button type="button" onClick={(): void => void loadNewQuote()}>
            Random Quote
          </button>
        </React.Fragment>
      )}
    </div>
  );
};

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

Теперь тот же компонент с использованием шаблона контроллера:

interface Props {
  onNewQuote: (newQuote: string) => void;
}
export const FancyQuotes: React.FC<Props> = (props) => {
  const controller = useController(props);

  return (
    <div>
      {controller.state.isLoading && <LoadingIndicator />}

      {!controller.state.isLoading && (
        <React.Fragment>
          <div>{controller.state.currentQuote}</div>

          <button type="button" onClick={(): void => controller.loadNewQuote()}>
            Random Quote
          </button>
        </React.Fragment>
      )}
    </div>
  );
};

interface State {
  currentQuote: string;
  isLoading: boolean;
}
interface Controller {
  state: State;

  loadNewQuote: () => void;
}
function useController(props: Props): Controller {
  const [state, setState] = React.useState<State>({
    currentQuote: '',
    isLoading: false,
  });

  React.useEffect(() => {
    void loadNewQuote();
  }, []);

  React.useEffect(() => {
    if (state.currentQuote === '') {
      return;
    }

    props.onNewQuote(state.currentQuote);
  }, [state.currentQuote]);

  async function loadNewQuote(): Promise<void> {
    setState((state) => ({ ...state, isLoading: true }));

    const quote = await loadRandomQuote();

    setState((state) => ({ ...state, isLoading: false, currentQuote: quote }));
  }

  return {
    state: state,

    loadNewQuote: (): void => {
      void loadNewQuote();
    },
  };
}

Что мы здесь сделали? Во-первых, вы заметите, что наш компонент FancyQuotes не содержит никакой бизнес-логики. Вместо этого он просто использует хук (useController()) и возвращает какой-то JSX. Таким образом, глядя на этот компонент, вы можете легко понять, как составлен пользовательский интерфейс. Вам не нужно понимать (или даже смотреть) бизнес-логику.

Контроллер — это настраиваемый хук, который содержит всю бизнес-логику: он извлекает цитату из API, управляет состоянием и т. д. Теперь магия зависит от данных, которые возвращает хук. Возвращаемое значение четко сообщает: «Эта функция предназначена для вызова в презентационном компоненте («в JSX»). И этот объект также может быть доступен в нашем JSX». Это та часть, где бизнес-логика и презентация связаны. Чтобы четко определить этот API, мы объявляем для него интерфейс:

interface Controller {
  state: State;

  loadNewQuote: () => void;
}

Если бы мы хотели использовать что-то еще в презентационном компоненте, мы бы просто добавили это в интерфейс контроллера:

interface Controller {
  state: State;

  userName: string;

  loadNewQuote: () => void;
}

И, конечно же, нам нужно было бы добавить реальную бизнес-логику в наш контроллер:

function useController(props: Props): Controller {
  // ...

  const userName = React.useMemo((): string => {
    // ...

    return getUserName();
  }, []);

  return {
    state: state,

    userName: userName,

    loadNewQuote: (): void => {
      void loadNewQuote();
    },
  };
}

Теперь всякий раз, когда разработчик смотрит на ваш компонент, он или она может ясно видеть, что userName предназначен для доступа в JSX. Если бы у нас были структуры данных или вспомогательные функции внутри нашего контроллера, которые не предназначены для вызова в JSX, мы бы просто не возвращали их из контроллера.

Состояние

Мы могли бы использовать состояние так же, как в нашем обычном компоненте, и возвращать его из нашего контроллера:

function useController(props: Props): Controller {
  const [currentQuote, setCurrentQuote] = React.useState('');
  const [isLoading, setIsLoading] = React.useState(false);

  // ...

  return {
    currentQuote: currentQuote,
    isLoading: isLoading,
    // ...
  };
}

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

Чтобы обновить наше состояние, мы вызываем setState() следующим образом:

setState((state) => ({ ...state, isLoading: false, currentQuote: quote }));

Заманчиво вернуть функцию установки состояния из контроллера и напрямую использовать ее в JSX. Однако вместо этого рекомендуется создавать вспомогательные функции:

interface State {
  counter: number;
}
interface Controller {
  state: State;

  incrementCounter: () => void;
}
function useController(): Controller {
  const [state, setState] = React.useState<State>({
    counter: 1,
  });

  return {
    state: state,

    incrementCounter: (): void => {
      setState((state) => ({ ...state, counter: state.counter + 1 }));
    },
  };
}

Таким образом, вы можете определить четкий «API обновления состояния». Кроме того, вы, вероятно, заметите, что одна функция, возвращаемая контроллером, часто вызывается в 2 или 3 местах в JSX, что добавило бы избыточности, если бы вы не использовали такую ​​вспомогательную функцию.

Работа с «тупыми» компонентами

Если ваш компонент не содержит никакой бизнес-логики, просто исключите контроллер:

interface Props {
  quote: string;
  loadNewQuote: () => void;
}
export const FancyQuotes: React.FC<Props> = (props) => {
  return (
    <div>
      <div>{props.quote}</div>

      <button type="button" onClick={(): void => props.loadNewQuote()}>
        Random Quote
      </button>
    </div>
  );
};

Заключение

Шаблон контроллера строго разделяет бизнес-логику и представление. Недостатком этого шаблона является то, что вам нужно написать больше кода. Однако следует помнить, что вы, вероятно, будете читать свой код гораздо чаще, чем писать его (см. https://devblogs.microsoft.com/oldnewthing/20070406-00/?p=27343 и https://www. goodreads.com/quotes/835238-действительно-соотношение-времени, потраченного на чтение и написание-есть). Таким образом, инвестиции в четкую структуру компонентов помогут вам оставаться продуктивными в долгосрочной перспективе.

Бонусные советы

Типобезопасные обновления состояния

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

setState((state) => ({ ...state, isLoading: false, currentQuote: quote }));

В TypeScript это на самом деле не полностью безопасно для типов. Если вы сделаете опечатку в ключе объекта, компилятор не будет жаловаться:

setState((state) => ({ ...state, LOADING: false, currentQuote: quote }));

Чтобы исправить это, вы можете определить функцию merge следующим образом:

export function merge<T extends object>(
  baseObject: T,
  patchObject: Partial<T>,
): T {
  return { ...baseObject, ...patchObject };
}

Затем обновите свое состояние, используя этот шаблон:

setState((state) => merge(state, { isLoading: false, currentQuote: quote }));

Опечатка в ключе объекта теперь будет приводить к ошибке компилятора.

Используйте фрагменты кода

Вы используете VSCode? Если да, то этот совет для вас!

Создайте файл .vscode/snippets.code-snippets. (Имя файла должно заканчиваться на .code-snippets, но префикс не имеет значения.)

Затем вставьте следующее:

{
  "reactcomponent": {
    "scope": "typescriptreact",
    "prefix": "reactcomponent",
    "body": [
      "import React from 'react';",
      "",
      "interface Props {",
      "",
      "}",
      "export const $1: React.FC<Props> = props => {",
      "  const controller = useController(props);",
      "",
      "  return <React.Fragment></React.Fragment>",
      "}",
      "",
      "interface State {",
      "",
      "}",
      "interface Controller {",
      "  state: State;",
      "}",
      "function useController(props: Props): Controller {",
      "  const [state, setState] = React.useState<State>({});",
      "",
      "  return {",
      "    state: state,",
      "  }",
      "}"
    ]
  }
}

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

Заключительные заметки

Несколько случайных заключительных заметок автора (Мартина Бучалика).

  • Одна идея, о которой раньше не упоминалось: если вы хотите написать тесты для своей бизнес-логики, шаблон контроллера может сделать вашу жизнь довольно легкой, потому что вы можете легко протестировать «API», возвращаемый контроллером.
  • Я придумал название «Шаблон контроллера». Возможно, уже существует аналогичный подход под другим названием.
  • Описывая Container Pattern, я говорю о компонентах Presentation и Container. Я не нашел четких определений, говорящих о том, как должны называться два компонента, поэтому я решил просто определить два имени таким образом. (Некоторые источники просто добавляют «Контейнер» к фактическому имени компонента, но это сбивает с толку пользователя компонента: «Я хотел использовать компонент «Предупреждение», но почему здесь экспортируется только компонент с именем «AlertContainer»?» Это можно «исправить» только с помощью экспорта по умолчанию, но это открывает еще одну длинную дискуссию…)
  • Пожалуйста, попробуйте рассматривать паттерн контроллера как источник вдохновения, а не фиксированный набор правил: возможно, разработчики в вашей команде предпочитают деструктурировать реквизиты. Или создать два крючка вместо одного. Или… Просто адаптируйте шаблон к своим личным предпочтениям. Единственное, что я предлагаю: четко определить (например, в файле Readme), как должен выглядеть идеальный компонент. И попытайтесь обеспечить соблюдение стандартов, например. с помощью линтера. В противном случае вы получите несколько «разновидностей» в своей кодовой базе, уменьшая преимущества шаблона контроллера.