Часть 3. Порты, адаптеры и UI

Продолжаем серию постов и экспериментов о разработке и проектировании приложений.

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

Анализируем UI приложения

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

Конвертер с полем ввода базовой валюты, селектором квот-валюты и кнопкой обновления котировок
Конвертер с полем ввода базовой валюты, селектором квот-валюты и кнопкой обновления котировок

Конвертер будет хранить в себе некоторое состояние — данные, которые будут влиять на отрисовку компонентов на экране. Чтобы понять, как с этими данными работать, посмотрим, какие компоненты и что будут выводить на экране, как информация будет «протекать» через приложение во время работы с ним.

UI как функция от состояния

Первое, на что мы обратим внимание — это заголовок компонента и текстовое поле под ним. Поле содержит текущее значение базовой валюты, а заголовок — значение квот-валюты, вычисленное по текущему курсу:

Схема компонентов и данных, которые они показывают пользователю
Схема компонентов и данных, которые они показывают пользователю

Мы можем представить эти два компонента, как «преобразование» данных из доменной модели в набор и вид компонентов на экране:

[Domain Model]:   =>   [UI Components]:
_________________________________________

BaseValue         =>   <BaseValueInput />
BaseValue
  & QuoteValue    =>   <CurrencyPair />
  & ExchangeRate

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

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

При разделении данных и представления эффекты оказываются как бы «изолированы» где-то на краю приложения. Это делает код удобнее в понимании, отладке и тестировании.

Однако, не все данные, которые влияют на отрисовку UI — это исключительно модель, и нам стоит также учитывать (и выделять в коде) другие виды состояния.

Виды состояния

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

Например, если нажать на кнопку обновления котировок в конвертере, то приложение загрузит данные с сервера API. Загруженные оттуда данные — это часть серверного состояния. Мы его почти не контролируем и задача UI — синхронизироваться с ним, чтобы показывать актуальную информацию оттуда.

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

Как правило, UI-состояние — это данные, которые описывают непосредственно UI. Оно, например, включает вещи вроде «Заблокирована ли кнопка», «Как отсортирован список валют», «Какой экран открыт» и реагирует на действия пользователя, даже если модель не меняется.

Потоки данных

В сложных интерфейсах компоненты могут зависеть сразу от нескольких видов состояния. Например, если селектор квот-валют должен быть отсортирован, то этот компонент будет функцией от модели и UI-состояния одновременно:

[Domain Model]:   =>  [UI State]:    =>  [UI Component]:
___________________________________________________________

CurrentQuoteCode  =>  SortDirection  =>  <CurrencySelector
& CurrencyList                            selected={QuoteCurrencyCode}
                                          options={CurrencyList}
                                          sort={SortDirection} />

В коде мы будем стараться подчёркивать эти зависимости и разделять разные виды состояния. Не всегда мы будем разделять их физически (по файлам), но как минимум будем стараться делать это концептуально — в типах и значениях переменных, передаваемых компонентам.

Разнвые виды состояния меняются по разным причинам и с разной частотой. Часто меняющийся код (volatile) мы будем ставить ближе к «краям приложения», чем более стабильный (robust). Так мы постараемся исключить влияние частых изменений на ядро приложения, ограничить распространение изменений по кодовой базе, покажем, что данные модели первичны и укажем направление, в котором данные «протекают» к компонентам.

Порты и адаптеры

Ранее мы представляли приложение, как «коробку с рычажками и слотами», через которые оно общается с внешним миром. Пользовательский ввод и вывод на экран можно считать таким «общением»:

Ядро приложения «общается» с интерфейсом через «рычажки» и «слоты» пользовательского ввода и вывода
Ядро приложения «общается» с интерфейсом через «рычажки» и «слоты» пользовательского ввода и вывода

Когда пользователь нажимает на кнопку или меняет значение в текстовом поле, UI отправляет сигнал (a.k.a. команду, экшен) в ядро приложения, чтобы то запустило какой-либо юзкейс. В нашем приложении таким сигналом будет функция, которая реализует входной порт:

// Входной порт в приложение:
type RefreshRates = () => Promise<void>;

// Функция, реализующая тип `RefreshRates`:
const refresh: RefreshRates = async () => {};

// При клике по кнопке UI вызовет функцию,
// реализующую этот входной порт.

Чтобы обработать событие клика по кнопке в Реакте, мы можем написать вот такой компонент:

function RefreshRates() {
	return (
		<button type="button" onClick={refresh}>
			Refresh Rates
		</button>
	);
}

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

Управляющие адаптеры и UI

Компоненты берут на себя обработку пользовательского ввода и представления информации на экране. Они «переводят» сигналы с языка приложения на язык, понятный пользователю и браузерным API:

Компоненты переводят намерение пользователя на язык приложения и обратно
Компоненты переводят намерение пользователя на язык приложения и обратно

