React как чистое представление в Storybook

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

В данной статье мы порассуждаем о том, как такое разделение сделать.

React view как чистая функция состояния

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

Ещё считается, что React внес реактивность в пользовательский интерфейс, но это не так, так как шаблоны MVVM и фреймворки, которые сильно полагаются на реактивность, были введены раньше React. (Knockout и Angular с двойным биндингом данных, Ember.js Observable)

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

Однако React делает себе подножку когда вводит состояние и обратный поток данных, открывая дверь для высокой связности между бизнес-логикой и логикой представления.

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

Чтобы помочь React получить максимум от шаблонов, которым следует Elm, мы должны удалить из него обратный поток данных и принудительно прокидывать данные сверху вниз, а не через Redux-контейнеры.

Я продемонстрирую, как этот подход позволит нам получить то, что мы ищем — разделенное представление и уменьшение бойлерплейта.

Относительно переживаний по поводу prop-drilling — когда мы описываем представление как декларативное дерево, дерево пропсов просто отражает дерево компонентов, так что это не дриллинг в традиционном понимании. Дриллинг же — это когда мы поле someField прокидываем в той же форме явно из компонента в компонент.

Создание статической версии приложения с помощью Storybook

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

Слой презентации в Storybook
Слой презентации в Storybook
Состояние приложения (тип)
Состояние приложения (тип)

Пропсы для всего представления легко компонуются в структуру, которая точно соответствует структуре дерева компонентов, и, вдобавок, статически типизированы, если это реализовано на TS.

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

Однако, следует отметить, что теперь, когда наш пользовательский интерфейс чётко соответствует макетам в Figma, кажется немного утомительным преобразовывать дизайны в компоненты. В идеале это должно быть автоматизировано. Это, кстати, одна из причин, почему разработка пользовательского интерфейса так сложна — требуется много труда для высококачественного переноса дизайнов в код.

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

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

Но об этом мы ещё поговорим.

Event Wrapper

Итак, мы создали статическую версию UI, работающую как чистая функция от состояния.

Однако, чтобы сделать UI интерактивным, подобно MVU в Elm, нам нужно как-то триггерить действия. При этом мы должны быть внимательны, и не связаться с логикой.

Подход, заключается в том, чтобы ввести декларативные обертки, которые принимают метаданные: идентификаторы и информацию о событии, а затем отправляют ее в Obsevable Subject, на который мы можем подписаться по мере необходимости. Но мы не должны отправлять какие-либо данные о том, какое действие мы хотим выполнить, ведь это связало бы нас с бизнес-логикой. Это часто делается как в Elm, так и в Redux.

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

Вот вам пример того, как может выглядеть декларативный шаблон.

Обратите внимание, что это просто демонстрация концепции. Чтобы это полноценно работало, нужно дооптимизировать данную реализацию.

export const EventWrapper: React.FC<
  React.PropsWithChildren<{
    id: { controlId: string; uniqueId?: string };
  }>
> = (props) => {
  const { children, id } = props;
  const childrenWithProps = React.Children.map<
    React.ReactNode,
    React.ReactNode
  >(children, (child) => {
    if (React.isValidElement(child)) {
      return React.cloneElement(child, {
        id: [Object(id).values].join("-"),
        onClick: (e: React.MouseEvent) => {
          e.preventDefault();
          EventSubject.next({ type: "click", id });
        },
        onChange: (e: React.ChangeEvent<HTMLInputElement>) => {
          e.preventDefault();
          EventSubject.next({ type: "change", id, payload: e?.target?.value });
        },
        onKeyDown: (e: React.KeyboardEvent<HTMLInputElement>) => {
          if (e.key === "Enter") {
            e.preventDefault();
            EventSubject.next({ type: "enter", id, payload: e?.target?.value });
          }
        },
        // NOTE: This should be extensible for various handlers
      });
    }
    return child;
  });
  return <>{childrenWithProps}</>;
};

Теперь мы можем обернуть в это наши интерактивные компоненты следующим образом:

<EventWrapper id={{ id: "input" }}>
    <input disabled={disabled} className="input" value={input} />
</EventWrapper>

Вероятно, было бы лучше декларативно выбирать необходимые обработчики, как показано ниже:

<EventWrapper id={{ id: "input" }} handlers={["onClick", "onChange", "onFocus"]}>
  <input disabled={disabled} className="input" value={input} />
</EventWrapper>

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

<input 
  disabled={disabled} 
  className="input" 
  value={input} 
  onClick={EventWrapper.onClick(IdObject)} 
  onChange={onClick={EventWrapper.onChange(IdObject)}} 
/>

Здесь методы EventWrapper возвращают обработчики, которые передают необходимую информацию подписчику и работают точно так же, как и первоначальная обертка.

