Где должна быть бизнес-логика в React приложении

год назад·6 мин. на чтение

В этой статье мы подробно рассмотрим работу с бизнес-логикой в React

Мы уже подробно разбирали масштабируемую структуру React приложения, то, как называть наши файлы, когда использовать хуки для управления побочными эффектами и т.д.: В этой статье мы подробно рассмотрим работу с бизнес-логикой. Во многих случаях разработчики пишут бизнес логику прямо в компонентах. Даже опытные разработчики ограничиваются вынесением этих вычислений в кастомные хуки или какие-либо вспомогательные функции. Но все еще это оставляет проблему нерешенной. Дело в том, что даже если у нас есть более мелкие компоненты и логика перемещена в хуки или хэлперы, они буквально разбросаны повсюду неорганизованно. Возьмем, к примеру, приложение онлайн магазина, если мы хотим изменить логику в cart, скорее всего, нам также придется изменить модули product и validation. И нам обычно приходится менять как хэлперы, так и представления (не говоря уже о связанных с ними тестах).

Как обстоят дела в React

Рассмотрим проблему на более высоком уровне. Если вы внимательно посмотрите на React и согласитесь, что он отвечает только за визуальную часть нашего приложения, многие проблемы будут решены автоматически. Независимо от того, используем ли мы традиционные шаблоны MVC/MVP или их вариант MVVM, если React — это V, очевидно, нам нужно что-то еще, чтобы заполнить роль M или VM в приложении. Среди многих проектов я также обнаружил, что многие хорошие практики, которые мы используем в бэкенде, не признаны в мире фронтенда, такие как слоеная структура, паттерны проектирования и т. д. Одна из возможных причин заключается в том, что фронтенд относительно молодой и ему нужно некоторое время, чтобы наверстать упущенное. Например, в типичном приложении Spring MVC у нас были бы controller, service и repository, и каждый разработчик принимает причину такого разделения: controller не содержит бизнес-логики, service не знает, как модель отображается или сериализуется для пользователей, а repository работает только о доступом к данным. Однако во фронтенд-приложениях на React из-за отсутствия встроенной поддержки (например, отсутствия контроллеров или слоя репозитория) мы вместо этого пишем этот код в компоненты. И это приведет к тому, что бизнес-логика будет повсюду. Итерации станут медленными, а качество кода низким.

Утечка бизнес-логики

Мы можем назвать эту ситуацию утечкой бизнес-логики, имея в виду, что бизнес-логика должна была быть размещена в правильное место, и по какой-то причине была размещена неправильно. Хотя у нас нет подходящего механизма для правильного размещения, в результате бизнес логика написана везде где удобно (в компонентах, хуках и вспомогательных функциях). Сложно уловить такую утечку в коде. Вы должны уделять больше внимания, чтобы увидеть такие ситуации. Вот несколько распространенных симптомов, которые я обнаружил:
  • Использование преобразователей данных
  • x.y.z
  • Защитное программирование

Использование преобразователей данных

Эту паттерн легко обнаружить: если вы делаете map для преобразования данных, вы, вероятно, пересекаете два ограниченных контекста (что может привести к утечке логики). Мы все видели или, возможно, писали такой код, как:
fetch(`https://example.com/api/addresses`)
.then((r) => r.json())
.then((data) => {
    const addresses = data.map((item: RemoteAddress) => ({
        street: item.streetName,
        address: item.streetAddress,
        postcode: item.postCode
    }))
    setAddresses(addresses)
});
В приведенном выше фрагменте то, что возвращает бэкэнд, не совсем соответствует тому, что потребляет UI, поэтому нам нужно преобразовать полученные данные. Мы можем использовать сервис, разработанный другой командой, или использовать сторонний сервис (например, Google Search API). Таким образом, казалось бы, безобидный код нарушил здесь несколько принципов:
  • Компонент должен знать тип RemoteAddress
  • Компоненту необходимо определить новый тип Address (setAddresses)
  • data.map выполняет низкоуровневое сопоставление

Симптом x.y.z (нарушение закона Деметры)

