Привет, Хабр!
Сколько раз вы сталкивались с ситуацией, когда сделали аккуратный Vue-компонент на <script setup>
, вроде всё красиво, а потом... вам внезапно нужно из родительского компонента сфокусировать инпут, сбросить фильтр, открыть модалку, валидировать форму. Казалось бы, задача тривиальная, но script setup
не даёт просто так вынуть методы наружу.
Сегодня рассмотрим одну из самых неочевидных, но крайне полезных возможностей Vue 3 — функцией defineExpose()
.
Зачем вообще нужен defineExpose()
Когда Vue 3 представил синтаксис script setup
, он упростил декларативную структуру компонентов. Нет необходимости возвращать объект из setup
, не нужно явно импортировать defineComponent
, всё стало более компактным.
Однако, с этой лаконичностью пришло одно жёсткое ограничение: всё, что вы определяете в script setup
, остаётся приватным. Ни один метод, переменная или реактивное состояние не будет доступен извне компонента, даже если родитель создал ref
на этот компонент.
Это сознательное архитектурное решение. Vue предполагает, что компонент должен быть инкапсулирован. Но в жизни часто сталкиваемся с кейсами, когда нужно дать родителю инструмент для управления дочерним компонентом напрямую.
Тут и поможет defineExpose()
.
Проблема: как добраться до метода внутри компонента?
Посмотрим на типичную ситуацию:
<!-- Parent.vue -->
<Child ref="childRef" />
<!-- Child.vue -->
<script setup>
const logMessage = () => {
console.log("Привет из компонента");
}
</script>
Теперь допустим, хочется вызвать logMessage
из родителя:
childRef.value.logMessage(); // Ошибка: undefined
Это не сработает потому что script setup
инкапсулирует всё внутри себя. Вся логика и данные компонента просто недоступны через ref
.
В отличие от классического setup
, где можно вернуть объект или использовать expose
, script setup
изначально ничего наружу не отдаёт. Vue рассматривает его как изолированную зону.
Что делает defineExpose() и как он работает
defineExpose()
позволяет явно указать, какие переменные или методы компонента вы хотите сделать доступными родителю через ref
.
Пример:
<script setup>
const sayHi = () => {
console.log('Привет из публичного API');
}
defineExpose({
sayHi
});
</script>
Теперь, если вы получите ref
на этот компонент в родителе, вы сможете вызвать:
childRef.value.sayHi(); // всё работает!
Только то, что вы передадите в defineExpose
, будет доступно. Всё остальное останется приватным, как и положено хорошему модульному коду.
Как это связано с ref на компонент
Для доступа к публичному API компонента необходимо использовать ref
на сам компонент, а не на внутренние элементы:
<ChildComponent ref="childRef" />
В setup
родителя:
const childRef = ref(null);
onMounted(() => {
childRef.value.sayHi(); // работает, если sayHi был передан через defineExpose
});
Если вы попытаетесь вызвать метод без defineExpose
, childRef.value
будет пустым объектом или вообще undefined
.
Примеры применения
.focus() у поля ввода
Компонент:
<template>
<input ref="inputEl" />
</template>
<script setup>
const inputEl = ref();
const focus = () => {
inputEl.value?.focus();
}
defineExpose({ focus });
</script>
Родитель:
<template>
<MyInput ref="inputRef" />
<button @click="inputRef.focus()">Сфокусировать</button>
</template>
<script setup>
const inputRef = ref();
</script>
Без defineExpose
это бы не работало. Vue не стал бы передавать focus
наружу.
.validate() в форме
Представим, что есть форма, в которой необходимо проверить корректность данных перед отправкой. Компонент формы:
<script setup>
const isValid = ref(false);
const validate = () => {
isValid.value = /* логика валидации */;
return isValid.value;
}
defineExpose({ validate });
</script>
В родителе:
if (formRef.value.validate()) {
submitForm();
}
Можно использовать этот паттерн и в более сложных случаях, например, при асинхронной валидации.
Фильтр с .reset()
Часто в интерфейсах есть фильтры, которые пользователь должен сбрасывать по кнопке.
<script setup>
const filters = reactive({
category: null,
status: 'active'
});
const reset = () => {
filters.category = null;
filters.status = 'active';
}
defineExpose({ reset });
</script>
Родитель теперь может сбросить фильтры одним вызовом:
filterPanelRef.value.reset();
Как вообще работает defineExpose()
Все таки с этим нужно разобраться немного подробнее.
Когда Vue создаёт экземпляр компонента, он формирует внутреннюю структуру — набор связей, реактивных переменных, методов, локального состояния. Всё это — приватное, изолированное. И тут важно: даже если ты определил ref
, объявил функцию focus()
или создал computed
, родитель не получит доступ к этим штукам просто так через ref
на компонент. Vue их не прокидывает наружу по умолчанию.
Именно для этого внутри у каждого компонента есть специальное хранилище — ctx.exposed
. Это объект, который существует исключительно для того, чтобы родитель через ref.value
мог достучаться до только тех методов и переменных, которые ты явно разрешил. В классическом setup()
ты управляешь этим вручную — получаешь expose()
из аргументов и сам передаёшь туда, что хочешь экспортировать:
setup(props, { expose }) {
const focus = () => { input.value?.focus() }
expose({ focus });
}
Когда ты используешь <script setup>
, ты не видишь setup()
напрямую. Но Vue всё равно генерирует его. И если ты пишешь defineExpose({ focus })
, это буквально превращается в expose({ focus })
внутри сгенерированной функции..
Теперь пример для ясности:
defineExpose({
validate,
reset,
isOpen
});
Что получит родитель? Только эти три метода/переменных. ref.value.validate()
— работает. ref.value.reset()
— тоже. ref.value.anythingElse()
— уже нет. Это и есть суть: ты объявляешь API компонента, ты отвечаешь за его контракт.
Дальше интересней. Всё, что передается в defineExpose
, Vue сохраняет не в каком-то абстрактном пространстве, а в конкретном месте: vnode.component.exposed
. Это свойство объекта, в котором Vue аккуратно хранит публичный интерфейс компонента. Когда родитель рендерит <Child ref="childRef" />
, Vue создаёт VNode, вызывает setup()
, заполняет instance.exposed
, и в момент связывания просто делает childRef.value = instance.exposed
.
Вот почему ref.value
— это не сам компонент, не this
, а именно тот объект, который явно передали через expose()
или defineExpose()
.
Ещё нюанс: Vue не делает проксирование локального состояния. Он просто копирует ссылки. То есть если ты передаёшь ref
, родитель получит его .value
и будет видеть обновления. Но если передадим обычную переменную, например let visible = true
, то родитель увидит снимок на момент связывания — и не больше.
Пример:
const count = ref(0);
const reset = () => { count.value = 0 };
defineExpose({ count, reset });
Теперь родитель может вызывать childRef.value.reset()
и читать childRef.value.count.value
. Но increment()
— если ты его не экспознул — останется приватным. Vue ничего не угадывает, и это делает поведение абсолютно предсказуемым.
А если ты вообще не вызвал defineExpose()
или передал туда пустой объект — ref.value
будет undefined
или {}
. Это частая ошибка у тех, кто только начал использовать <script setup>
и надеется, что методы будут проброшены автоматически. Не будут. Vue не прокидывает наружу ничего, если ты об этом не попросил.
Плюс: defineExpose()
работает только если родитель указал ref
на компонент. Без этого всё, что ты экспознешь, будет просто лежать внутри компонента — недоступно извне. И это нормально: Vue создаёт API не "на авось", а по запросу. Кроме того, defineExpose()
не влияет ни на v-model
, ни на события, ни на слоты, ни на provide/inject
. Это — исключительно про доступ к API через ref.value
.
Теперь логичный вопрос: что происходит, когда компонент уничтожается? Здесь Vue тоже сработал грамотно. При unmount
компонента, он сам очищает ref.value
у родителя. То мы не обязаны вручную сбрасывать ссылку — Vue позаботится об адекватной очистке.
А вы уже использовали defineExpose()
в своих проектах? Поделитесь, где и как в комментариях.
Если вы работаете с Vue на практике — у вас наверняка есть задачи, которые не ограничиваются чистой теорией. Именно для таких случаев Otus проводит открытые уроки: короткие, по делу и на живых примерах. Ниже — два ближайших. Присоединяйтесь, если тема в фокусе вашей текущей работы или стоит в бэклоге.
14 апреля — Автосохранение в Vue: реализация через localStorage на примере боевого интерфейса
22 апреля — Vue.js 3: быстрый старт с актуальным стеком и подходами