Навигация

Компоненты, слоты и композиция

Vue строит интерфейс из компонентов, но «состоит из компонентов» — ещё не значит «хорошо скомпонован». Настоящая гибкость появляется тогда, когда компонент умеет делегировать часть своей разметки родителю через слоты, менять тип выводимого элемента на лету, сохранять состояние при переключении вкладок или выносить кусок UI за пределы своего DOM-дерева. В этой статье мы разбираем именно эти механизмы композиции: слоты, render-функции, динамические компоненты, <KeepAlive> и <Teleport>.

Мы будем использовать Composition API и <script setup> как основной стиль. Options API упомянем только там, где это нужно для чтения legacy-кода. Каждая тема сопровождается примерами, сравнением с React и практическими советами, которые можно использовать на собеседованиях и в реальных проектах.

Содержание

  1. Почему композиция важна
  2. Слоты: default, named и scoped
  3. Render-функции и h()
  4. JSX во Vue
  5. Динамические компоненты <component :is>
  6. <KeepAlive> — сохранение состояния
  7. <Teleport> — рендеринг в другой узел DOM
  8. Сравнение с React
  9. Лучшие практики и антипаттерны
  10. Ключевые тезисы для интервью
  11. Заключение
  12. Полезные ссылки

Почему композиция важна

Компонент во Vue — это замыкание, в котором живут данные, шаблон и стили. Но если компонент всё решает сам, он быстро становится монолитом. Хороший компонент декларирует структуру и поведение, но позволяет внешнему коду подставлять содержимое, стилизацию или дочерние элементы.

Vue предоставляет несколько механизмов для такой композиции:

МеханизмЧто делаетТипичный сценарий
SlotsДочерний компонент получает разметку от родителяКарточка с настраиваемым заголовком и телом
Scoped slotsДочерний компонент передаёт данные в слот родителяТаблица, список, слайдер, где родитель решает, как отрисовать элемент
Render-функции / h()Компонент строит VNode вручнуюАбстрактные обёртки, высокоуровневые библиотеки, сложная условная логика
<component :is>Меняет рендеримый компонент по значениюТабы, мастеры, динамические формы
<KeepAlive>Кэширует экземпляр компонента при размонтированииВкладки, многошаговые формы, переключатели представлений
<Teleport>Рендерит содержимое в другой узел DOMМодальные окна, тултипы, toast-уведомления

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


Слоты: default, named и scoped

Слоты — это механизм, с помощью которого родительский компонент передаёт дочернему разметку, которую тот вставляет в своём шаблоне. Это аналог children в React, но со встроенной поддержкой именованных и scoped-слотов.

Default slot

Если в дочернем компоненте написать <slot></slot>, туда попадёт всё содержимое, переданное между открывающим и закрывающим тегом компонента.

<!-- BaseCard.vue -->
<template>
  <div class="card">
    <slot></slot>
  </div>
</template>

<style scoped>
.card {
  border: 1px solid #e2e8f0;
  border-radius: 8px;
  padding: 16px;
}
</style>
<!-- Parent.vue -->
<template>
  <BaseCard>
    <h3>Заголовок</h3>
    <p>Текст карточки</p>
  </BaseCard>
</template>

Всё, что оказалось внутри <BaseCard>, будет вставлено вместо <slot></slot>.

Fallback-контент

Слот может иметь содержимое по умолчанию, которое отобразится, если родитель ничего не передал.

<template>
  <button class="btn">
    <slot>Подтвердить</slot>
  </button>
</template>
<template>
  <BaseButton />                       <!-- "Подтвердить" -->
  <BaseButton>Сохранить</BaseButton>  <!-- "Сохранить" -->
</template>

Named slots

Компонент может иметь несколько слотов с именами. Родитель использует <template v-slot:name> или сокращение #name.

<!-- BaseLayout.vue -->
<template>
  <header>
    <slot name="header"></slot>
  </header>
  <main>
    <slot></slot>
  </main>
  <footer>
    <slot name="footer"></slot>
  </footer>