Если вы используете более одного оператора точки ., вероятно, это означает, что отсутствуют некоторые концепции. person.deliveryAddress лучше, чем person.primaryAddress.street.streetNumber + person.primaryAddress.suburb так как первый вариант правильно скрывает детали. Приведенный ниже код показывает, что ProductDialog слишком много знает о product, и как только структура product изменится, нам придется менять множество мест (тесты и компоненты)
const ProductDialog = (props) => {
  const { product } = props;
  if(product.item.type === 'Portion') {
    //do something
  }
}
Здесь мы имеем дело с данными, а не с моделью. Таким образом, product.isPortion() будет более значимым, чем проверка необработанных данных.

Защитное программирование

Во многих проектах люди склонны делать слишком много в компоненте, и это создает много шума в коде. Например:
const ProductDetails = (props) => {
  const { product } = props
  const { item } = product
  const { media } = item as MenuItem
  
  const title = (media && media.name) || ''
  const description = (media && media.description) || ''
  return (
    <div>
      {/* product details */}
    </div>
  )
}
Обратите внимание, что мы проверяем на null и предоставляем запасное значение в компоненте. Однако мы должны выполнять этот тип логики в специально отведенном месте.

Как решить проблему?

На практике мы можем попробовать двухэтапный подход к решению проблемы.
  1. Регулярный рефакторинг
  2. Создание моделей

Регулярный рефакторинг

Во-первых, мы можем выполнить рефакторинг, как обычно в других случаях, когда мы видим некоторую логику в компонентах React. Например, переместив логику/вычисления из:
  • Использования преобразователей данных
  • x.y.z
  • Защитного программирования
во вспомогательные функции. Возьмем, к примеру, преобразователь данных выше. Мы можем извлечь анонимную функцию в именованную функцию и переместить ее в отдельный файл:
const transformAddress: Address = (address: RemoteAddress) => {
    return ({
        street: datum.streetName,
        address: datum.streetAddress,
        postcode: datum.postCode
    })
}
//...
const addresses = data.map(transformAddress)
Также иногда бывает нужно преобразовать аббревиатуры в текст такие как VIC или NSW, но нам нужно показать их в полном тексте на странице как Victoria или New South Wales.
const states = {
  vic: "Victoria",
  nsw: "New South Wales",
  //...
};

const transformAddress: Address = (address: RemoteAddress) => {
  return {
    street: address.streetName,
    address: address.streetAddress,
    postcode: address.postCode,
    state: states[address.state.toLowerCase()]
  };
};
Точно так же мы можем использовать функцию, для проверки title и description и вывода запасного значения:
const getTitle = (media) => (media && media.name) || ''
const getDescription = (media) => (media && media.description) || ''
По мере добавления все больше и больше логики, такой transformAddress и getTitle, они будут перемещаться в helpers.ts, в конечном итоге у нас будет огромный файл. Это означает, что он станет нечитаемым и будет иметь высокие затраты на обслуживание. Мы можем разделить файл на модули, но связи между этими функциями могут затруднить их понимание. Это похоже на проблему, с которой мы сталкивались до объектно-ориентированного программирования - у нас слишком много модулей и функций в каждом модуле, и слишком сложно ориентироваться в них. Другими словами, нам нужен лучший способ организации этих вспомогательных функций. К счастью, нам не нужно изобретать велосипеды. Нам может помочь объектно-ориентированное программирование. Просто используя классы и инкапсуляцию в ООП, мы можем легко сгруппировать эти функции и сделать код намного более читабельным. Чтобы сгруппировать код создадим модели.

Создание моделей

