Декларативные шаблоны 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
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 проще для рендер-функций
Мне больше нравится писать через 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" /> и не думаешь.
Поэтому используем рендер-функции только там, где шаблон действительно не справляется. Для обычных компонентов со слотами шаблоны в сто раз удобнее.
JSX — золотая середина
Если настроить 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: если нужно вынести элемент из родителя
Теперь про вторую часть — 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
Напишем один компонент модального окна для всего проекта:
<!-- 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 избыточен
Не нужно телепортировать всё подряд.
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. Записаться
