Vue
Паттерны компонентов
В приложении четыре места, где есть список с поиском, сортировкой и выделением: таблица заказов, выбор адресата, каталог и админка. Логика одна и та же, но вёрстка везде разная. Скопировать компонент четыре раза значит размножить баги. Сделать один компонент с двадцатью пропсами под все варианты значит превратить его в неуправляемого монстра. Правильный ответ это разделение логики и представления: композабл или renderless-компонент держит поведение, а вёрстку каждое место рисует своё через слоты. Этот урок про четыре паттерна, которые делают компоненты переиспользуемыми без копипасты.
- VueUse: сотни композаблов вроде useMouse и useLocalStorage, переиспользуемая логика без вёрстки
- Headless UI и Radix Vue: доступные компоненты без стилей, вёрстку даёт потребитель через слоты
- TanStack Table для Vue: вся логика таблицы в headless-ядре, разметку рисует разработчик
- provide и inject в дизайн-системах: тема и конфигурация прокидываются вниз по дереву без проп-дриллинга
- Слоты как API: компонент модалки задаёт поведение, а содержимое заголовка и тела передаёт потребитель
Предварительные знания
- Уверенная работа с композаблами и реактивным состоянием
- Понимание слотов и scoped-слотов на уровне идеи
- Знакомство с пропсами, событиями и деревом компонентов
Паттерн композабла
Композабл это функция, имя которой по соглашению начинается с use, возвращающая реактивное состояние и методы для работы с ним. Это базовый способ вынести логику из компонента: вместо того чтобы держать обработку поиска и сортировки внутри каждого списка, её собирают в одну функцию и переиспользуют. Компонент при этом отвечает только за разметку, а вся механика живёт в композабле.
Функция параметризована типом T, поэтому работает с любым типом элемента без any и без кастов. Предикат matches передаётся снаружи, что отделяет общую механику фильтрации от конкретной логики совпадения. Любой компонент со списком теперь подключает useFilteredList и получает готовое реактивное поведение.
Композабл переиспользует именно логику, но не разметку. Каждый компонент по-прежнему пишет свой шаблон. Это сильная сторона, когда вёрстка разная, а поведение одно. Когда же повторяется ещё и часть разметки или нужен общий жизненный цикл, в дело вступают renderless-компоненты.
Что именно переиспользует паттерн композабла между компонентами?
Renderless и headless компоненты
Renderless-компонент (его же называют headless) держит поведение, но не рисует собственную разметку. Вместо вывода готового HTML он отдаёт своё состояние и методы в scoped-слот, а как это отрисовать решает потребитель. Так компонент владеет логикой и жизненным циклом, но не навязывает ни одного тега и ни одного класса. Headless-библиотеки вроде Radix Vue построены именно на этом: они дают доступность и поведение, а внешний вид полностью на стороне приложения.
Компонент не содержит ни input, ни li: только slot, в который проброшены filtered, query и метод обновления. Потребитель получает эти данные в scoped-слоте и рисует любую вёрстку. Один и тот же FilterProvider обслуживает таблицу, выпадашку и каталог, при этом каждый рисует своё.
- Композабл — Чистая функция с состоянием. Переиспользует логику, не встраивается в дерево, не владеет слотами. Выбирается, когда нужна только общая механика
- Renderless-компонент — Встраивается в дерево, владеет жизненным циклом и отдаёт состояние через scoped-слот. Выбирается, когда нужен общий жизненный цикл или передача данных через слот
В большинстве случаев композабл проще и достаточен: он легче типизируется и не добавляет лишний узел в дерево. Renderless-компонент оправдан, когда поведение завязано на жизненный цикл компонента, на provide и inject или когда удобнее отдавать данные именно через scoped-слот, например в публичной библиотеке.
Как renderless (headless) компонент передаёт своё состояние потребителю, если сам не рисует разметку?
provide, inject и слоты как API
Когда значение нужно передать глубоко вниз по дереву (тема, текущий пользователь, конфигурация формы), передавать его пропсами через каждый промежуточный компонент утомительно и хрупко. Этот антипаттерн зовут проп-дриллингом. provide и inject решают его: родитель один раз предоставляет значение через provide, а любой потомок на любой глубине получает его через inject, минуя промежуточные слои.
InjectionKey<Theme> связывает ключ с типом значения, поэтому inject возвращает правильно типизированный результат без каста. inject может вернуть undefined, если провайдера выше нет, и здесь это обрабатывается явной проверкой с понятной ошибкой, а не non-null assertion. Так потребитель сразу узнаёт о неправильном использовании, и тип при этом сужается честно.
Последний паттерн смотрит на слоты как на публичный API компонента. Именованные слоты это объявленные точки расширения: компонент модалки задаёт поведение (фокус-ловушка, закрытие по Escape, оверлей), но содержимое заголовка, тела и подвала отдаёт потребителю через слоты header, default и footer. Scoped-слоты делают эти точки управляемыми данными, передавая в них внутреннее состояние. Так один компонент задаёт контракт расширения, а наполнение остаётся гибким.
provide и inject создают неявную зависимость: потомок завязан на наличие провайдера где-то выше, и это не видно в его сигнатуре. Поэтому ключ типизируют через InjectionKey, оборачивают доступ в композабл useX с проверкой и осмысленной ошибкой, а сам паттерн применяют для кросс-секционных данных вроде темы, а не как замену обычным пропсам.
Тему приложения нужно отдать множеству компонентов на разной глубине дерева. Какой паттерн подходит и как сделать его типобезопасным без каста?
Связь с другими темами
Урок опирается на композаблы и питается типизацией:
- Композаблы — Композабл это базовый способ вынести логику, на нём строятся остальные паттерны
- TypeScript в Vue — Дженерики и типизированные слоты делают переиспользуемые паттерны типобезопасными
- Сборка: SFC-компилятор — Компилятор превращает слоты и scoped-слоты в функции рендера, что объясняет их поведение
Итог
- Паттерн композабла выносит логику с состоянием в функцию useX, которую можно переиспользовать в любом компоненте без дублирования
- Renderless или headless компонент держит поведение, но не рисует разметку сам: вёрстку отдаёт потребителю через scoped-слот
- Scoped-слот передаёт данные снизу вверх: компонент отдаёт состояние и методы в слот, а потребитель решает, как их отрисовать
- provide и inject прокидывают значение вниз по дереву на любую глубину без передачи пропсов через промежуточные компоненты
- Слоты это публичный API компонента: именованные слоты задают точки расширения, а scoped-слоты делают эти точки управляемыми данными
- Композабл переиспользует логику, а renderless-компонент дополнительно встраивается в дерево и владеет жизненным циклом, поэтому выбор зависит от нужд
Связанные уроки
- vue-40-typescript-vue — Дженерик-компоненты и типизированные слоты дают паттернам типобезопасность
- vue-43-build-tooling — Понимание компилятора SFC объясняет, во что превращаются слоты и provide во время сборки