Перейти к содержанию

Слоты панели и повторное оформление

Оглавление серии «Vue 3: кабинет сотрудников «Команда»»

Форма и карточки решают разные задачи, но обе части кабинета могут использовать одинаковую оболочку: заголовок раздела, содержимое и небольшое пояснение. Создадим TeamPanel со слотами. Результат урока — повторное оформление без переноса данных сотрудников внутрь универсальной панели. Панель знает, где разместить заголовок и тело, а вызывающий компонент знает, что именно следует показать.

Это следующая ступень разделения ответственности после props и events. Свойство хорошо передаёт значение, например заголовок. Слот позволяет передать целый фрагмент разметки, который остаётся связанным с контекстом родителя. В нашей форме содержимое включает MemberForm и обработчик create; панели не нужно создавать промежуточное событие с тем же названием, чтобы просто обрамить этот компонент.

Оболочка с тремя областями

Ниже полный новый src/components/TeamPanel.vue. title задаёт обязательную текстовую подпись, стандартный слот содержит основную часть, именованный hint позволяет заменить короткое пояснение. Если родитель не передал hint, используется запасной текст.

<script setup>
defineProps({ title: { type: String, required: true } })
</script>
<template>
  <section>
    <header><h2>{{ title }}</h2></header>
    <div><slot /></div>
    <footer><slot name="hint">Учебные данные кабинета.</slot></footer>
  </section>
</template>

Руководство Vue о слотах объясняет предоставление содержимого родителем и запасной вариант. Для нашего примера особенно полезно помнить, что выражения внутри переданного содержимого читают переменные App, а не внутренние переменные TeamPanel. Сам факт размещения внутри шаблона панели не меняет владельца выражения и не даёт доступ к чужому состоянию.

Полная замена App.vue подключает одну панель для добавления и другую для просмотра. Чтобы не дублировать два заголовка одной формы, заменим MemberForm.vue и уберём из него h2; логика ввода останется прежней.

<script setup>
import { ref } from 'vue'
const emit = defineEmits(['create'])
const name = ref('')
const role = ref('')
const error = ref('')
function submit() {
  const draft = { name: name.value.trim(), role: role.value.trim() }
  if (!draft.name || !draft.role || draft.name.length > 80 || draft.role.length > 80) {
    error.value = 'Введите имя и роль длиной от 1 до 80 символов.'
    return
  }
  emit('create', draft)
  name.value = ''; role.value = ''; error.value = ''
}
</script>
<template>
  <form @submit.prevent="submit">
    <label>Имя <input v-model="name" maxlength="80" required></label>
    <label>Роль <input v-model="role" maxlength="80" required></label>
    <p v-if="error" role="alert">{{ error }}</p>
    <button type="submit">Добавить участника</button>
  </form>
</template>
<script setup>
import { computed, ref } from 'vue'
import { seedMembers } from './data/members.js'
import MemberCard from './components/MemberCard.vue'
import MemberForm from './components/MemberForm.vue'
import TeamPanel from './components/TeamPanel.vue'
const members = ref(seedMembers.map(member => ({ ...member })))
const query = ref('')
const onlyAvailable = ref(false)
const descending = ref(false)
const visibleMembers = computed(() => members.value.filter(member =>
  `${member.name} ${member.role}`.toLocaleLowerCase('ru')
    .includes(query.value.trim().toLocaleLowerCase('ru'))
  && (!onlyAvailable.value || member.available)
).sort((a, b) => (descending.value ? -1 : 1) * a.name.localeCompare(b.name, 'ru')))
function toggle(id) {
  const member = members.value.find(item => item.id === id)
  if (member) member.available = !member.available
}
function create(draft) {
  members.value.push({ id: crypto.randomUUID(), ...draft, available: true, version: 1 })
}
</script>
<template>
  <main>
    <h1>Команда</h1>
    <TeamPanel title="Новый участник">
      <MemberForm @create="create" />
      <template #hint>Добавление изменяет локальную коллекцию.</template>
    </TeamPanel>
    <TeamPanel title="Сотрудники">
      <label>Поиск <input v-model="query" type="search"></label>
      <label><input v-model="onlyAvailable" type="checkbox"> Только доступные</label>
      <label><input v-model="descending" type="checkbox"> Обратный порядок</label>
      <p>Показано {{ visibleMembers.length }} из {{ members.length }}</p>
      <p v-if="!visibleMembers.length">Подходящих участников нет.</p>
      <MemberCard v-for="member in visibleMembers" :key="member.id"
        :member="member" @toggle="toggle" />
      <template #hint>Всего участников: {{ members.length }}.</template>
    </TeamPanel>
  </main>
