Strona główna / Artykuły

Vue 3 w praktyce: komponenty składane, typowane kontrakty i stan proporcjonalny

Jak API składnikowe Vue 3, typowane właściwości i wyzwalacze, Pinia oraz stopniowe wdrażanie umożliwiają aplikacji rozwój tylko do stopnia złożoności, jaki jest konieczny, oraz w sytuacjach, gdy Vue nie jest odpowiednim wyborem.

Wydawca: REACTAPP.TOP12 min czytania2722 słów

Źródła: Vue.js Is the Frontend Framework More Teams Should Be Using

Gdy zespół wybiera React, nikt nie pyta o uzasadnienie; gdy zespół wybiera Vue, prawie zawsze ktoś o to pyta. Ta reakcja mówi bardziej o nawyku niż o samym frameworku, ponieważ Vue 3 to dojrzała opcja z oficjalnym routowaniem i zarządzaniem stanem, pełnym wsparciem dla TypeScript oraz możliwościami full-stackowymi dzięki Nuxt. Ten artykuł przygląda się Vue 3 na podstawie praktycznego kodu: jak odczytuje się stan i wartości pochodne, jak komponenty typu composables pakują zachowanie, jak typowane właściwości i sygnały ujawniają umowy komponentów, jak pasuje do tego Pinia oraz w których przypadkach Vue po prostu nie jest właściwym wyborem.

Częstym tematem jest proporcja. Vue zapewnia wystarczającą strukturę dla poważnych aplikacji, nie zamieniając każdej małej funkcji w decyzję architektoniczną, a tę cechę najłatwiej ocenić w rzeczywistym komponencie, a nie poprzez porównanie funkcji.

Rzeczywisty pierwszy komponent: wyszukiwanie z filtrowaniem

Aplikacje do zarządzania zadaniami niewiele mówią o frameworku. Nieco bardziej realistycznym testem jest wyszukiwanie produktów: pole wprowadzenia, lista produktów oraz ich podzbiór odpowiadający zapytaniu.

Logika poniżej wykorzystuje dwa elementy podstawowe. ref tworzy reaktywny stan – w tym przypadku tekst wyszukiwania oraz tablicę produktów. computed wyprowadza wartość z tego stanu i przechowuje ją w pamięci cache, dopóki nie zmieni się jedna z jej zależności. Zauważ, że filtr raz przekształca i zamienia zapytanie na małe litery, zwraca pełną listę przy pustym zapytaniu, a dopiero potem aplikuje filtrację.

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)
  )
})

Śablona łączy się bezpośrednio z tymi wartościami. v-model utrzymuje synchronizację między polem wprowadzenia a zmienną search w obu kierunkach, v-for renderuje po jednym elemencie na produkt, który przeszedł filtrację, a :key dostarcza Vue stabilnej tożsamości dla każdego wiersza, aby mógł on efektywnie aktualizować listę.

<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>

Nie ma tu zbyt wielu formalności. Stan jest widoczny, wartość pochodna jest widoczna, a markup, który je wykorzystuje, również jest widoczny. Można go przeczytać od góry do dołu bez konieczności najpierw poznawania specyficznej dla frameworka terminologii. Może to brzmieć jak drobna zaleta, ale w miarę rozwoju bazy kodu szybkość, z jaką ktoś potrafi zrozumieć nieznany plik, staje się jedną z najważniejszych kwestii wpływanych przez framework.

Jeden szczegół, który warto znać: w szablonach referencje są automatycznie rozwijane, więc pisze się search, a nie search.value. W kodzie skryptowym nadal używa się .value, dlatego funkcja obliczeniowa odczytuje search.value i products.value.

The Composition API: ponowne wykorzystanie zachowań, a nie komponentów

Największą zmianą pomiędzy starszą wersją Vue a Vue 3 jest API składnikowe. API opcji grupowało kod według kategorii frameworku (dane, metody, obliczenia, nadzorcy), co powodowało rozproszenie danej funkcji między kilkoma sekcjami. API składnikowe umożliwia grupowanie kodu według tego, co dana funkcja robi.

