Контракт компонента
Вопрос
Как компонент принимает значение снаружи, отдаёт его потомкам и куда деваются атрибуты, которые не объявлены как пропсы?
Короткий ответ
Во Vue 3 v-model на компоненте — это проп modelValue и событие update:modelValue. Именованная модель пишется как v-model:title и даёт пару title / update:title. С Vue 3.4 макрос defineModel() возвращает ref: запись в него сама эмитит обновление.
provide / inject передают значение любому потомку без проброса пропсов через промежуточные узлы. Это зависимость для составного виджета (Tabs и Tab, форма и поле), а не замена Pinia. Чтобы связь осталась реактивной, отдают ref или reactive. Чтобы потомок не мутировал состояние молча, отдают readonly и функцию-мутатор.
Атрибуты, не объявленные в props и emits (class, style, id, слушатели), Vue вешает на корневой элемент. inheritAttrs: false отключает это, и их ставят вручную через v-bind="$attrs". Несколько корней без явной привязки дают предупреждение: Vue не знает, куда их положить.
Кастомная директива — только низкоуровневая работа с DOM. Если задачу закрывает компонент, директива не нужна.
Разбор
<script setup>
const model = defineModel()
const title = defineModel('title')
</script>До defineModel то же самое писали руками: defineProps, defineEmits и emit('update:modelValue', value).
Ключ для provide лучше делать через InjectionKey, а не строку. inject('user') теряет тип. Symbol, связанный с InjectionKey<Ref<UserContext>>, проверяет и сторону provide, и сторону inject.
Сквозные атрибуты на обёртке кнопки:
<script setup>
defineOptions({ inheritAttrs: false })
</script>
<template>
<div class="wrap">
<button v-bind="$attrs"><slot /></button>
</div>
</template>Корень здесь div, а disabled и @click должны попасть на button.
Хуки директивы повторяют жизненный цикл элемента: mounted, updated, unmounted. Практические случаи: v-click-outside, маска ввода, v-permission, которая убирает узел, если роли нет. Проверку роли в шаблоне через v-if обычно проще читать. Директива оправдана, когда одно и то же DOM-поведение нужно повесить на разные теги без обёртки.
На что смотрят на senior
Provide не заменяет стор приложения. Контекст таба умирает вместе с <Tabs>. Корзина и сессия живут дольше дерева и не должны требовать, чтобы потребитель сидел внутри конкретного родителя.
emits, которые не объявили, утекут в $attrs как нативные слушатели. На фрагменте это особенно заметно: клик «пропадает» или садится не на тот узел.
Директива, которая читает стор и удаляет узел, плохо тестируется и обходит реактивность: роль сменилась, а элемент уже снят с DOM. Для прав доступа чаще оставляют v-if или не рендерят маршрут.
Типичные ошибки
- Объявлять модель Vue 3 как
valueиinput. - Класть в
provideсырой объект и ждать, что потомок увидит замену поля. - Забывать
inheritAttrs: falseна обёртке и получатьclassи на корне, и на внутреннем теге, если повесили$attrsдважды. - Писать директиву ради логики, которой место в composable.
Задача
Tabs должен знать активную вкладку, Tab на три уровня ниже не должен получать её пропом через промежуточные обёртки. Куда класть состояние и как не дать потомку записать его напрямую?
Показать ответ
provide в Tabs: readonly(active) и функция select. Потомок делает inject. Для типов ключ — InjectionKey, не строка.
Pinia здесь лишняя: вкладка живёт вместе с виджетом, не со всем приложением. Без readonly любой Tab сможет заменить ref молча, и источник правды размажется.