Короче говоря, создание моделей — это объединение данных и поведения, сокрытие деталей и обеспечение общего API для потребителей. Например, мы не должны использовать product.item.type === 'Portion', вместо этого мы должны создать класс Product, и у него есть isPortion для их потребителей. Это очень распространено в бэкенд-сервисах, но не получило широкого распространения в мире фронтенда. Причина в том, что, как упоминалось выше, люди упускают из виду, что React отвечает только за визуализацию. И здоровое фронтенд-приложение должно иметь и другие части. Ему нужны модели и логика для взаимодействия с серверной частью, даже для ведения логирования. Возвращаясь к приведенному выше примеру, определив класс Address для замены анонимной функции внутри data.map, мы получим:
class Address {
  constructor(private addr: RemoteAddress) {}
  get street() {
    return this.addr.streetAddress;
  }
  get postcode() {
    return this.addr.postcode;
  }
}
Нет никакой разницы в использовании:
const AddressLine = ({ address }: { address: Address }) => (
  <li>
    <div className="result">{address.street}</div>
  </li>
);
Единственное, что нужно изменить, это заменить transformAddress на new Address:
const addresses = data.map((addr: RemoteAddress) => new Address(addr))
И для частного члена/функции для перевода названия штата:
private readonly states = {
  vic: "Victoria",
  nsw: "New South Wales",
  //...
};

get state() {
  return this.states[this.addr.state.toLowerCase()];
}
Структура теперь намного точнее. states теперь является приватным членом класса Address. Класс хорош тем, что он объединяет всю связанную логику в одну часть, что делает его изолированным и простым в обслуживании. Размещение всей связанной логики в одном месте имеет и другие преимущества. Во-первых, такое разделение делает тестирование простым и надежным, поскольку компоненты зависят от модели (а не от исходных данных). Нам не нужно готовить данные с нулевым значением или значения вне границ предусмотренных значений для тестов компонентов. Точно так же модель тестирования больше фокусируется на данных и логике (пустое значение, проверка и запасное значение). Во-вторых, согласованность повышает вероятность его повторного использования в других сценариях. Наконец, если нам нужно переключиться на другую стороннюю службу, нам нужно только изменить модели, и представления могут остаться нетронутыми. По мере того, как создается все больше и больше моделей, нам может понадобиться целый слой для них. Эта часть кода не знает о существовании компонентов пользовательского интерфейса и связана исключительно с бизнес-логикой.

Итоги

Инкапсуляция бизнес-логики, даже в контексте тонких клиентов, является относительно большой темой. В этой статье мы рассмотрели несколько симптомов утечки бизнес-логики и то, как с ними бороться. Проводя регулярный рефакторинг, мы можем гарантировать, что компоненты отвечают только за рендеринг данных и не должны выполнять какие-либо вычисления или сопоставление данных. Мы должны разделить эту логику на чистые файлы JavaScript (а не jsx/tsx). И с помощью создания моделей мы можем использовать объекты только для того, чтобы скрыть детали доступа к данным. Преимущества этого подхода заключаются в том, что тестирование как модели, так и представлений значительно упрощается, легче отслеживать изменения бизнес-требований и гораздо более простой код в представлениях (поскольку большая часть этого делается в моделях).

Как передать пропсы React компоненту

год назад·5 мин. на чтение

Компоненты React используют пропсы (props) для связи друг с другом. Каждый родительский компонент может передавать некоторую информацию своим дочерним компонентам, предоставляя им пропсы. Пропсы могут напоминать атрибуты HTML, но вы можете передавать через них любое значение JavaScript, включая объекты, массивы и функции.

Содержание туториала по React Компоненты React используют пропсы (props) для связи друг с другом. Каждый родительский компонент может передавать некоторую информацию своим дочерним компонентам, предоставляя им пропсы. Пропсы могут напоминать атрибуты HTML, но вы можете передавать через них любое значение JavaScript, включая объекты, массивы и функции.

Известные пропсы

Пропсы — это информация, которую вы передаете тегу JSX. Например, className, src, alt, width и height — вот некоторые пропсы, которые вы можете передать <img>:
function Avatar() {
  return (
    <img
      className="avatar"
      src="https://example.com/userpic.jpg"
      alt="Userpic"
      width={100}
      height={100}
    />
  );
}

export default function Profile() {
  return <Avatar />;
}
Пропсы, которые вы можете передать тегу <img>, предопределены (ReactDOM соответствует стандартам HTML). Но вы можете передать любые пропсы своим собственным компонентам, таким как <Avatar>, чтобы настроить их.

Передача пропсов в компонент

