Таблица на Vue 3. Добавляем Scoped Slots

В прошлой статье мы сделали на Vue 3 с нуля таблицу для отображения данных, получаемых с сервера. Эта таблица имеет разбивку на страницы и возможность менять сортировку. А в этой новой статье будет показано как сделать тот код более пригодным для повторного использования. Будет рассказано о технике, которая делает компоненты гораздо более универсальными.

Содержание

Создаем базовый компонент

Сейчас у нас компонент таблицы называется PostDataTable. До этого момента нас это устраивало, так как нужно было работать только с записями одной сущности. Но наш разговор, начатый ранее, ведется о подходах, которые помогают делать код более приспособленным для использования в других ситуациях.

Поэтому сначала необходимо сделать компонент универсальным. Это будет компонент DataTable, который будет использоваться внутри специализированных компонентов. Такой подход будет похож на наследование, так как у нас будет базовая сущность с общим набором характеристик и специализированные сущности, которые наследуют ряд качеств и добавляют свои. Однако, строго говоря, это не наследование, конечно.

И так, вынесем общую функциональность в базовый компонент. Для этого нужна сделать возможность передавать функцию получения данных и настройки колонок извне. Используем свойства для этого.

<template>
...
</template>

<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import SvgIcon from '@jamescoyle/vue-icon'
import {
  mdiArrowLeft as previousIcon,
  mdiArrowRight as nextIcon,
  mdiArrowDown as ascSortIcon,
  mdiArrowUp as descSortIcon
} from '@mdi/js'
import {
  covertDataTableOptionsToQueryParams,
  convertApiResponceToDataTableOptions
} from '@/api/helpers.js'

const props = defineProps({
  // Настройки колонок
  headers: { type: Array, required: true },
  // Функция, которая будет получать данные для таблицы
  loadDataFunction: { type: Function, required: true }
})

// Строки текущей страницы
const pageItems = ref([])
// Флаг активности процесса загрузки данных
const loading = ref(true)
// Общее число строк на сервере
const totalItemsCount = ref(0)

const options = reactive({
  page: 1, // Номер страницы
  itemsPerPage: 10, // Число строк на страницу
  sortBy: 'id', // Имя столбца для сортировки
  sortDir: 'asc' // Порядок сортировки
})

// Число страниц
const pagesCount = computed(() => Math.ceil(totalItemsCount.value / options.itemsPerPage))

// Обработка нажатия кнопки перехода на следующую страницу
function handleNextPageAction() {
  options.page++
  loadData()
}

// Обработка нажатия кнопки перехода на предыдущую страницу
function handlePreviousPageAction() {
  options.page--
  loadData()
}

// Обработка нажатия заголовка столбца для сортировки
function handleToggleSortAction(filed) {
  if (!filed.sortable) {
    return
  }

  /**
   * 3 режима сортировки: возрастание, убывание и отсутствие сортировки
   */
  const filedName = filed.key
  if (options.sortBy === filedName) {
    if (options.sortDir === 'asc') {
      options.sortDir = 'desc'
    } else {
      options.sortBy = null
    }
  } else {
    options.sortBy = filedName
    options.sortDir = 'asc'
  }

  loadData()
}

// Загрузка данных для текущей страницы с сервера
async function loadData() {
  loading.value = true

  const params = covertDataTableOptionsToQueryParams(options)
  const data = convertApiResponceToDataTableOptions(await props.loadDataFunction(params))
  pageItems.value = data.items
  totalItemsCount.value = data.total

  loading.value = false
}

onMounted(loadData)
</script>

<style>
...
</style>

Все изменения находятся в секции script. Содержимое шаблона и стилей можно взять из предыдущей статьи (компонент PostDataTable.vue).

Самое главное, были добавлены свойства:

...
const props = defineProps({
  // Настройки колонок
  headers: { type: Array, required: true },
  // Функция, которая будет получать данные для таблицы
  loadDataFunction: { type: Function, required: true }
})
...

Теперь настройки колонок передаются снаружи через свойство headers, а не задаются жестко внутри. Также функция получения данных передается через свойство loadDataFunction и вызывается внутри:

const data = convertApiResponceToDataTableOptions(await props.loadDataFunction(params))

Сам же компонент PostDataTable.vue сильно уменьшился в размерах, так как основная его функциональность была перенесена в DataTable.vue:

<template>
  <data-table :headers="headers" :load-data-function="getPosts"></data-table>
</template>

<script setup>
import { getPosts } from '@/api/posts.js'
import DataTable from '@/components/DataTable.vue'

/**
 * Настройки колонок
 */
