Рендер-функции и Teleport в Vue.js
- среда, 24 декабря 2025 г. в 00:00:12
Декларативные шаблоны Vue решают 90% задач фронтенда. Но периодически возникают ситуации, где шаблонного синтаксиса оказывается мало. Нужен более тонкий контроль над рендерингом или возможность вынести часть компонента за пределы его естественной позиции в DOM-дереве. Для таких случаев Vue 3 послал нам render-функции и встроенный компонент Teleport.
Vue компилирует шаблоны в JavaScript-функции автоматически. Пишем <div>{{ text }}</div>, а Vue превращает это в h('div', text) на этапе сборки. Обычно об этом не нужно думать.
Но иногда шаблонного синтаксиса не хватает. Пример: компонент заголовка, который рендерит h1, h2 или h3 в зависимости от уровня вложенности. Через шаблон это выглядит так:
<template>
<h1 v-if="level === 1">{{ text }}</h1>
<h2 v-else-if="level === 2">{{ text }}</h2>
<h3 v-else-if="level === 3">{{ text }}</h3>
<h4 v-else-if="level === 4">{{ text }}</h4>
<h5 v-else-if="level === 5">{{ text }}</h5>
<h6 v-else>{{ text }}</h6>
</template>Работает, но выглядит уродливо. Шесть повторений одной и той же логики. Добавлю новое требование — поменяется в шести местах.
Через рендер-функцию:
import { h } from 'vue';
export default {
props: ['level', 'text'],
render() {
return h(`h${this.level}`, this.text);
}
}Одна строка. Работает с любым уровнем. Если нужно добавить класс или атрибут — меняю в одном месте.
Вот в чём суть.
Рендер-функции адекватных в четырёх случаях:
1. Динамические теги или компоненты. Когда выбор элемента зависит от данных. Допустим, есть компонент-обёртка, который рендерит либо <a>, либо <button>, либо <router-link> в зависимости от пропсов. В шаблоне это три копии одной разметки с v-if. В рендер-функции — одна переменная с выбором компонента.
2. Рекурсивные структуры. Дерево файлов, комментарии с ответами, вложенное меню. Можно сделать через рекурсивный компонент в шаблоне, но код получается запутанный. Рендер-функция с рекурсией читается естественнее.
3. Обёртки с прокидыванием всего подряд. Делаю wrapper над кнопкой сторонней библиотеки. Нужно пробросить все атрибуты и события, но добавить свою логику. В рендер-функции есть полный доступ к attrs и slots, можно манипулировать ими как угодно.
4. Библиотечные компоненты. Когда хочется написать переиспользуемый компонент для разных проектов, часто нужна высокая гибкость. Рендер-функции дают её.
В остальных случаях шаблоны проще и понятнее. Коллеги лучше их читают. IDE лучше их подсвечивает. Отладка проще.
h — это фабрика виртуальных узлов (VNode). Принимает три аргумента:
h(тег, пропсы, дети)Первый — что рендерить. Строка для HTML-тега ('div', 'button') или объект компонента для Vue-компонента.
Второй — пропсы и атрибуты. Объект с любыми свойствами. События начинаются с on:
h('button', {
class: 'btn primary',
disabled: isLoading,
onClick: handleClick
})Третий — дочерние элементы. Строка для текста или массив других VNode:
h('div', { class: 'card' }, [
h('h2', 'Заголовок'),
h('p', 'Текст')
])Можно опускать пропсы, если они не нужны:
h('div', 'Просто текст')
h('ul', [h('li', 'Один'), h('li', 'Два')])Честно говоря, вложенные вызовы h() быстро становятся нечитаемыми. Поэтому можно выносить части в отдельные функции:
function renderCard(item) {
return h('div', { class: 'card' }, [
renderHeader(item.title),
renderBody(item.content)
]);
}
function renderHeader(title) {
return h('h2', { class: 'card-title' }, title);
}Так структура видна явно.
Мне больше нравится писать через Composition API. Возвращаю функцию из setup():
import { h, ref } from 'vue';
export default {
setup() {
const count = ref(0);
return () => h('div', [
h('p', `Счётчик: ${count.value}`),
h('button', { onClick: () => count.value++ }, '+1')
]);
}
}Все переменные доступны через замыкание. Не нужно думать про this и его контекст. В Options API рендер-функция живёт отдельно от данных, а здесь всё в одном месте.
Можно сделать кнопку с индикацией загрузки. Она принимает асинхронный обработчик и сама управляет состоянием:
import { h, ref } from 'vue';
export default {
props: {
onClick: Function
},
setup(props, { slots }) {
const loading = ref(false);
const handleClick = async () => {
loading.value = true;
try {
await props.onClick?.();
} finally {
loading.value = false;
}
};
return () => h('button',
{
disabled: loading.value,
onClick: handleClick
},
loading.value ? 'Загрузка...' : slots.default?.()
);
}
}Используем так: <LoadingButton :onClick="saveData">Сохранить</LoadingButton>. Кнопка сама показывает "Загрузка..." и блокируется. В шаблоне пришлось бы пробрасывать состояние наружу.
Слоты доступны через $slots в Options API или второй аргумент setup(props, { slots }) в Composition API. Каждый слот — это функция, которая возвращает массив VNode:
export default {
render() {
return h('div', { class: 'card' }, [
this.$slots.header?.(),
h('div', { class: 'body' }, this.$slots.default?.()),
this.$slots.footer?.()
]);
}
}Знак ?. обязателен, если слот не передан, будет undefined. Без проверки упадёт с ошибкой.
Слоты могут принимать параметры (scoped slots). Передаёте объект при вызове:
this.$slots.default?.({ item, index })Работа со слотами в рендер-функциях менее удобна, чем в шаблонах. Приходится помнить про вызов функции и проверку на существование. В шаблоне просто пишешь <slot name="header" /> и не думаешь.
Поэтому используем рендер-функции только там, где шаблон действительно не справляется. Для обычных компонентов со слотами шаблоны в сто раз удобнее.
Если настроить Vite с плагином @vitejs/plugin-vue-jsx, можно писать JSX вместо вложенных h():
export default {
setup() {
const count = ref(0);
return () => (
<div>
<p>Счётчик: {count.value}</p>
<button onClick={() => count.value++}>+1</button>
</div>
);
}
}Читается проще, структура видна сразу. По сути это те же рендер-функции, просто синтаксис другой.
JSX используют для компонентов с большим количеством условной логики. Когда в шаблоне начинается куча v-if и вложенных элементов, JSX часто получается чище. Но для простых компонентов шаблоны всё равно удобнее — Vue-specific директивы вроде v-model в них работают естественнее.
Теперь про вторую часть — Teleport. Это решение проблемы, с которой сталкивался каждый фронтендер, как правильно показать модальное окно.
Суть проблемы: компонент модального окна живёт где-то внутри вашего приложения, вложенный в кучу других компонентов. Но для адекватной работы CSS ему нужно быть в конце <body>. Иначе:
родительский overflow: hidden обрежет модальное окно
z-index не сработает из-за контекста наложения
backdrop не покроет весь экран
position: fixed будет считаться относительно родителя с transform
До Vue 3 я использовали библиотеку Portal-Vue. Она работала, но была сторонней зависимостью. Постоянно проверяли совместимость версий, ждали обновлений.
Vue 3 сделал порталы нативными через компонент <Teleport>. Всё, что внутри него, рендерится в другом месте DOM:
<template>
<button @click="open = true">Открыть</button>
<teleport to="body">
<div v-if="open" class="modal-backdrop" @click="open = false">
<div class="modal" @click.stop>
<h2>Модальное окно</h2>
<button @click="open = false">Закрыть</button>
</div>
</div>
</teleport>
</template>Контент внутри <teleport to="body"> физически окажется в конце <body>. Но с точки зрения Vue это часть вашего компонента — доступ к данным, реактивность, события работают нормально.
Напишем один компонент модального окна для всего проекта:
<!-- Modal.vue -->
<template>
<teleport to="body">
<div v-if="modelValue" class="modal-backdrop" @click="close">
<div class="modal" @click.stop>
<button class="close" @click="close">×</button>
<slot />
</div>
</div>
</teleport>
</template>
<script>
export default {
props: {
modelValue: Boolean
},
emits: ['update:modelValue'],
setup(props, { emit }) {
const close = () => emit('update:modelValue', false);
return { close };
}
}
</script>Используем через v-model:
<button @click="showModal = true">Редактировать</button>
<Modal v-model="showModal">
<h2>Редактирование профиля</h2>
<form @submit.prevent="save">
<input v-model="name" />
<button>Сохранить</button>
</form>
</Modal>Модал всегда всплывает корректно, независимо от того, откуда его вызвали.
Можно менять цель телепортации динамически. Допустим, есть проект с виджетами, которые пользователь может перетаскивать между зонами на странице:
<template>
<select v-model="zone">
<option value="#sidebar">Боковая панель</option>
<option value="#main">Основная зона</option>
<option value="#footer">Подвал</option>
</select>
<teleport :to="zone">
<div class="widget">
Виджет статистики
</div>
</teleport>
</template>При смене zone Vue перемещает DOM-элемент из одного контейнера в другой. Важный момент: элемент именно перемещается, а не пересоздаётся. Если внутри виджета работает таймер или проигрывается видео — оно не сбросится.
Это отличается от обычного v-if, который уничтожает и создаёт элемент заново. Teleport сохраняет состояние.
Самый практичный пример — система уведомлений. Создаем контейнер в index.html:
<body>
<div id="app"></div>
<div id="notifications"></div>
</body>Компонент с Teleport и composable для вызова откуда угодно:
<!-- Notifications.vue -->
<template>
<teleport to="#notifications">
<div class="notifications">
<div
v-for="notif in notifications"
:key="notif.id"
class="notification"
>
{{ notif.message }}
</div>
</div>
</teleport>
</template>
<script>
import { ref } from 'vue';
const notifications = ref([]);
let id = 0;
export function notify(message) {
const notif = { id: ++id, message };
notifications.value.push(notif);
setTimeout(() => {
notifications.value = notifications.value.filter(n => n.id !== notif.id);
}, 3000);
}
export default {
setup() {
return { notifications };
}
}
</script>Теперь из любого компонента:
import { notify } from '@/components/Notifications.vue';
async function saveData() {
try {
await api.save(data);
notify('Данные сохранены');
} catch (e) {
notify('Ошибка сохранения');
}
}Все уведомления собираются в одном месте вверху экрана. Не важно, из какого компонента вызвали — они всегда рендерятся в #notifications.
Не нужно телепортировать всё подряд.
Teleport имеет смысл для:
Модальных окон
Глобальных уведомлений
Dropdown-меню, которые должны всплывать поверх всего
Контекстных меню
Не имеет смысла для:
Обычного контента страницы
Элементов, у которых нет проблем с позиционированием
Компонентов, которые должны быть частью естественного flow
Лишний Teleport усложняет отладку, приходится искать элемент в другом месте DOM, не там, где он объявлен в коде.
Перед тем как написать рендер-функцию, спрашивайте себя:
Можно ли решить через обычный шаблон с v-if и v-for?
Код станет проще или я просто хочу показать, что умею?
Поймут ли коллеги этот код через полгода?
Если хотя бы на один вопрос ответ нет, используйте шаблон.
Перед тем как добавить Teleport:
Есть ли проблема с позиционированием или z-index?
Элемент должен всплывать поверх всего остального?
Создан ли целевой контейнер в HTML?
Если нет реальной проблемы — оставляем элемент на месте.

Если после рендер-функций, JSX и Teleport хочется системно разобраться, как Vue работает «под капотом» и где заканчивается шаблонная магия, есть смысл посмотреть в сторону практического курса по Vue.js уровня Pro. Он фокусируется на архитектуре, реактивности, SPA, тестировании и production-подходах — ровно там, где Vue перестаёт быть «простым» и становится инженерным инструментом.
Для знакомства с форматом обучения и экспертами приходите на бесплатные демо-уроки:
23 декабря в 20:00. Vue.js Быстрый старт — собираем мини-соцсеть с нуля. Записаться
14 января в 20:00. Vue + WebSockets — создаём real-time криптобиржу с живыми графиками. Записаться
21 января в 20:00. Новый роутер для VueJS — Kitbag Router. Записаться