Vue 3 на практиці: композаблі, типовані контракти та пропорційний стан
Як Composition 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 спонукають до такого підходу.
Компоненти з одним файлом: одне місце для однієї одиниці інтерфейсу
Компоненти Vue з одним файлом поєднують шаблон, скрипт та стилі в файлі .vue. Критики іноді вважають це стилістичною особливістю, але справжня ідея не полягає у «розміщенні всього в одному файлі». Йдеться про те, щоб одна UI-одиниця знаходилась в одному місці. Тут важливий сама модель компонента, а не формат файлу.
Функціонування простого перемикача відображення полягає у одному булевому значенні та функції, яка його змінює:
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 для керування станом додатку, розвинені інструменти для розробників, широкий вибір бібліотек компонентів користувацького інтерфейсу та Nuxt для команд, які хочуть повноцінну фреймворкову платформу.
Разом вони охоплюють широкий спектр проєктів: окремий інтерактивний віджет, великий SPA, сайт, який генерується на сервері, або продукт з повноцінним стеком. Вам не потрібно знати, який саме проєкт ви створюєте, ще до написання першої рядка коду, і саме ця гнучкість є окремою перевагою.
Аргумент, який важче оцінити: рішення приймаються в залежності від потреб
Найскладніше для кількісної оцінки — це відчуття від щоденних рішень. Ви відкриваєте компонент та розумієте його функціонал. Вам потрібна повторно використовувана логіка, тож ви пишете композитний елемент. Стан належить одному компоненту, тож залишається там. Якщо щось справді потрібно поділитися, це переміщується до магазину даних. Додатку потрібна система навігації, тож ви додаєте роутер. Коли проект переростає у клієнтський додаток, ви починаєте використовувати Nuxt.
Кожен крок є відповіддю на реальну складність. Ви приймаєте архітектурні рішення тому, що це вимагає продукт, а не тому, що фреймворк цього вимагає з самого початку. Протягом усього життєвого циклу проекту ця різниця посилюється.
Коли Vue — неправильний вибір
Vue не є автоматично правильним рішенням. Організація з великою базою коду на React, власною бібліотекою компонентів React, глибоким внутрішнім досвідом роботи з React та наймом інженерів, спеціалізованих на React, ймовірно, втратить більше, ніж здобуде, переходячи на інший фреймворк. Фреймворки ніколи не обираються ізольовано; навички команди, наявна інфраструктура, витрати на найм та міграцію мають принаймні таку саму вагу, як і модель програмування.
Vue також не захищає вас від поганої інженерної практики. У Vue цілком можливо створювати величезні компоненти, перевантажені глобальні сховища даних, зайві шари абстракції та повільні сторінки, так само як і в будь-якому іншому фреймворку. Він пропонує зручні стандартні налаштування, але не є заміною особистого судження.
Пропорційність як принцип дизайну
Те, що вирізняє Vue, — це його здатність до пропорційності. Невелика проблема має невелике рішення. Повторно використовувана поведінка стає композаблом. Спільний стан перетворюється на сховище лише тоді, коли це необхідно. Навігація належить до роутера; більші за масштабом додатки можуть використовувати Nuxt, а TypeScript описує межі без необхідності контролювати весь кодовий базис.
Усе це не є революційним, і саме в цьому суть. Фреймворк не повинен щомісяця винаходити заново методи розробки фронтенду; його завдання — полегшити вирішення існуючих проблем.
Ключові висновки
refтаcomputedу Vue 3 забезпечують чітке визначення стану та похідних значень, що допомагає зберігати компоненти зрозумілими у міру їхнього розвитку.- Композабли діляться поведінкою, а не маркапом; кожен виклик створює незалежний стан, тому використовуйте сховище лише тоді, коли дані дійсно потрібно обмінюватися.
defineProps та defineEmits робить публічний контракт компонента видимим та підлягаючим перевірці компілятором.