22. Renderless компоненты
🎭 Урок 23 — Renderless компоненты и продвинутые паттерны
Заголовок раздела «🎭 Урок 23 — Renderless компоненты и продвинутые паттерны»Renderless компоненты — это один из самых элегантных паттернов в Vue, который разделяет логику и представление. Компонент содержит умную логику, но не рендерит никакого HTML — всю разметку делает потребитель через scoped slots. Это headless UI в мире Vue.
🤔 Зачем нужны renderless компоненты?
Заголовок раздела «🤔 Зачем нужны renderless компоненты?»Представь: тебе нужна функциональность выпадающего списка — открытие, закрытие, навигация клавишами, accessibility. Но дизайн должен быть разным: где-то это dropdown в шапке, где-то — inline-список в форме.
<!-- Без renderless: дублируем логику в каждом компоненте 😢 --><HeaderDropdown /> <!-- кнопка + список --><FormSelect /> <!-- лейбл + список --><MobileMenu /> <!-- бургер + drawer --><!-- У всех одна логика, разный вид -->
<!-- С renderless: логика один раз, вид — любой 🎉 --><DropdownLogic v-slot="{ isOpen, toggle, items }"> <!-- Ты рисуешь что хочешь! --> <button @click="toggle">{{ isOpen ? '▲' : '▼' }}</button> <ul v-show="isOpen"> <li v-for="item in items" :key="item.id">{{ item.label }}</li> </ul></DropdownLogic>🔧 Создание renderless компонента
Заголовок раздела «🔧 Создание renderless компонента»Простейший renderless компонент возвращает только $slots.default():
<!-- FetchData.vue — получает данные, не рендерит DOM --><script setup>import { ref, onMounted } from 'vue'
const props = defineProps({ url: { type: String, required: true }})
const data = ref(null)const error = ref(null)const loading = ref(true)
onMounted(async () => { try { const res = await fetch(props.url) data.value = await res.json() } catch (e) { error.value = e } finally { loading.value = false }})</script>
<template> <slot :data="data" :error="error" :loading="loading" /></template>Использование:
<FetchData url="https://api.example.com/users"> <template #default="{ data, error, loading }"> <div v-if="loading">⏳ Загружаем...</div> <div v-else-if="error">❌ {{ error.message }}</div> <ul v-else> <li v-for="user in data" :key="user.id">{{ user.name }}</li> </ul> </template></FetchData>📐 Scoped Slots как API данных
Заголовок раздела «📐 Scoped Slots как API данных»Scoped slots — это способ передавать данные из дочернего компонента в родительский шаблон. Это инверсия обычного потока данных и ключевой механизм renderless-компонентов:
<!-- DataTable.vue — renderless таблица --><script setup>import { ref, computed } from 'vue'
const props = defineProps({ items: Array, sortable: Boolean})
const sortKey = ref(null)const sortDir = ref('asc')
const sorted = computed(() => { if (!sortKey.value) return props.items return [...props.items].sort((a, b) => { const val = (x) => x[sortKey.value] const dir = sortDir.value === 'asc' ? 1 : -1 return val(a) > val(b) ? dir : -dir })})
const sort = (key) => { if (sortKey.value === key) { sortDir.value = sortDir.value === 'asc' ? 'desc' : 'asc' } else { sortKey.value = key sortDir.value = 'asc' }}</script>
<template> <slot :rows="sorted" :sort="sort" :sortKey="sortKey" :sortDir="sortDir" /></template><!-- Использование: полный контроль над видом таблицы --><DataTable :items="users" sortable v-slot="{ rows, sort, sortKey, sortDir }"> <table> <thead> <tr> <th @click="sort('name')"> Имя {{ sortKey === 'name' ? (sortDir === 'asc' ? '↑' : '↓') : '↕' }} </th> <th @click="sort('age')"> Возраст {{ sortKey === 'age' ? (sortDir === 'asc' ? '↑' : '↓') : '↕' }} </th> </tr> </thead> <tbody> <tr v-for="user in rows" :key="user.id"> <td>{{ user.name }}</td> <td>{{ user.age }}</td> </tr> </tbody> </table></DataTable>🏗️ Compound Components паттерн
Заголовок раздела «🏗️ Compound Components паттерн»Compound Components — группа компонентов, которые работают вместе и делят состояние через provide/inject:
<!-- Accordion.vue — родительский компонент-контейнер --><script setup>import { ref, provide } from 'vue'
const openItems = ref(new Set())
const toggle = (id) => { if (openItems.value.has(id)) { openItems.value.delete(id) } else { openItems.value.add(id) } openItems.value = new Set(openItems.value) // триггер реактивности}
// Передаём состояние и методы внизprovide('accordion', { openItems, toggle})</script>
<template> <div class="accordion"> <slot /> </div></template><!-- AccordionItem.vue — дочерний компонент --><script setup>import { inject, computed } from 'vue'
const props = defineProps({ id: { type: String, required: true }})
const { openItems, toggle } = inject('accordion')
const isOpen = computed(() => openItems.value.has(props.id))</script>
<template> <div class="accordion-item"> <button class="accordion-trigger" @click="toggle(id)"> <slot name="title" /> <span>{{ isOpen ? '▲' : '▼' }}</span> </button> <Transition name="accordion"> <div v-show="isOpen" class="accordion-content"> <slot /> </div> </Transition> </div></template><!-- Использование — декларативно и читаемо! --><Accordion> <AccordionItem id="faq-1"> <template #title>Что такое Vue 3?</template> Vue 3 — прогрессивный JavaScript фреймворк... </AccordionItem> <AccordionItem id="faq-2"> <template #title>Как установить Vue?</template> npm create vue@latest... </AccordionItem></Accordion>🪝 Hooks паттерн для переиспользования логики
Заголовок раздела «🪝 Hooks паттерн для переиспользования логики»В Vue 3 Composables (hooks) — предпочтительный способ переиспользования логики. Но для UI-компонентов renderless-паттерн иногда удобнее:
import { ref } from 'vue'
export function useToggle(initial = false) { const state = ref(initial)
const toggle = () => { state.value = !state.value } const setTrue = () => { state.value = true } const setFalse = () => { state.value = false }
return { state, toggle, setTrue, setFalse }}import { ref, computed } from 'vue'
export function useList(initial = []) { const items = ref([...initial])
const add = (item) => items.value.push(item) const remove = (index) => items.value.splice(index, 1) const clear = () => { items.value = [] } const move = (from, to) => { const item = items.value.splice(from, 1)[0] items.value.splice(to, 0, item) }
const isEmpty = computed(() => items.value.length === 0) const count = computed(() => items.value.length)
return { items, add, remove, clear, move, isEmpty, count }}🔒 Renderless с TypeScript и generic типами
Заголовок раздела «🔒 Renderless с TypeScript и generic типами»// UseModelValue.vue — типизированный renderless компонент<script setup lang="ts" generic="T">import { computed } from 'vue'
interface Props { modelValue: T validator?: (value: T) => string | null}
const props = defineProps<Props>()const emit = defineEmits<{ 'update:modelValue': [value: T] }>()
const error = computed(() => props.validator ? props.validator(props.modelValue) : null)
const update = (value: T) => emit('update:modelValue', value)</script>
<template> <slot :value="modelValue" :update="update" :error="error" :isValid="!error" /></template>🎮 MouseTracker — классический renderless пример
Заголовок раздела «🎮 MouseTracker — классический renderless пример»<script setup>import { ref, onMounted, onUnmounted } from 'vue'
const x = ref(0)const y = ref(0)
const track = (e) => { x.value = e.clientX y.value = e.clientY}
onMounted(() => window.addEventListener('mousemove', track))onUnmounted(() => window.removeEventListener('mousemove', track))</script>
<template> <slot :x="x" :y="y" /></template><!-- Использование в разных контекстах --><MouseTracker v-slot="{ x, y }"> <div class="cursor-follow" :style="{ left: x + 'px', top: y + 'px' }" /></MouseTracker>
<MouseTracker v-slot="{ x, y }"> <p>Курсор: {{ x }}, {{ y }}</p></MouseTracker>⚙️ WindowSize renderless composable
Заголовок раздела «⚙️ WindowSize renderless composable»import { ref, onMounted, onUnmounted } from 'vue'
export function useWindowSize() { const width = ref(window.innerWidth) const height = ref(window.innerHeight)
const update = () => { width.value = window.innerWidth height.value = window.innerHeight }
onMounted(() => window.addEventListener('resize', update)) onUnmounted(() => window.removeEventListener('resize', update))
return { width, height }}📋 Renderless форма с валидацией
Заголовок раздела «📋 Renderless форма с валидацией»<script setup>import { ref, computed } from 'vue'
const props = defineProps({ rules: { type: Array, default: () => [] }})
const value = ref('')const touched = ref(false)
const errors = computed(() => { if (!touched.value) return [] return props.rules .map(rule => rule(value.value)) .filter(Boolean)})
const isValid = computed(() => errors.value.length === 0)</script>
<template> <slot :value="value" :errors="errors" :isValid="isValid" :onInput="(v) => { value = v; touched = true }" :onBlur="() => { touched = true }" /></template>