Обратите внимание, что для элементов списка нам нужно предоставить дополнительную метаданные о них, чтобы мы могли отличать их друг от друга. controlId помогает нам определить, на какой элемент управления ссылается, а uniqueId — позволяет отличать различные элементы в списке.

<ul>
  {items.map(({ name, id }) => {
    return (
      <EventWrapper id={{ controlId: id.controlId, uniqueId: id.uniqueId }}>
        <li>{name}</li>
      </EventWrapper>
    )
  })}
</ul>

Примечания

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

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

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

Предварительный итог

Мы рассмотрели практический пример того, как сделать слой представления полностью отделенным от остальной части приложения, и использовали Storybook, чтобы быстро проверять различные состояния этого представления.

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

Как внедрить шаблон MVU в приложение React?

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

Шаблон MVU в Elm

Elm использует шаблон MVU с однонаправленным потоком данных.

Схема этой архитектуры выглядит вот так:

Elm MVU pattern
Elm MVU pattern

Есть модель — данные, которые представляют состояние нашего приложения. Эта модель используется слоем представления для отображения правильного состояния пользовательского интерфейса.

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

Однако есть еще одна часть — команды, которые производят побочные эффекты, а также реактивные подписки, которые добавочно усложняют этот шаблон.

Decoupler MVU

Было бы лучше если бы мы не разделяли UI и сайдэффекты — в конце концов, они все IO-агенты.

IO-агент - нечистая функция, которая принимает модель (состояние), что-то делает с ней и внешним миром, а затем возвращает сообщение.

В случае представления такие действия — это нажатия кнопок и другие действия пользователя. Пользователь видит определенный экран, принимает какое-то решение, и в результате как-то взаимодействует с интерфейсом, что приводит приводит к триггеру какого-то сообщения. Взаимодействие пользователя с пользовательским интерфейсом тут можно представить в виде подобного IO-агента.

А вот в случае другого IO, например, запросов HTTP, IO-агент видит некоторое состояние приложения, затем на основе определенных параметров решает запросить или не запросить что-то извне, а затем триггерит сообщение.

Эта абстракция, где и представление, и запросы и другие нечистые функции являются формой IO-агента, позволяет легко отделить логику IO от основы приложения. Тем самым она позволяет нам сделать однонаправленный поток легко-тестируемым, поскольку функция обновления является просто чистой функцией (которую можно тестировать в блэкбокс-стиле), а IO-агенты теперь могут быть заменены упрощенными представлениями (dependency inversion principle) для удобства тестирования.

Decoupler MVU
Decoupler MVU

Эта абстракция также имеет смысл для подавляющего большинства UI приложений, поскольку большинство из них являются приложениями, связанными именно с IO (в отличие от приложений, связанных с CPU или GPU), где основная цель — общаться с различными компонентами ввода-вывода (побочные эффекты) — такими как запросы HTTP, WebSockets, UI-user взаимодействие и многие другие.

Таким образом, чтобы упростить этот шаблон и дать больше гибкости и лучше отделить компоненты, мы можем использовать шаблон IO-Update.

Однонаправленный поток приложения теперь может быть представлен простой функцией:

const applicationLoop = async (state: PState): Promise<void> => {
  try {
    const action = await io(state);

    const nextState = update(state, action);

    return applicationLoop(nextState);
  } catch (e) {
    console.error(e);

    return applicationLoop(state);
  }
};

Такое представление приложения кажется и простым и интуитивным: агент видит состояние и производит действие, действие обновляет состояние, агент видит состояние и производит действие и т.д.

Применение этого шаблона

Пытаясь реализовать подобный MVU паттерн, я создал класс и назвал его «decoupler». Вот ссылка на приложение, где я связал представление и остальную часть приложения, используя этот шаблон.

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

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

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

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

Заключение

В этой части мы рассмотрели практический пример того, как реализовать шаблон MVU, сделав UI интерактивным. Плюс мы поговорили о немного измененной и упрощенной версии MVU — “Decoupler MVU”.

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

Полезные ссылки

  1. Кричащая архитектура (Роберт Мартин)

  2. Thinking in React

  3. Storybook Documentation

  4. Elm Architecture

  5. React Components and Props

  6. Understanding React’s Unidirectional Data Flow

  7. React Container Components

  8. React Observable Subject

  9. Figma Design Tool

  10. Example of a Static Version of an App

  11. Elm Architecture Explained

  12. Figma Official Website

  13. UI Design Handbook by Design+Code

  14. Widget Code Generator by Figma

Комментарии (1)


  1. vanomak2
    07.12.2023 06:22
    +1

    Расскажите пожалуйста, почему ваше приложение безбожно тормозит на мобилках?