Skip to content

Композиция пользовательского интерфейса с помощью компонентов

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

Компоненты занимают центральное место в подходе Vue к построению интерфейсов.

В этой главе мы сделаем следующее:

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

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

Примечание о стилях

Чтобы избежать длинных текстов кода, мы опустим примеры иконок и стилей в примерах кода. Полный код, а также стили и иконки можно найти в репозитории GitHub этой книги по адресу https://github.com/PacktPublishing/Vue.js-3-Design-Patterns-and-Best-Practices.

Технические требования

Требования к выполнению этой главы такие же, как и в главе 3, Установка рабочего проекта.

Посмотрите следующее видео, чтобы увидеть код в действии.

Файлы кода этой главы можно найти на GitHub здесь.

Составление страницы с помощью компонентов

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

  1. Как мы можем представить макет и множество элементов с помощью компонентов?
  2. Как эти компоненты будут взаимодействовать друг с другом и связаны между собой?
  3. Какие динамические элементы будут входить и выходить из сцены, и какие события или состояния приложения будут их вызывать?
  4. Какие шаблоны проектирования мы можем применить, чтобы наилучшим образом удовлетворить требованиям данного сценария использования, учитывая компромиссы?

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

Этап 1 - определение макетов и элементов пользовательского интерфейса

Этот этап отвечает на вопрос: Как мы можем представить макет и множество элементов с помощью компонентов?

Мы возьмем страницу в целом и подумаем, какой макет подходит лучше всего, учитывая дизайн. Следует ли использовать колонки? Разделы? Навигационные меню? Острова контента? Есть ли диалоговые или модальные окна? Простой подход заключается в том, чтобы взять изображение дизайна и обозначить прямоугольниками участки, которые могут представлять собой компоненты, начиная с самого большого и заканчивая наименьшей единицей интерактивности. Итерируйте эту нарезку страницы до тех пор, пока не получите комфортное количество компонентов. Если рассматривать новый дизайн приложения To-Do, то этот шаг может выглядеть следующим образом:

image

Рисунок 4.1 - Разбивка конструкции на компоненты с пунктирными рамками

После того как мы определили компоненты, необходимо выявить связи между ними, создав иерархию от самого верхнего корневого компонента (обычно это наш App.vue). Новые компоненты могут появиться в результате группировки компонентов по контексту или функциональности. Это подходящее время для присвоения имен компонентам. Эта начальная архитектура будет развиваться по мере реализации шаблонов проектирования. В соответствии с этим примером иерархия может выглядеть следующим образом:

image

Рисунок 4.2 - Начальный подход к иерархии компонентов

Заметьте, как из группировки других компонентов появился новый компонент ToDoProject.vue. Компонент App обычно имеет дело с основным макетом приложения и является отправной точкой в иерархии. Теперь, когда мы создали первоначальный дизайн, пора переходить к следующему шагу.

Шаг 2 - определение связей, потока данных, взаимодействий и событий

Этот шаг отвечает на вопрос: Как эти компоненты будут взаимодействовать и относиться друг к другу?

На этом этапе нам необходимо понять, как будет взаимодействовать пользователь (с помощью use-case нотаций, user story или чем-то еще). Для каждого компонента мы решаем, какую информацию он будет хранить (состояние), что будет передавать своим дочерним компонентам, что ему нужно от родителя и какие события он будет вызывать.

Во Vue компоненты могут соотноситься друг с другом только по вертикали. Братья и сестры по большей части игнорируют существование друг друга. Если компоненту-сиблингу необходимо поделиться данными с другим компонентом, то эти данные должны быть размещены у общей третьей стороны, которая может поделиться ими с обоими компонентами, обычно это родитель, который имеет общую видимость. Для этого существуют и другие решения, например, реактивное управление состоянием, которое мы подробно рассмотрим в главе 7, Управление потоками данных. В этой главе мы остановимся на базовой функциональности отношений.

Существует множество способов документирования собранной информации: заметки в дереве иерархии (см. Рисунок 4.2), описательная формальная документация, UML-диаграммы (UML означает Universal Modeling Language, иконографическое представление компонентов программного обеспечения) и многое другое. Для простоты запишем только один сегмент дерева в формате таблицы:

КомпонентФункцияСостояние, ввод/вывод, события
ToDoProject.vueСодержит список дел и координирует взаимодействие с пользователем. Этот компонент будет активно изменять элементы.Состояние: Список дел

События: Открытие новых, редактирование и удаление модалов.
ToDoSummary.vueОтображает суммарный подсчет элементов дел по состоянию.Ввод: Список дел

Состояние: Счетчики для каждого состояния элемента
ToDoFilter.vueСобирает строку для фильтрации списка дел.Выход: Строка фильтра

Состояние: Вспомогательная переменная
ToDoList.vueОтображает список дел и сигнальные операции для каждого из них.Входные данные: Список дел, строка фильтра