Weźmy stronę produktu, która musi pobierać dane oraz śledzić stan ładowania i błędów. Takie zachowanie może zostać przeniesione do komponentu składnikowego – zwykłej funkcji, której nazwa konwencjonalnie zaczyna się od use i która zwraca reaktywny stan wraz z funkcjami służącymi do jego modyfikacji:

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
  }
}

Kilka elementów zostało tu celowo zaprojektowanych w ten sposób. Zmienna loading jest resetowana w bloku finally, dzięki czemu zostaje wyzerowana niezależnie od tego, czy żądanie się powiedzie, czy nie. Zmienna error jest również wyzerowana przed każdą próbą, aby stary komunikat nie pozostawał. Odpowiedź o kodzie innym niż 2xx jest przekształcana w wyjątek, ponieważ funkcja fetch odrzuca żądanie tylko w przypadku awarii sieci, a nie z powodu błędów statusu HTTP. Blok catch dodatkowo filtruje wartość zmiennej err za pomocą warunku instanceof Error przed odczytaniem wartości message, co jest konieczne, ponieważ typy w TypeScript traktują te wartości jako unknown.

Kluczowa różnica polega na tym, co jest ponownie wykorzystywane. Nie dzielisz się komponentem wraz z jego kodem strukturalnym, lecz zachowaniem. Komponenty skupiają się na tym, co widzi użytkownik, a elementy kompozytowe – na sposobie działania danej funkcji; żaden z nich nie musi stać się globalną abstrakcją tylko po to, by dwa ekrany mogły dzielić się pewną logiką. Jedna uwaga: każde wywołanie useProducts() tworzy własny, niezależny stan. Zazwyczaj jest to dokładnie to, czego chcesz, ale jeśli dwa komponenty muszą widzieć tę samą listę produktów, potrzebny jest wspólny stan, wtedy właśnie przychodzi do głosu magazyn danych.

Dawanie każdej kwestii naturalnego miejsca

Framework może być potężny albo dlatego, że oferuje wiele możliwości, albo dlatego, że zmniejsza rozdźwięk pomiędzy tym, co zamierzasz zrobić, a tym, co wpisujesz. Vue skłania się ku drugiemu podejściu.

Rozważmy wymóg, który wydaje się prosty: gdy zmienia się filtr, należy to odzwierciedlić w adreście URL, ponownie pobrać dane, zachować strukturę paginacji i pokazać wskaźnik ładowania. W takiej sytuacji występuje kilka elementów, a w rozsądnej architekturze Vue każdy z nich ma wyraźnego odpowiedzialnego:

  • komponent renderuje interfejs
  • kompozyt implementuje zachowanie danej funkcji
  • router zarządza stanem adresu URL
  • klient API zajmuje się komunikacją z serwerem
  • store przechowuje dane tylko wtedy, gdy faktycznie muszą być udostępniane w całym aplikacji

Żaden pojedynczy element nie musi znać wszystkiego. Taka separacja nie jest wyłącznością Vue – można ją osiągnąć w każdej nowoczesnej ramie. Wyjątkowe jest to, jak naturalnie elementy bazowe Vue skłaniają do takiego podejścia.

Komponenty jednoplikowe: jeden plik na jedną jednostkę interfejsu

Komponenty jednoplikowe w Vue łączą szablon, skrypt i style w pliku .vue. Krytycy czasami traktują to jako osobliwość stylistyczną, ale podstawową ideą nie jest „umieszczenie wszystkiego w jednym pliku”. Chodzi raczej o to, aby jedna jednostka interfejsu użytkownika znajdowała się w jednym miejscu. Ważny jest tutaj model komponentu, a nie format pliku.

Zachowanie prostego przełącznika pokazywania/ukrywania polega na jednej zmiennej logicznej oraz funkcji, która ją zmienia:

import { ref } from 'vue'

const isOpen = ref(false)
function toggle(): void {
  isOpen.value = !isOpen.value
}