Чтобы сделать это чуть более очевидным, давайте слегка изменим компонент, чтобы он предотвращал дефолтное поведение при клике по кнопке:

function RefreshRates() {
	const clickHandler = useCallback((e) => {
		// Интерфейс браузерных API:
		e.preventDefault();

		// Интерфейс ядра приложения:
		// - функция `refresh` реализует входной порт,
		// - компонент опирается на её тип
		//   и рассчитывает на обещанное поведение.
		refresh();
	}, []);

	// Интерфейс, понятный пользователю:
	// - элемент похож на кнопку, значит его можно нажать,
	// - у него есть подпись, которая объясняет,
	//   что произойдёт после нажатия.
	return (
		<button type="button" onClick={clickHandler}>
			Refresh Rates
		</button>
	);
}

Компонент RefreshRates как бы преобразует намерение пользователя в язык доменной модели:

UserIntent => ButtonClickEvent => RefreshRates

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

«Адаптерами» могут быть не только компоненты, а в принципе любая функция, которая умеет обрабатывать события UI:

window.addEventListener('focus', () => refresh());
window.onresize = debounce(refresh, 250);
setTimeout(() => refresh(), 15000);

// FocusEvent => RefreshRates
// ResizeEvent => Debounced => RefreshRates
// TimerFiredEvent => RefreshRates

Главная ценность этих «адаптеров» в том, что они очерчивают зону ответственности UI-кода. Он не может напрямую вмешиваться в работу ядра приложения, потому что единственный способ как-то на неё повлиять — отправить сигнал через входной порт.

Такое разграничение помогает ограничивать распространение изменений. Так как все входные порты чётко определены, ядру не важно, что именно будет происходить «по ту сторону». Представление на экране может изменяться как угодно, но мы будем всегда уверены, что общение с ядром будет проходить по заранее определённым правилам:

Изменение компонентов не влияет на ядро, потому что всё «общение» проходит через неизменные порты. Так мы можем использовать разные компоненты для одного и того же юзкейса или кусочка данных модели
Изменение компонентов не влияет на ядро, потому что всё «общение» проходит через неизменные порты. Так мы можем использовать разные компоненты для одного и того же юзкейса или кусочка данных модели

Реализация компонентов

Попробуем теперь, опираясь на порты приложения, написать компоненты для пользовательского интерфейса. Начнём с простого и напишем текстовое поле, отвечающее за обновление значения базовой валюты.

BaseValueInput

Сперва создадим разметку:

// ui/BaseValueInput

export function BaseValueInput() {
	return (
		<label>
			<span>Value in RPC (Republic Credits):</span>
			<input type="number" min={0} step={1} value={0} />
		</label>
	);
}

Теперь добавим обработку пользовательского ввода и запуск функции, реализующей входной порт UpdateBaseValue:

// ui/BaseValueInput

// Пока что просто заглушка,
// реализующая интерфейс входного порта:
const updateBaseValue: UpdateBaseValue = () => {};

export function BaseValueInput() {
	// Компонент-«адаптер», который переводит сигнал от поля (событие изменения)
	// в сигнал, понятный ядру приложения (вызов порта `updateBaseValue` с нужными параметрами).

	const onChange = useCallback(
		(e: ChangeEvent<HTMLInputElement>) => updateBaseValue(e.currentTarget.valueAsNumber),
		[]
	);

	return (
		<label>
			<span>Value in RPC (Republic Credits):</span>
			<input type="number" min={0} step={1} value={0} onChange={onChange} />
		</label>
	);
}

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

type BaseValueInputProps = {
	updateBaseValue: UpdateBaseValue;
};

export function BaseValueInput({ updateBaseValue }: BaseValueInputProps) {
	return; /*...*/
}

В тестах компонента, мы сможем передать в пропсах мок, который будет проверять, что при вводе в текстовое поле мы действительно вызываем эту функцию с правильными параметрами:

describe('when entered a value in the field', () => {
	it('triggers the base value update handler', () => {
		const updateBaseValue = vi.fn();
		render(<BaseValueInput updateBaseValue={updateBaseValue} />);

		// Находим поле, вводим в него строку 42:
		const field = screen.getByLabelText(/Value in RPC/);
		act(() => fireEvent.change(field, { target: { value: '42' } }));
		//        ↑ Лучше использовать `userEvent`.

		// Проверяем, что входной порт был вызван с _числом_ 42.
		expect(updateBaseValue).toHaveBeenCalledWith(42);

		// (То есть задача компонента извлечь нужные данные из поля
		//  в виде числа и передать их в функцию входного порта.)
	});
});

Заметим, что в тесте мы проверяем только код, который находится перед портом. Всё, что происходит после вызова updateBaseValue — уже не ответственность компонента. Его работа в том, чтобы вызвать определённую функцию с правильными параметрами, а дальше приложение берёт ответственность за выполнение на себя.

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

