Vue. Продвинутая работа со слотами

В этой статье мы продолжим обсуждение темы повторного использования кода во Vue.js. Тема была начата несколькими статьями ранее. Здесь, как и было обещано ранее, будет обсуждение расширенной работы со слотами и описание вариантов того, как это можно применить в реальных проектах.

Содержание

Вложенные компоненты со слотами

В следующих примерах в свойства компонента будут передаваться 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 })
}
Результат работы элемента PostsDataTable.vue

Вложенные слоты внутри одного компонента

Еще одной интересной возможностью является вложение слотов друг в друга в рамках одного компонента. Рассмотрим следующий элемент, который представляет собой поле ввода с кнопкой:

<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 будет продолжено освещение и обобщение принципов написания кода рассчитанного на повторное использование.

Если у вас есть какие-либо вопросы по темам статей или интересные идеи, прошу присылать. Постараюсь рассмотреть их в следующих статьях.

Чтобы не пропустить очередную статью вы можете подписаться на Телеграм канал.

Оставить ответ

Ваш адрес email не будет опубликован. Обязательные поля помечены *