Składnia jest równie czytelna. @click łączy przycisk z funkcją toggle, a v-if dodaje lub usuwa panel w zależności od wartości isOpen:

<button type="button" @click="toggle">
  Account Settings
</button>

<div v-if="isOpen">
  Manage your account preferences.
</div>

Odległość pomiędzy stanem a interfejsem użytkownika jest mała i taką pozostaje. Gdy wracasz do danego modułu po kilku miesiącach w celu jego naprawy, związek pomiędzy flagą a tym, co jest wyświetlane na ekranie, nadal jest oczywisty. Należy zauważyć, że v-if całkowicie usuwa element z DOM-u; jeśli panel jest zmieniany bardzo często lub zawiera kosztowne dane, v-show, który jedynie ukrywa go za pomocą CSS, może być lepszym wyborem.

Dobra architektura komponentów nie polega na minimalizowaniu liczby plików. Chodzi o to, aby łatwo można było znaleźć powiązane elementy.

TypeScript – gdzie znajdują się umowy

Aplikacja frontendowa jest pełna umów: właściwości, które akceptuje komponent, zdarzenia, które emituje, wartości, które zwraca komposable, modele generowane przez klienta API, stan i akcje udostępniane przez store. Gdy te umowy są niejasne, refaktoryzacja dużego kodu oznacza poleganie na pamięci ludzi co do założeń zamiast na ich czytaniu.

Vue 3 umożliwia umieszczenie tych umów tuż obok logiki komponentu. Wewnątrz <script setup> funkcje defineProps i defineEmits są makroami kompilatora, które przyjmują jedynie deklaracje typów:

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)
}

Typ właściwości wskazuje, że komponent wymaga obiektu user o typie User. Typ wyzwalanego zdarzenia używa sygnatury w stylu tablicy, co oznacza, że zdarzenie updated przekazuje dokładnie jeden argument – obiekt typu User. Użycie funkcji emit z niewłaściwą nazwą zdarzenia lub treścią jest błędem typu. Każdy, kto otworzy plik, może od razu zobaczyć publiczną interfejs komponentu.

To właśnie ta cecha TypeScripta przynosi największe korzyści w dużych aplikacjach front-endowych. Funkcje autodopasowywania i wskazówki edytora są przyjemne, ale to wyraźne umowy sprawiają, że zmiany są łatwiejsze do przewidzenia, a refaktoryzacja mniej ryzykowna.

Zachowuj stan w jak najmniejszym zakresie

Wiarygodnym sposobem na skomplikowanie interfejsu użytkownika jest umieszczanie wszystkiego w globalnym stanie: magazynu dla modalu, magazynu dla pola formularza, magazynu dla wybranej wiersza tabeli oraz magazynu dla tymczasowej zapytania wyszukiwawczego. Wkrótce połowa aplikacji znajduje się w jednej rozległej warstwie stanu, gdzie każdy kod może zmieniać cokolwiek.

Vue umożliwia łagodniejszy rozwój:

  • przechowywanie lokalnego stanu w komponencie, który go posiada
  • przenoszenie zachowań wielokrotnie używalnych do komponentów typu composables
  • umieszczanie rzeczywiście współdzielonego stanu w magazynie

W tym ostatnim kroku Pinia jest oficjalnym magazynem Vue. Poniższy przykład przedstawia magazyn w stylu options dla koszyka zakupów. state zwraca dane początkowe, funkcja total oblicza łączną kwotę koszyka na podstawie pozycji i ilości, a akcje modyfikują listę. Funkcja addItem zwiększa ilość, jeśli produkt już znajduje się w koszyku, w przeciwnym razie dodaje kopię pozycji, aby magazyn nie dzielił się referencją obiektu z wywołującym kodem.

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
      )
    }
  }
})

Sklep nie jest najciekawszą częścią – decyzja o jego stworzeniu tak. Pole tekstowe prawie nigdy nie wymaga stanu globalnego. Koszyk zakupów, który jest używany przez nagłówek, strony produktów oraz proces płatności, z pewnością go potrzebuje. Vue daje przestrzeń na taką ocenę, zamiast umieszczać każdy element stanu w tym samym kontenerze. Pinia wspiera również składnię typu setup, która bezpośrednio wykorzystuje ref i computed, co preferują niektóre zespoły, ponieważ odpowiada sposobowi pisania komponentów składowych.