</template>
<!-- Parent.vue -->
<template>
  <BaseLayout>
    <template #header>
      <h1>Логотип</h1>
    </template>

    <p>Основной контент</p>

    <template #footer>
      <p>© 2026</p>
    </template>
  </BaseLayout>
</template>
СинтаксисЗначение
v-slot:headerПолная форма для named slot
#headerСокращение от v-slot:header
v-slot:defaultЯвное указание default slot
#default="{ data }"Scoped slot с деструктуризацией

Scoped slots

Иногда дочерний компонент хочет предоставить родителю не только место для вставки, но и данные, которые родитель может использовать при рендеринге. Это называется scoped slot.

<!-- UserList.vue -->
<script setup>
import { ref, onMounted } from 'vue';

const users = ref([]);

onMounted(async () => {
  const res = await fetch('/api/users');
  users.value = await res.json();
});
</script>

<template>
  <ul>
    <li v-for="user in users" :key="user.id">
      <slot :user="user" :isActive="user.status === 'active'">
        {{ user.name }}
      </slot>
    </li>
  </ul>
</template>
<!-- Parent.vue -->
<template>
  <UserList v-slot="{ user, isActive }">
    <span :class="{ active: isActive }">
      {{ user.name }} ({{ user.email }})
    </span>
  </UserList>
</template>

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

Деструктуризация и переименование

Vue позволяет деструктурировать scoped slot props прямо в шаблоне:

<template>
  <UserList v-slot="{ user: person, isActive }">
    <span>{{ person.name }}</span>
  </UserList>
</template>

Здесь user переименован в person для локального использования.

Scoped slot с несколькими слотами

Если в компоненте несколько named слотов, каждый из них может иметь свои scoped props:

<!-- DataTable.vue -->
<template>
  <table>
    <thead>
      <tr>
        <slot name="header" :columns="columns"></slot>
      </tr>
    </thead>
    <tbody>
      <tr v-for="row in rows" :key="row.id">
        <slot name="row" :row="row"></slot>
      </tr>
    </tbody>
  </table>
</template>
<!-- Parent.vue -->
<template>
  <DataTable :columns="columns" :rows="rows">
    <template #header="{ columns }">
      <th v-for="col in columns" :key="col.key">{{ col.label }}</th>
    </template>

    <template #row="{ row }">
      <td>{{ row.name }}</td>
      <td>{{ row.role }}</td>
    </template>
  </DataTable>
</template>

Доступ к слотам из setup

Внутри <script setup> слоты доступны через макрос useSlots():

<script setup>
import { useSlots } from 'vue';

const slots = useSlots();

function hasHeader() {
  return Boolean(slots.header);
}
</script>

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

Слоты и доступ к DOM

Если нужно передать ссылку на DOM-элемент или компонент через слот, используйте template refs в родителе:

<script setup>
import { ref, onMounted } from 'vue';

const slotContent = ref(null);

onMounted(() => {
  console.log(slotContent.value);
});
</script>

<template>
  <Child>
    <input ref="slotContent" />
  </Child>
</template>

Render-функции и h()

Шаблоны Vue компилируются в render-функции, которые возвращают виртуальные узлы — VNode. В большинстве случаев шаблон удобнее, но иногда нужна максимальная гибкость: условная логика, динамические теги, создание компонентов на лету, библиотеки UI-китов.

Функция h()

h() — это хелпер для создания VNode. Она принимает:

h(type, props, children);
АргументТипОписание
typestring | Component | VNodeHTML-тег, Vue-компонент или символ
propsobject | nullАтрибуты, пропсы, события, директивы
childrenstring | number | VNode | ArrayДочерние узлы

Простой пример:

<script setup>
import { h } from 'vue';

const vnode = h('div', { class: 'box' }, 'Hello');
// <div class="box">Hello</div>
</script>

Render-функция в компоненте

Компонент может вернуть render-функцию из setup():

<script>
import { h, ref } from 'vue';

export default {
  setup() {
    const count = ref(0);

    return () => h('button', {
      onClick: () => count.value++,
    }, `Count: ${count.value}`);
  },
};
</script>