В этом коде компонент Profile не передает никаких пропсов своему дочернему компоненту Avatar:
export default function Profile() {
  return <Avatar />;
}
Вы можете передать в Avatar некоторые пропсы в два этапа.

Шаг 1: Передайте пропсы дочернему компоненту

Во-первых, передайте некоторые пропсы в Avatar. Например, давайте передадим два пропса: person (объект) и size (число):
export default function Profile() {
  return (
    <Avatar person={{ name: 'User Name 1', imageId: '12345' }} size={100} />
  );
}
Если двойные фигурные скобки после person= вас смущают, помните, что они являются просто объектом внутри фигурных скобок JSX. Теперь вы можете прочитать эти пропсы внутри компонента Avatar.

Шаг 2: Прочтите пропсы внутри дочернего компонента

Вы можете прочитать эти пропсы, указав их имена - person, size - разделенные запятыми внутри ({ и }) непосредственно после function Avatar. Это позволяет использовать их внутри кода Avatar, как если бы вы использовали переменную.
function Avatar({ person, size }) {
  // person и size можно здесь использовть
}
Добавьте немного логики в Avatar, которая использует пропсы person, size в отображении, и все готово. Теперь вы можете настроить Avatar для отображения разными способами с разными пропсы.
// utils.js

export function getImageUrl(person, size = 's') {
  return 'https://example.com/' + person.imageId + size + '.jpg';
}
// App.js

import { getImageUrl } from './utils.js';

function Avatar({ person, size }) {
  return (
    <img
      className="avatar"
      src={getImageUrl(person)}
      alt={person.name}
      width={size}
      height={size}
    />
  );
}

export default function Profile() {
  return (
    <div>
      <Avatar
        size={100}
        person={{
          name: 'User Name 1',
          imageId: '12345',
        }}
      />
      <Avatar
        size={80}
        person={{
          name: 'User Name 2',
          imageId: '12346',
        }}
      />
      <Avatar
        size={50}
        person={{
          name: 'User Name 3',
          imageId: '12347',
        }}
      />
    </div>
  );
}
Пропсы позволяют вам думать о родительских и дочерних компонентах независимо друг от друга. Например, вы можете изменить пропсы person или size внутри Profile, не задумываясь о том, как Avatar их использует. Точно так же вы можете изменить то, как Avatar использует эти пропсы, не заглядывая в Profile. Вы можете думать о пропсах как о «ручках», которые вы можете регулировать. Они выполняют ту же роль, что и аргументы для функций — на самом деле пропсы являются единственным аргументом для вашего компонента. Функции компонента React принимают один аргумент, объект props:
function Avatar(props) {
  let person = props.person;
  let size = props.size;
  // ...
}
Обычно вам не нужен весь объект пропса, поэтому можно разбить его на отдельные пропсы. Не пропустите пару фигурных скобок { и } внутри ( и ) при объявлении пропсов:
function Avatar({ person, size }) {
  // ...
}
Этот синтаксис называется «деструктурированием» и эквивалентен чтению свойств из параметра функции:
function Avatar(props) {
  let person = props.person;
  let size = props.size;
  // ...
}

Как указать значения по умолчанию для пропса

Если вы хотите присвоить пропсу значение по умолчанию, чтобы использовать его, когда значение не указано, вы можете сделать это с помощью деструктуризации, поставив = и значение по умолчанию сразу после параметра:
function Avatar({ person, size = 100 }) {
  // ...
}
Теперь, если в <Avatar person={...} /> передан пропс size, размер будет установлен на 100. Значение по умолчанию используется только в том случае, если параметр size отсутствует или если вы передаете size={undefined}. Но если вы передадите size={null} или size={0}, значение по умолчанию не будет использоваться.

Перенаправление пропсов с синтаксисом распыления JSX