const headers = [
  {
    title: 'ID',
    align: 'end',
    sortable: true,
    key: 'id',
    width: '5em'
  },
  {
    title: 'Title',
    align: 'start',
    sortable: true,
    key: 'title',
    width: '100%'
  }
]
</script>

Как можно видеть в нем задаются только настройки колонок и подключается функция загрузки данных. В шаблоне эти вещи передаются в базовый компонент через свойства:

...
<data-table :headers="headers" :load-data-function="getPosts"></data-table>
...

Код примера можно взять из репозитория.

Кнопки действий в строках

Теперь представим, что нам нужно удалять строки и редактировать их. Могут быть и другие действия. Например, переход к таблице с комментариями. Было бы удобно иметь в каждой строке кнопки с действиями. Какими путями это могло бы быть достигнуто?

Мы могли бы в компоненте DataTable сделать свойства, которые бы служили для конфигурирования набора кнопок. К примеру, можно было бы ввести такие свойства:

const props = defineProps({
  // Включение кнопок редактирования в строках
  editAction: { type: Boolean, default: false },
  // Включение кнопок удаления в строках
  deleteAction: { type: Boolean, default: false },
  // Включение ссылок с подробностями в строках 
  detailsLink: { type: String, default: null },
  // ...
})

То есть логические свойства для показа кнопок удаления и редактирования, а также текстовое свойство для ссылки на произвольную страницу. И еще для кнопок, вероятно, придется создать события, которые будут обрабатываться в компоненте верхнего уровня (PostDataTable).

Такой подход будет работать. Но что, если если в какой то таблице (ведь в реальном проекте у нас будет не только PostDataTable) нужен будет другой порядок кнопок, другой набор кнопок? Что если этим кнопкам нужно будем менять внешний вид? Делать их другого цвета, менять надписи, делать их пиктограммами?

Чтобы сделать поддержку всех возможных ситуаций через свойства нам понадобиться множество свойств. И по мере увеличения их числа в них разобраться будет все сложнее. Все труднее будет избежать коллизий и сделать такую систему удобной и понятной.

Мы приходим к тому, что свойства в этом месте достигают предела сферы своего применения. И наиболее удобными становятся Scoped Slots.

Немного о Scoped Slots

От обычных слотов слоты с ограниченной областью видимости (Scoped Slots) отличаются возможностью передачи параметров. Из внутреннего компонента в иерархии компонентов (компонент-ребенок) можно передавать параметры во внешний компонент (компонент-родитель), где можно работать с ними. Компонент-родитель обрабатывает данные, полученные от компонента-ребенка, формирует на основе этих данных кусочек шаблона и передает его компоненту-ребенку, чтобы тот использовал этот фрагмент шаблона при отрисовке (рендеринге) своего шаблона в целом.

Рассмотрим небольшой пример из документации Vue. Представим, что у нас есть компонент MyComponent и шаблон компонента-родителя, где используется MyComponent:

Иллюстрация работы Scoped Slots

В MyComponent в теге slot происходит передача двух параметров (text и count) в сторону компонента-родителя. В компоненте-родителе в атрибуте v-slot задается имя переменной (slotProps), свойствами которой станут те параметры. Таким образом компонент верхнего уровня участвует в формировании шаблона компонента нижнего уровня. Можно сказать, что верхний говорит нижнему как нужно формировать отдельные фрагменты шаблона нижнего. А нижний сообщает верхнему какие данные у него есть для этого.

Применяем Scoped Slots для таблиц

Как раз эти принципы дадут нам возможность легко встроить нужные кнопки в строки таблицы. При этом мы не потеряем гибкость. У нас могут быть несколько таблиц с разным набором таких кнопок. И мы бы смогли осуществлять такое гораздо удобнее, чем это было бы со свойствами.

И так, чтобы добавить возможность вставки фрагментов в ячейки таблицы в базовый компонент DataTable нужно будет добавить совсем немного кода.

Было так:

<!-- Создание ячейки с данными в каждой строке -->
<td v-for="field in headers" :key="field.key" :style="{ textAlign: field.align }">
    <!-- Содержимое ячейки -->
    {{ item[field.key] }}
</td>

Стало так:

<!-- Создание ячейки с данными в каждой строке -->
<td v-for="field in headers" :key="field.key" :style="{ textAlign: field.align }">
    <!-- Содержимое ячейки. Можно менять при помощи слота с именем item.ИМЯ_ПОЛЯ -->
    <!-- Внешнему компоненту передается строка с данными в свойстве row -->
    <slot :name="`item.${field.key}`" :row="item">
    <!-- Если слот не используется, то выводится- как есть -->
    {{ item[field.key] }}
    </slot>
</td>