Важно: возвращаемая функция должна быть стрелочной или обычной функцией, которая вызывается при каждом рендере. Внутри неё можно использовать реактивные значения — Vue отследит зависимости автоматически.

Render-функция в <script setup>

В <script setup> нельзя вернуть render-функцию напрямую, потому что нет явного return. Но можно использовать экспорт по умолчанию через defineOptions или вынести render-функцию в отдельный .js/.ts файл. На практике render-функции в <script setup> редки — их чаще пишут в обычном <script> или в composable.

<script>
import { h } from 'vue';

export default {
  setup(props, { slots }) {
    return () => h('div', { class: 'wrapper' }, slots.default?.());
  },
};
</script>

Условный рендеринг в render-функциях

Поскольку render-функция — это обычный JavaScript, вся логика пишется через if, switch, тернарные операторы:

<script>
import { h, ref } from 'vue';

export default {
  props: ['variant'],
  setup(props) {
    const count = ref(0);

    return () => {
      const tag = props.variant === 'primary' ? 'button' : 'a';
      return h(tag, {
        class: ['btn', `btn--${props.variant}`],
        onClick: () => count.value++,
      }, `Count: ${count.value}`);
    };
  },
};
</script>

События и модификаторы в h()

В render-функциях события передаются как обычные обработчики в props. Модификаторы v-on из шаблона приходится реализовывать вручную:

h('input', {
  onInput: (event) => {
    const value = event.target.value.trim();
    emit('update:modelValue', value);
  },
});

Работа со слотами в render-функциях

Слоты в render-функциях — это функции, возвращающие VNode:

<script>
import { h } from 'vue';

export default {
  setup(props, { slots }) {
    return () => h('div', { class: 'panel' }, [
      slots.header ? h('div', { class: 'panel__header' }, slots.header()) : null,
      h('div', { class: 'panel__body' }, slots.default?.()),
      slots.footer ? h('div', { class: 'panel__footer' }, slots.footer()) : null,
    ]);
  },
};
</script>

Scoped slots в render-функциях

Чтобы передать данные в scoped slot, передаём объект в функцию слота:

<script>
import { h } from 'vue';

export default {
  setup(props, { slots }) {
    const items = [{ id: 1, name: 'A' }, { id: 2, name: 'B' }];

    return () => h('ul', null, items.map(item =>
      h('li', { key: item.id }, slots.item?.({ item }))
    ));
  },
};
</script>

Родитель использует это так:

<template>
  <List :items="items">
    <template #item="{ item }">
      {{ item.name }}
    </template>
  </List>
</template>

Типизация render-функций

При использовании TypeScript можно типизировать h() через дженерики компонента:

import { h, defineComponent } from 'vue';
import type { VNode } from 'vue';

export default defineComponent({
  props: {
    title: {
      type: String,
      required: true,
    },
  },
  setup(props, { slots }) {
    return (): VNode => h('h1', null, props.title);
  },
});

JSX во Vue

JSX — это альтернатива шаблонам и h(). Vue поддерживает JSX через плагин @vitejs/plugin-vue-jsx.

<script setup lang="tsx">
import { ref } from 'vue';

const count = ref(0);
</script>

<template>
  <button onClick={() => count.value++}>
    Count: { count.value }
  </button>
</template>

Или полностью через JSX без <template>:

import { ref } from 'vue';

export default function Counter() {
  const count = ref(0);

  return () => (
    <button onClick={() => count.value++}>
      Count: {count.value}
    </button>
  );
}

Различия JSX Vue и React

АспектVue JSXReact JSX
СобытияonClick — нативное событиеonClick — SyntheticEvent
v-modelРеализуется через modelValue + событиеНет прямого аналога
ДирективыНет v-if/v-for, используется JSНет директив вообще
РеактивностьАвтоматическое отслеживаниеНет автоматического отслеживания
СлотыПередаются через v-slots или slotsЧерез children или render props

В Vue JSX менее распространён, чем в React, потому что шаблоны обычно удобнее и лучше оптимизируются компилятором. JSX полезен для сложных render-функций и интеграции с TypeScript.


Динамические компоненты <component :is>