Тривиальный тест

Иногда мы можем обнаружить, что компонент занимается тривиальной задачей — например, просто дёргает функцию в ответ на действие пользователя, без каких-то дополнительных действий. Например:

const SampleComponent = ({ someInputPort }) => {
	return (
		<button type="button" onClick={someInputPort}>
			Click!
		</button>
	);
};

Тест такого компонента сведётся к проверке, что по клику вызвалась переданная функция:

describe('when clicked', () => {
	it('triggers the input port function', () => {
		// ...
	});
});

Писать ли такие тесты — зависит от предпочтений и политики проекта. Я здесь соглашусь с Марком Симанном, который пишет, что такие тесты не особо полезны. Если сложность функции равна 1, то тесты таких функций можно пропустить, чтобы не зашумлять код.

Значение в BaseValueInput

Кроме реакции на пользовательский ввод, поле базовой валюты должно ещё и показывать собственно значение. Добавим новый входной порт, который обеспечит это значение:

// core/ports.input

type SelectBaseValue = () => BaseValue;

Теперь мы можем обновить зависимости компонента, указав там этот порт тоже:

// ui/BaseValueInput

type BaseValueInputProps = {
	updateBaseValue: UpdateBaseValue;
	selectBaseValue: SelectBaseValue;
};

Реализацией такого порта может быть любая функция, в том числе и хук, поэтому мы можем сразу поменять имя в зависимостях на useBaseValue:

// ui/BaseValueInput

type BaseValueInputProps = {
	updateBaseValue: UpdateBaseValue;
	useBaseValue: SelectBaseValue;
};

После этого мы можем использовать этот хук, чтобы получить нужное значение и вывести его в поле:

export function BaseValueInput({ updateBaseValue, useBaseValue }: BaseValueInputProps) {
	// Получаем значение через хук,
	// который реализует входной приложения:
	const value = useBaseValue();

	// ...

	return (
		<label>
			<span>Value in RPC (Republic Credits):</span>

			{/* Выводим это значение в текстовом поле: */}
			<input type="number" min={0} step={1} value={value} onChange={onChange} />
		</label>
	);
}

Чтобы протестировать селектор данных useBaseValue, мы проверим отформатированное значение внутри поля:

// ui/BaseValueInput.test

const updateBaseValue = vi.fn();
const useBaseValue = () => 42;
const dependencies = {
	updateBaseValue,
	useBaseValue
};

// ...

it('renders the value from the specified selector', () => {
	render(<BaseValueInput {...dependencies} />);
	const field = screen.getByLabelText<HTMLInputElement>(/Value in RPC/);
	expect(field.value).toEqual('42');
});

Такой тест тоже можно считать тривиальным и не создавать его отдельно, а проверить всё вместе с помощью интеграционного теста.

Зависимости как пропсы

Вы могли заметить, что сейчас мы передаём зависимости компонента как пропсы:

type BaseValueInputProps = {
	updateBaseValue: UpdateBaseValue;
	useBaseValue: SelectBaseValue;
};

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

  • Пока нет готовой инфраструктуры (API, стор), нам неоткуда импортировать хук useBaseValue и использовать его напрямую. Поэтому сейчас мы опираемся на интерфейс этого хука, как на гарантии, что это поведение «когда-то и кем-то» будет обеспечено.
  • Зацепление между UI и ядром приложения становится ниже из-за «буферной зоны» между ними, поэтому проектировать UI и бизнес-логику можно параллельно и независимо. Это нужно не всегда, но мы пишем код «по книжкам», поэтому следуем и этой рекомендации тоже.
  • Композиция UI и остального приложения становится более явной, так как если не передать конкретные реализации зависимостей, приложение не соберётся.

Это вовсе не значит, что нам придётся использовать эти пропсы в будущем. В одном из следующих постов мы отметим момент, когда будем готовы убрать такую «явную композицию» и импортировать хуки напрямую в компоненты.

Презентационные компоненты и контейнеры

Кроме компонентов, которые обеспечивают связь с ядром приложения, мы можем ещё выделить компоненты, которые занимаются исключительно отрисовкой данных на экране — так называемые презентационные компоненты. Они не содержат бизнес-логики, не знают о приложении и зависят только от своих пропсов, а область их действия ограничивается UI-слоем.

Презентационные компоненты как бы «отделены» от приложения контейнерами. Они не знают, откуда в них появляются данные и куда уходят события — этим занимаются «контейнеры»
Презентационные компоненты как бы «отделены» от приложения контейнерами. Они не знают, откуда в них появляются данные и куда уходят события — этим занимаются «контейнеры»

В нашем приложении таким презентационным компонентом может быть, например, Input. Это «обёртка» над стандартным текстовым полем с некоторыми настроенными стилями:

