Композиция пользовательского интерфейса с помощью компонентов
В этой главе мы подробно рассмотрим, как строить пользовательские интерфейсы с помощью компонентов. Хотя мы могли бы просто создать всю веб-страницу с помощью одного компонента, как мы это сделали с нашим начальным приложением Список дел в главе 3, Установка рабочего проекта, такой подход не является хорошей практикой, за исключением простых приложений, частичного переноса функциональности в существующих веб-приложениях или некоторых крайних случаев, когда другого варианта быть не может.
Компоненты занимают центральное место в подходе Vue к построению интерфейсов.
В этой главе мы сделаем следующее:
- Узнаем, как строить пользовательские интерфейсы с помощью иерархии компонентов
- Познакомимся с различными способами взаимодействия и связи компонентов друг с другом
- Рассмотрим специальные и пользовательские компоненты
- Создать пример плагина с применением шаблонов проектирования
- Перепишите наше приложение для выполнения дел, используя наш плагин и композицию компонентов
В этой главе мы познакомимся с основными и расширенными концепциями и получим инструменты для создания надежных веб-приложений с многократно используемыми компонентами. В частности, мы применим наши знания о шаблонах проектирования из главы 2, Принципы и шаблоны проектирования программного обеспечения, при реализации кода.
Примечание о стилях
Чтобы избежать длинных текстов кода, мы опустим примеры иконок и стилей в примерах кода. Полный код, а также стили и иконки можно найти в репозитории GitHub этой книги по адресу https://github.com/PacktPublishing/Vue.js-3-Design-Patterns-and-Best-Practices.
Технические требования
Требования к выполнению этой главы такие же, как и в главе 3, Установка рабочего проекта.
Посмотрите следующее видео, чтобы увидеть код в действии.
Файлы кода этой главы можно найти на GitHub здесь.
Составление страницы с помощью компонентов
Для создания пользовательского интерфейса необходимо иметь отправную точку, будь то грубый набросок или фантазийный полноценный дизайн. Графический дизайн веб-приложения выходит за рамки данной книги, поэтому мы будем считать, что он уже создан. Чтобы воплотить дизайн в компоненты, мы можем подойти к этому как к процессу, который отвечает на следующие вопросы:
- Как мы можем представить макет и множество элементов с помощью компонентов?
- Как эти компоненты будут взаимодействовать друг с другом и связаны между собой?
- Какие динамические элементы будут входить и выходить из сцены, и какие события или состояния приложения будут их вызывать?
- Какие шаблоны проектирования мы можем применить, чтобы наилучшим образом удовлетворить требованиям данного сценария использования, учитывая компромиссы?
Vue 3 специально подходит для создания динамичных, интерактивных интерфейсов. Эти вопросы приводят нас к повторяемому подходу к реализации. Итак, давайте определим общий процесс с четко определенными этапами, шаг за шагом.
Этап 1 - определение макетов и элементов пользовательского интерфейса
Этот этап отвечает на вопрос: Как мы можем представить макет и множество элементов с помощью компонентов?
Мы возьмем страницу в целом и подумаем, какой макет подходит лучше всего, учитывая дизайн. Следует ли использовать колонки? Разделы? Навигационные меню? Острова контента? Есть ли диалоговые или модальные окна? Простой подход заключается в том, чтобы взять изображение дизайна и обозначить прямоугольниками участки, которые могут представлять собой компоненты, начиная с самого большого и заканчивая наименьшей единицей интерактивности. Итерируйте эту нарезку страницы до тех пор, пока не получите комфортное количество компонентов. Если рассматривать новый дизайн приложения To-Do, то этот шаг может выглядеть следующим образом:
Рисунок 4.1 - Разбивка конструкции на компоненты с пунктирными рамками
После того как мы определили компоненты, необходимо выявить связи между ними, создав иерархию от самого верхнего корневого компонента (обычно это наш App.vue). Новые компоненты могут появиться в результате группировки компонентов по контексту или функциональности. Это подходящее время для присвоения имен компонентам. Эта начальная архитектура будет развиваться по мере реализации шаблонов проектирования. В соответствии с этим примером иерархия может выглядеть следующим образом:
Рисунок 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 управляет этим взаимодействием в ответ на определенные события. Этот процесс проиллюстрирован на данной диаграмме последовательности:
Рисунок 4.3 - Взаимодействие пользователя через модалы - редактирование элемента.
На этой диаграмме компонент ToDoProject разделяет список дел с компонентом ToDoList. Когда пользователь вызывает событие edit, дочерний компонент уведомляет родительский, испуская такое событие. После этого родительский компонент создает копию элемента и открывает модальный диалог, передавая ему эту копию.
Когда диалог принят, родитель модифицирует исходный элемент с учетом изменений. Затем реактивность Vue отражает изменение состояния в дочерних компонентах.
Зачастую такое взаимодействие помогает нам выявить необходимость в дополнительных компонентах, которые не были очевидны в шаге 1, например, в реализации шаблонов проектирования... что является следующим шагом.
Шаг 4 - выявление шаблонов проектирования и компромиссов
Этот шаг отвечает на вопрос: Какие шаблоны проектирования мы можем применить, чтобы наилучшим образом удовлетворить требованиям конкретного случая использования, с учетом компромиссов?
Решение вопроса о том, какие шаблоны использовать, может быть очень творческим процессом. Не существует "серебряной пули", и несколько решений могут дать разные результаты. Обычно создается несколько прототипов для тестирования различных подходов.
В нашем новом приложении мы ввели концепцию модальных диалогов для получения пользовательского ввода. Модальные диалоги используются в тех случаях, когда для выполнения операции требуется действие или решение пользователя. Пользователь может принять или отклонить диалог и не может взаимодействовать с другими частями приложения до тех пор, пока не примет решение.
Учитывая эти условия, одним из возможных шаблонов для применения является шаблон Async Promise.
В нашем коде мы хотим открыть модальный диалог в виде промиса, который, по определению, предоставит нам функцию resolve() (принять) или reject() (отменить). Более того, мы хотим иметь возможность использовать это решение в нескольких проектах и глобально в нашем приложении. Для этого мы можем создать плагин и использовать шаблон инъекции зависимостей для доступа к модальной функциональности из любого компонента. Эти шаблоны обеспечат нам решение, необходимое для того, чтобы сделать наш модальный диалог многократно используемым.
На данном этапе мы практически готовы приступить к концептуальной реализации компонентов. Однако, чтобы создать наиболее подходящее и надежное приложение и реализовать вышеупомянутые шаблоны, нам следует уделить время более подробному изучению компонентов Vue.
Компоненты в деталях
Компоненты - это строительные блоки фреймворка. В главе 1, Фреймворк Vue 3, мы рассмотрели, как работать с компонентами, объявлять реактивные переменные и многое другое. В этом разделе мы рассмотрим более продвинутые возможности и определения.
Локальные и глобальные компоненты
При запуске нашего приложения Vue 3 мы монтируем главный компонент (App.vue) к элементу HTML в файле main.js. После этого в секции script каждого компонента мы можем импортировать другие компоненты для локального использования с помощью следующей команды:
import MyComponent from "./MyComponent.vue"
Таким образом, чтобы использовать MyComponent в другом компоненте, нам необходимо импортировать его в этот компонент еще раз. Если один компонент постоянно используется в нескольких компонентах, то это повторяющееся действие нарушает принцип DRY разработки (см. главу 2, Принципы и шаблоны проектирования программного обеспечения).
Альтернативный вариант - объявить компонент как глобальный, прикрепив его непосредственно к нашему приложению Vue вместо каждого компонента. В файле main.js мы можем использовать метод App.component() для этого случая:
Main.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. Эта функция принимает в качестве параметра другую функцию, которая возвращает динамический импорт. Вот пример:
import {defineAsyncComponent} from "vue"
const MyComponent = defineAsyncComponent(
() => import("MyComponent.vue")
)
Использование этой функции делает ее безопасной для применения в большинстве сборщиков. Альтернативой этому синтаксису является Vue Router, который мы рассмотрим в главе 5, Одностраничные приложения - динамическое объявление import(), предоставляемое JavaScript. Оно имеет очень похожий синтаксис:
const MyComponent = () => import('./MyComponent.vue')
Как видите, этот синтаксис более лаконичен. Однако его можно использовать только при определении маршрутов с помощью Vue Router, так как внутри Vue 3 и Vue Router по-разному обрабатывают ленивую загрузку компонентов. В конечном итоге оба подхода позволят разделить основной файл пакета на несколько файлов меньшего размера, которые будут автоматически загружаться при необходимости в нашем приложении.
Однако defineAsyncComponent имеет ряд преимуществ. Мы можем передать любую функцию, возвращающую промис, который разрешается в компонент. Это позволяет нам реализовать логику динамического управления процессом во время выполнения.
Приведем пример, в котором мы решили загрузить один компонент на основе значения входного параметра:
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
Приведем пример, в котором используются все эти атрибуты:
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 и любого из следующих форматов аргументов:
- В виде массива строк - например:
const $props = defineProps(['name', 'last_name'])
- В виде объекта, атрибуты которого используются в качестве имени, а значение имеет тип данных - например:
const $props = defineProps({name: String, age: Number})
- В качестве объекта, атрибуты которого определяют объект с типом и значением по умолчанию - например,
const $props = defineProps({
name: { type: String, default: "John"},
last_name: {type: String, default: "Doe"}
Необходимо помнить, что примитивные значения передаются в компонент по значению (это означает, что изменение их значения в дочернем компоненте не повлияет на их значение в родительском). Однако сложные типы данных, такие как объекты и массивы, передаются как ссылки, поэтому изменения их внутренних ключей/значений отразятся в родительском компоненте.
Примечание о сложных типах
При определении пропсов типа Object или Array со значениями по умолчанию, атрибут default должен быть функцией, возвращающей указанный объект или массив. В противном случае ссылка на объект/массив будет общей для всех экземпляров компонента.
События - это сигналы, которые дочерний компонент подает родительскому. Вот пример определения событий для компонента в синтаксисе script setup:
const $emit = defineEmits(['eventName'])
В отличие от пропсов, defineEmits принимает только массив строк. События также могут передавать значение. Вот пример вызова из вышеупомянутого определения:
$emit('eventName', some_value)
Как видите, defineEmits возвращает функцию, принимающую в качестве первого аргумента одно из имен, указанных в массиве определений. Второй аргумент, some_value, является необязательным.
Настраиваемые контроллеры ввода
Одним из особых применений совместного действия пропсов и событий является создание пользовательских контроллеров ввода. В предыдущих примерах мы использовали директиву Vue v-model для базовых элементов ввода HTML, чтобы перехватить их значение. Пропсы и события, которые следуют специальному соглашению об именовании, позволяют создавать компоненты ввода, которые принимают директиву v-model.
Рассмотрим следующий код:
Шаблон родительского компонента
<MyComponent v-model="parent_variable"></MyComponent>
Теперь, когда у нас есть MyComponent, используемый внутри родительского компонента, давайте посмотрим, как мы создаем привязку:
script setup
MyComponent
const $props = defineProps(['modelValue']),
$emit = defineEmits(['update:modelValue'])
Для краткости мы используем определение Props через массив. Обратите внимание, что имя пропса - modelValue, а событие - update:modelValue. Такой синтаксис является ожидаемым от нас Vue. Когда родитель присваивает переменной v-model, ее значение будет скопировано в modelValue. Когда дочерняя переменная выдает событие update:modelValue, значение родительской переменной будет обновлено.
Таким образом, можно создавать мощные элементы управления вводом. Но это еще не все - можно иметь несколько v-model!
Считаем, что modelValue является значением по умолчанию при использовании v-модели. В Vue 3 появился новый синтаксис для этой директивы, благодаря чему мы можем иметь несколько моделей. Объявление очень простое. Рассмотрим объявление следующего дочернего компонента:
Пропсы и события дочернего компонента
const
$props = defineProps(['modelValue', 'title']),
$emit = defineEmits(['update:modelValue','update:title'])
После определения props и emits мы можем ссылаться на них из родительского компонента, как показано в следующем примере:
Шаблон родительского компонента
<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. С их помощью родительский или корневой компонент предоставляет данные (в виде значения или ссылки, например, объекта), которые могут быть инжектированы в любой из его дочерних компонентов, расположенных ниже по иерархическому дереву. Визуально мы можем представить эту ситуацию следующим образом:
Рисунок 4.4 - Представление Provide/Inject
Как видите, процесс очень прост, как и синтаксис для реализации шаблона:
- В родительском (корневом) компоненте мы импортируем функцию provide из Vue и создаем провизию с ключом (именем) и данными для передачи:
import { provide } from "vue"
provide("provision_key_name", data)
- В компоненте-получателе мы импортируем функцию inject и получаем данные по ключу (имени):
import { inject } from "vue".
const $received_data = inject("provision_key_name")
Мы также можем предоставить ресурс на уровне приложения следующим образом:
const app = createApp({})
app.provide('provision_key_name', data_or_value)
Таким образом, провизия может быть внедрена в любой компонент нашего приложения. Стоит отметить, что мы можем предоставлять и сложные типы данных, такие как массивы, объекты и реактивные переменные. В следующем примере мы предоставляем объект с функциями и ссылками на родительские методы:
В родительском/корневом компоненте
import {provide} from "vue"
function logMessage() { console.log("Hi") }
const _provision_data = {runLog: logMessage}
provide("service_name", _provision_data)
В дочернем компоненте
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, например так:
Родительский компонент
<MyMenuBar>
<button>Option 1</button>
<button>Option 2</button>
</MyMenuBar>
Компонент MyMenuBar
<template>
<div class="...">
<slot></slot>
</div>
</template>
При условии, что мы применили необходимые стили и классы в MyMenuBar, конечный рендер шаблона может выглядеть примерно так:
Рисунок 4.5 - Строка меню с использованием слотов
Применяемая логика достаточно проста. Слот во время выполнения программы будет заменен на содержимое, предоставленное родительским компонентом внутри дочерних тегов. В предыдущем примере, если проанализировать конечный HTML, можно обнаружить примерно следующее (учитывая, что мы используем классы W3.css):
<div class="w3-bar w3-border w3-light-grey">
<button>Вариант 1</button>
<button>Вариант 2</button>
</div>
Это фундаментальная концепция проектирования пользовательского интерфейса. А что, если нам нужно несколько "слотов" - например, для создания компонента макета? Здесь на помощь приходит альтернативный синтаксис, называемый именованные слоты. Рассмотрим следующий пример:
Компонент MyLayout
<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:
Родительский компонент
<MyLayout>
<template v-slot="sidebar"> ... </template>
<template v-slot="header"> ... </template>
<template v-slot="content"> ... </template>
</MyLayout>
Директива v-slot принимает один аргумент, соответствующий имени слота, с такими замечаниями:
- Если имя не совпадает ни с одним доступным слотом, содержимое не выводится.
- Если имя не указано или используется имя default, то содержимое отображается в безымянном слоте по умолчанию.
- Если для шаблона не указано содержимое, то будут показаны элементы по умолчанию внутри определения слота. Содержимое по умолчанию помещается между тегами слотов: ...содержимое по умолчанию здесь....
Для директивы v-slot также существует сокращенное обозначение. Мы просто префиксируем имя слота знаком (#). Например, шаблоны в предыдущем родительском компоненте можно упростить следующим образом:
<template #sidebar> ... </template>
<template #header> ... </template>
<template #content> ... </template>
Слоты во Vue 3 очень сильный инструмент, вплоть до того, что в них даже предусмотрен способ передачи пропсов родителю, если это необходимо. Синтаксис различается в зависимости от того, используем ли мы слот по умолчанию или именованные слоты. Например, рассмотрим следующее определение шаблона компонента:
Компонент PassingPropsUpward
<div>
<slot :data="some_text"></data>
</div>
Здесь слот передает родительскому компоненту пропс с именем data. Родительский компонент может получить к нему доступ с помощью следующего синтаксиса:
<PassingPropsUpward v-slot="upwardProp">
{{upwardProp.data}} //Рендеринг содержимого some_text
</PassingPropsUpward>
В родительском компоненте мы используем директиву v-slot и присваиваем локальное имя пропсу, передаваемому слотом, - в данном случае upwardProp. В эту переменную будет передан объект, аналогичный по функциям объекту props, но привязанный к элементу. Из-за этого такие слоты называются именованными слотами, и синтаксис их аналогичен. Посмотрите на этот пример:
<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
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
import {useDocumentScroll} from "./DocumentScroll.js"
const { y } = useDocumentScroll()
...
Импортировав реактивную переменную, мы можем использовать ее в коде и шаблоне как обычно. Если нам нужно использовать этот фрагмент логики в нескольких компонентах, достаточно импортировать составной (принцип DRY ).
Наконец, на сайте https://vueuse.org/ собрана внушительная коллекция композабл функций для наших проектов. С ней стоит ознакомиться.
Динамические компоненты с помощью "component :is"
Фреймворк Vue 3 предоставляет специальный компонент под названием <component\>
, задача которого заключается в том, чтобы быть держателем для динамического отображения других компонентов. Он работает со специальным атрибутом :is, который может принимать либо строку с именем компонента, либо переменную с определением компонента. Он также принимает некоторые базовые выражения (строка кода, которая преобразуется в значение). Вот простой пример с использованием выражения:
Компонент CoinFlip
<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, который будет инжектироваться как зависимость в те компоненты, которым необходимо открыть модальный диалог. Это взаимодействие можно представить следующим образом:
Рисунок 4.6 - Представление модального плагина
Компоненты будут реализовывать модальный элемент, а открывать диалог мы будем с помощью кода. При открытии модального диалога он будет возвращать обещание по шаблону async. Когда пользователь принимает модальное окно, обещание разрешается, а при отмене происходит отказ. Содержимое модального окна будет предоставляться родительским компонентом с помощью слотов.
Реализация
Для этого плагина нам понадобится всего два файла - один для логики плагина и один для нашего компонента. Создайте файлы index.js и Modal.vue в папке src/plugins/modal. На данный момент достаточно просто набросать компонент с настройкой скрипта, шаблона и стиля раздела. К его завершению мы вернемся позже. С этими файлами давайте начнем пошагово с файла index.js:
/src/plugins/modals/index.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(). Эта функция получает два параметра в следующем порядке:
- Экземпляр приложения (App).
- Объект с опциями, если таковые были переданы в процессе установки.
В экземпляре приложения мы регистрируем Modal как глобальный компонент и предоставляем API как инжектируемый ресурс под именем $modals, и то и другое на уровне приложения. Чтобы использовать плагин в нашем приложении, мы должны импортировать его в main.js и зарегистрировать с помощью метода use. Код выглядит следующим образом:
/src/Main.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
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
<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.
<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:
<Modal name="myModal" title="Пример модального компонента">
Здесь есть важное содержание
</Modal>
- В нашем скрипте инжектируем зависимость следующим кодом:
const $modals = inject("$modals")
- Отобразим модальный компонент по заданному имени с помощью следующего кода:
$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() сервиса, не в компоненте. Переход между состояниями можно представить следующим образом:
Рисунок 4.7 - Круговой конечный автомат.
Вам может быть знакома эта конструкция, поскольку она представляет собой круговой конечный автомат состояния. Конечные автоматы состояний очень удобны для представления возможных состояний элемента и условий, вызывающих каждое изменение (в нашем случае - щелчок пользователя).
Существует множество способов реализации машины состояний, но одним из самых простых является оператор switch, как в нашем примере:
Todo.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?
- Что бы вы изменили в реализации?