Иногда тип компонента известен только во время выполнения: табы, мастеры, дашборды с виджетами. Для этого Vue предоставляет встроенный компонент <component> с атрибутом :is.

<script setup>
import { ref, shallowRef } from 'vue';
import TabA from './TabA.vue';
import TabB from './TabB.vue';
import TabC from './TabC.vue';

const currentTab = shallowRef(TabA);
</script>

<template>
  <div>
    <button @click="currentTab = TabA">Tab A</button>
    <button @click="currentTab = TabB">Tab B</button>
    <button @click="currentTab = TabC">Tab C</button>

    <component :is="currentTab" />
  </div>
</template>

Строковые имена компонентов

Если компонент зарегистрирован глобально, можно передать строку:

<template>
  <component :is="'MyGlobalComponent'" />
</template>

Но в современных приложениях предпочтительнее передавать сам импортированный компонент, чтобы сохранить типизацию и tree-shaking.

Сохранение состояния

По умолчанию <component :is> размонтирует старый компонент и смонтирует новый. Состояние теряется. Чтобы сохранить состояние, оборачиваем в <KeepAlive>:

<template>
  <KeepAlive>
    <component :is="currentTab" />
  </KeepAlive>
</template>

Динамические HTML-элементы

:is можно использовать и для обычных HTML-элементов:

<script setup>
import { ref } from 'vue';

const tag = ref('h1');
</script>

<template>
  <component :is="tag">Заголовок</component>
</template>

Ограничения

  • Если :is — строка и компонент не найден, Vue попытается отрендерить HTML-элемент с таким тегом.
  • Динамический компонент не сохраняет состояние без <KeepAlive>.
  • При использовании с v-model нужно убедиться, что все возможные компоненты поддерживают одинаковый интерфейс пропсов и событий.

<KeepAlive> — сохранение состояния

<KeepAlive> — это встроенный компонент, который кэширует экземпляры дочерних компонентов вместо их уничтожения. Когда компонент снова становится активным, Vue использует сохранённый экземпляр, а не создаёт новый.

<script setup>
import { ref, shallowRef } from 'vue';
import Editor from './Editor.vue';
import Preview from './Preview.vue';

const current = shallowRef(Editor);
</script>

<template>
  <div>
    <button @click="current = Editor">Редактор</button>
    <button @click="current = Preview">Превью</button>

    <KeepAlive>
      <component :is="current" />
    </KeepAlive>
  </div>
</template>

Lifecycle-хуки KeepAlive

Компонент внутри <KeepAlive> получает два дополнительных lifecycle-хука:

ХукКогда вызывается
onActivatedКомпонент становится активным после дезактивации
onDeactivatedКомпонент уходит в кэш и перестаёт быть активным
<script setup>
import { onActivated, onDeactivated, onMounted, onUnmounted } from 'vue';

onMounted(() => {
  console.log('первый монтаж');
});

onActivated(() => {
  console.log('компонент активен');
});

onDeactivated(() => {
  console.log('компонент деактивирован');
});

onUnmounted(() => {
  console.log('компонент уничтожен');
});
</script>

Порядок вызова при переключении вкладок:

  1. Монтаж: onMounted → onActivated.
  2. Переход на другую вкладку: onDeactivated.
  3. Возврат: onActivated.
  4. Размонтирование <KeepAlive>: onDeactivated → onUnmounted.

Пропсы <KeepAlive>

ПропсОписание
includeМассив или регулярка: какие компоненты кэшировать по имени
excludeМассив или регулярка: какие компоненты не кэшировать
maxМаксимальное количество кэшируемых экземпляров (LRU)
<template>
  <KeepAlive :include="['Editor']" :max="5">
    <component :is="current" />
  </KeepAlive>
</template>

Именованные компоненты

Для include/exclude компонент должен иметь name. В <script setup> имя задаётся через defineOptions:

<script setup>
defineOptions({ name: 'Editor' });
</script>

Когда не использовать KeepAlive

<KeepAlive> не подходит, если:

  • Компонент держит тяжёлые ресурсы, которые лучше освобождать при уходе.
  • Состояние должно сбрасываться при каждом открытии.
  • Компонент часто меняется и создаёт утечки памяти.