События: Переключение состояния элемента, редактирование и удаление элемента.

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

Шаг 3 - определение элементов пользовательской интерактивности (входов, диалогов, уведомлений и т.д.)

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

В нашем приложении основные CRUD-операции (CRUD расшифровывается как Create, Read, Update, Delete данных) связаны с использованием модальных диалогов, представляемых пользователю. Как уже говорилось, именно компонент ToDoProject.vue управляет этим взаимодействием в ответ на определенные события. Этот процесс проиллюстрирован на данной диаграмме последовательности:

image

Рисунок 4.3 - Взаимодействие пользователя через модалы - редактирование элемента.

На этой диаграмме компонент ToDoProject разделяет список дел с компонентом ToDoList. Когда пользователь вызывает событие edit, дочерний компонент уведомляет родительский, испуская такое событие. После этого родительский компонент создает копию элемента и открывает модальный диалог, передавая ему эту копию.

Когда диалог принят, родитель модифицирует исходный элемент с учетом изменений. Затем реактивность Vue отражает изменение состояния в дочерних компонентах.

Зачастую такое взаимодействие помогает нам выявить необходимость в дополнительных компонентах, которые не были очевидны в шаге 1, например, в реализации шаблонов проектирования... что является следующим шагом.

Шаг 4 - выявление шаблонов проектирования и компромиссов

Этот шаг отвечает на вопрос: Какие шаблоны проектирования мы можем применить, чтобы наилучшим образом удовлетворить требованиям конкретного случая использования, с учетом компромиссов?

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

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

Учитывая эти условия, одним из возможных шаблонов для применения является шаблон Async Promise.

В нашем коде мы хотим открыть модальный диалог в виде промиса, который, по определению, предоставит нам функцию resolve() (принять) или reject() (отменить). Более того, мы хотим иметь возможность использовать это решение в нескольких проектах и глобально в нашем приложении. Для этого мы можем создать плагин и использовать шаблон инъекции зависимостей для доступа к модальной функциональности из любого компонента. Эти шаблоны обеспечат нам решение, необходимое для того, чтобы сделать наш модальный диалог многократно используемым.

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

Компоненты в деталях

Компоненты - это строительные блоки фреймворка. В главе 1, Фреймворк Vue 3, мы рассмотрели, как работать с компонентами, объявлять реактивные переменные и многое другое. В этом разделе мы рассмотрим более продвинутые возможности и определения.

Локальные и глобальные компоненты

При запуске нашего приложения Vue 3 мы монтируем главный компонент (App.vue) к элементу HTML в файле main.js. После этого в секции script каждого компонента мы можем импортировать другие компоненты для локального использования с помощью следующей команды:

js
import MyComponent from "./MyComponent.vue"

Таким образом, чтобы использовать MyComponent в другом компоненте, нам необходимо импортировать его в этот компонент еще раз. Если один компонент постоянно используется в нескольких компонентах, то это повторяющееся действие нарушает принцип DRY разработки (см. главу 2, Принципы и шаблоны проектирования программного обеспечения).

Альтернативный вариант - объявить компонент как глобальный, прикрепив его непосредственно к нашему приложению Vue вместо каждого компонента. В файле main.js мы можем использовать метод App.component() для этого случая:

Main.js

js
import { createApp } from "vue"
import App from './App.vue'
import MyComponent from "./MyComponent.vue"
createApp(App)
    .component('MyComponent', MyComponent)
    .mount("#app")

Метод component() получает два аргумента: String, представляющий собой HTML-тег компонента, и объект с определением компонента (импортированным или встроенным). После регистрации он становится доступным для всех компонентов нашего приложения. Однако у использования глобальных компонентов есть несколько недостатков:

  • Компонент будет включен в финальную сборку, даже если никогда не будет использоваться
  • Глобальная регистрация затушевывает отношения и зависимости между компонентами
  • Возможна коллизия имен с локально импортированными компонентами

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

Статический, асинхронный и динамический импорт

До сих пор все компоненты, которые мы импортировали, были определены статически с помощью синтаксиса import XYZ from "filename". Такие сборщики, как Vite, включают их в один JavaScript файл. Это увеличивает размер пакета и может привести к задержкам при запуске нашего приложения, поскольку браузеру необходимо загрузить, разобрать и выполнить пакет и все его зависимости, прежде чем произойдет взаимодействие с пользователем.

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

В Vue 3 предусмотрена функция defineAsyncComponent. Эта функция принимает в качестве параметра другую функцию, которая возвращает динамический импорт. Вот пример:

js
import {defineAsyncComponent} from "vue"
const MyComponent = defineAsyncComponent(
    () => import("MyComponent.vue")
)

Использование этой функции делает ее безопасной для применения в большинстве сборщиков. Альтернативой этому синтаксису является Vue Router, который мы рассмотрим в главе 5, Одностраничные приложения - динамическое объявление import(), предоставляемое JavaScript. Оно имеет очень похожий синтаксис:

