Перейти к содержимому

22. Renderless компоненты

🎭 Урок 23 — Renderless компоненты и продвинутые паттерны

Заголовок раздела «🎭 Урок 23 — Renderless компоненты и продвинутые паттерны»

Renderless компоненты — это один из самых элегантных паттернов в Vue, который разделяет логику и представление. Компонент содержит умную логику, но не рендерит никакого HTML — всю разметку делает потребитель через scoped slots. Это headless UI в мире Vue.


Представь: тебе нужна функциональность выпадающего списка — открытие, закрытие, навигация клавишами, 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 компонент возвращает только $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 — это способ передавать данные из дочернего компонента в родительский шаблон. Это инверсия обычного потока данных и ключевой механизм 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 — группа компонентов, которые работают вместе и делят состояние через 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-паттерн иногда удобнее:

composables/useToggle.js
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 }
}
composables/useList.js
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 }
}

// 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.vue
<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>

composables/useWindowSize.js
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 }
}

FormField.vue
<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>