</template>

Ожидаемый кабинет содержит две обрамлённые области. После добавления участника меняется счётчик в hint второй панели, поскольку выражение читает реактивную коллекцию App. TeamPanel не получает members и не выполняет расчёт. Таким образом повторная оболочка не притягивает предметную модель только ради размещения её текста в footer.

Чем слот отличается от свойства

Если бы панель принимала готовую HTML-строку, ради её вывода понадобилось бы менять безопасный текстовый договор на обработку HTML. Слот передаёт Vue-разметку, написанную автором компонента, и сохраняет обычные связи данных. Поэтому title остаётся простым текстовым свойством, а сложное содержимое передаётся шаблоном. Такое сочетание не является универсальным рецептом: оно отвечает конкретной разнице между подписью и структурой блока.

Именованный слот обозначается #hint, что является сокращением v-slot:hint. Имя должно совпадать с name="hint" в панели. Полезный ошибочный случай — написать #help. Переданный фрагмент не займёт ожидаемую область hint, и пользователь увидит запасной текст. Ошибку нужно искать в договоре имён, а не в реактивности members. Правильное значение может существовать у родителя, но не попасть в нужную область шаблона.

Запасной текст проявляется только при отсутствии содержимого соответствующего слота. Это удобно, когда компонент иногда используется без уточняющей подписи. Однако запасной текст тоже должен быть верным в любом допускаемом контексте. Наша панель предназначена для учебного кабинета, поэтому фраза «Учебные данные кабинета» допустима. Если переносить компонент в рабочее приложение, эту подпись необходимо переосмыслить, а не оставить как случайное свойство общего дизайна.

Обычный слот не делает доступными внутренние данные панели. Если панель начнёт вычислять, например, состояние свёрнутости и родителю понадобится читать его для содержимого, потребуется scoped slot с явным значением. В текущем примере таких внутренних данных нет. Добавлять scoped slot заранее означало бы вводить более сложный контракт без результата, который читатель способен наблюдать. Полезно отличать возможность технологии от необходимости текущего шага.

Структура страницы после обёртки

Заголовки панели h2 находятся под общим h1. MemberCard пока также использует h2, и это допускает самостоятельные названия карточек, но при более строгой иерархии внутри раздела можно выбрать h3. Изменение уровня относится к структуре экрана, а не к механизму слотов. Если один компонент используется на разных глубинах страницы, ему может потребоваться явный договор заголовка или отдельные варианты. Не прячьте такой выбор в случайную вложенность div.

У панели нет собственной кнопки, поэтому она не перехватывает действия формы. @create расположен на MemberForm в родительском содержимом и сразу вызывает create App. Дополнительный emit в TeamPanel был бы лишним посредником для этой задачи. Если же сама панель получит действие «Свернуть», оно действительно станет частью её договора. Разница определяется тем, кто создаёт пользовательское намерение и кому принадлежит соответствующее состояние.

Для самостоятельного разбора удалите hint у первой панели только в своей копии и заранее опишите ожидаемую подпись. Затем перенесите выражение members.length в основной слот и объясните, почему оно продолжит обновляться. Эти изменения помогают увидеть, что слот управляет местом представления, а не переносит владение данными. Следующий урок выделит сами правила списка в composable, оставив панели и карточки читателями его результата.

Повторное использование без лишних условий

Представим третью панель с отчётом о числе доступных людей. Её содержимое может вычислить число у родителя и передать обычный текст в слот. TeamPanel останется тем же. Если вместо этого добавить в панель флаг reportMode и передать members, оболочка начнёт знать сразу про форму, список и отчёт. Такая связь затруднит развитие каждой части независимо и потребует новых условий при следующей панели. Слот сохраняет более узкий договор размещения.

Это не означает, что у оболочки никогда не может быть собственных свойств оформления. Например, разрешённая ширина или нейтральный вариант плотности могут относиться именно к панели. Но свойство shouldInviteMember уже описывает предметную операцию и не помогает обычной оболочке. Оценивайте будущие параметры по тому, какую ответственность они добавляют.

Пустой стандартный слот также допустим с точки зрения механизма, однако такая панель может оказаться бессмысленной для человека. Если приложение действительно поддерживает пустое содержимое, родитель должен передать объяснение отсутствия данных. Не рассчитывайте, что рамка и заголовок сами сообщат причину. Слоты создают место для правильного текста, но не придумывают его автоматически.

Для самостоятельного воспроизведения результата «Собрать панель с независимым содержимым» выберите lesson-07 в архиве исходников. Статус выполнения этого состояния: not_run.