Droga rozwoju zamiast natychmiastowych zobowiązań

Vue nie wymaga, abyś od razu ustalał całą architekturę. Zaczynasz od samego Vue, dodajesz Vue Router, gdy potrzebujesz routingu, dodajesz Pinia, gdy wymagany jest stan aplikacji na poziomie całego oprogramowania, a do Nuxt przechodzisz, gdy chcesz renderowanie na serwerze, routy serwera oraz szerszą konfigurację full-stack. Mała aplikacja może pozostać mała, natomiast duża może nabrać struktury w miarę pojawiania się nowych wymagań.

To ma największe znaczenie w przypadku istniejących produktów. Vue można zintegrować z częścią aplikacji renderowanej na serwerze, stworzonej za pomocą Django, Laravel, Rails lub zwykłego PHP, bez konieczności przebudowy całej części frontend. Być może tylko panel analityczny wymaga zaawansowanej interaktywności. Być może jedna strona konfiguracyjna przewyższa możliwości szablonów serwera, albo edytor potrzebuje złożonego zachowania po stronie klienta. Vue może obsłużyć tę część, pozostawiając resztę nietkniętą.

Nie każdy frontend musi być SPA

Ekosystem czasami traktuje „aplikację frontend” i „aplikację jednostronicową” jako synonimy. Nie są nimi. Wiele produktów lepiej funkcjonuje dzięki stronom generowanym na serwerze z kilkoma starannie dobranej interaktywnymi elementami, a Vue doskonale pasuje do tego modelu.

Dla zespołów utrzymujących zaawansowane aplikacje to całkowicie zmienia perspektywę migracji. Pytanie przestaje brzmieć „jak przenieść produkt na Vue?”, a staje się „gdzie Vue może zredukować najwięcej przeszkód?”. To drugie pytanie prowadzi do mniejszych, bezpieczniejszych kroków oraz szybszego uzyskania korzyści.

Wydajność pochodzi z architektury, a nie z testów wydajnościowych

Vue radzi sobie dobrze, ale wykresy benchmarków to słaby powód do wyboru jakiegokolwiek frameworka. Aplikacje produkcyjne rzadko są wolne z powodu straty kilku milisekund w mikrobenchmarkach spowodowanej ich frameworkiem. Są wolne, ponieważ renderują ogromne listy, wysyłają zbyt duże pliki, powtarzają żądania sieciowe, przechowują zbyt dużo stanu lub wykonywają kosztowne operacje w niewłaściwym miejscu.

Model Vue ułatwia unikanie tych problemów:

  • komponenty mogą pozostać małe i skoncentrowane
  • stan może być przechowywany lokalnie, zamiast wyzwalać aktualizacje w całej aplikacji
  • funkcje i trasy mogą być ładowane w trybie opóźnionym
  • logika wielokrotnie używana może być izolowana w composables
  • stan globalny może być ograniczony do tego, co rzeczywiście tam należy

Żadne z tych rozwiązań nie gwarantuje automatycznie dobrej wydajności. Ułatwiają one zrozumienie dobrych wyników wydajności, co jest cenniejsze niż zwycięstwo w benchmarku.

Zrozwinięty ekosystem

Wybór Vue oznaczał kiedyś akceptację mniejszego ekosystemu. Ten argument jest teraz znacznie słabszy. Istnieje oficjalny router, Pinia do zarządzania stanem aplikacji, rozwinięte narzędzia dla programistów, szeroki wybór bibliotek komponentów interfejsu użytkownika oraz Nuxt dla zespołów, które chcą pełnego frameworka aplikacyjnego.