import type { InputHTMLAttributes } from 'react';
import styles from './Input.module.css';

type InputProps = Omit<InputHTMLAttributes<HTMLInputElement>, 'className'>;

export function Input(props: InputProps) {
	return <input {...props} className={styles.input} />;
}

Задача этого компонента — правильно и красиво нарисовать текстовое поле. Логики в нём нет, он не использует хуки, не получает доступа к каким-либо данным или функциональности, и всё его поведение зависит исключительно от его пропсов.

Использовать такой компонент в BaseValueInput мы могли бы таким образом:

import { Input } from '~/shared/ui/Input';

export function BaseValueInput(/*...*/) {
	const value = useBaseValue();
	const onChange = useCallback(/*...*/);

	return (
		<label>
			<span>Value in RPC (Republic Credits):</span>
			<Input type="number" min={0} step={1} value={value} onChange={onChange} />
		</label>
	);
}

В отличие от презентационного Input, компонент BaseValueInput знает о приложении и умеет посылать в него сигналы и считывать из него информацию. Когда-то давно такие компоненты назывались контейнерами.

Грубо говоря, работа презентационного компонента — быть красивым, работа контейнера — обеспечить связь с приложением. Поэтому BaseValueInput знает, как вызвать «юзкейсы» и как адаптировать данные и сигналы приложения к презентационному компоненту Input:

Контейнер `BaseValueInput` «переводит» события презентационного компонента в сигналы приложения и обеспечивает его всеми нужными данными
Контейнер `BaseValueInput` «переводит» события презентационного компонента в сигналы приложения и обеспечивает его всеми нужными данными

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

UI и асинхронные операции

Вернёмся к кнопке обновления котировок. Мы помним, что клик по ней запускает асинхронный процесс:

// core/ports.input

type RefreshRates = () => Promise<void>;

Функцию, реализующую входной порт RefreshRates, можно передать в качестве обработчика клика по кнопке:

// ui/RefreshRates

type RefreshRatesProps = {
	refreshRates: RefreshRates;
};

export function RefreshRates({ refreshRates }: RefreshRatesProps) {
	return (
		<Button type="button" onClick={refreshRates}>
			Refresh Rates
		</Button>
	);
}

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

Обновим тип RefreshRatesProps и укажем, что компонент зависит не только от входного порта приложения, но и ещё от некоторого состояния:

type RefreshAsync = {
	// Компонент всё ещё получает функцию,
	// реализующую входной порт:
	execute: RefreshRates;

	// Но кроме этого он ещё получает данные,
	// ассоциированные со статусом этой операции:
	status: { is: 'idle' } | { is: 'pending' };
};

type RefreshRatesDeps = {
	// Допустим, компонент получает это всё через хук:
	useRefreshRates: () => RefreshAsync;
};

Внутри компонента мы можем опереться на гарантии от useRefreshRates, что при изменении статуса будут меняться и ассоциированные данные:

export function RefreshRates({ useRefreshRates }: RefreshRatesDeps) {
	const { execute, status } = useRefreshRates();
	const pending = status.is === 'pending';

	return (
		<Button type="button" onClick={execute} disabled={pending}>
			Refresh Rates
		</Button>
	);
}

Теперь компонент можно протестировать, передав в качестве хука-зависимости стаб, который будет возвращать определённое UI-состояние:

const execute = vi.fn();
const idle: Status = { is: 'idle' };
const pending: Status = { is: 'pending' };

describe('when in idle state', () => {
	it('renders an enabled button', () => {
		const useRefreshRates = () => ({ status: idle, execute });
		render(<RefreshRates useRefreshRates={useRefreshRates} />);

		const button = screen.getByRole<HTMLButtonElement>('button');

		expect(button.disabled).toEqual(false);
	});
});

describe('when in pending state', () => {
	it('renders a disabled button', () => {
		const useRefreshRates = () => ({ status: pending, execute });
		render(<RefreshRates useRefreshRates={useRefreshRates} />);

		const button = screen.getByRole<HTMLButtonElement>('button');

		expect(button.disabled).toEqual(true);
	});
});

describe('when the button is clicked', () => {
	it('triggers the refresh rates action', () => {
		const useRefreshRates = () => ({ status: idle, execute });
		render(<RefreshRates useRefreshRates={useRefreshRates} />);

		const button = screen.getByRole<HTMLButtonElement>('button');
		act(() => fireEvent.click(button));

		expect(execute).toHaveBeenCalledOnce();
	});
});

…А настоящую реализацию хука useRefreshRates мы напишем в одном из следующих постов 🙃

В следующий раз

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

Ссылки

Все ссылки на книги, статьи и другие материалы, упомянутые в статье.

UI как функция от состояния

Паттерны и шаблоны

Тестирование UI

Прочее

Другие части серии