js
const MyComponent = () => import('./MyComponent.vue')

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

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

Приведем пример, в котором мы решили загрузить один компонент на основе значения входного параметра:

js
const ExampleComponent = defineAsyncComponent(() => {
    return new Promise((resolve, reject) => {
         if(some_input_value_is_true) {
             import OneComponent from "OneComponent.vue"
                 resolve(OneComponent)
             } else {
                 import AnotherComponent from
                    "AnotherComponent.vue"
                 resolve(AnotherComponent)
             }
     })
})

Третий синтаксис для defineAsyncComponent, пожалуй, самый полезный. В качестве аргумента мы можем передать объект с атрибутами, что обеспечивает больший контроль над операцией загрузки. Он имеет такие атрибуты:

  • загрузчик (обязательно): Он должен предоставлять функцию, возвращающую промис, который загружает компонент
  • loadingComponent: компонент, который будет отображаться во время загрузки асинхронного компонента
  • delay: количество миллисекунд, которое нужно подождать перед отображением loadingComponent
  • errorComponent: Компонент, который будет отображаться, если обещание будет отклонено или если загрузка не удастся по какой-либо причине
  • timeout: время в миллисекундах до признания операции неудачной и отображения errorComponent

Приведем пример, в котором используются все эти атрибуты:

js
const HeavyComponent = defineAsyncComponent({
    loader: () => import("./HeavyComponent"),
    loadingComponent: SpinnerComponent,
    delay: 200,
    errorComponent: LoadingError,
    timeout: 60000
})

Пока браузер получает компонент из аттрибута loader, мы отображаем SpinnerComponent, чтобы сообщить пользователю, что операция выполняется. По истечении 1 минуты ожидания, определяемой параметром timeout, автоматически отображается компонент LoadingError.

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

Пропсы, события и директива v-model

Мы рассмотрели основные возможности использования props и events как средств передачи данных от компонента к его родителю. Однако с помощью различных синтаксисов возможны и более сильные определения. Пропсы могут быть определены в синтаксисе script setup с помощью команды defineProps и любого из следующих форматов аргументов:

  • В виде массива строк - например:
js
const $props = defineProps(['name', 'last_name'])
  • В виде объекта, атрибуты которого используются в качестве имени, а значение имеет тип данных - например:
js
const $props = defineProps({name: String, age: Number})
  • В качестве объекта, атрибуты которого определяют объект с типом и значением по умолчанию - например,