Razem obejmują one szeroki zakres zastosowań: pojedynczy interaktywny widget, dużą aplikację typu SPA, stronę renderowaną na serwerze lub produkt full-stack. Nie musisz wiedzieć, co dokładnie budujesz, zanim napiszesz pierwszą linię kodu, a ta elastyczność sama w sobie jest zaletą.

Mniej mierzalny argument: decyzje wynikają z potrzeb

Najtrudniejszą do skwantyfikowania korzyścią jest to, jak wyglądają codzienne decyzje. Otwierasz komponent i go rozumiesz. Potrzebujesz zachowań wielokrotnego użycia, więc piszesz komponent składalny. Stan należy do jednego komponentu, więc pozostaje tam. Coś naprawdę wymaga udostępnienia, więc przenosi się do magazynu stanów. Aplikacja potrzebuje routingu, więc dodajesz routera. Gdy aplikacja przekracza granice zwykłej aplikacji klienckiej, wprowadzasz Nuxt.

Każdy krok jest odpowiedzią na rzeczywistą złożoność. Podejmujesz decyzje architektoniczne, ponieważ produkt tego wymaga, a nie dlatego, że framework ich od razu wymusza. W trakcie trwania projektu ta różnica się pogłębia.

Kiedy Vue to zły wybór

Vue nie jest automatycznie właściwym rozwiązaniem. Organizacja posiadająca dużą bazę kodu napisanego w React, własną bibliotekę komponentów React, bogate doświadczenie wewnętrzne w tym frameworku oraz proces rekrutacji skierowany na inżynierów React prawdopodobnie straci więcej, niż zyska dzięki migracji. Frameworki nigdy nie są wybierane w izolacji; umiejętności zespołu, istniejąca infrastruktura, koszty rekrutacji i migracji mają co najmniej takie samo znaczenie jak model programowania.

Vue również nie chroni przed słabą praktyką inżynierską. W tym frameworku można tak samo jak w każdym innym stworzyć ogromne komponenty, przeciążone globalne zbiory danych, niepotrzebne warstwy abstrakcji oraz wolno działające strony. Oferuje przyjemne domyślne ustawienia, ale nie zastępuje one rozsądku.

Proporcja jako zasada projektowania

To, co wyróżnia Vue, to jego zmysł proporcji. Mały problem znajduje małe rozwiązanie. Zachowania wielokrotnie używalne stają się komponentami składalnymi. Stan wspólny staje się magazynem danych tylko wtedy, gdy jest to konieczne. Nawigacja należy do routera – większe aplikacje mogą korzystać z Nuxt, a TypeScript określa granice bez przejmowania kontroli nad kodem.

Żadna z tych rzeczy nie jest rewolucyjna, i to właśnie chodzi. Framework nie musi co kilka miesięcy wynajdywać na nowo metody rozwoju interfejsu użytkownika; musi jedynie ułatwiać rozwiązywanie istniejących problemów.

Główne wnioski

  • ref i computed w Vue 3 sprawiają, że stan i wartości pochodne są jawne, co zapewnia czytelność komponentów w miarę ich rozwoju.
  • Komponenty składalne dzielą się zachowaniami, a nie markupiem; każde wywołanie tworzy niezależny stan, więc należy używać magazynu danych tylko wtedy, gdy dane rzeczywiście muszą być udostępniane.
  • Tylko typowe funkcje defineProps i defineEmits sprawiają, że publiczny kontrakt komponentu jest widoczny i poddawany weryfikacji przez kompilator.
  • Celowo eskaluj stan: najpierw lokalnie, potem poprzez komponenty składane, a tylko Pinia do danych rzeczywiście globalnych.
  • Vue można wprowadzać stopniowo, po jednej interaktywnej obszarze w istniejącej aplikacji renderowanej na serwerze, co czyni go opcją o niskim ryzyku dla dojrzałych produktów.
  • Ekspercka wiedza zespołu, istniejący kod oraz możliwości rekrutacji mogą przeważyć nad ergonomią jakiegokolwiek frameworku, dlatego należy ocenić Vue w tym kontekście, a nie na podstawie popularności czy testów wydajnościowych.
  • Literatura pokrewna