<Teleport> — рендеринг в другой узел DOM

<Teleport> позволяет render-дереву компонента продолжаться в другом месте реального DOM, не нарушая логическую иерархию компонентов. Это удобно для модалок, тултипов, toast-уведомлений и дропдаунов.

<script setup>
import { ref } from 'vue';

const isOpen = ref(false);
</script>

<template>
  <button @click="isOpen = true">Открыть модалку</button>

  <Teleport to="body">
    <div v-if="isOpen" class="modal">
      <p>Содержимое модалки</p>
      <button @click="isOpen = false">Закрыть</button>
    </div>
  </Teleport>
</template>

Атрибут to принимает CSS-селектор или HTMLElement. Содержимое <Teleport> рендерится внутри указанного узла, но события и реактивность продолжают работать в контексте исходного компонента.

Множественные Teleport в один узел

Несколько <Teleport> могут рендерить в один и тот же узел. Vue добавит их в порядке монтирования:

<template>
  <Teleport to="#modals">
    <ModalA />
  </Teleport>

  <Teleport to="#modals">
    <ModalB />
  </Teleport>
</template>

Отключение Teleport

Пропс disabled временно отключает телепортацию:

<template>
  <Teleport to="body" :disabled="!isFullscreen">
    <div>...</div>
  </Teleport>
</template>

Пропсы <Teleport>

ПропсТипОписание
tostring | HTMLElementЦелевой узел DOM
disabledbooleanОтключает телепортацию
deferbooleanОткладывает монтирование до завершения текущего render-цикла

Ограничения

  • Целевой узел должен существовать в DOM на момент монтирования.
  • <Teleport> не перемещает компонент между экземплярами Vue — он только меняет место рендеринга в DOM.
  • Стили scoped компонента-источника всё ещё применяются к телепортированным элементам.

Типичный паттерн: портал для модалок

Часто в index.html добавляют пустой контейнер:

<body>
  <div id="app"></div>
  <div id="modals"></div>
</body>

А компонент модалки телепортирует своё содержимое туда:

<template>
  <Teleport to="#modals">
    <div class="modal-overlay" @click.self="close">
      <div class="modal-content">
        <slot />
      </div>
    </div>
  </Teleport>
</template>

Сравнение с React

Концепция VueАналог в ReactОтличия
<slot>childrenВ Vue слоты декларативны и могут быть именованными
Scoped slotsRender propsVue передаёт данные через слот, React — через пропс-функцию
h()React.createElementПохожий API, но Vue использует реактивную систему
JSX во VueJSX в ReactВ Vue JSX менее распространён и имеет отличия в событиях
<component :is>Условный рендеринг компонентовВ Vue есть встроенный механизм смены типа
<KeepAlive>Нет прямого аналогаReact требует ручного управления состоянием или сторонних библиотек
<Teleport>createPortalПохожая идея, но API отличается

Слоты vs children

В React родитель передаёт детей через пропс children:

function Card({ children }) {
  return <div className="card">{children}</div>;
}

В Vue то же самое делается через <slot>:

<template>
  <div class="card">
    <slot></slot>
  </div>
</template>

Но Vue сразу поддерживает несколько именованных слотов, а в React для этого пришлось бы использовать несколько пропсов.

Scoped slots vs render props

В React render props выглядят так:

<DataList renderItem={(item) => <div>{item.name}</div>} />

В Vue scoped slot делает то же самое, но через декларативный шаблон:

<DataList>
  <template #default="{ item }">
    <div>{{ item.name }}</div>
  </template>
</DataList>

Оба подхода разделяют логику и представление, но Vue-вариант лучше вписывается в HTML-подобный стиль шаблонов.

Teleport vs createPortal

React использует createPortal(children, domNode):

import { createPortal } from 'react-dom';

return createPortal(<Modal />, document.getElementById('modals'));

Vue использует декларативный <Teleport to="...">:

<Teleport to="#modals">
  <Modal />
</Teleport>

Разница в синтаксисе, но суть одна: содержимое рендерится вне иерархии компонента, но сохраняет контекст.


