В компонентных фреймворках ключевую роль играет разделение логики и представления. В Stencil это разделение реализуется через подход Container и Presentational компонентов, заимствованный из функционального и реактивного программирования. Такой подход повышает читаемость кода, упрощает тестирование и делает архитектуру масштабируемой.
Container-компоненты отвечают за данные, состояние и бизнес-логику, тогда как Presentational-компоненты концентрируются исключительно на отображении и не знают, откуда пришли данные и что с ними происходит за пределами UI.
Container-компонент — это компонент-оркестратор. Он управляет состоянием, работает с API, слушает события и передаёт данные вниз по иерархии.
Характерные признаки:
@State,
@Prop({ mutable: true })Пример container-компонента в Stencil:
import { Component, State, h } from '@stencil/core';
@Component({
tag: 'user-container',
shadow: true,
})
export class UserContainer {
@State() users: Array<{ id: number; name: string }> = [];
@State() loading = true;
async componentWillLoad() {
const response = await fetch('/api/users');
this.users = await response.json();
this.loading = false;
}
render() {
return (
<user-list
users={this.users}
loading={this.loading}
/>
);
}
}
Здесь компонент полностью сосредоточен на данных и жизненном цикле, а визуальное представление делегировано другому компоненту.
Presentational-компоненты (иногда называемые dumb-components) отображают данные и реагируют на переданные параметры, не храня собственного состояния и не выполняя бизнес-логику.
Основные свойства:
@Prop@StateПример presentational-компонента:
import { Component, Prop, h } from '@stencil/core';
@Component({
tag: 'user-list',
shadow: true,
})
export class UserList {
@Prop() users: Array<{ id: number; name: string }>;
@Prop() loading: boolean;
render() {
if (this.loading) {
return <p>Загрузка...</p>;
}
return (
<ul>
{this.users.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}
}
Компонент полностью детерминирован: одинаковые props всегда приводят к одинаковому UI.
Stencil по своей природе следует принципу однонаправленного потока данных:
Для этого используются @Event и
@Listen.
Пример передачи события из presentational-компонента:
import { Component, Event, EventEmitter, Prop, h } from '@stencil/core';
@Component({
tag: 'user-item',
shadow: true,
})
export class UserItem {
@Prop() name: string;
@Event() removeUser: EventEmitter<void>;
render() {
return (
<div>
<span>{this.name}</span>
<button onCl ick={() => this.removeUser.emit()}>
Удалить
</button>
</div>
);
}
}
Обработка события в container-компоненте:
<user-item
name={user.name}
onRemoveU ser={() => this.deleteUser(user.id)}
/>
Stencil поощряет композицию компонентов, и разделение на container и presentational логично ложится на этот принцип.
Вместо одного большого компонента:
Это снижает связанность и предотвращает разрастание компонентов до монолитов.
Presentational-компоненты идеально подходят для построения дизайн-систем:
Они не зависят от бизнес-контекста и могут использоваться в разных container-компонентах, а также в разных приложениях.
Stencil позволяет экспортировать такие компоненты как Web Components, что делает их независимыми от фреймворков.
Разделение container и presentational компонентов напрямую влияет на качество тестов:
Пример теста presentational-компонента:
it('renders users', async () => {
const page = await newSpecPage({
components: [UserList],
html: `<user-list></user-list>`,
});
page.root.users = [{ id: 1, name: 'Alex' }];
page.root.loading = false;
await page.waitForChanges();
expect(page.root.shadowRoot.textContent).toContain('Alex');
});
Чёткое разделение ролей упрощает работу с TypeScript:
Особенно важно явно типизировать входные данные presentational-компонентов, так как они используются многократно.
@State там, где достаточно
@PropТакой подход формирует устойчивую архитектуру, в которой компоненты остаются простыми, предсказуемыми и удобными для поддержки в долгосрочной перспективе.