javascript

Рендер-функции и Teleport в Vue.js

  • среда, 24 декабря 2025 г. в 00:00:12
https://habr.com/ru/companies/otus/articles/978308/

Декларативные шаблоны 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, не там, где он объявлен в коде.


Чек-лист перед использованием

Перед тем как написать рендер-функцию, спрашивайте себя:

  1. Можно ли решить через обычный шаблон с v-if и v-for?

  2. Код станет проще или я просто хочу показать, что умею?

  3. Поймут ли коллеги этот код через полгода?

Если хотя бы на один вопрос ответ нет, используйте шаблон.

Перед тем как добавить Teleport:

  1. Есть ли проблема с позиционированием или z-index?

  2. Элемент должен всплывать поверх всего остального?

  3. Создан ли целевой контейнер в HTML?

Если нет реальной проблемы — оставляем элемент на месте.

Готовы к обучению на курсе по Vue.js? Пройдите входной тест
Готовы к обучению на курсе по Vue.js? Пройдите входной тест

Если после рендер-функций, 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. Записаться