Иногда отправка пропсов повторяется:
function Profile({ person, size, isSepia, thickBorder }) {
  return (
    <div className="card">
      <Avatar
        person={person}
        size={size}
        isSepia={isSepia}
        thickBorder={thickBorder}
      />
    </div>
  );
}
В повторяющемся коде нет ничего плохого. Но иногда хочется сделать код короче. Некоторые компоненты передают все свои пропсы своим дочерним компонентам, например, как Profile делает с Avatar. Поскольку они не используют никакие свои пропсы напрямую, может иметь смысл использовать более краткий синтаксис распыления (spread):
function Profile(props) {
  return (
    <div className="card">
      <Avatar {...props} />
    </div>
  );
}
Это пример перенаправления всех пропсов Profile в Avatar без перечисления каждого из их имен. Используйте расширенный синтаксис с ограничениями. Если вы используете его в каждом компоненте - значит что-то не так. Часто это указывает на то, что следует разделить компоненты и передать дочерние компоненты как JSX. Подробнее об этом далее.

Передача JSX в качестве дочернего компонента

Обычно встроенные теги браузера вкладывают друг в друга:
<div>
  <img />
</div>
Иногда вы захотите вложить свои собственные компоненты таким же образом:
<Card>
  <Avatar />
</Card>
Когда вы вкладываете контент в тег JSX, родительский компонент получит этот контент в просе, называемом children. Например, компонент Card ниже получит проп children, который является <Avatar />, и отобразит его в обертке div:
// App.js

import Avatar from './Avatar.js';

function Card({ children }) {
  return <div className="card">{children}</div>;
}

export default function Profile() {
  return (
    <Card>
      <Avatar
        size={100}
        person={{
          name: 'User Name',
          imageId: '12345',
        }}
      />
    </Card>
  );
}
// Avatar.jsx

import { getImageUrl } from './utils.js';

export default function Avatar({ person, size }) {
  return (
    <img
      className="avatar"
      src={getImageUrl(person)}
      alt={person.name}
      width={size}
      height={size}
    />
  );
}
// utils.js

export function getImageUrl(person, size = 's') {
  return 'https://example.com/' + person.imageId + size + '.jpg';
}
Вы можете думать о компоненте с пропсом children как о специальной лозейке, которую можно «заполнить» родительскими компонентами с произвольным JSX. Вы часто будете использовать проп children для визуальных оболочек: панелей, сеток и т.д.

Как пропсы меняются со временем

Компонент Clock ниже получает два пропса от своего родительского компонента: color и time. (Код родительского компонента опущен, поскольку он использует состояние, в которое мы пока не будем углубляться.)
export default function Clock({ color, time }) {
  return <h1 style={{ color: color }}>{time}</h1>;
}
Этот пример иллюстрирует, что компонент может получать пропсы с течением времени. Проп не всегда статичен. Здесь проп time меняется каждую секунду, а проп color меняется, когда вы выбираете другой цвет. Пропсы отражают данные компонента в любой момент времени, а не только в начале. Пропсы иммутабельны — термин из информатики, означающий «неизменяемый». Когда компоненту необходимо изменить свои пропсы (например, в ответ на взаимодействие с пользователем или новые данные), ему придется «попросить» родительский компонент передать ему другие пропсы — новый объект. Затем его старые пропсы будут отброшены, и в конечном итоге движок JavaScript очистит занятую ими память. Не пытайтесь "изменить пропсы" напрямую. Когда вам нужно отреагировать на пользовательский ввод (например, изменить выбранный цвет), вам нужно будет "установить состояние", о котором вы можете узнать в разделе "Состояние - память компонента".

Резюме

  • Чтобы передать пропсы, добавьте их в JSX, как и в случае с атрибутами HTML.
  • Чтобы прочитать пропсы, используйте синтаксис деструктурирования function Avatar({ person, size }).
  • Вы можете указать значение по умолчанию, например size = 100, которое используется для отсутствующих и неопределенных пропсов.
  • Вы можете перенаправить все пропсы с помощью синтаксиса распыления JSX <Avatar {...props} />, но не злоупотребляйте им.
  • Вложенный JSX, такой как <Card><Avatar /></Card>, будет отображаться как проп children компонента Card.
  • Пропсы - доступны только для чтения. Это такой снимок компонента во времени: каждый рендер получает новую версию пропса.
  • Нельзя менять пропсы внутри компонента. Когда вам нужна интерактивность, вам нужно установить состояние.