Резюме: Шаблонът на контролера е еволюция на модела на контейнера. Тя ви позволява ясно да отделите вашата логика (като използването на кукички, вътрешно състояние на компонент и т.н.) от вашата презентация (т.е. HTML/JSX). Като приложите модела на контролера, можете да се уверите, че всички ваши компоненти са структурирани по един и същи начин, което улеснява поддържането на вашите компоненти в дългосрочен план.

Въведение

React ви дава много свобода, когато става въпрос за структуриране на вашите компоненти. За разлика от рамки като Angular, не сте принудени да отделяте вашата бизнес логика (и данни) от вашия HTML. От една страна, тази свобода е страхотна, защото можете да работите по начина, по който ви ви харесва. Искате бързо да скицирате компонент? Просто създайте функция, добавете малко данни, върнете малко JSX и сте готови. Въпреки че тази свобода е страхотна за бързо започване на изграждането на първоначалните части на вашето приложение, тя може да се превърне в тежест в дългосрочен план: къде свършва бизнес логиката и започва презентационната част на моя компонент? Вече има десетки (помощни) функции, но коя всъщност трябваше да бъде извикана при щракване върху този бутон? Нов съм в тази кодова база и всички компоненти изглеждат различно — откъде да започна?

Моделът на контейнера

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

Към модела на контролера

Използвайки Container Pattern, вие очертавате ясна линия между потребителския интерфейс и логиката. Това обаче изисква да създадете два компонента. В старите версии на 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>
  );
};

Нека също да добавим Props, за да направим това малко по-интересно:

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-indeed-the-ratio-of-time-spent-reading-versus-writing-is). По този начин инвестирането в ясна структура на компонентите ще ви помогне да останете продуктивни в дългосрочен план.

Бонус съвети

Актуализации на състояние, безопасно за тип

Както може би си спомняте, актуализирахме състоянието на нашия компонент по следния начин:

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,",
      "  }",
      "}"
    ]
  }
}

Сега, когато напишете „reactcomponent“, получавате предложение, което, след като бъде прието, създава цялата шаблонна плоча за компонент на шаблон на контролер. Вижте документацията за повече информация относно кодови фрагменти, дефинирани от потребителя.

Последни бележки

Някои произволни финални бележки от автора (Мартин Бухалик).

  • Една идея, която не е споменавана преди: Ако искате да пишете тестове за вашата бизнес логика, моделът на контролера може да улесни живота ви доста лесно, защото можете лесно да тествате „API“, който се връща от контролера.
  • Измислих името „Controller Pattern“. Възможно е вече да има подобен подход под друго име.
  • Когато описвам Container Pattern, говоря за Presentation и Container компоненти. Не намерих ясни дефиниции, казващи как да се наричат ​​двата компонента, затова реших просто да дефинирам двете имена така. (Някои източници просто добавят „Контейнер“ към действителното име на компонента, но това го прави доста объркващо за потребителя на компонента: „Исках да използвам компонента за предупреждение, но защо тук се експортира само компонент, наречен „AlertContainer“?“ Това може да бъде „поправено“ само чрез използване на експорти по подразбиране, но това отваря друга дълга дискусия...)
  • Моля, опитайте се да видите шаблона на контролера като източник на вдъхновение вместо фиксиран набор от правила: Може би разработчиците във вашия екип предпочитат да деструктурират Props. Или да създадете две куки вместо една. Или... Просто адаптирайте модела към вашите лични предпочитания. Единственото нещо, което предлагам: Ясно дефинирайте (например във файл Readme) как трябва да изглежда идеалният компонент. И се опитайте да наложите стандарти, напр. с помощта на линтер. В противен случай ще се окажете с множество „вкусове“ във вашата кодова база, намалявайки предимствата на модела на контролера.