js
const $props = defineProps({
     name: { type: String, default: "John"},
     last_name: {type: String, default: "Doe"}

Необходимо помнить, что примитивные значения передаются в компонент по значению (это означает, что изменение их значения в дочернем компоненте не повлияет на их значение в родительском). Однако сложные типы данных, такие как объекты и массивы, передаются как ссылки, поэтому изменения их внутренних ключей/значений отразятся в родительском компоненте.

Примечание о сложных типах

При определении пропсов типа Object или Array со значениями по умолчанию, атрибут default должен быть функцией, возвращающей указанный объект или массив. В противном случае ссылка на объект/массив будет общей для всех экземпляров компонента.

События - это сигналы, которые дочерний компонент подает родительскому. Вот пример определения событий для компонента в синтаксисе script setup:

js
const $emit = defineEmits(['eventName'])

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

js
$emit('eventName', some_value)

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

Настраиваемые контроллеры ввода

Одним из особых применений совместного действия пропсов и событий является создание пользовательских контроллеров ввода. В предыдущих примерах мы использовали директиву Vue v-model для базовых элементов ввода HTML, чтобы перехватить их значение. Пропсы и события, которые следуют специальному соглашению об именовании, позволяют создавать компоненты ввода, которые принимают директиву v-model.

Рассмотрим следующий код:

Шаблон родительского компонента

vue
<MyComponent v-model="parent_variable"></MyComponent>

Теперь, когда у нас есть MyComponent, используемый внутри родительского компонента, давайте посмотрим, как мы создаем привязку:

script setup MyComponent

js
const $props = defineProps(['modelValue']),
      $emit = defineEmits(['update:modelValue'])

Для краткости мы используем определение Props через массив. Обратите внимание, что имя пропса - modelValue, а событие - update:modelValue. Такой синтаксис является ожидаемым от нас Vue. Когда родитель присваивает переменной v-model, ее значение будет скопировано в modelValue. Когда дочерняя переменная выдает событие update:modelValue, значение родительской переменной будет обновлено.

Таким образом, можно создавать мощные элементы управления вводом. Но это еще не все - можно иметь несколько v-model!

Считаем, что modelValue является значением по умолчанию при использовании v-модели. В Vue 3 появился новый синтаксис для этой директивы, благодаря чему мы можем иметь несколько моделей. Объявление очень простое. Рассмотрим объявление следующего дочернего компонента:

Пропсы и события дочернего компонента

js
const
  $props = defineProps(['modelValue', 'title']),
  $emit = defineEmits(['update:modelValue','update:title'])

После определения props и emits мы можем ссылаться на них из родительского компонента, как показано в следующем примере:

Шаблон родительского компонента

vue
<ChildComponent v-model="varA" v-model:title="varB"></ChildComponent>

Как мы видим, к директиве v-model:name_of_prop можно присоединить модификатор. Теперь в компоненте Child имя события должно включать префикс update:

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

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

Инъекция зависимости с помощью Provide и Inject

Когда данные в родительском компоненте должны быть доступны в глубоко вложенном дочернем, используя только props, нам придется "передавать" данные между компонентами, даже если они не нуждаются в них или не используют их. Эта проблема называется props drilling.

То же самое происходит и с событиями, идущими в обратном направлении, - им приходится "подниматься" вверх. Для решения этой проблемы Vue предлагает реализацию шаблона инъекции зависимостей с помощью двух функций Provide и Inject. С их помощью родительский или корневой компонент предоставляет данные (в виде значения или ссылки, например, объекта), которые могут быть инжектированы в любой из его дочерних компонентов, расположенных ниже по иерархическому дереву. Визуально мы можем представить эту ситуацию следующим образом:

image

Рисунок 4.4 - Представление Provide/Inject

Как видите, процесс очень прост, как и синтаксис для реализации шаблона:

  1. В родительском (корневом) компоненте мы импортируем функцию provide из Vue и создаем провизию с ключом (именем) и данными для передачи:
js
import { provide } from "vue"
provide("provision_key_name", data)
  1. В компоненте-получателе мы импортируем функцию inject и получаем данные по ключу (имени):
js
import { inject } from "vue".
const $received_data = inject("provision_key_name")

Мы также можем предоставить ресурс на уровне приложения следующим образом:

js
const app = createApp({})
app.provide('provision_key_name', data_or_value)

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

В родительском/корневом компоненте

js
import {provide} from "vue"
function logMessage() { console.log("Hi") }
const _provision_data = {runLog: logMessage}
provide("service_name", _provision_data)

В дочернем компоненте

js
import {inject} from "vue"
const $service = inject("service_name")
$service.runLog()

В данном примере мы фактически предоставили интерфейс прикладного программирования (API) через объект в рамках всей системы. Хорошей практикой при именовании "ключа предоставления" (имени сервиса) является соблюдение соглашения, которое будет понятно всей команде и позволит определить функциональность, контекст и, возможно, источник предоставляемого сервиса, а также избежать возможных коллизий.

Например, инжектируемый сервис с именем Admin.Users.Individual.Profile является более описательным, чем user_data. Определять соглашение об именовании должна команда разработчиков (именование типа "путь" является лишь предложением, а не стандартом). Как уже упоминалось в этой книге, после того как вы определились с соглашением, главное, чтобы оно было последовательным во всем исходном коде.

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

Специальные компоненты

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

Слоты, слоты и еще раз слоты...

Используя пропсы, наш компонент может получать данные JavaScript. По аналогии, можно также передавать фрагменты шаблона (HTML, JSX и т.д.) в определенные части шаблона компонента, используя для этого так называемые слоты. Как и пропсы, они принимают несколько типов синтаксиса. Начнем с самого простого: слот по умолчанию.

Допустим, у нас есть компонент с именем MyMenuBar, который выступает в качестве верхнего меню. Мы хотим, чтобы родительский компонент заполнял опции так же, как мы используем обычные HTML-теги, такие как header или div, например так:

Родительский компонент

vue
<MyMenuBar>
    <button>Option 1</button>
    <button>Option 2</button>
</MyMenuBar>

Компонент MyMenuBar

vue
<template>
<div class="...">
    <slot></slot>
</div>
</template>

При условии, что мы применили необходимые стили и классы в MyMenuBar, конечный рендер шаблона может выглядеть примерно так:

image

Рисунок 4.5 - Строка меню с использованием слотов

Применяемая логика достаточно проста. Слот во время выполнения программы будет заменен на содержимое, предоставленное родительским компонентом внутри дочерних тегов. В предыдущем примере, если проанализировать конечный HTML, можно обнаружить примерно следующее (учитывая, что мы используем классы W3.css):

html
<div class="w3-bar w3-border w3-light-grey">
  <button>Вариант 1</button>
  <button>Вариант 2</button>
</div>

Это фундаментальная концепция проектирования пользовательского интерфейса. А что, если нам нужно несколько "слотов" - например, для создания компонента макета? Здесь на помощь приходит альтернативный синтаксис, называемый именованные слоты. Рассмотрим следующий пример:

Компонент MyLayout

vue
<div class="layout-wrapper">
    <section><slot name="sidebar"></slot></section>
    <header><slot name="header"></slot></header>
    <main><slot name="content"></slot></main>
</div>

Как видите, мы присвоили имя каждому слоту с помощью атрибута name. Теперь в родительском компоненте мы должны использовать элемент template с директивой v-slot для доступа к каждому из них. Вот как родительский компонент будет использовать MyLayout:

Родительский компонент

vue
<MyLayout>
    <template v-slot="sidebar"> ... </template>
    <template v-slot="header"> ... </template>
    <template v-slot="content"> ... </template>
</MyLayout>

Директива v-slot принимает один аргумент, соответствующий имени слота, с такими замечаниями:

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

Для директивы v-slot также существует сокращенное обозначение. Мы просто префиксируем имя слота знаком (#). Например, шаблоны в предыдущем родительском компоненте можно упростить следующим образом:

vue
<template #sidebar> ... </template>
<template #header> ... </template>
<template #content> ... </template>

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

Компонент PassingPropsUpward

vue
<div>
    <slot :data="some_text"></data>
</div>

Здесь слот передает родительскому компоненту пропс с именем data. Родительский компонент может получить к нему доступ с помощью следующего синтаксиса:

vue
<PassingPropsUpward v-slot="upwardProp">
    {{upwardProp.data}} //Рендеринг содержимого some_text
</PassingPropsUpward>

В родительском компоненте мы используем директиву v-slot и присваиваем локальное имя пропсу, передаваемому слотом, - в данном случае upwardProp. В эту переменную будет передан объект, аналогичный по функциям объекту props, но привязанный к элементу. Из-за этого такие слоты называются именованными слотами, и синтаксис их аналогичен. Посмотрите на этот пример:

vue
<template #header="upwardProp">
    {{upwardProp.data}}
</template>

Существуют и другие расширенные возможности использования слотов, которые охватывают крайние случаи, но мы не будем рассматривать их в этой книге. Вместо этого я рекомендую вам более подробно изучить эту тему в официальной документации по адресу https://vuejs.org/guide/components/slots.html.

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

Композабл функции (composables) и миксины

В Vue 2 специальный компонент mixin позволял обмениваться кодом между компонентами, избегая повторения кода. Такой подход порождал ряд проблем и неприятных побочных эффектов, решение которых привело к созданию Composition API во Vue 3. Использование миксинов по-прежнему поддерживается для обеспечения обратной совместимости, но категорически не рекомендуется. В этой книге мы не будем рассматривать миксины; вместо этого мы сосредоточимся на технологии, которая пришла им на смену и превзошла их: composables.

Композабл функция - это функция, использующая Composition API для инкапсуляции и повторного использования логики с сохранением стейта (stateful) между компонентами. Важно отличать композабл функции от сервисных классов или других инкапсуляций бизнес-логики. Основное назначение композабл функции - совместное использование пользовательского интерфейса или логики взаимодействия с пользователем. В общем случае каждая композабл функция выполняет следующие действия:

  • Экспортирует функцию, которая возвращает реактивные переменные.
  • Следует соглашению об именовании с префиксом use в формате camelCase - например, useStore(), useAdmin(), useWindowsEvents(), и так далее.
  • Это самостоятельный модуль.
  • Обрабатывает логикe с сохранением стейта. Это означает, что она управляет данными, которые сохраняются и изменяются с течением времени.

Классический пример композабл функции - привязывание к событиям окружающей среды (изменение размеров окна, движение мыши, датчики, анимация и т.д.). Реализуем простую композабл функцию, который считывает вертикальный скролл документа:

DocumentScroll.js

js
import {ref, onMounted, onUnmounted} from "vue"                     //1
function useDocumentScroll() {
     const y=ref(window.scrollY)                                    //2
     function update(){y.value=window.scrollY}
     onMounted(()=>{
         document.addEventListener('scroll', update)})              //3
     onUnmounted (()=>{
         document.removeEventListener('scroll', update)})           //4
     return {y}                                                     //5
}
export { useDocumentScroll };                                       //6

В этой небольшой композабл функции мы начинаем с импорта событий жизненного цикла компонента и реактивного конструктора из Vue (//1). Наша главная функция, useDocumentScroll, содержит весь код, которым мы будем делиться и экспортировать позже (//6). В //2 мы создаем реактивную константу и инициализируем ее текущим значением вертикальной прокрутки окна. Затем мы создаем внутреннюю функцию update, которая обновляет значение y. Мы добавляем эту функцию в качестве слушателя события прокрутки документа в //3, а затем удаляем ее в //4 (принцип "Clean after yourself" из главы 2, Принципы и шаблоны проектирования программного обеспечения, ). Наконец, в //5 мы возвращаем нашу реактивную константу, обернутую в объект. Затем в компоненте мы используем эту композабл функцию таким образом:

SomeComponent.js script setup

js
import {useDocumentScroll} from "./DocumentScroll.js"
const { y } = useDocumentScroll()
...

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

Наконец, на сайте https://vueuse.org/ собрана внушительная коллекция композабл функций для наших проектов. С ней стоит ознакомиться.

Динамические компоненты с помощью "component :is"

Фреймворк Vue 3 предоставляет специальный компонент под названием <component\>, задача которого заключается в том, чтобы быть держателем для динамического отображения других компонентов. Он работает со специальным атрибутом :is, который может принимать либо строку с именем компонента, либо переменную с определением компонента. Он также принимает некоторые базовые выражения (строка кода, которая преобразуется в значение). Вот простой пример с использованием выражения:

Компонент CoinFlip

vue
<script setup>
    import Heads from "./heads.vue"
    import Tails from "./tails.vue"
    function flipCoin() { return Math.random() > 0.5 }
</script>
<template>
    <component :is = "flipCoin() ? Heads : Tails"></component>
</template>

При выводе этого компонента мы увидим либо компонент Heads, либо компонент Tails в зависимости от результата выполнения функции flipCoin().

На этом этапе у вас может возникнуть вопрос, почему бы не использовать простой v-show / v-if? Сила этого компонента становится очевидной, когда управление компонентами происходит динамически и мы не знаем, какие из них доступны на момент создания шаблона. Официальный Vue Router, который мы рассмотрим в главе 5, Одностраничные приложения, использует этот специальный компонент для имитации страничной навигации.

Однако есть один крайний случай, о котором следует знать. Хотя большинство атрибутов шаблона будут передаваться динамическому компоненту, использование некоторых директив, таких как v-model, не будет работать на нативных элементах ввода. Эта ситуация настолько редкая, что мы не будем обсуждать ее подробно, но ее можно найти в официальной документации по адресу https://vuejs.org/api/built-in-special-elements.html#component.

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

Реальный пример - плагин модалов

Мы рассмотрели множество подходов к совместному использованию данных и функциональности в рамках проекта. Плагины - это шаблон проектирования, позволяющий обмениваться функциональностью между проектами и одновременно расширять возможности системы. Vue 3 предоставляет очень простой интерфейс для создания плагинов и их подключения к нашему экземпляру приложения. Плагином может стать любой объект, экспортирующий метод install() или функцию, принимающую те же параметры. Подключаемый модуль может выполнять следующие действия:

  • Регистрировать глобальные компоненты и директивы
  • Регистрировать инжектируемый ресурс на уровне приложения
  • Создавать и присоединять к приложению новые свойства или методы

В этом разделе мы создадим плагин, реализующий модальные диалоги в виде глобальных компонентов. Мы будем использовать инъекцию зависимостей для предоставления их в качестве ресурсов и реактивность Vue для управления ими с помощью промисов.

Настройка нашего проекта

Следуйте инструкциям, приведенным в главе 3, Установка рабочего проекта, чтобы у вас была отправная точка. В каталоге src/ создайте новую папку plugins/ с подпапкой modals/. Это стандартный подход - размещать наши плагины в отдельных каталогах внутри папки plugins/.

Конструкция

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

image

Рисунок 4.6 - Представление модального плагина

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

Реализация

Для этого плагина нам понадобится всего два файла - один для логики плагина и один для нашего компонента. Создайте файлы index.js и Modal.vue в папке src/plugins/modal. На данный момент достаточно просто набросать компонент с настройкой скрипта, шаблона и стиля раздела. К его завершению мы вернемся позже. С этими файлами давайте начнем пошагово с файла index.js:

/src/plugins/modals/index.js

js
import { reactive } from "vue"                  //1
import Modal from "./Modal.vue"
const
    _current = reactive({}),                    //2
    api = {},                                   //3
    plugin = {
          install(App, options) {               //4
              App.component("Modal", Modal)
              App.provide("$modals", api)
          }
    }
export default plugin

В строке //1 мы начинаем с импорта конструктора reactive из Vue и компонента Modal, файл которого мы еще не создали. Затем, в строке //2, мы создаем внутреннее свойство состояния _current, а в //3 - объект, который будет нашим API. Пока что это лишь условные обозначения. Важный раздел находится в строке //4, где мы определяем функцию install(). Эта функция получает два параметра в следующем порядке:

  1. Экземпляр приложения (App).
  2. Объект с опциями, если таковые были переданы в процессе установки.

В экземпляре приложения мы регистрируем Modal как глобальный компонент и предоставляем API как инжектируемый ресурс под именем $modals, и то и другое на уровне приложения. Чтобы использовать плагин в нашем приложении, мы должны импортировать его в main.js и зарегистрировать с помощью метода use. Код выглядит следующим образом:

/src/Main.js

js
import { createApp } from 'vue'
import App from './App.vue'
import Modals from "./plugins/modals"
createApp(App).use(Modals).mount('#app')

Как видите, создать и использовать плагин довольно просто. Однако пока что наш плагин делает не так уж много. Давайте вернемся к коду нашего плагина и доработаем API. Нам нужно следующее:

  • Метод show(), который принимает имя, идентифицирующее реализацию модального диалога, и возвращает обещание. Затем мы сохраним это имя и ссылки на функции resolve() и reject() в нашем реактивном состоянии.
  • Методы accept() и cancel() для разрешения и отклонения обещания, соответственно.
  • Метод active() для получения имени текущего модала.

Следуя этим рекомендациям, мы можем завершить код так, чтобы наш файл index.js выглядел следующим образом:

/src/plugins/modals/index.js

js
import { reactive } from "vue"
import Modal from "./Modal.vue"
const
_current = reactive({name:"",resolve:null,reject:null}),
api = {
      active() {return _current.name;}
      show(name) {
            _current.name = name;
            return new Promise(
                (resolve = () => { }, reject = () => { }) => {
                _current.resolve = resolve;
                _current.reject = reject;
                })
      },
      accept() {_current.resolve();_current.name = "" },
      cancel() {_current.reject();_current.name = "" }
},
plugin = {...} // Опущено для краткости
export default plugin;

Наше внутреннее состояние хранится в переменной reactive и доступно только через наш API. В целом, это хороший дизайн для любого API. Теперь настало время совершить волшебство в нашем компоненте Modal.vue, чтобы завершить рабочий процесс. Для краткости я опускаю классы и стили, но полный код можно найти в репозитории GitHub этой книги по адресу https://github.com/PacktPublishing/Vue.js-3-Design-Patterns-and-Best-Practices.

Наш модальный компонент должен будет выполнять следующие действия:

  • Прикрыть всю область просмотра полупрозрачным элементом, чтобы заблокировать взаимодействие с остальной частью приложения
  • Определить отображаемый диалог:
    • prop для регистрации имени компонента, предоставленного родителем.
    • header для отображения заголовка. Заголовок также будет пропсом.
    • Область для заполнения родительского компонента настраиваемым содержимым.
    • Нижний колонтитул с кнопками принять и отменить.
    • Реактивное свойство, которое срабатывает, когда компонент должен появиться.

После того как мы определились с определением, давайте поработаем над шаблоном:

/src/plugins/modals/Modal.vue

vue
<template>
<div class="viewport-wrapper" v-if="_show">                 //1
  <div class="dialog-wrapper">
   <header>{{$props.title}}</header>                        //2
   <main><slot></slot></main>                               //3
   <footer>
     <button @click="closeModal(true)">Accept</button>      //4
     <button @click="closeModal(false)">Cancel</button>
   </footer>
  </div>
</div>
</template>

В строке //1 реактивная переменная _show управляет видимостью модального диалога. В строке //2 мы отображаем пропс title, а в строке //3 резервируем слот. Кнопки в строке //4 будут закрывать модальный диалог по событию click, каждая из которых имеет представительное булево значение.

Ну вот и пришло время написать логику работы компонента. В нашем скрипте нам необходимо сделать следующее:

  • Определить два пропса: title (для отображения) и name (для идентификации)
  • Инжектируйте ресурс $modals, чтобы мы могли взаимодействовать с API и выполнять следующие действия:
    • Проверка соответствия имени модала текущему компоненту (это "открывает" модальный диалог)
    • Закрытие модала путем разрешения или отклонения обещания

Следуя этим указаниям, мы можем завершить настройку script setup.

vue
<script setup>
  import { inject, computed } from "vue"                        //1
  const
    $props = defineProps({                                      //2
        name: { type: String, default: "" },
        title: { type: String, default: "Модальный диалог" }
    }),
    $modals = inject("$modals"),                                //3
    _show = computed(() => {                                    //4
       return $modals.active() == $props.name
    })
  function closeModal(accept = false) {
    accept ? $modals.accept() : $modals.cancel()                 //5
  }
</script>

В строке //1 мы начинаем с импорта функций inject и computed. В строке //2 мы создаем пропс со значениями по умолчанию. В строке //3 мы инжектируем ресурс $modals (зависимость), который мы будем использовать в свойстве computed в строке //4 для получения текущего активного модала и сравнения его с компонентом. Наконец, в строке //5, основываясь на нажатии кнопок, мы запускаем разрешение или отклонение промиса.

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

  • В шаблоне определите модальный компонент с именем, зарегистрированным в нашем плагине (Modal). Обратите внимание на использование атрибутов props:
vue
<Modal name="myModal" title="Пример модального компонента">
       Здесь есть важное содержание
</Modal>
  • В нашем скрипте инжектируем зависимость следующим кодом:
js
const $modals = inject("$modals")
  • Отобразим модальный компонент по заданному имени с помощью следующего кода:
js
$modals.show("myModal").then(() => {
       // Модал принят.
}, () => {
       //Модал отменен.
})

На этом мы закончили работу над нашим первым плагином на Vue 3. Давайте применим его в нашем новом приложении "To Do".

Реализация нашего нового приложения To-Do

В начале этой главы мы рассмотрели дизайн нашего нового приложения "To Do" и разбили его на иерархические компоненты (см. Рисунок 4.1). Для продолжения работы над этим разделом вам понадобится копия исходного кода из репозитория GitHub этой книги https://github.com/PacktPublishing/Vue.js-3-Design-Patterns-and-Best-Practices.

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

  • /src/plugins, куда мы поместили наш Modals плагин.
  • /src/services, где мы размещаем модули с нашей бизнес-логикой или логикой промежуточного ПО. Здесь мы создали объект сервиса для обработки бизнес-логики нашего списка дел: файл todo.js.

В файле main.js мы импортируем и добавляем наш плагин в объект приложения, используя метод .use(Modals) для регистрации нашего плагина.

Файл App.vue стал в первую очередь компонентом верстки, без какой-либо другой логики приложения. Мы импортируем и используем заголовок (MainHeader.vue) и родительский компонент для управления списком дел и пользовательским интерфейсом (ToDoProject.vue), как и в дизайне, показанном на рисунке 4.2.

Компонент ToDoProject содержит состояние списка через реактивные переменные, где мы имеем следующее:

  • _items - массив, содержащий наши пункты To-Do
  • _item - это вспомогательная реактивная переменная, которую мы используем для создания новых элементов или редактирования дубликата элемента
  • _filter - еще одна вспомогательная реактивная переменная, которая используется для ввода строки для фильтрации нашего списка

Следует отметить, что мы также объявляем константу $modals, которая принимает инжектированный API объекта Modals. Обратите внимание, как функция showModal() открывает и управляет результатом диалога для новых и редактируемых элементов с помощью этого объекта. После этого в шаблоне появляется соответствующий модал, по окончании помеченный комментарием. Обычно все модальные шаблоны располагаются в конце компонента, а не разбросаны по всему шаблону.

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

Еще один момент, о котором стоит упомянуть, - это использование сервисных объектов и классов. В нашем приложении есть todo.js, который мы импортируем как todoService там, где это необходимо. В данном случае это синглтон, но это может быть и конструктор класса. Обратите внимание, что он не содержит никакой интерфейсной логики, только прикладную или бизнес-логику. Это является определяющим фактором, отличающим его от компонентов, с которыми мы уже сталкивались ранее.

Еще одно изменение заключается в том, что теперь пункты To-Do имеют несколько состояний, и мы можем переходить от одного состояния к другому одним щелчком мыши. Мы реализовали эту логику в функции toggleStatus() сервиса, не в компоненте. Переход между состояниями можно представить следующим образом:

image

Рисунок 4.7 - Круговой конечный автомат.

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

Существует множество способов реализации машины состояний, но одним из самых простых является оператор switch, как в нашем примере:

Todo.js

js
[function] toggleStatus(status) {
    switch(status) {
         case "not_started":        return "in_progress"
         case "in_progress":        return "completed"
         case "completed":          return "not_started"
     }
}

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

Последним моментом, который следует отметить в этой новой реализации, является использование вычисляемых свойств в компоненте ToDoSummary. С их помощью мы выводим на экран сводные карточки с различными состояниями наших элементов. Обратите внимание, как хорошо работает реактивность - как только мы изменяем состояние элемента в списке, сводка немедленно обновляется!

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

Небольшая критика нашего нового приложения To-Do

Новая версия приложения To-Do - это явное улучшение по сравнению с нашим первым подходом, но ее можно усовершенствовать:

  • У нас по-прежнему только один список задач.
  • Все по-прежнему происходит на одной странице.
  • Наши элементы эфемерны. Они исчезают, когда мы закрываем или обновляем браузер.
  • Нет никакой безопасности, нет возможности иметь несколько пользователей и т.д.
  • Мы можем добавлять только обычный текст. А как насчет изображений или насыщенного текста?
  • После некоторой работы мы могли бы расширить наше приложение, чтобы оно управляло несколькими проектами, дополнительным контентом, заданиями и т.д.
  • Мы добились хороших результатов, но нам еще многое предстоит сделать.

Подведение итогов

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

Затем мы узнали о специальных компонентах, создали плагин для модальных диалогов с использованием инъекции зависимостей фреймворка и применили другие шаблоны, чтобы сделать наше кодирование более простым и конгруэнтным. Кроме того, мы провели рефакторинг нашего приложения и расширили его возможности, взглянув на более эффективное управление состояниями, независимое от HTML-элемента, который мы использовали ранее. Мы добились значительного прогресса, но нам еще есть над чем работать.

В следующей главе мы создадим одностраничное приложение (SPA) с использованием того, чему мы научились за это время.

Вопросы для проверки

Ответьте на следующие вопросы, чтобы проверить свои знания по этой главе:

  • Как мы можем начать с визуального дизайна или прототипа и спланировать реализацию с помощью компонентов?
  • Какими способами компоненты могут взаимодействовать друг с другом?
  • Как мы можем повторно использовать код в нескольких компонентах? Есть ли другой способ?
  • Что такое плагин и как его создать?
  • Какие шаблоны мы применили к новому приложению To-Do?
  • Что бы вы изменили в реализации?