Статьи серии
В этой статье мы продолжим обсуждение темы повторного использования кода во Vue.js. Тема была начата несколькими статьями ранее. Здесь, как и было обещано ранее, будет обсуждение расширенной работы со слотами и описание вариантов того, как это можно применить в реальных проектах.
Содержание
- Вложенные компоненты со слотами
- То же, но с более богатой разметкой
- Вложенные компоненты с именованными слотами
- Вложенные именованных слоты с Vuetify
- Вложенные слоты внутри одного компонента
- Итоги
- Что дальше
Вложенные компоненты со слотами
В следующих примерах в свойства компонента будут передаваться CSS свойства. В реальности, конечно, так чаще всего, лучше не делать. Но за счет такого подхода получится нагляднее всего продемонстрировать как работают вложенные компоненты со слотами.
И так, рассмотрим в качестве примера следующие компоненты:
<template>
<!-- С помощью свойств компонента влияем на его внешний вид -->
<div
class="child-block-with-props"
:style="{ backgroundColor: props.backgroundColorStyle, border: props.borderStyle }"
>
<!-- Основной слот (слот по-умолчанию) -->
<slot />
</div>
</template>
<script setup>
/**
* Компонент нижнего уровня иерархии вложенности.
* У него есть свои свойства и слот.
*/
const props = defineProps({
backgroundColorStyle: {
type: String,
default: "yellowgreen"
},
borderStyle: {
type: String,
default: "none"
}
});
</script>
<style>
.child-block-with-props {
margin: 7px 0;
padding: 7px;
border-radius: 5px;
}
</style>
<template>
<!-- Определяем свойства по-умолчанию для вложенного элемента -->
<!-- и применяем свойства текущего к style -->
<ChildBlock
class="parent-block-with-props"
borderStyle="1px dashed darkslategray"
backgroundColorStyle="lightcoral"
:style="{ color: props.textColorStyle }"
>
<!-- Основной слот (слот по-умолчанию) ParentBlock -->
<slot />
</ChildBlock>
</template>
<script setup>
import ChildBlock from "@/components/nested-with-props/ChildBlock.vue";
/**
* Компонент второго уровня иерархии.
* У него есть свои свойства
* и в то же время он как-бы "наследует" свойства ChildBlock
*/
const props = defineProps({
textColorStyle: {
type: String,
default: "black"
}
});
</script>
<template>
<!-- Определяем свойства по-умолчанию для элементов нижних уровней -->
<ParentBlock class="grand-block-with-props" backgroundColorStyle="orange" textColorStyle="white">
<slot />
</ParentBlock>
</template>
<script setup>
import ParentBlock from "@/components/nested-with-props/ParentBlock.vue";
/**
* Компонент третьего уровня иерархии.
* У него есть свои свойства
* и в то же время он как-бы "наследует" свойства ChildBlock и ParentBlock
*/
</script>
Здесь ChildBlock.vue
выполняет роль базового компонента со основным слотом (слотом по-умолчанию). Компонент ParentBlock.vue
будет своего рода наследником функциональности первого с добавлением некоторой своей. Он является компонентом-оберткой. А GrandBlock.vue
находится на самом верху. У каждого из них есть основной слот.
В реальных проектах такое обертывание может оказаться весьма полезным. Например, есть уже некоторый компонент из какой-либо библиотеки со своей функциональностью. И мы хотим, поменять значение каких-либо параметров по-умолчанию для него. Чтобы в массе мест, где нам нужен этот компонент нам не приходилось указывать значения этих параметров явно. При этом слот, компонента должен работать как и раньше. Мы не должны лишиться работающего слота из-за приема с оборачиванием.
И так, за счет того, что в слот по-умолчанию (то есть в место между открывающим и закрывающим тегом компонента) на каждом последующем уровне мы помещаем <slot />
, слот продолжает работать. Мы можем использовать любой из этих компонентов и передавать вовнутрь какой-либо шаблон.
<template>
<div class="nested-components">
<h1>Nested With Props</h1>
<p>Nested components with slots and customization through props.</p>
<ChildBlock><strong>Passing content to ChildBlock</strong></ChildBlock>
<ParentBlock><strong>Passing content to ParentBlock</strong></ParentBlock>
<GrandBlock><strong>Passing content to GrandBlock</strong></GrandBlock>
</div>
</template>
<script setup>
import ParentBlock from "@/components/nested-with-props/ParentBlock.vue";
import ChildBlock from "@/components/nested-with-props/ChildBlock.vue";
import GrandBlock from "@/components/nested-with-props/GrandBlock.vue";
</script>
Вот как выглядит это в браузере:
Код примера доступен в репозитории. Конечно, при использовании CSS-классов кода было бы меньше. Но тогда продемонстрирована была бы только работа слотов без работы со свойствами компонентов. Если компонент имеет специфичный класс, то нам и не нужна бы была вложенность. Мы могли бы переопределить CSS-класс для всех исходных таких компонентов в проекте.
Поэтому здесь еще раз отметим, что дело не в CSS, а возможности реализовать что-то типа наследования для компонентов с переопределением свойств и сохранением работы слотов.
То же, но с более богатой разметкой
Часто с оборачиванием компонента мы сталкиваемся, когда на новом уровне нужно добавить и кое-какую разметку. Когда новый компонент будет содержать не только один вложенный, но и какой-то более сложный шаблон. В этом случае нам нужно сохранить работающей еще одну вещь, которая в предыдущем примере работала и так.
Речь про передачу свойств. Если в предыдущем примере мы могли взять компонент-обертку и передать в него свойства для вложенного компонента, то в новых обстоятельствах это работать не будет само. Но мы можем использовать прием v-bind="$attrs"
и inheritAttrs: false
. Это позволит отменить привязку переданных свойств к корневому элементу и отправить эти свойства в нужный нам элемент.
Вот код каждого компонента и их использование:
<template>
<div
class="child-block-with-own-template"
:style="{ backgroundColor: props.backgroundColorStyle, border: props.borderStyle }"
>
<div>Child Content</div>
<slot />
</div>
</template>
<script setup>
const props = defineProps({
backgroundColorStyle: {
type: String,
default: "yellowgreen"
},
borderStyle: {
type: String,
default: "none"
}
});
</script>
<style>
.child-block-with-own-template {
margin: 7px 0;
padding: 7px;
border-radius: 5px;
}
</style>
<template>
<div class="parent-block-with-own-template">
<div>Parent Content</div>
<!-- Применяем v-bind="$attrs", чтобы указать куда будут переданы атрибуты -->
<ChildBlock
borderStyle="1px dashed darkslategray"
backgroundColorStyle="lightcoral"
:style="{ color: props.textColorStyle }"
v-bind="$attrs"
>
<slot />
</ChildBlock>
</div>
</template>
<script setup>
import ChildBlock from "@/components/nested-with-own-templates/ChildBlock.vue";
const props = defineProps({
textColorStyle: {
type: String,
default: "black"
}
});
/**
* Запрещаем передачу атрибутов в элемент div class="parent-block-with-own-template"
*/
defineOptions({
inheritAttrs: false
});
</script>
<style>
.parent-block-with-own-template {
margin: 7px 0;
padding: 7px;
border-radius: 5px;
border: 1px solid blueviolet;
}
</style>
<template>
<div class="grand-block-with-own-template">
<div>Grand Content</div>
<ParentBlock
class="grand-block-with-props"
backgroundColorStyle="orange"
textColorStyle="white"
v-bind="$attrs"
>
<slot />
</ParentBlock>
</div>
</template>
<script setup>
import ParentBlock from "@/components/nested-with-own-templates/ParentBlock.vue";
defineOptions({
inheritAttrs: false
});
</script>
<style>
.grand-block-with-own-template {
margin: 7px 0;
padding: 7px;
border-radius: 5px;
border: 1px solid green;
}
</style>
Как можно видеть, слоты продолжают работать при такой вложенности.
Вложенные компоненты с именованными слотами
Когда мы увидели как это работает на основном слоте (слоте по-умолчанию), не мешало бы развить идею. Это больше приблизит нас к реальным ситуациям.
В развитых библиотеках компонентов Vue элементы достаточно хорошо расширяемы. У элементов есть, как правило, несколько слотов. При использовании таких элементов в проектах, мы кастомизируем ряд элементов. И тут важно не свести на нет расширяемость. Поэтому слоты должны продолжать работать.
Представим, что исходный элемент библиотеки – текстовое поле:
<template>
<div class="text-input-container">
<slot name="prepend"></slot>
<input type="text" v-bind="$attrs" class="field" />
<slot name="append"></slot>
</div>
</template>
<script setup>
defineOptions({
inheritAttrs: false
});
</script>
<style lang="scss">
// Стили смотрите в репозитории
</style>
Здесь есть пара слотов – prepend
и append
.
Предположим, что на базе него мы хотим сделать свой элемент. Пусть это будет поле с подписью (label
). Мы сделаем обертку для исходного элемента:
<template>
<div class="custom-text-input-container">
<label v-if="label" :for="$attrs.id">
{{ label }}
</label>
<TextInput v-bind="$attrs"></TextInput>
</div>
</template>
<script setup>
import TextInput from "@/components/nested-with-named-slots/TextInput.vue";
defineProps({
label: {
type: String
}
});
defineOptions({
inheritAttrs: false
});
</script>
<style scoped>
label {
font-weight: 500;
}
</style>
Подпись мы добавим, но потеряем работу слотов. Чтобы сохранить их работу мы могли бы указать каждый и в “наследнике”. Но если в исходном добавятся новые слоты, то нам придется менять и наш элемент. Поэтому есть более гибкий способ – переменная $slots
, содержащая данные обо всех переданных слотах.
<TextInput v-bind="$attrs">
<template v-for="(slot, name) in $slots" v-slot:[name]="slotProps">
<slot :name="name" v-bind="slotProps"></slot>
</template>
</TextInput>
При таком подходе слоты будут проброшены на следующий уровень.
Также эта переменная открывает широкие возможности у правлению показом слотов. Мы можем пропустить или нет конкретный слот дальше. Можем не показывать один, если передан какой-то другой (логически его исключающий). Можем не печатать обрамляющую слот разметку, если слот не передан.
Вот окончательный вариант CustomTextInput.vue
:
<template>
<div class="custom-text-input-container">
<!-- Выводим тег label, только если передана подпись в виде свойства или слота -->
<label v-if="$slots.label || label" :for="$attrs.id">
<slot name="label">
{{ label }}
</slot>
</label>
<TextInput v-bind="$attrs">
<!-- Пробрасываем все слоты на следующий уровень -->
<template v-for="(slot, name) in $slots" v-slot:[name]="slotProps">
<slot :name="name" v-bind="slotProps"></slot>
</template>
</TextInput>
</div>
</template>
<script setup>
import TextInput from "@/components/nested-with-named-slots/TextInput.vue";
defineProps({
label: {
type: String
}
});
defineOptions({
inheritAttrs: false
});
</script>
<style scoped>
label {
font-weight: 500;
}
</style>
Здесь кроме пробрасывания всех слотов исходному элементу мы можем передавать подпись слотом label
. Если этот слот не передан, то тег label
мы не отображаем.
Теперь на базе этого элемента CustomTextInput.vue
мы можем создать новый. К примеру, поле поиска SearchInput.vue
.
<template>
<CustomTextInput>
<template #prepend>
<FontAwesomeIcon :icon="faSearch" class="icon" />
</template>
<template #append>
<input type="submit" value="Search" class="button" />
</template>
</CustomTextInput>
</template>
<script setup>
import CustomTextInput from "@/components/nested-with-named-slots/CustomTextInput.vue";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { faSearch } from "@fortawesome/free-solid-svg-icons";
</script>
Передадим в него слоты prepend
и append
. Они будут работать за счет поддержки их в TextInput.vue
и пробрасывания через CustomTextInput.vue
.
Продемонстрируем работу элемента каждого уровня:
<template>
<div class="nested-with-named-slots">
<h1>Nested With Named Slots</h1>
<p>Level 1. TextInput. Base component</p>
<TextInput id="text-input-1"></TextInput>
<p>Level 2. CustomTextInput without label</p>
<CustomTextInput id="custom-input-1"></CustomTextInput>
<p>Level 2. CustomTextInput with label as prop</p>
<CustomTextInput id="custom-input-2" label="Label as prop"></CustomTextInput>
<p>Level 2. CustomTextInput with label as slot</p>
<CustomTextInput id="custom-input-3">
<template #label><i>Label as Slot</i></template>
</CustomTextInput>
<p>Level 3. SearchInput without label</p>
<SearchInput id="search-input-1"></SearchInput>
<p>Level 3. SearchInput with label</p>
<SearchInput id="search-input-2" label="Text of label property"></SearchInput>
</div>
</template>
<script setup>
import SearchInput from "@/components/nested-with-named-slots/SearchInput.vue";
import TextInput from "@/components/nested-with-named-slots/TextInput.vue";
import CustomTextInput from "@/components/nested-with-named-slots/CustomTextInput.vue";
</script>
Вложенные именованных слоты с Vuetify
Описанные выше приемы могут сильно помочь в написании кода с лучшим опытом повторного использования. Они пригодятся как при написании собственных библиотек компонентов так и при расширении готовых.
Следующий пример показывает применение этих принципов к v-data-table-server
из Vuetify 3.
В реальном приложении понадобится кастомизировать готовый элемент из Vuetify. Появится новый элемент таблицы MyDataTable.vue
, на базе которого будут создавать уже компоненты для конкретных таблиц. Например, PostsDataTable.vue
. При этом не должна пропасть возможность использовать слоты из v-data-table-server
.
Кастомизированный элемент таблицы:
<template>
<v-data-table-server
:headers="headers"
v-model:page="options.page"
v-model:items-per-page="options.itemsPerPage"
v-model:sort-by="options.sortBy"
:items-length="totalItems"
:items="serverItems"
:loading="loading"
item-value="id"
@update:options="handleUpdateOptions"
>
<template
v-for="(slot, name) in $slots"
v-slot:[name]="item"
>
<slot
:name="name"
v-bind="item"
></slot>
</template>
</v-data-table-server>
</template>
<script setup>
import {ref, reactive, toRefs} from 'vue'
const props = defineProps({
headers: { type: Array, required: true },
apiDataRequest: { type: Function, required: true },
sortBy: { type: Array, default: () => [] },
itemsPerPage: { type: Number, default: 10 }
})
const defaults = {
page: 1,
itemsPerPage: props.itemsPerPage,
sortBy: props.sortBy,
}
const options = reactive(defaults)
const { headers } = toRefs(props);
const serverItems = ref([])
const loading = ref(true)
const totalItems = ref(0)
function covertOptionsToQueryParams(opts) {
const result = {
_limit: opts.itemsPerPage,
_page: opts.page,
}
if (opts.sortBy.length) {
result._sort = opts.sortBy[0].key
result._order = opts.sortBy[0].order ? opts.sortBy[0].order : 'asc'
}
return result
}
async function handleUpdateOptions () {
loading.value = true
const data = await props.apiDataRequest(covertOptionsToQueryParams(options))
serverItems.value = data.data
totalItems.value = data.headers['x-total-count']
loading.value = false
}
</script>
Для создании конкретной таблицы нам понадобится уже гораздо меньше кода:
<template>
<my-data-table
:headers="headers"
:api-data-request="apiDataRequest"
:items-per-page="25"
:sort-by="[ { key: 'id', order: 'asc' } ]"
>
<template v-slot:[`item.actions`]="{ item }">
<v-btn @click="handleDelete(item)">REMOVE</v-btn>
</template>
</my-data-table>
</template>
<script setup>
import { getPosts } from "@/api/posts.js"
import MyDataTable from "@/components/MyDataTable.vue"
const headers = [
{
title: 'ID',
align: 'end',
sortable: true,
key: 'id',
width: '7em'
}, {
title: 'Title',
align: 'start',
sortable: true,
key: 'title',
width: '100%'
}, {
title: '',
align: 'center',
sortable: false,
key: 'actions',
},
]
async function apiDataRequest(optionsData) {
return getPosts(optionsData)
}
function handleDelete(item) {
alert(`REMOVE ${item.id}`)
}
</script>
Здесь мы передали просто описания столбцов и информацию о том, где брать данные. А также в колонку actions
передана разметка с кнопкой. Это работает за счет пробрасывания слотов.
import axios from 'axios';
export async function getPosts(params) {
return await axios.get('https://jsonplaceholder.typicode.com/posts', { params })
}
Вложенные слоты внутри одного компонента
Еще одной интересной возможностью является вложение слотов друг в друга в рамках одного компонента. Рассмотрим следующий элемент, который представляет собой поле ввода с кнопкой:
<template>
<div class="text-input-container">
<input type="text" v-bind="$attrs" class="field" />
<slot name="button">
<button class="button">
<slot name="icon">
<FontAwesomeIcon :icon="faCheck" class="icon" />
</slot>
</button>
</slot>
</div>
</template>
<script setup>
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { faCheck } from "@fortawesome/free-solid-svg-icons";
defineOptions({
inheritAttrs: false
});
</script>
<style lang="scss">
// Стили - в репозитории
</style>
Здесь мы можем использовать как слот button
, чтобы заменить кнопку целиком, так и слот icon
, чтобы заменить только иконку.
Примеры:
<template>
<div class="nested-slots-in-same-component">
<h1>Nested Slots In Same Component</h1>
<p>TextInput with default button and icon</p>
<TextInput></TextInput>
<p>TextInput with custom button without icon</p>
<TextInput>
<template #button>
<button class="button">Save</button>
</template>
</TextInput>
<p>TextInput with custom icon</p>
<TextInput>
<template #icon>
<FontAwesomeIcon :icon="faSave" class="icon" />
</template>
</TextInput>
</div>
</template>
<script setup>
import TextInput from "@/components/nested-slots-in-same-component/TextInput.vue";
import { faSave } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
</script>
Как можно видеть такой подход также помогает достигнуть лучшего опыта повторного использования кода.
Итоги
На рассмотренных выше примерах видно каким мощным инструментом являются слоты. Достичь расширения функциональности каких-либо элементов можно предусмотрев слоты в нужных местах. С помощью создания элемента-обертки можно достигнуть эффекта схожего с наследованием в ООП.
Однако, при этом нужно уделить внимание тому, чтобы слоты передавались к вложенным элементам. А также необходимо передавать атрибуты и свойства обертки в нужный вложенный элемент.
Код примеров можно найти в репозитории.
Что дальше
В следующих статья раздела Vue.js будет продолжено освещение и обобщение принципов написания кода рассчитанного на повторное использование.
Если у вас есть какие-либо вопросы по темам статей или интересные идеи, прошу присылать. Постараюсь рассмотреть их в следующих статьях.
Чтобы не пропустить очередную статью вы можете подписаться на Телеграм канал.