Vue 3 на практике: композаблы, типизированные контракты и пропорциональное состояние
Как API композиции Vue 3, типизированные атрибуты и события, Pinia и постепенное внедрение позволяют приложению становиться настолько сложным, насколько это необходимо, и определяют ситуации, когда Vue является неподходящим выбором.
Источники: Vue.js Is the Frontend Framework More Teams Should Be Using
Когда команда выбирает React, никто не спрашивает о обосновании; когда же команда выбирает Vue, почти всегда возникают вопросы. Такая реакция говорит скорее о привычке, чем о самом фреймворке, ведь Vue 3 — это зрелый выбор с официальной системой навигации и управления состоянием, первоклассной поддержкой TypeScript и возможностью использования полноценных решений через Nuxt. В этой статье рассматривается Vue 3 на примерах рабочего кода: как читаются данные состояния и производные значения, как композаблы организуют поведение компонентов, как типизированные атрибуты и сигналы определяют контракты компонентов, как вписывается в эту структуру Pinia и в каких случаях Vue просто не является подходящим выбором.
Основная идея заключается в соблюдении пропорций. Vue предоставляет достаточную структуру для серьезных приложений, не превращая каждую мелкую функциональность в архитектурное решение, и этот аспект легче оценить на реальном компоненте, чем при сравнении функций.
Реалистичный первый компонент: фильтрация поиска
Приложения для управления задачами мало говорят о фреймворке. Немного более реалистичным тестом является поиск продуктов: поле ввода, список продуктов и та его часть, которая соответствует запросу.
Приведённая ниже логика использует два примитива. ref создаёт реактивное состояние — здесь это текст поиска и массив продуктов. computed вычисляет значение на основе этого состояния и кэширует его до тех пор, пока не изменится один из его зависимостей. Обратите внимание, что фильтр сначала обрабатывает запрос (убирает лишние символы и приводит к маленькой букве), затем возвращает полный список при пустом запросе, а уже после этого применяет фильтрацию.
import { computed, ref } from 'vue'
interface Product {
id: number
name: string
price: number
}
const search = ref('')
const products = ref<Product[]>([
{ id: 1, name: 'Mechanical Keyboard', price: 120 },
{ id: 2, name: '4K Monitor', price: 450 },
{ id: 3, name: 'Wireless Mouse', price: 60 }
])
const filteredProducts = computed(() => {
const query = search.value.toLowerCase().trim()
if (!query) {
return products.value
}
return products.value.filter(product =>
product.name.toLowerCase().includes(query)
)
})
Шаблон напрямую связывается с этими значениями. v-model поддерживает синхронизацию между полем ввода и переменной search в обоих направлениях, v-for отображает по одному элементу на каждый отфильтрованный продукт, а :key предоставляет Vue стабильный идентификатор для каждой строки, что позволяет эффективно обновлять список.
<input
v-model="search"
type="search"
placeholder="Search products..."
/>
<ul>
<li
v-for="product in filteredProducts"
:key="product.id"
>
{{ product.name }} - ${{ product.price }}
</li>
</ul>
Здесь почти нет лишних формальностей. Само состояние видно, видна и полученная на его основе величина, а также виден маркап, который их использует. Его можно прочитать сверху вниз, не изучая заранее специфическую терминологию фреймворка. Это может показаться незначительным преимуществом, но по мере роста кодовой базы скорость понимания незнакомого файла становится одним из важнейших факторов, на которые влияет фреймворк.
Стоит знать один нюанс: в шаблонах ссылки автоматически разбираются, поэтому пишется search, а не search.value. В коде скриптов всё равно используется .value, поэтому функции, вычисляющие значения, содержат search.value и products.value.
API Composition: повторное использование поведения, а не компонентов
Самое значительное изменение между старой версией Vue и Vue 3 — это Composition API. Options API группировал код по категориям фреймворка (данные, методы, вычисляемые значения, обозреватели), из-за чего одна функция распределялась по нескольким разделам. Composition API позволяет группировать код в зависимости от того, для чего предназначена данная функция.
Возьмем страницу товара, которая должна загружать данные и отслеживать состояния загрузки и ошибок. Такая логика может быть помещена в композабельную функцию — простую функцию, название которой традиционно начинается с use, и которая возвращает реактивное состояние вместе с функциями для его изменения:
import { ref } from 'vue'
export interface Product {
id: number
name: string
price: number
}
export function useProducts() {
const products = ref<Product[]>([])
const loading = ref(false)
const error = ref<string | null>(null)
async function loadProducts(): Promise<void> {
loading.value = true
error.value = null
try {
const response = await fetch('/api/products')
if (!response.ok) {
throw new Error(`Request failed with ${response.status}`)
}
products.value = (await response.json()) as Product[]
} catch (err) {
error.value =
err instanceof Error
? err.message
: 'Failed to load products.'
} finally {
loading.value = false
}
}
return {
products,
loading,
error,
loadProducts
}
}
Здесь сделаны некоторые преднамеренные решения. Переменная loading сбрасывается в блоке finally, поэтому она очищается независимо от того, увенчалась ли операция успехом или неудачей. Переменная error очищается перед каждой попыткой, чтобы старые сообщения не оставались. Ответ с кодом, отличным от 2xx, преобразуется в исключение, поскольку функция fetch отклоняет запросы только при сетевых сбоях, а не при ошибках HTTP-статусов. Кроме того, в блоке catch переменная err фильтруется с помощью проверки instanceof Error перед чтением значения message; это необходимо, так как типы TypeScript автоматически преобразуют захваченные значения в тип unknown.
Важное отличие заключается в том, что переиспользуется. Вы не делитесь компонентом вместе с его маркировкой; вы делитесь поведением. Компоненты сосредотачиваются на том, что видит пользователь, а композаблы — на том, как работает функция, и ни те, ни другие не должны превращаться в глобальные абстракции лишь для того, чтобы два экрана могли делиться частью логики. Однако есть один недостаток: каждый вызов useProducts() создаёт собственное независимое состояние. Обычно это именно то, что нужно, но если два компонента должны видеть один и тот же список продуктов, требуется общее состояние, и здесь на сцену позже выходит хранилище данных.
Предоставление каждой задаче подходящего места
Фреймворк может быть мощным как в том смысле, что он предлагает множество возможностей, так и в том смысле, что сокращает разницу между тем, что вы планируете, и тем, что пишете. Vue склоняется ко второму варианту.
Рассмотрим требование, которое кажется простым: при изменении фильтра его необходимо отразить в URL, перезагрузить данные, сохранить структуру страницирования и показать индикатор загрузки. Здесь есть несколько компонентов, и в разумной архитектуре Vue у каждого из них есть четкий ответственный:
- компонент отвечает за отображение интерфейса
- композабл реализует логику функционала
- роутер управляет состоянием URL
- клиент API обеспечивает связь с сервером
- хранилище сохраняет данные только тогда, когда это действительно необходимо для обмена внутри приложения
Ни один компонент не должен знать обо всем. Такое разделение не является уникальной особенностью Vue — его можно реализовать в любой современной фреймворке. Примечательно то, насколько естественным образом примитивы Vue способствуют такой структуре.
Компоненты с одним файлом: по одной единице UI на одно место
Компоненты Vue с одним файлом объединяют шаблон, скрипт и стили в файле с расширением .vue. Критики иногда считают это стилистической особенностью, но суть не в том, чтобы «положить всё в один файл». Речь идет о том, что одна элементарная единица интерфейса должна находиться в одном месте. Здесь важен модель компонента, а не формат файла.
Для реализации простого переключателя отображения достаточно одного логического значения и функции, которая его меняет:
import { ref } from 'vue'
const isOpen = ref(false)
function toggle(): void {
isOpen.value = !isOpen.value
}
Маркировка кода также понятна без пояснений. Атрибут @click связывает кнопку с функцией toggle, а v-if добавляет или удаляет панель в зависимости от значения isOpen:
<button type="button" @click="toggle">
Account Settings
</button>
<div v-if="isOpen">
Manage your account preferences.
</div>
Расстояние между логикой приложения и интерфейсом минимально, и оно таким и остается. Когда через несколько месяцев вы возвращаетесь к данной функциональности для отладки, связь между флагом и тем, что отображается на экране, по-прежнему очевидна. Обратите внимание, что v-if полностью удаляет элемент из DOM; если панель переключается очень часто или содержит ресурсоемкий контент, v-show, который просто скрывает элемент с помощью CSS, может стать лучшим выбором.
Хорошая архитектура компонентов заключается не в минимизации количества файлов, а в том, чтобы связанные элементы было легко найти.
TypeScript: где находятся контракты
Фронтенд-приложение состоит из множества контрактов: параметров, которые принимает компонент, событий, которые он генерирует, значений, возвращаемых композабельными элементами, моделей, создаваемых клиентом API, а также состояния и действий, предоставляемых хранилищем данных. Когда эти контракты неявны, рефакторинг крупной базы кода означает зависимость от того, что люди помнят предположения, вместо того чтобы читать их напрямую.
Vue 3 позволяет размещать эти контракты прямо рядом с логикой компонента. Внутри <script setup> функции defineProps и defineEmits являются макросами компилятора, которые принимают только объявления типов:
interface User {
id: number
name: string
email: string
}
const props = defineProps<{
user: User
}>()
const emit = defineEmits<{
updated: [user: User]
}>()
function save(): void {
emit('updated', props.user)
}
В описании свойств указано, что компонент требует параметр user типа User. В описании сигнатуры эмитиментов используется формат туплей, что означает, что событие updated передаёт ровно один аргумент — объект типа User. Вызов метода emit с неверным именем события или содержимым является ошибкой типа. Любой, кто откроет файл, сможет мгновенно увидеть публичный интерфейс компонента.
Именно это в TypeScript наиболее полезно в крупных фронтендах. Функции автодополнения и подсказки в редакторе удобны, но именно чётко определённые контракты позволяют безопасно анализировать изменения и снижают риски при их переработке.
Храните состояние как можно ближе к месту его использования
Надежным способом усложнить фронтенд является хранение всего в глобальном состоянии: хранилище для модала, хранилище для поля формы, хранилище для выбранной строки таблицы, хранилище для временного запроса к поиску. Вскоре половина приложения оказывается в одном обширном слое состояния, где любой код может изменить что угодно.
Vue позволяет осуществлять более постепенное развитие:
- хранить локальное состояние внутри компонента, который им управляет
- переносить повторно используемое поведение в композаблы
- хранить действительно общее состояние в хранилище
Для этого последнего шага Pinia является официальным хранилищем Vue. Приведённый ниже пример — это хранилище в стиле options для корзины покупок. Поле state возвращает начальные данные, геттер total вычисляет общую сумму в корзине на основе элементов и их количеств, а действия изменяют список. Функция addItem увеличивает количество товара, если он уже находится в корзине, в противном случае добавляет копию элемента, чтобы хранилище не делилось ссылкой на объект с вызывающим кодом.
import { defineStore } from 'pinia'
export interface CartItem {
id: number
name: string
price: number
quantity: number
}
interface CartState {
items: CartItem[]
}
export const useCartStore = defineStore('cart', {
state: (): CartState => ({
items: []
}),
getters: {
total: (state): number =>
state.items.reduce(
(sum, item) => sum + item.price * item.quantity,
0
)
},
actions: {
addItem(item: CartItem): void {
const existing = this.items.find(
current => current.id === item.id
)
if (existing) {
existing.quantity += item.quantity
} else {
this.items.push({ ...item })
}
},
removeItem(itemId: number): void {
this.items = this.items.filter(
item => item.id !== itemId
)
}
}
})
Сам магазин — не самая важная часть; важен выбор его создания. Поле ввода текста почти никогда не требует глобального состояния. Однако корзина, которую используют заголовок, страницы товаров и процесс оформления заказа, почти наверняка его требует. Vue предоставляет возможность принимать такие решения, вместо того чтобы помещать всё состояние в один контейнер. Pinia также поддерживает синтаксис в стиле настройки, который позволяет напрямую использовать ref и computed; некоторые команды предпочитают его, поскольку он соответствует способу написания композаблов.
Путь роста вместо сразу же принятых обязательств
Vue не требует от вас определять всю архитектуру с первого дня. Вы начинаете с самого Vue, добавляете Vue Router при необходимости в навигации, Pinia — для управления состоянием всего приложения, а переходите на Nuxt, когда нужна обработка на сервере, серверные маршруты и более полная фул-стек-настройка. Небольшое приложение может оставаться небольшим, а крупное может постепенно приобретать структуру по мере появления новых требований.
Это особенно важно для существующих продуктов. Vue можно интегрировать в часть приложения, генерируемого на сервере с использованием Django, Laravel, Rails или обычного PHP, без необходимости полной перестройки фронтенда. Возможно, только панель аналитики требует высокой интерактивности. Возможно, один экран настройки вышел за рамки возможностей серверных шаблонов, или редактору нужна сложная работа с клиентской частью. Vue может обработать именно эту область, оставив всё остальное без изменений.
Не каждый фронтенд обязан быть SPA
Экосистема иногда рассматривает понятия «фронтенд-приложение» и «одностраничное приложение» как синонимы. На самом деле это не так. Для многих продуктов более подходящим решением являются страницы, генерируемые на сервере, с несколькими тщательно подобранными интерактивными элементами, и Vue отлично соответствует этой модели.
Для команд, обслуживающих зрелые приложения, это полностью меняет подход к миграции. Вопрос перестает быть «как нам переписать продукт на Vue?» и становится «где Vue сможет устранить наибольшее количество препятствий?». Второй вопрос позволяет делать меньшие, более безопасные шаги и быстрее получать полезный результат.
Производительность зависит от архитектуры, а не от тестов производительности
Vue показывает хорошие результаты, но диаграммы тестов производительности — это слабая причина для выбора любой фреймворковой среды. Продакшн-приложения редко работают медленно из-за того, что их фреймворк проиграл в микротестах на несколько миллисекунд. Они работают медленно из-за отрисовки огромных списков, отправки слишком больших пакетов данных, повторных сетевых запросов, хранения слишком большого количества информации в памяти или выполнения ресурсоемких операций в неподходящем месте.
Модель Vue позволяет легче избегать этих проблем:
- компоненты могут оставаться небольшими и сфокусированными
- состояние может оставаться локальным, вместо того чтобы запускать обновления по всему приложению
- функции и маршруты могут загружаться по мере необходимости
- повторно используемая логика может быть изолирована в композаблы
- глобальное состояние может ограничиваться только тем, что действительно к нему относится
Ничто из этого не гарантирует автоматическую высокую производительность. Однако это делает понимание хорошей производительности более простым, что ценнее, чем победа в тестах производительности.
Зрелая экосистема
Выбор Vue раньше означал согласие на более узкую экосистему. Сейчас это аргумент стал гораздо слабее. Существует официальный маршрутизатор, Pinia для управления состоянием приложения, совершенствованные инструменты для разработчиков, широкий выбор библиотек UI-компонентов, а также Nuxt для команд, желающих использовать полноценную фреймворковую среду.
Все эти инструменты вместе позволяют создавать самые разные проекты: от простого интерактивного виджета до крупных SPA, сайтов с серверной отрисовкой или полноценных продуктов типа full-stack. Вам не нужно знать заранее, какой именно тип проекта вы будете разрабатывать, прежде чем написать первую строку кода, и именно такая гибкость является отдельной преимуществом.
Менее осязаемый аргумент: решения принимаются в зависимости от потребностей
Самым сложным для количественной оценки аспектом является восприятие повседневных решений. Вы открываете компонент и понимаете его структуру. Вам нужна поведенческая модель, которую можно использовать неоднократно, поэтому вы пишете композитный элемент. Состояние принадлежит одному компоненту, поэтому оно остается там. Если что-то действительно требует общего использования, оно перемещается в хранилище состояния. Приложению нужна система маршрутизации, поэтому вы добавляете роутер. Когда приложение вырастает за рамки клиентской версии, вы переходите на Nuxt.
Каждый шаг предпринимается в ответ на реально существующую сложность. Вы принимаете архитектурные решения потому, что этого требует продукт, а не потому, что фреймворк настаивает на этом с самого начала. На протяжении всего срока жизни проекта это различие усиливается.
Когда Vue — неподходящий выбор
Vue не всегда является оптимальным решением. Организация с большой базой кода на React, собственной библиотекой компонентов React, глубоким внутренним опытом работы с этой технологией и наймом специалистов, ориентированным на React-инженеров, скорее всего, потеряет больше, чем приобретёт, переходя на другой фреймворк. Фреймворки никогда не выбираются изолированно; навыки команды, существующая инфраструктура, затраты на найм и миграцию имеют не меньшее значение, чем сама модель программирования.
Vue также не защищает от плохой практики в разработке. В этом фреймворке так же возможно создание огромных компонентов, перегруженных глобальных хранилищ данных, ненужных уровней абстракции и медленно загружающихся страниц, как и в любом другом фреймворке. Он предлагает удобные стандартные настройки, но не заменяет собой здравый смысл.
Пропорциональность как принцип дизайна
Особенностью Vue является её сбалансированность. Небольшая проблема решается простым способом. Поведение, которое можно использовать несколько раз, превращается в компоненты-композиты. Общее состояние используется в виде хранилища только тогда, когда это необходимо. Навигация относится к маршрутизатору; для более крупных приложений подходит Nuxt, а TypeScript определяет границы без необходимости полного управления кодовой базой.
Всё это не является чем-то революционным, и именно в этом суть. Фреймворк не обязан каждые несколько месяцев заново изобретать методы разработки фронтенда; его задача — сделать существующие проблемы более легкими для решения.
Основные выводы
- В Vue 3 функции
refиcomputedделают состояние и вычисляемые значения явными, что обеспечивает читаемость компонентов по мере их развития. - Компоненты-композиты обмениваются поведением, а не маркиупом; каждый вызов создаёт независимое состояние, поэтому следует использовать хранилище только тогда, когда данные действительно нужно делиться.
defineProps и defineEmits, предназначенные только для указания типов, делают публичный интерфейс компонента видимым и подлежащим проверке компилятором.