Лучшие практики и антипаттерны

Практики

1. Используйте именованные слоты для сложных layout-компонентов

<BaseLayout>
  <template #sidebar>...</template>
  <template #main>...</template>
</BaseLayout>

Это делает API компонента очевидным и самодокументируемым.

2. Предоставляйте fallback-контент

Слот с содержимым по умолчанию работает корректно, даже если родитель ничего не передал.

3. Используйте scoped slots для списков и таблиц

Дочерний компонент отвечает за данные, родитель — за представление. Это классическое разделение ответственности.

4. Используйте shallowRef для динамических компонентов

const currentTab = shallowRef(TabA);

Компонент — это объект, и делать его глубоко реактивным избыточно. shallowRef дешевле и безопаснее.

5. Ограничивайте <KeepAlive> через include/max

Не кэшируйте всё подряд. Указывайте имена компонентов и максимальный размер кэша.

6. Проверяйте наличие слота через useSlots

const slots = useSlots();
const hasActions = Boolean(slots.actions);

Это помогает избежать пустых обёрток в DOM.

Антипаттерны

1. Мутация scoped slot props

<!-- ❌ Неправильно -->
<template #item="{ item }">
  <input v-model="item.name" />
</template>

Scoped slot props могут быть реактивными объектами, но их мутация в родителе нарушает инкапсуляцию. Лучше отправлять событие в дочерний компонент.

2. Избыточные render-функции

Не пишите render-функцию там, где справится шаблон. Шаблоны лучше оптимизируются компилятором Vue и проще читаются.

3. <KeepAlive> для компонентов с побочными эффектами

Если компонент открывает WebSocket, подписку или таймер, <KeepAlive> может привести к утечкам, если эффекты не очищаются в onDeactivated.

4. Телепортация в несуществующий узел

Убедитесь, что целевой элемент существует в DOM. В SSR это особенно важно, так как document может быть недоступен.

5. Пропуск key при динамических компонентах

Если один и тот же компонент рендерится с разными пропсами, используйте key, чтобы Vue понимал, что это разные экземпляры:

<component :is="currentTab" :key="tabId" />

Ключевые тезисы для интервью

  1. Слоты позволяют родителю передавать разметку в дочерний компонент. Бывают default, named и scoped.
  2. Scoped slot передаёт данные из дочернего компонента в родительский шаблон, что удобно для списков, таблиц и кастомизации.
  3. Render-функции и h() дают полный контроль над VNode. Используются, когда шаблона недостаточно.
  4. JSX во Vue поддерживается, но менее распространён, чем в React, потому что шаблоны лучше оптимизируются компилятором.
  5. <component :is> меняет рендеримый компонент динамически. Состояние не сохраняется без <KeepAlive>.
  6. <KeepAlive> кэширует экземпляры компонентов и добавляет хуки onActivated/onDeactivated.
  7. <Teleport> рендерит содержимое в другой узел DOM, сохраняя реактивность и контекст компонента.
  8. Scoped slots во Vue близки к render props в React, но выражены через декларативный шаблонный синтаксис.
  9. Для динамических компонентов лучше использовать shallowRef, а не ref, чтобы избежать избыточной реактивности.
  10. При использовании <KeepAlive> важно корректно очищать побочные эффекты в onDeactivated, чтобы избежать утечек.

Заключение

Компоненты, слоты, render-функции, динамические компоненты, <KeepAlive> и <Teleport> — это инструменты, которые превращают Vue из набора изолированных блоков в гибкую систему для построения сложных интерфейсов. Каждый механизм решает свою задачу: слоты отвечают за композицию разметки, render-функции — за низкоуровневый контроль, <component :is> — за динамику, <KeepAlive> — за сохранение состояния, а <Teleport> — за рендеринг вне иерархии DOM.

На собеседованиях важно не только знать синтаксис, но и понимать, когда какой инструмент применять. Шаблоны предпочтительнее render-функций для обычных задач, scoped slots помогают строить универсальные компоненты, а <KeepAlive> и <Teleport> решают специфические UI-проблемы без костылей.


Полезные ссылки