Анти-легаси архитектура для UI приложений
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
Состояние приложения (тип)
Пропсы для всего представления легко компонуются в структуру, которая точно соответствует структуре дерева компонентов, и, вдобавок, статически типизированы, если это реализовано на 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) => {
e.preventDefault();
EventSubject.next({ type: "change", id, payload: e?.target?.value });
},
onKeyDown: (e: React.KeyboardEvent) => {
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
возвращают обработчики, которые передают необходимую информацию подписчику и работают точно так же, как и первоначальная обертка.
Обратите внимание, что для элементов списка нам нужно предоставить дополнительную метаданные о них, чтобы мы могли отличать их друг от друга. controlId
помогает нам определить, на какой элемент управления ссылается, а uniqueId
— позволяет отличать различные элементы в списке.
{items.map(({ name, id }) => {
return (
- {name}
)
})}
Примечания
Да, можно использовать Storybook для быстрой оценки того, насколько правильно работает приложение, даже если слой представления не полностью отделен от логики.
Однако при такой связи потребуется мокать зависимости или полагаться на фактическое взаимодействие с интегрированными частями приложения, и это гарантировано превратится в сложный клубок.
Какое-то время это будет работать, но со временем станет слишком муторным и ненадежным. Сохранение чистого слоя представления позволит нам проверять слой представления в необходимых состояниях и полагаться на автоматизацию.
Предварительный итог
Мы рассмотрели практический пример того, как сделать слой представления полностью отделенным от остальной части приложения, и использовали Storybook, чтобы быстро проверять различные состояния этого представления.
Возможность полностью отделить слой представления как чистую функцию дает ряд преимуществ. Среди них есть возможность автоматизировать преобразование дизайнов в код, где слой представления больше не будет привязан к определенной технологии.
Как внедрить шаблон MVU в приложение React?
Теперь, когда мы научили наш слой представления триггерить действия, мы наконец можем влиться в однонаправленный поток данных, чтобы сделать его интерактивным.
Шаблон MVU в Elm
Elm использует шаблон MVU с однонаправленным потоком данных.
Схема этой архитектуры выглядит вот так:
Elm MVU pattern
Есть модель — данные, которые представляют состояние нашего приложения. Эта модель используется слоем представления для отображения правильного состояния пользовательского интерфейса.
Когда пользователь взаимодействует с UI, сообщение отправляется в функцию update, которая возвращает новую модель (новое состояние), которая в свою очередь используется представлением, таким образом замыкая полный круг.
Однако есть еще одна часть — команды, которые производят побочные эффекты, а также реактивные подписки, которые добавочно усложняют этот шаблон.
Decoupler MVU
Было бы лучше если бы мы не разделяли UI и сайдэффекты — в конце концов, они все IO-агенты.
IO-агент — нечистая функция, которая принимает модель (состояние), что-то делает с ней и внешним миром, а затем возвращает сообщение.
В случае представления такие действия — это нажатия кнопок и другие действия пользователя. Пользователь видит определенный экран, принимает какое-то решение, и в результате как-то взаимодействует с интерфейсом, что приводит приводит к триггеру какого-то сообщения. Взаимодействие пользователя с пользовательским интерфейсом тут можно представить в виде подобного IO-агента.
А вот в случае другого IO, например, запросов HTTP, IO-агент видит некоторое состояние приложения, затем на основе определенных параметров решает запросить или не запросить что-то извне, а затем триггерит сообщение.
Эта абстракция, где и представление, и запросы и другие нечистые функции являются формой IO-агента, позволяет легко отделить логику IO от основы приложения. Тем самым она позволяет нам сделать однонаправленный поток легко-тестируемым, поскольку функция обновления является просто чистой функцией (которую можно тестировать в блэкбокс-стиле), а IO-агенты теперь могут быть заменены упрощенными представлениями (dependency inversion principle) для удобства тестирования.
Decoupler MVU
Эта абстракция также имеет смысл для подавляющего большинства UI приложений, поскольку большинство из них являются приложениями, связанными именно с IO (в отличие от приложений, связанных с CPU или GPU), где основная цель — общаться с различными компонентами ввода-вывода (побочные эффекты) — такими как запросы HTTP, WebSockets, UI-user взаимодействие и многие другие.
Таким образом, чтобы упростить этот шаблон и дать больше гибкости и лучше отделить компоненты, мы можем использовать шаблон IO-Update.
Однонаправленный поток приложения теперь может быть представлен простой функцией:
const applicationLoop = async (state: PState): Promise => {
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-функции.
Полезные ссылки
Кричащая архитектура (Роберт Мартин)
Thinking in React
Storybook Documentation
Elm Architecture
React Components and Props
Understanding React«s Unidirectional Data Flow
React Container Components
React Observable Subject
Figma Design Tool
Example of a Static Version of an App
Elm Architecture Explained
Figma Official Website
UI Design Handbook by Design+Code
Widget Code Generator by Figma