Здесь мы просто обернули вывод содержимого ячейки тегом slot. Если во внешнем компоненте слот не будет использоваться, то данные в ячейке выведутся как есть.

С помощью item.${field.key} конструкции мы динамически формируем имя слота. Для ячеек столбца title слот будет иметь имя item.title, для ячеек столбца idtitle.id.

Через свойство слота row передаются во внешний (родительский) компонет данные всей строки.

Совсем немного кода дали нам огромную возможность расширения, которая в полной мере раскроется при добавлении некоторого кода в компонент PostDataTable. Его шаблон до изменений был очень простым:

<data-table :headers="headers" :load-data-function="getPosts"></data-table><template>
  <data-table :headers="headers" :load-data-function="getPosts"></data-table>
</template>

Сейчас же во внутрь тега data-table нужно добавить шаблон с кнопками.

<template>
  <data-table :headers="headers" :load-data-function="getPosts">
    <!-- Используем слот для колонки alerts. Имя - item.actions -->
    <template v-slot:[`item.actions`]="{ row }">
      <div class="icons-group">
        <!-- В обработчик кнопки передаем данные row из внутреннего компонента -->
        <button
            class="icon-btn info"
            @click="handleEdit(row)"
        >
          <svg-icon type="mdi" :path="editIcon"></svg-icon>
        </button>

        <button
            class="icon-btn alert"
            @click="handleDelete(row)"
        >
          <svg-icon type="mdi" :path="deleteIcon"></svg-icon>
        </button>
      </div>
    </template>
  </data-table>
</template>

Здесь через директиву v-slot мы обращаемся к слоту, объявленному в DataTable.vue. Имя слота – item.actions, то есть – работаем с колонкой actions, которую добавим в headers позже.

В обработчики handleEdit и handleDelete передааем данные всей строки, которые попадают сюда благодаря передаче из дочернего компонента (DataTable.vue).

JavaScript-часть компонента PostDataTable будет включать описание функций-обработчиков и добавление столбца с действиями:

<script setup>
import { getPosts } from '@/api/posts.js'
import DataTable from '@/components/DataTable.vue'

import SvgIcon from '@jamescoyle/vue-icon'
import {
  mdiTextBoxEdit as editIcon,
  mdiTextBoxRemove as deleteIcon,
} from '@mdi/js'

/**
 * Настройки колонок
 */
const headers = [
  {
    title: 'ID',
    align: 'end',
    sortable: true,
    key: 'id',
    width: '5em'
  },
  {
    title: 'Title',
    align: 'start',
    sortable: true,
    key: 'title',
    width: '100%'
  },
  // Столбец для кнопок
  {
    title: 'Actions',
    align: 'center',
    sortable: false,
    key: 'actions',
  }
]

function handleEdit(post) {
  /**
   * В обработчиках мы можем использовать данные всей строки таблицы,
   * так как из внутреннего (дочернего) компонента передается вся строка
   */
  if(confirm(`Edit the post #${post.id}?`)) {
    alert('TODO: Display edit form')
  }
}

function handleDelete(post) {
  if(confirm(`Delete post #${post.id}?`)) {
    alert('TODO: Call server API')
  }
}
</script>

В итоге наша таблица примет такой вид:

Vue таблица с использованием Scoped Slots для добавления кнопок

При нажатиях на кнопку в строке будет вызван обработчик и в него будут переданы данные строки, которые можно использовать для осуществления действий со строкой. Как минимум в этом случае нам понадобится ID записи, и мы легко его получим.

Итоги

В итоге после этих изменений мы можем вставлять произвольные шаблоны в ячейки наших таблиц. Это гораздо гибче и эргономичнее, чем иметь множество свойств для этих целей.

Базовую таблицу с помощью слотов можно расширять и далее. Добавить слоты в область заголовков столбцов, в нижнюю часть (футер) и в переключатель страниц.

В целом такой инструмент позволяет реализовывать что-то типа наследования. В примере PostDataTable является оберткой для DataTable и это очень похоже на наследование. Мы могли бы сделать другие обертки. Некоторые из них могли бы быть не такими узкоцпециализованными как PostDataTable. Для этих оберток, в свою очередь, можно было бы делать еще один уровень оберток. Таким образом мы бы получили эквивалент наследования.

Код примера таблиц данных на Vue со слотами можно получить в репозитории.

Что дальше

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

Например, интересный аспект – прокидывание слотов на более чем один уровень в глубину иерархии компонетов. Не между родителем и ребенкном, а между дедушкой и внуком.

Также интересна гибкость, которую дает совместное применение директивы v-if со слотами.

Интересна будет и возможность обратиться из компонента-обертки к методам вложенного компонента, чтобы это еще больше было похоже на наследование.

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

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

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