Realizacja Aplikacji Internetowych

Rok akademicki: 2026/27

Instrukcja laboratoryjna: Architektura Frameworka Vue.js (Vue 3.5+), Środowisko Vite, Composition API, Pinia, Typowany Routing (unplugin-vue-router), TanStack Query, Tailwind CSS i Testowanie (Vitest & Playwright POM)

Prowadzący: Tomasz Goluch
Wersja: 1.0


1. Wprowadzenie: Architektura MVVM, Progresywność i System Reaktywności (Proxy)

Cel: Zrozumienie założeń architektonicznych frameworka Vue.js, wzorca Model-View-ViewModel (MVVM), mechanizmu Wirtualnego DOM (Virtual DOM) oraz ewolucji systemu reaktywności w Vue 3 opartego na obiektach ES6 Proxy.

Vue.js to progresywny framework JavaScript/TypeScript służący do budowania nowoczesnych interfejsów użytkownika oraz aplikacji jednostronicowych (SPA – Single-Page Applications). Określenie „progresywny” oznacza, że Vue zostało zaprojektowane tak, aby można je było adaptować stopniowo – od lekkiej biblioteki dołączanej z serwera CDN, wzbogacającej klasyczne widoki serwerowe HTML, aż po pełnoskalowy framework korporacyjny wykorzystujący komponenty jednoplikowe (.vue), kompilację w Vite, silnie typowany routing, zarządzanie stanem (Pinia, TanStack Query) oraz renderowanie po stronie serwera (SSR – Nuxt).

1. Wzorzec architektoniczny MVVM (Model-View-ViewModel)

Architektura Vue.js jest bezpośrednio inspirowana wzorcem MVVM, który oddziela logikę warstwy prezentacji od stanu danych i logiki biznesowej:

  ┌───────────────────┐           ┌───────────────────────────┐           ┌───────────────────┐
  │    WIDOK (View)   │           │   WIDOK-MODEL (ViewModel) │           │   MODEL (State)   │
  │                   │  DOM      │                           │           │                   │
  │  Elementy HTML    │ ◄──────── │  Wiązania Danych (Proxy)  │ ◄───────► │  Obiekty danych   │
  │  Szablony Vue     │           │                           │           │  JS / TypeScript  │
  │  (#app)           │ ────────► │  Nasłuchiwanie Zdarzeń    │           │  (ref, reactive,  │
  │                   │  Zdarzenia│  (v-on / @click / v-model)│           │   data, Pinia)    │
  └───────────────────┘           └───────────────────────────┘           └───────────────────┘

2. Jak działa reaktywność w Vue 3? (ES6 Proxy vs Object.defineProperty)

Kluczową cechą Vue jest automatyczna reaktywność: gdy zmienisz wartość zmiennej w kodzie JavaScript, powiązany fragment drzewa DOM odświeża się samoczynnie.

🧠 Zrozumieć różnicę pokoleniową: Vue 2 vs Vue 3 (3.5+)


2. Środowisko Uruchomieniowe, Narzędzia (NVM, Vite, IDE) i Konteneryzacja (Docker)

Cel: Poznanie sposobów instalacji Vue.js, konfiguracji środowiska Node.js z menedżerem NVM, zrozumienie architektury narzędzia budującego Vite oraz uruchamianie projektu w IDE i kontenerach Docker.

Laboratorium może być realizowane na maszynach fizycznych z wykorzystaniem preferowanego edytora lokalnego (Visual Studio Code, WebStorm, Visual Studio 2022, JetBrains Rider) lub w odizolowanym środowisku kontenerowym Docker / Docker Compose.

1. Sposób I: Tryb bez kroku budowania (Pobranie biblioteki z CDN)

W przypadku prostych stron lub nauki podstawowych dyrektyw nie trzeba instalować żadnych narzędzi budujących – wystarczy dołączyć skrypt pobierający globalną wersję Vue 3 bezpośrednio z serwera CDN (w miejsce 3 lub latest serwer podstawi najnowszą stabilną wersję z gałęzi 3.x):

<!-- Wersja globalna z unpkg (zalecana do szybkiego prototypowania) -->
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>

<!-- Alternatywne serwery CDN (jsDelivr oraz cdnjs) -->
<script src="https://cdn.jsdelivr.net/npm/vue@3/dist/vue.global.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/vue/3.5.13/vue.global.min.js"></script>

2. Sposób II: Profesjonalne środowisko SPA oparte na Node.js i Vite

Aby korzystać z pełnych możliwości ekosystemu Vue.js – takich jak Komponenty Jednoplikowe (.vue), TypeScript, nowoczesne wtyczki, Pinia czy testy jednostkowe – potrzebujemy środowiska wykonawczego Node.js oraz narzędzia budującego Vite.

A. Zarządzanie wersjami Node.js za pomocą NVM (Node Version Manager)

Zamiast instalować pojedynczą, sztywną wersję Node.js w systemie, pracę warto rozpocząć od instalacji menedżera NVM. Pozwala on na błyskawiczne przełączanie się pomiędzy różnymi wersjami Node.js (np. stabilną wersją LTS a wersją najnowszą) bez konieczności reinstalacji środowiska.

Podstawowe komendy narzędzia nvm (wymagają uruchomienia terminala z uprawnieniami administratora):

B. Alternatywne menedżery pakietów (pnpm, yarn) oraz npm-gui

Wraz z Node.js instalowany jest domyślny menedżer pakietów npm. Alternatywnie można zainstalować szybszy i oszczędzający miejsce na dysku menedżer pnpm (wykorzystujący globalny magazyn dowiązań twardych) lub Yarn:

npm install --global pnpm
# lub:
npm install --global yarn

Przydatnym narzędziem wizualnym jest graficzny interfejs do zarządzania zależnościami z pliku package.json:

npm install -g npm-gui
npm-gui
C. Dlaczego Vite zastąpiło dawne Vue CLI (Webpack)?

Do inicjalizacji nowych projektów Vue 3 wykorzystujemy oficjalny kreator create-vue oparty na narzędziu Vite (zastępujący przestarzałe narzędzie vue-cli oparte na Webpacku).

1. TRADYCYJNY BUNDLER (Webpack / Vue CLI):
   [Wszystkie pliki .ts/.vue] ──► [Budowanie całej paczki (Bundle)] ──► [Serwer Dev (wolny start)]

2. NOWOCZESNY SERWER ESM (Vite):
   [Natychmiastowy start serwera] ──► Przeglądarka żąda pliku HTTP GET ──► [Kompilacja 1 pliku w locie (ESM)]
D. Tworzenie i uruchamianie projektu za pomocą create-vue

Aby utworzyć nowy projekt z pełną konfiguracją (TypeScript, Vue Router, Pinia, Vitest, Playwright, ESLint oraz Prettier), wykonujemy w terminalu:

npm create vue@latest

Kreator przeprowadzi nas przez interaktywną konfigurację projektu:

Vue.js - The Progressive JavaScript Framework

✔ Project name: … rai-lab
✔ Add TypeScript? … Yes
✔ Add JSX Support? … No
✔ Add Vue Router for Single Page Application development? … Yes
✔ Add Pinia for state management? … Yes
✔ Add Vitest for Unit Testing? … Yes
✔ Add an End-to-End Testing Solution? › Playwright
✔ Add ESLint for code quality? › Yes
✔ Add Prettier for code formatting? … Yes
✔ Add Vue DevTools 7 extension for debugging? (experimental) … Yes

Instalacja zależności i uruchomienie serwera deweloperskiego:

cd rai-lab
npm install
npm run format
npm run dev

Po uruchomieniu w konsoli pojawią się adresy lokalne (domyślny port Vite to 5173):

3. Konfiguracja w środowiskach IDE (VS Code oraz Visual Studio 2022)

A. Visual Studio Code
B. Visual Studio 2022 (Plik projektu .esproj)

Aby otworzyć i uruchamiać projekt Vue w pełnym środowisku Visual Studio 2022 (np. w ramach wspólnej solucji z backendem ASP.NET Core Web API), wykorzystywane są dwa pliki konfiguracyjne:

  1. Plik projektu rai-lab.esproj – określa wersję SDK JavaScript, komendę startową z package.json, framework testowy oraz katalog wyjściowy kompilacji produkcyjnej:
    <Project Sdk="Microsoft.VisualStudio.JavaScript.Sdk/1.0.1184077">
      <PropertyGroup>
        <StartupCommand>npm run dev</StartupCommand>
        <JavaScriptTestRoot>src\</JavaScriptTestRoot>
        <JavaScriptTestFramework>Vitest</JavaScriptTestFramework>
        <ShouldRunBuildScript>false</ShouldRunBuildScript>
        <BuildOutputFolder>$(MSBuildProjectDirectory)\dist</BuildOutputFolder>
      </PropertyGroup>
    </Project>
  2. Plik .vscode/launch.json – wskazuje przeglądarkę docelową i port serwera Vite (http://localhost:5173).

4. Konteneryzacja aplikacji Vue w Docker i Docker Compose

Zgodnie z wymogami nowoczesnych wdrożeń, aplikację Vue możemy uruchomić w kontenerze Docker zarówno w trybie deweloperskim (z obsługą HMR), jak i w zoptymalizowanym obrazie produkcyjnym serwowanym przez serwer Nginx:

Plik Dockerfile (Wieloetapowy – Multi-stage build):

# ETAP 1: Środowisko deweloperskie i budujące (Node.js LTS)
FROM node:22-alpine AS build-stage
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

# ETAP 2: Lekki serwer produkcyjny Nginx
FROM nginx:stable-alpine AS production-stage
COPY --from=build-stage /app/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

Plik docker-compose.yml (Uruchomienie serwera deweloperskiego Vite w kontenerze):

services:
  vue-dev:
    image: node:22-alpine
    working_dir: /app
    volumes:
      - .:/app
      - /app/node_modules
    ports:
      - "5173:5173"
    command: sh -c "npm install && npm run dev -- --host 0.0.0.0"

3. Tworzenie Aplikacji Vue.js, Instancja Główna i Wbudowane Dyrektywy

Cel: Utworzenie pierwszej reaktywnej aplikacji Vue 3, poznanie globalnego i lokalnego API instancji oraz opanowanie wbudowanych i własnych dyrektyw szablonów.

1. Pierwsza aplikacja Vue 3 (Standalone CDN)

Rozpoczniemy od utworzenia prostej aplikacji bez narzędzi budujących, składającej się z pliku labVUE.html oraz skryptu labVUE.js.

Plik labVUE.html:

<!DOCTYPE html>
<html lang="pl">
<head>
  <meta charset="UTF-8">
  <title>Laboratorium Vue.js 3</title>
  <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
</head>
<body>
  <div id="app" class="demo">
    <h2>{{ title }}</h2>
    <input v-model="title" placeholder="Zmień tytuł aplikacji">
    <p>{{ $filters.capitalize('To jest przykładowy napis') }}</p>
    <p>{{ "Sufit z 7,45 to: " + Math.ceil(7.45) }}</p>
  </div>

  <script src="labVUE.js"></script>
</body>
</html>

Plik labVUE.js:

// Definicja obiektu głównego komponentu (Root Component)
const MyVueApp = {
  // UWAGA: W Vue 3 nie używamy już opcji 'el: "#app"' znanej z Vue 2!
  // Opcja data() w Vue 3 zawsze powinna być funkcją zwracającą obiekt stanu:
  data() {
    return {
      title: 'Moja aplikacja Vue.js'
    };
  }
};

// 1. Tworzymy instancję aplikacji za pomocą globalnego API Vue.createApp()
const myApp = Vue.createApp(MyVueApp);

// 2. Rejestracja właściwości globalnych (np. zastępujących wycofane z Vue 2 filtry)
// MUSI nastąpić PRZED wywołaniem metody mount()!
myApp.config.globalProperties.$filters = {
  capitalize(value) {
    if (!value) return '';
    return value.toString().toUpperCase();
  }
};

// 3. Montujemy aplikację w elemencie DOM '#app'.
// Metoda mount() zwraca instancję głównego komponentu (ViewModel - Proxy), a nie samą aplikację!
const vm = myApp.mount('#app');

⚠️ Ważna różnica między myApp (Instancją Aplikacji) a vm (Instancją Komponentu / Proxy):
Większość metod konfiguracyjnych instancji aplikacji (takich jak myApp.component(), myApp.directive(), myApp.use()) zwraca sam obiekt aplikacji myApp, co pozwala na ich wygodne łańcuchowanie (Fluent API):
Vue.createApp(MyVueApp).use(router).mount('#app').
Wyjątkiem jest metoda .mount('#app'), która kończy konfigurację i zwraca reaktywną instancję głównego komponentu (vm – obiekt Proxy). Właściwości globalne (globalProperties) muszą zostać zdefiniowane przed wywołaniem .mount(), w przeciwnym razie otrzymamy ostrzeżenie: [Vue warn]: Property "$filters" was accessed during render but is not defined on instance.

2. Wbudowane dyrektywy Vue.js

Dyrektywy to specjalne atrybuty HTML rozpoczynające się prefiksem v-, które nakładają reaktywne zachowanie na renderowane elementy DOM. Dzięki nim nasz kod w JS skupia się wyłącznie na logice stanu, a wszelkie operacje na drzewie DOM wykonuje za nas Vue.

A. Dwukierunkowe wiązanie formularzy: v-model

Dyrektywa v-model automatycznie synchronizuje wartość kontrolki HTML (<input>, <select>, <textarea>) ze zmienną w stanie komponentu. Wspiera przydatne modyfikatory:

B. Renderowanie list i znaczenie atrybutu :key: v-for

Dyrektywa v-for pozwala iterować po tablicach oraz właściwościach obiektów. Rozbudujmy metodę data() w pliku labVUE.js:

data() {
  return {
    title: 'Moja aplikacja Vue.js',
    tab: [
      { id: 1, name: 'kot', checked: true },
      { id: 2, name: 'pies', checked: false },
      { id: 3, name: 'mysz', checked: false }
    ]
  };
}

Renderowanie listy z dostępem do elementu oraz jego bieżącego indeksu (index):

<ul>
  <!-- Zawsze podawaj unikalny atrybut :key podczas używania v-for! -->
  <li v-for="(pet, index) in tab" :key="pet.id">
    <label>
      <input type="checkbox" v-model="pet.checked">
      {{ index }}. {{ pet.name }}
    </label>
  </li>
</ul>

🧠 Dlaczego modyfikacja w konsoli powinna odbywać się przez vm.tab.push(...) oraz dlaczego :key jest obowiązkowe?

  1. Dostęp przez Proxy (vm): Jeśli otworzysz konsolę przeglądarki (F12) i wpiszesz vm.tab.push({ id: 4, name: 'lis', checked: true }), nowy element natychmiast pojawi się na ekranie! Dzieje się tak, ponieważ vm to reaktywne Proxy utworzone przez Vue. Gdybyś próbował modyfikować surowy obiekt zewnętrzny zdefiniowany poza Vue, ominąłbyś Proxy i widok nie zostałby powiadomiony o zmianie.
  2. Rola atrybutu :key: Algorytm Wirtualnego DOM w Vue porównuje stare i nowe drzewo węzłów. Unikalny identyfikator :key="pet.id" pozwala Vue precyzyjnie śledzić tożsamość każdego wiersza przy sortowaniu, filtrowaniu lub usuwaniu elementów, zamiast kosztownego przebudowywania całych elementów DOM i gubienia stanu zaznaczenia kontrolek.
C. Jednokierunkowe wiązanie atrybutów, klas i stylów: v-bind (skrót :)

Dyrektywa v-bind:<atrybut> (w zapisie skróconym po prostu dwukropek :<atrybut>) wiąże atrybut HTML z wyrażeniem JavaScript:

// W data():
path: 'https://vuejs.org/images/',
filename: 'logo.png'
<!-- Wersja pełna i zalecana wersja skrócona (:src) -->
<img v-bind:src="path + filename" height="42" alt="Logo Vue" />
<img :src="path + filename" height="42" alt="Logo Vue" />

Dla atrybutów :class oraz :style Vue oferuje specjalną składnię obiektową oraz tablicową, pozwalającą dynamicznie przełączać klasy CSS w zależności od warunków logicznych:

<ul>
  <!-- Składnia obiektowa: klasa 'checked_class' zostanie dodana, gdy pet.checked === true -->
  <li v-for="pet in tab" :key="pet.id" :class="{ 'checked_class': pet.checked }">
    {{ pet.name }}
  </li>
</ul>

<ul>
  <!-- Składnia tablicowa z operatorem trójargumentowym: przełączanie między dwiema klasami -->
  <li v-for="pet in tab" :key="pet.id" :class="[pet.checked ? 'checked_class' : 'unchecked_class']">
    <div class="checkbox">
      <label>
        <input type="checkbox" v-model="pet.checked"> {{ pet.name }}
      </label>
    </div>
  </li>
</ul>
D. Renderowanie warunkowe: v-if / v-else-if / v-else vs v-show

Vue udostępnia dwa mechanizmy warunkowego wyświetlania elementów:

<div class="checkbox" v-for="pet in tab" :key="pet.id">
  <label>
    <input type="checkbox" v-model="pet.checked">
    <!-- v-if oraz v-else muszą znajdować się bezpośrednio po sobie! -->
    <span v-if="pet.checked">{{ pet.name }}</span>
    <span v-else>removed</span>
  </label>

  <!-- Alternatywnie z użyciem v-show: -->
  <span v-show="pet.checked">(Widoczny przez CSS)</span>
</div>
Kryterium Dyrektywa v-if (v-else-if, v-else) Dyrektywa v-show
Mechanizm działania w DOM Fizycznie tworzy lub usuwa element z drzewa DOM (wraz z wywołaniem haków cyklu życia komponentów potomnych). Element jest zawsze renderowany w DOM, a przełączana jest jedynie właściwość CSS display: none;.
Zachowanie przy starcie (false) Leniwe (Lazy): Jeśli na starcie warunek wynosi false, nic nie jest renderowane. Element zostaje od razu zbudowany w DOM w stanie ukrytym.
Koszt wydajnościowy Wyższy koszt przełączania (modyfikacja struktury DOM), niski koszt początkowy. Bardzo niski koszt przełączania, wyższy koszt początkowego renderowania.
Kiedy stosować? Gdy warunek zmienia się rzadko w trakcie działania aplikacji (np. uprawnienia administratora). Gdy element jest bardzo często przełączany przez użytkownika (np. zakładki, rozwijane panele).

⚠️ Ważna zasada w Vue 3 (v-if vs v-for):
Nigdy nie umieszczaj v-if oraz v-for na tym samym znaczniku HTML! W Vue 3 dyrektywa v-if ma wyższy priorytet niż v-for, co oznacza, że warunek v-if nie ma jeszcze dostępu do zmiennej iteracyjnej z v-for. Jeśli chcesz przefiltrować listę, użyj właściwości obliczonej (computed) lub opakuj listę w znacznik <template v-for="...">.

E. Obsługa zdarzeń DOM: v-on (skrót @)

Dyrektywa v-on:<zdarzenie> (w zapisie skróconym znak @<zdarzenie>) przypisuje nasłuchiwacze zdarzeń. Metody definiujemy w sekcji methods komponentu:

// W MyVueApp:
data() {
  return {
    counter: 0
  };
},
methods: {
  increment(event) {
    // Słowo kluczowe 'this' w Options API automatycznie wskazuje na instancję komponentu
    this.counter += 1;
  }
}
<hr>
<!-- Wywołanie metody zdefiniowanej w sekcji methods -->
<button v-on:click="increment">Dodaj 1</button>

<!-- Zapis skrócony (@click) wraz z wyrażeniem inline -->
<button @click="counter++">(Wersja inline) Dodaj 1</button>

<!-- Użycie modyfikatora .prevent (odpowiednik event.preventDefault()) -->
<form @submit.prevent="increment">
  <button type="submit">Zatwierdź bez przeładowania strony</button>
</form>

<p>Przycisk naciśnięto {{ counter }} razy.</p>

3. Tworzenie niestandardowych dyrektyw (Custom Directives)

Gdy potrzebujemy bezpośredniego, niskopoziomowego dostępu do elementu DOM (np. automatyczne ustawienie fokusu, kolorowanie czy formatowanie), rejestrujemy własną dyrektywę metodą myApp.directive():

// Przykład 1: Dyrektywa v-random-color nadająca losowy kolor tekstu po zamontowaniu
myApp.directive('random-color', {
  mounted: (el) => {
    const hex = Math.floor(Math.random() * 16777215).toString(16).padStart(6, '0');
    el.style.color = `#${hex}`;
  }
});

// Przykład 2: Dyrektywa v-ceil odbierająca przekazaną wartość przez parametr 'binding'
myApp.directive('ceil', {
  mounted: (el, binding) => {
    el.textContent = Math.ceil(binding.value);
  },
  updated: (el, binding) => {
    el.textContent = Math.ceil(binding.value);
  }
});

Użycie własnych dyrektyw w szablonie HTML:

<h2 v-random-color>{{ title }}</h2>
<p>Sufit z 7,45 to: <span v-ceil="7.45"></span></p>

Obiekt definicji dyrektywy w Vue 3 udostępnia uchwyty (hooks) odpowiadające cyklowi życia komponentu: created, beforeMount, mounted, beforeUpdate, updated, beforeUnmount oraz unmounted.


4. Właściwości Obliczone (Computed Properties) i Obserwatory (Watchers)

Cel: Opanowanie deklaratywnego tworzenia danych pochodnych z wykorzystaniem buforowanych właściwości obliczonych (computed) oraz wykonywania asynchronicznych efektów ubocznych w odpowiedzi na zmianę stanu (watch).

1. Właściwości Obliczone (computed) i mechanizm Cachowania

Zbyt rozbudowane wyrażenia w szablonach HTML ({{ firstName + ' ' + lastName.toUpperCase() }}) zmniejszają czytelność kodu. Właściwości obliczone (computed) pozwalają deklaratywnie definiować wartości pochodne, które zależą od innych reaktywnych danych.

// Fragment JS (w obiekcie MyVueApp):
const MyVueApp = {
  data() {
    return {
      firstName: 'Jan',
      lastName: 'Kowalski'
    };
  },
  computed: {
    // Funkcja zostanie wywołana TYLKO wtedy, gdy zmieni się this.firstName lub this.lastName
    fullName() {
      console.log('Obliczanie fullName...');
      return `${this.firstName} ${this.lastName}`;
    }
  }
};
<!-- Fragment HTML: używamy 'fullName' jak zwykłej właściwości (BEZ nawiasów '()'!) -->
<input v-model="firstName" placeholder="Imię">
<input v-model="lastName" placeholder="Nazwisko">
<p>Pełne imię i nazwisko: {{ fullName }}</p>

2. Obserwatory (watch) – Reagowanie na zmiany i Efekty Uboczne

Podczas gdy właściwości computed muszą być czystymi funkcjami synchronicznymi zwracającymi wartość (nie wolno w nich wykonywać zapytań HTTP ani modyfikować innych zmiennych!), Obserwatory (watch) służą do wywoływania tzw. efektów ubocznych (Side Effects) – np. asynchronicznego odpytywania serwera API, zapisu do localStorage lub uruchamiania timerów w odpowiedzi na zmianę konkretnej właściwości.

// Fragment JS (w obiekcie MyVueApp):
const MyVueApp = {
  data() {
    return {
      question: '',
      answer: 'Pytania zwykle kończą się znakiem zapytania "?" ;-)'
    };
  },
  methods: {
    getAnswer() {
      this.answer = 'Myślę...';
      // Symulacja asynchronicznego zapytania do zewnętrznego API
      setTimeout(() => {
        this.answer = 'Odpowiedź brzmi: 42.';
      }, 1500);
    }
  },
  watch: {
    // Nazwa metody obserwatora musi dokładnie odpowiadać nazwie obserwowanej właściwości!
    // Vue przekazuje do niej nową (newQuestion) oraz poprzednią (oldQuestion) wartość:
    question(newQuestion, oldQuestion) {
      if (newQuestion.includes('?')) {
        this.getAnswer();
      }
    }
  }
};
<!-- Fragment HTML -->
<p>
  Zadaj pytanie:
  <input v-model="question" placeholder="Wpisz pytanie zakończone znakiem '?'" />
</p>
<p><strong>Odpowiedź:</strong> {{ answer }}</p>

5. Architektura Komponentów, Komunikacja (Props / Emits), Sloty i v-model

Cel: Podział interfejsu na reużywalne komponenty, zrozumienie jednokierunkowego przepływu danych (Props Down, Events Up), wstrzykiwanie treści przez <slot> oraz implementacja własnych kontrolek obsługujących v-model.

Komponenty pozwalają podzielić złożony interfejs użytkownika na niezależne, hermetyczne i wielokrotnie używane klocki.

1. Tworzenie komponentów, szablony <template> i izolacja stanu data()

Podczas globalnej rejestracji komponentu metodą app.component('nazwa-komponentu', { ... }) zaleca się stosowanie notacji kebab-case (z myślnikiem), ponieważ standardowy kod HTML w przeglądarce nie rozróżnia wielkości liter (case-insensitive).

Zamiast pisać kod HTML wewnątrz łańcucha znaków w JavaScript, w aplikacjach bez kroku kompilacji definiujemy szablon w znaczniku <template id="...">:

<!-- Szablon komponentu zdefiniowany w pliku HTML -->
<template id="component_id">
  <h2>{{ msg }} {{ msg2 }}</h2>
</template>

<div id="app">
  <!-- Przekazanie właściwości z rodzica do dziecka za pomocą dyrektywy v-bind (:msg2) -->
  <my-component :msg2="gMsg"></my-component>
</div>
const app = Vue.createApp({
  data() {
    return {
      gMsg: 'global'
    };
  }
});

app.component('my-component', {
  template: '#component_id',
  // Jawna deklaracja parametrów wejściowych (Props) odbieranych od komponentu nadrzędnego:
  props: ['msg2'],
  // KRYTYCZNE: W komponencie właściwość 'data' MUSI być funkcją zwracającą nowy obiekt!
  data() {
    return {
      msg: 'To jest'
    };
  }
});

app.mount('#app');

⚠️ Dlaczego data w komponencie musi być funkcją, a nie zwykłym obiektem?
Gdyby właściwość data była zwykłym obiektem (data: { count: 0 }), wszystkie instancje <my-component> umieszczone na stronie współdzieliłyby tę samą referencję w pamięci RAM – kliknięcie przycisku w jednym komponencie zmieniłoby stan we wszystkich pozostałych! Dzięki temu, że data() jest funkcją fabrykującą, każde utworzenie nowej instancji komponentu wywołuje funkcję i otrzymuje własną, odizolowaną kopię obiektu stanu. Przekazanie płaskiego obiektu wywoła błąd: [Vue warn]: The data option must be a function. Plain object usage is no longer supported.


2. Komunikacja między komponentami: Props Down, Events Up

W Vue obowiązuje zasada jednokierunkowego przepływu danych (One-Way Data Flow):

  1. Rodzic \(\rightarrow\) Potomek (Props Down): Dane spływają w dół drzewa komponentów przez atrybuty props. Komponent potomny nie może bezpośrednio mutować otrzymanego propsa (jest on dla niego tylko do odczytu!).
  2. Potomek \(\rightarrow\) Rodzic (Events Up): Aby poinformować rodzica o zmianie danych, komponent potomny emituje zdarzenie za pomocą metody this.$emit('nazwa-zdarzenia', ladunekDanych), a komponent nadrzędny nasłuchuje na nie za pomocą dyrektywy @nazwa-zdarzenia="metodaRodzica".
         ┌─────────────────────────────────────────────────────┐
         │         Komponent Nadrzędny (multi-component)       │
         │                Stan: mMsg = "komponent"             │
         └──────────┬───────────────────────────────┬──────────┘
                    │                               │
   1. Props Down:   │ :in_msg="mMsg"                │ 3. Props Down:
      Przekazuje    │                               │    :msg2="mMsg"
      wartość       ▼                               ▼
         ┌─────────────────────┐         ┌─────────────────────┐
         │    inp-component    │         │    my-component     │
         │  <input :value=...> │         │ <h2>To jest {{msg2}}│
         └──────────┬──────────┘         └─────────────────────┘
                    │                               ▲
   2. Events Up:    │ $emit('inpupdated', newVal)   │
      Emituje nową  └───────────────────────────────┘
      wartość do rodzica (@inpupdated="updatemsg")
Kompletny, działający przykład komunikacji między trzema komponentami:

Struktura szablonów w HTML:

<!-- 1. Szablon komponentu wyświetlającego napis -->
<template id="component_id">
  <h2>{{ msg }} {{ msg2 }}</h2>
</template>

<!-- 2. Szablon komponentu pola tekstowego -->
<template id="inp_id">
  <input :value="in_msg" @input="onInput" placeholder="Wpisz nową nazwę..." />
</template>

<!-- 3. Szablon komponentu nadrzędnego łączącego oba powyższe komponenty -->
<!-- UWAGA: Nasłuchiwanie @inpupdated umieszczamy bezpośrednio na znaczniku <inp-component>! -->
<template id="multi_comp_id">
  <div>
    <inp-component :in_msg="mMsg" @inpupdated="updatemsg"></inp-component>
    <my-component :msg2="mMsg"></my-component>
  </div>
</template>

<!-- Miejsce montowania głównej aplikacji -->
<div id="app">
  <multi-component></multi-component>
</div>

Implementacja logiki w JavaScript:

const app = Vue.createApp({});

// 1. Komponent prezentacyjny
app.component('my-component', {
  template: '#component_id',
  props: ['msg2'],
  data() {
    return { msg: 'To jest' };
  }
});

// 2. Komponent wejściowy (emituje zdarzenie 'inpupdated' przy każdym wciśnięciu klawisza)
app.component('inp-component', {
  template: '#inp_id',
  props: ['in_msg'],
  emits: ['inpupdated'], // Jawna deklaracja emitowanych zdarzeń (dobra praktyka w Vue 3)
  methods: {
    onInput(event) {
      this.$emit('inpupdated', event.target.value);
    }
  }
});

// 3. Komponent nadrzędny (zarządza wspólnym stanem mMsg i obsługuje zdarzenie updatemsg)
app.component('multi-component', {
  template: '#multi_comp_id',
  data() {
    return {
      mMsg: 'komponent'
    };
  },
  methods: {
    updatemsg(newValue) {
      this.mMsg = newValue;
    }
  }
});

app.mount('#app');

💡 Zasięg zdarzeń komponentów (Brak bąbelkowania):
W przeciwieństwie do natywnych zdarzeń DOM, zdarzenia emitowane przez $emit nie bąbelkują (do not bubble) w górę drzewa. Można nasłuchiwać wyłącznie zdarzeń emitowanych przez bezpośredniego potomka. Do komunikacji między głęboko zagnieżdżonymi lub siostrzanymi komponentami stosujemy parę provide / inject lub globalny magazyn stanu Pinia.


3. Dystrybucja treści za pomocą Slotów (<slot>)

Sloty to wyznaczone miejsca w szablonie komponentu potomnego, w które rodzic może wstrzyknąć dowolny fragment kodu HTML lub inne komponenty (idealne do budowy uniwersalnych przycisków, kart, ramek i okien modalnych).

// Definicja komponentu ze slotem oraz zawartością domyślną (Fallback Content):
app.component('fancy-button', {
  template: `
    <button class="fancy-btn">
      <slot>Domyślny tekst przycisku</slot>
    </button>
  `
});
<!-- Użycie w komponencie nadrzędnym: -->
<div id="app">
  <fancy-button>Kliknij mnie!</fancy-button>

  <fancy-button>
    <span style="font-weight: bold;">&#10004; Zatwierdź formularz</span>
  </fancy-button>
</div>

4. Komponenty kompatybilne z dyrektywą v-model

W Vue 3 możemy sprawić, aby nasz własny komponent obsługiwał dwukierunkową dyrektywę v-model identycznie jak natywny <input>.

Pod maską zapis <custom-input v-model="message"></custom-input> jest lukrem składniowym (syntactic sugar), który kompilator Vue rozwija do:

  1. Przekazania właściwości (prop) o nazwie modelValue,
  2. Nasłuchiwania na zdarzenie o nazwie update:modelValue.
app.component('custom-input', {
  // 1. Akceptuj prop 'modelValue'
  props: ['modelValue'],
  // 2. Zadeklaruj emitowane zdarzenie 'update:modelValue'
  emits: ['update:modelValue'],
  // 3. W szablonie powiąż :value i wyemituj nową wartość przy zdarzeniu @input
  template: `
    <input
      :value="modelValue"
      @input="$emit('update:modelValue', $event.target.value)"
      placeholder="Własny komponent input"
    />
  `
});
<div id="app">
  <custom-input v-model="message"></custom-input>
  <p>Wiadomość w rodzicu: {{ message }}</p>
</div>

6. Cykl Życia Komponentu (Lifecycle Hooks)

Cel: Poznanie etapów istnienia komponentu od jego inicjalizacji, przez montowanie i aktualizację w DOM, aż po zniszczenie, oraz prawidłowe wykorzystanie haków cyklu życia.

Każda instancja komponentu Vue przechodzi przez uporządkowaną serię kroków inicjalizacyjnych: konfigurację reaktywności stanu, kompilację szablonu, zamontowanie w drzewie DOM, aktualizacje po zmianie danych oraz odmontowanie. Na każdym z tych etapów Vue wywołuje specjalne funkcje zwrotne – haki cyklu życia (Lifecycle Hooks).

                [ Renderer napotyka komponent ]
                               │
                               ▼
              ┌───► [ setup() (Composition API) ]
              │                │
              ├───► [ beforeCreate ]
              │                ▼
              │     (Inicjalizacja Options API: data, methods, computed)
              │                │
              ├───► [ created ]  (Reaktywność gotowa, ale brak jeszcze DOM!)
              │                ▼
              │     [ Kompilacja szablonu (jeśli brak prekompilacji) ]
              │                │
              ├───► [ beforeMount ]
              │                ▼
              │     (Utworzenie i wstawienie węzłów do prawdziwego DOM)
              │                │
              └───► [ mounted / onMounted ] ◄─── Tutaj pobieramy dane z API /
                               │                 operujemy na elementach DOM!
                               ▼
                        (( ZAMONTOWANY )) ◄──────────────────┐
                          │           │                      │
                          │           │ (Zmiana danych)      │
                          │           ▼                      │
                          │     [ beforeUpdate / onBeforeUpdate ]
                          │           │                      │
                          │           ▼                      │
                          │     (Przerysowanie Virtual DOM)  │
                          │           │                      │
                          │           └─► [ updated / onUpdated ]
                          │
                          │ (Usunięcie komponentu z widoku)
                          ▼
              ┌───► [ beforeUnmount / onBeforeUnmount ]
              │                │  (Czyszczenie timerów i nasłuchiwaczy!)
              │                ▼
              └───► [ unmounted / onUnmounted ]
                               │
                               ▼
                       (( ODMONTOWANY ))

Zestawienie haków w Options API oraz Composition API

Options API Composition API (<script setup>) Moment wywołania i typowe zastosowanie
beforeCreate (Zastąpione przez setup) Wywoływany przed inicjalizacją data i methods.
created (Zastąpione przez setup) Stan reaktywny jest gotowy, ale element $el w DOM jeszcze nie istnieje.
beforeMount onBeforeMount(() => ...) Tuż przed pierwszym wyrenderowaniem komponentu w drzewie DOM.
mounted onMounted(() => ...) Najczęściej używany hak. Komponent jest już w DOM – idealne miejsce na pobranie danych z API, inicjalizację wykresów lub dostęp do referencji DOM.
beforeUpdate onBeforeUpdate(() => ...) Po zmianie reaktywnych danych, ale tuż przed aktualizacją drzewa DOM.
updated onUpdated(() => ...) Po zaktualizowaniu drzewa DOM na skutek zmiany danych (uwaga: nie modyfikuj tu stanu, aby nie wywołać nieskończonej pętli!).
beforeUnmount onBeforeUnmount(() => ...) Tuż przed usunięciem komponentu – komponent jest jeszcze w pełni funkcjonalny.
unmounted onUnmounted(() => ...) Po usunięciu komponentu z DOM. Obowiązkowe miejsce na czyszczenie timerów (clearInterval) oraz globalnych nasłuchiwaczy (window.removeEventListener).

⚠️ Pułapka funkcji strzałkowych w Options API:
Definiując haki cyklu życia, metody lub właściwości computed w Options API, nigdy nie używaj funkcji strzałkowych (np. mounted: () => { this.fetchData(); }). Funkcje strzałkowe nie posiadają własnego kontekstu this – wiążą go leksykalnie z zakresem nadrzędnym (np. undefined w trybie ścisłym ESM), co skutkuje błędem:
Uncaught TypeError: Cannot read properties of undefined (reading 'fetchData'). Zawsze używaj skróconej składni metod obiektu: mounted() { this.fetchData(); }.


7. Komponenty Jednoplikowe (SFC .vue), Composition API (<script setup>) i Composables

Cel: Opanowanie architektury Komponentów Jednoplikowych (.vue), przejście z klasycznego Options API do nowoczesnego Composition API (<script setup>), poznanie makr defineModel i useTemplateRef oraz wydzielanie reużywalnej logiki do funkcji Composables.

1. Komponenty Jednoplikowe (Single-File Components – SFC)

W profesjonalnych projektach opartych na Vite zamykamy strukturę HTML (<template>), logikę TypeScript/JavaScript (<script>) oraz arkusz stylów CSS (<style>) powiązane z jednym komponentem w pojedynczym pliku z rozszerzeniem .vue.

2. Options API vs Composition API (<script setup>)

Vue 3 wspiera dwa style pisania komponentów:

Cecha Options API (Klasyczny) Composition API z <script setup> (Nowoczesny standard)
Organizacja kodu Podział według typów opcji (data, computed, methods, watch). Podział według cech funkcjonalnych (logicznych) – powiązane zmienne i funkcje leżą obok siebie.
Skalowalność w dużych plikach Prowadzi do tzw. „eksplozji opcji” – kod jednej funkcji jest rozrzucony po całym pliku. Wysoka czytelność i zwięzłość (brak słowa this oraz zbędnego kodu boilerplate).
Współdzielenie logiki Trudne – wymagało przestarzałych mixins (kolizje nazw, niejasne źródło właściwości). Bardzo proste i bezpieczne dzięki funkcjom Composables (useNazwa()).
Wsparcie dla TypeScript Wymaga złożonego wnioskowania typów dla kontekstu this. Natywne, pierwszorzędne wsparcie dla TypeScript (czyste zmienne i funkcje).
Porównanie: Komponent w Options API vs <script setup> (Composition API)

Wariant A: Klasyczne Options API (SinglePageComp.vue oraz App.vue)

<!-- App.vue (Options API) -->
<template>
  <div id="app">
    <single-page-comp></single-page-comp>
    <other-name></other-name>
  </div>
</template>

<script lang="ts">
import { defineComponent } from 'vue';
import SinglePageComp from './components/SinglePageComp.vue';

export default defineComponent({
  name: 'App',
  components: {
    SinglePageComp,
    'other-name': SinglePageComp
  },
  data() {
    return {
      msg: 'Witaj w aplikacji Vue.js'
    };
  }
});
</script>

Wariant B: Nowoczesne Composition API z <script setup> (Zalecane) W bloku <script setup lang="ts"> każdy zaimportowany komponent oraz każda zadeklarowana zmienna lub funkcja najwyższego poziomu są automatycznie dostępne w szablonie <template> bez konieczności ich ręcznego zwracania!

<!-- App.vue (Composition API <script setup>) -->
<script setup lang="ts">
import { ref, computed, watch } from 'vue';
import SinglePageComp from './components/SinglePageComp.vue';

// 1. Stan reaktywny (ref):
// W kodzie JS/TS odwołujemy się przez właściwość '.value', natomiast w <template> Vue automatycznie ją odpakowuje!
const firstName = ref('Jan');
const lastName = ref('Kowalski');
const question = ref('');
const answer = ref('Pytania zwykle kończą się znakiem "?" ;-)');

// 2. Właściwość obliczona (computed):
const fullName = computed(() => `${firstName.value} ${lastName.value}`);

// 3. Obserwator (watch):
watch(question, (newQuestion) => {
  if (newQuestion.includes('?')) {
    answer.value = 'Myślę...';
    setTimeout(() => {
      answer.value = 'Odpowiedź brzmi: 42.';
    }, 1500);
  }
});
</script>

<template>
  <SinglePageComp />
  <input v-model="firstName" placeholder="Imię" />
  <input v-model="lastName" placeholder="Nazwisko" />
  <p>Pełne imię: {{ fullName }}</p>

  <input v-model="question" placeholder="Zadaj pytanie z '?'" />
  <p>{{ answer }}</p>
</template>

3. Nowoczesna implementacja Slotów, Propsów i defineModel() (Vue 3.4 / 3.5+)

W komponentach jednoplikowych z <script setup> korzystamy z makr kompilatora (nie wymagają one importowania z 'vue'):

A. Komponent ze slotem w SFC (FancyButton.vue):
<!-- src/components/FancyButton.vue -->
<template>
  <button class="fancy-btn">
    <slot></slot>
  </button>
</template>

<style scoped>
.fancy-btn {
  background: #42b983;
  color: white;
  padding: 10px 16px;
  border: none;
  border-radius: 6px;
  cursor: pointer;
}
</style>
B. Komponent z v-model w SFC (CustomInput.vue): Klasyczne defineProps vs Nowoczesne defineModel()

Począwszy od Vue 3.4+, zamiast ręcznego deklarowania defineProps(['modelValue']) oraz defineEmits(['update:modelValue']), zaleca się użycie makra defineModel(), które zwraca gotową referencję ref zsynchronizowaną z rodzicem:

<!-- src/components/CustomInput.vue -->
<script setup lang="ts">
// NOWOCZESNY STANDARD (Vue 3.4+):
// Makro defineModel() automatycznie rejestruje prop 'modelValue' oraz zdarzenie 'update:modelValue'!
const model = defineModel<string>({ default: '' });

// NOWOŚĆ W VUE 3.5+: Reaktywna destrukturyzacja Propsów z wartościami domyślnymi:
const { label = 'Wprowadź tekst:' } = defineProps<{
  label?: string;
}>();
</script>

<template>
  <label>
    {{ label }}
    <!-- Możemy bezpośrednio użyć v-model="model" na wewnętrznym inpucie! -->
    <input v-model="model" placeholder="Własny input" />
  </label>
</template>

💡 Nowość w Vue 3.5+: useTemplateRef() do obsługi referencji DOM
Gdy potrzebujemy bezpośredniego odwołania do elementu HTML w <script setup> (np. <input ref="myInput">), od wersji Vue 3.5 stosujemy dedykowaną funkcję const inputEl = useTemplateRef<HTMLInputElement>('myInput');, która jest znacznie czytelniejsza niż dawne tworzenie pustego const myInput = ref(null) o tej samej nazwie co atrybut w szablonie.


4. Reużywalna logika stanowa: Funkcje Composables (use*)

Jedną z największych zalet Composition API jest możliwość wyizolowania powtarzalnej logiki stanowej (wraz z ref, watch i hakami cyklu życia) do zewnętrznych funkcji zwanych Composables. Zgodnie z konwencją ich nazwy zawsze rozpoczynają się od prefiksu use (np. useMousePosition, useFetch). Każde wywołanie funkcji Composable w komponencie tworzy niezależny, odizolowany stan dla tego komponentu.

Przykład 1: Śledzenie pozycji kursora myszy (src/composables/useMousePosition.ts)
import { ref, onMounted, onUnmounted } from 'vue';

export function useMousePosition() {
  const x = ref(0);
  const y = ref(0);

  function update(event: MouseEvent) {
    x.value = event.pageX;
    y.value = event.pageY;
  }

  onMounted(() => window.addEventListener('mousemove', update));
  onUnmounted(() => window.removeEventListener('mousemove', update));

  return { x, y };
}

Użycie w dowolnym komponencie:

<script setup lang="ts">
import { useMousePosition } from '@/composables/useMousePosition';

const { x, y } = useMousePosition();
</script>

<template>
  <p>Pozycja myszy to: {{ x }}, {{ y }}</p>
</template>
Przykład 2: Uniwersalne pobieranie danych asynchronicznych (src/composables/useFetch.ts)

Funkcja useFetch zarządza trzema stanami zapytania HTTP (data, error, loading) oraz udostępnia metodę refetch. Wykorzystanie funkcji pomocniczej toValue() (wprowadzonej w Vue 3.3+) pozwala przekazać jako argument zarówno zwykły tekst URL, jak i reaktywny ref lub funkcję getter, automatycznie ponawiając zapytanie przy zmianie adresu URL:

import { ref, watchEffect, toValue, type MaybeRefOrGetter } from 'vue';

export function useFetch<T = unknown>(url: MaybeRefOrGetter<string>) {
  const data = ref<T | null>(null);
  const error = ref<Error | null>(null);
  const loading = ref<boolean>(false);

  async function fetchData() {
    loading.value = true;
    error.value = null;

    try {
      const response = await fetch(toValue(url));
      if (!response.ok) {
        throw new Error(`Błąd HTTP: ${response.status}`);
      }
      data.value = (await response.json()) as T;
    } catch (err) {
      error.value = err instanceof Error ? err : new Error(String(err));
    } finally {
      loading.value = false;
    }
  }

  // watchEffect automatycznie śledzi toValue(url) i wywołuje fetchData przy każdej jego zmianie
  watchEffect(() => {
    fetchData();
  });

  return {
    data,
    error,
    loading,
    refetch: fetchData
  };
}

8. Routing w Aplikacjach SPA (Vue Router 4), Typowany Routing Plikowy (unplugin-vue-router) oraz TanStack Query

Cel: Budowa nawigacji w aplikacjach SPA za pomocą Vue Router 4, wdrożenie automatycznego, silnie typowanego routingu opartego na strukturze plików (unplugin-vue-router) oraz zarządzanie stanem serwera z biblioteką @tanstack/vue-query (na wzór ekosystemu React + TanStack).

1. Klasyczna konfiguracja Vue Router 4

Vue Router 4 to oficjalny router dla Vue 3, pozwalający mapować ścieżki adresów URL przeglądarki na odpowiednie komponenty widoków bez przeładowywania całej strony.

Podstawowa konfiguracja routera (src/router/index.ts):

import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router';
import HomeView from '@/views/HomeView.vue';

const routes: RouteRecordRaw[] = [
  {
    path: '/',
    name: 'home',
    component: HomeView
  },
  {
    // Leniwe ładowanie komponentu (Lazy Loading / Code Splitting) - plik JS zostanie
    // pobrany dopiero wtedy, gdy użytkownik odwiedzi ścieżkę '/about'
    path: '/about',
    name: 'about',
    component: () => import('@/views/AboutView.vue')
  },
  {
    // Ścieżka z parametrem dynamicznym :id przekazywanym jako prop do komponentu
    path: '/users/:id',
    name: 'user-details',
    component: () => import('@/views/UserDetailsView.vue'),
    props: true
  }
];

const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes
});

export default router;

Rejestracja w main.ts (app.use(router)) oraz użycie w głównym szablonie App.vue:

<template>
  <header>
    <nav>
      <!-- RouterLink generuje znacznik <a> przechwytujący kliknięcie bez przeładowania strony -->
      <RouterLink to="/">Strona główna</RouterLink> |
      <RouterLink to="/about">O projekcie</RouterLink> |
      <RouterLink :to="{ name: 'user-details', params: { id: 42 } }">Profil #42</RouterLink>
    </nav>
  </header>

  <main>
    <!-- RouterView to dynamiczny kontener, w którym renderowany jest komponent dopasowany do URL -->
    <RouterView />
  </main>
</template>

2. Nowoczesny Routing Plikowy i Pełne Bezpieczeństwo Typów: unplugin-vue-router

W klasycznym vue-router każdą nową podstronę musimy ręcznie dopisywać do tablicy routes, a literówka w ścieżce <RouterLink to="/uzytkownicy/15"> nie jest wykrywana przez kompilator TypeScript.

Wtyczka unplugin-vue-router (tworzona przez autora Vue Routera i Pinia – Eduardo San Martin Morote) wprowadza do Vue architekturę znaną z TanStack Router (React) oraz Nuxt:

  1. Automatyczne generowanie tras na podstawie plików (File-Based Routing): Wystarczy utworzyć plik .vue w katalogu src/pages/, a trasa powstaje automatycznie!
  2. 100% bezpieczeństwo typów TypeScript (Type-Safe Routing): Wtyczka generuje w locie plik deklaracji typed-router.d.ts, dzięki czemu edytor VS Code podpowiada poprawne ścieżki i parametry w <RouterLink> oraz useRoute(), a każdą literówkę podkreśla jako błąd kompilacji!
Konfiguracja unplugin-vue-router:
npm install -D unplugin-vue-router

W pliku vite.config.ts umieszczamy wtyczkę VueRouter() PRZED wtyczką vue():

// vite.config.ts
import { defineConfig } from 'vite';
import VueRouter from 'unplugin-vue-router/vite';
import vue from '@vitejs/plugin-vue';

export default defineConfig({
  plugins: [
    VueRouter({
      routesFolder: 'src/pages',
      dts: 'src/typed-router.d.ts' // Automatycznie generowany plik typów tras
    }),
    vue()
  ]
});

W pliku src/router/index.ts importujemy trasy z wirtualnego modułu vue-router/auto-routes:

import { createRouter, createWebHistory } from 'vue-router';
import { routes } from 'vue-router/auto-routes';

export const router = createRouter({
  history: createWebHistory(),
  routes
});
Konwencja nazewnictwa plików w katalogu src/pages/:
Ścieżka pliku w src/pages/ Wygenerowany adres URL Nazwa trasy w TypeScript
src/pages/index.vue / '/'
src/pages/about.vue /about '/about'
src/pages/users/index.vue /users '/users/'
src/pages/users/[id].vue /users/:id (parametr dynamiczny) '/users/[id]'
src/pages/[...path].vue /:path(.*) (strona błędu 404 Catch-all) '/[...path]'

Odczyt w pełni typowanego parametru w komponencie src/pages/users/[id].vue:

<script setup lang="ts">
import { useRoute } from 'vue-router';

// Przekazujemy nazwę trasy - TypeScript automatycznie wie, że route.params posiada właściwość 'id'!
const route = useRoute('/users/[id]');
</script>

<template>
  <h1>Szczegóły użytkownika o ID: {{ route.params.id }}</h1>
</template>

3. Zarządzanie Stanem Serwera z TanStack Query (@tanstack/vue-query)

W nowoczesnych aplikacjach SPA rozróżniamy dwa fundamentalnie odmienne rodzaje stanu:

Połączenie unplugin-vue-router + @tanstack/vue-query w Vue zapewnia dokładnie tę samą potężną architekturę pracy z danymi, co połączenie TanStack Router + TanStack Query w React.

Instalacja i rejestracja TanStack Query:
npm install @tanstack/vue-query
// W pliku src/main.ts:
import { createApp } from 'vue';
import { VueQueryPlugin } from '@tanstack/vue-query';
import App from './App.vue';

const app = createApp(App);
app.use(VueQueryPlugin); // Rejestracja globalnego klienta QueryClient
app.mount('#app');
Pobieranie danych (useQuery) i modyfikacja z automatycznym odświeżeniem cache (useMutation):
<!-- src/pages/users/index.vue -->
<script setup lang="ts">
import { ref } from 'vue';
import { useQuery, useMutation, useQueryClient } from '@tanstack/vue-query';

interface User {
  id: number;
  name: string;
  email: string;
}

const queryClient = useQueryClient();
const newUserName = ref('');

// 1. POBIERANIE I CACHOWANIE DANYCH (useQuery):
// Zapytanie jest identyfikowane kluczem ['users']. Jeśli inny komponent poprosi o ['users']
// w ciągu 60 sekund (staleTime), TanStack Query zwróci dane z pamięci RAM bez uderzania do sieci!
const { isPending, isError, data: users, error } = useQuery<User[]>({
  queryKey: ['users'],
  queryFn: async () => {
    const res = await fetch('https://jsonplaceholder.typicode.com/users');
    if (!res.ok) throw new Error('Błąd pobierania listy użytkowników');
    return res.json();
  },
  staleTime: 60 * 1000 // Dane są uznawane za "świeże" przez 60 sekund
});

// 2. MODYFIKACJA DANYCH NA SERWERZE (useMutation):
const createUserMutation = useMutation({
  mutationFn: async (name: string) => {
    const res = await fetch('https://jsonplaceholder.typicode.com/users', {
      method: 'POST',
      body: JSON.stringify({ name, email: `${name.toLowerCase()}@uczelnia.pl` }),
      headers: { 'Content-Type': 'application/json' }
    });
    return res.json();
  },
  onSuccess: () => {
    // Automatyczne unieważnienie klucza ['users'] wymusza pobranie świeżej listy w tle!
    queryClient.invalidateQueries({ queryKey: ['users'] });
    newUserName.value = '';
  }
});
</script>

<template>
  <div>
    <h2>Lista Użytkowników (TanStack Query)</h2>

    <form @submit.prevent="createUserMutation.mutate(newUserName)">
      <input v-model="newUserName" placeholder="Imię nowego użytkownika" required />
      <button type="submit" :disabled="createUserMutation.isPending.value">
        {{ createUserMutation.isPending.value ? 'Zapisywanie...' : 'Dodaj użytkownika' }}
      </button>
    </form>

    <p v-if="isPending">Ładowanie danych z serwera...</p>
    <p v-else-if="isError">Wystąpił błąd: {{ error?.message }}</p>
    <ul v-else>
      <li v-for="user in users" :key="user.id">
        <strong>{{ user.name }}</strong> ({{ user.email }})
      </li>
    </ul>
  </div>
</template>

9. Zarządzanie Globalnym Stanem Aplikacji (Pinia) i System Wtyczek (Plugins)

Cel: Centralizacja stanu klienta zgodnie ze wzorcem Single Source of Truth przy użyciu biblioteki Pinia (w składni Option Stores oraz Setup Stores) oraz wykorzystanie zewnętrznych wtyczek Vue.

1. Magazyn stanu Pinia (Single Source of Truth – SSOT)

W dużych aplikacjach przekazywanie danych przez dziesiątki poziomów props (Prop Drilling) i zdarzeń emits staje się nieczytelne. Pinia (oficjalny następca dawnego Vuex) realizuje wzorzec Single Source of Truth (SSOT), udostępniając modularne, w pełni typowane magazyny danych (Stores).

Każdy magazyn Pinia składa się z trzech filarów:

  1. State (stan): Odpowiednik data / ref w komponencie. Przechowuje aktualne dane.
  2. Getters (gettery): Odpowiednik właściwości obliczonych (computed). Buforowane wartości wyliczane na podstawie stanu.
  3. Actions (akcje): Odpowiednik metod (methods). Mogą być synchroniczne lub asynchroniczne i służą do modyfikacji stanu oraz wykonywania logiki biznesowej.
Wariant A: Magazyn w składni Option Store (state, getters, actions)
// src/stores/counterStore.ts
import { defineStore } from 'pinia';

interface UserInfo {
  name: string;
  age: number;
}

interface CounterState {
  count: number;
  userList: UserInfo[];
  user: UserInfo | null;
}

export const useCounterStore = defineStore('counter', {
  // 1. STATE: Funkcja zwracająca stan początkowy (bezpieczna dla SSR)
  state: (): CounterState => ({
    count: 0,
    userList: [],
    user: null
  }),

  // 2. GETTERS: Odpowiednik computed
  getters: {
    // Typ zwracany (number) jest wnioskowany automatycznie z parametru state:
    doubleCount(state) {
      return state.count * 2;
    },
    // Gdy w getterze używamy słowa 'this' (odwołując się do innego gettera),
    // w TypeScript musimy jawnie zadeklarować typ zwracany (: number):
    doublePlusOne(): number {
      return this.doubleCount + 1;
    }
  },

  // 3. ACTIONS: Odpowiednik methods (NIE używamy tu funkcji strzałkowych, bo potrzebujemy 'this'!)
  actions: {
    increment() {
      this.count++;
    },
    randomizeCounter() {
      this.count = Math.round(100 * Math.random());
    }
  }
});
Wariant B: Magazyn w nowoczesnej składni Setup Store (Composition API)

Pinia pozwala również definiować magazyny dokładnie tak samo jak komponenty <script setup>, gdzie:

// src/stores/contactsStore.ts
import { ref, computed } from 'vue';
import { defineStore } from 'pinia';

export interface Contact {
  id: number;
  name: string;
  phone: string;
}

export const useContactsStore = defineStore('contacts', () => {
  const contactsMode = ref(true);
  const contactsList = ref<Contact[]>([
    { id: 1, name: 'Jan Kowalski', phone: '+48 500 100 200' },
    { id: 2, name: 'Anna Nowak', phone: '+48 600 300 400' }
  ]);

  const totalContacts = computed(() => contactsList.value.length);

  function addContact(contact: Contact) {
    contactsList.value.push(contact);
  }

  return { contactsMode, contactsList, totalContacts, addContact };
});

⚠️ Bezpieczna destrukturyzacja magazynu Pinia (storeToRefs):
Zwykła destrukturyzacja obiektu magazynu const { count, doubleCount } = useCounterStore(); niszczy reaktywność zmiennych stanu i getterów! Aby wypakować właściwości stanu i gettery do osobnych zmiennych z zachowaniem reaktywności, zawsze używaj funkcji pomocniczej storeToRefs():

import { storeToRefs } from 'pinia';
const store = useCounterStore();
// Stan i gettery wypakowujemy przez storeToRefs():
const { count, doubleCount } = storeToRefs(store);
// Akcje (metody) możemy wypakować bezpośrednio ze store'a:
const { increment, randomizeCounter } = store;

2. System Wtyczek Vue (Plugins) na przykładzie vue-good-table-next

Wtyczki (Plugins) rozszerzają możliwości aplikacji na poziomie globalnym lub komponentowym (dodając nowe komponenty, dyrektywy lub funkcje globalne za pomocą app.use(NazwaWtyczki)).

Zainstalujmy wtyczkę udostępniającą zaawansowaną tabelę danych z obsługą sortowania, filtrowania i paginacji:

npm install vue-good-table-next
Użycie tabeli w połączeniu z magazynem Pinia w <script setup> (Zalecane):
<!-- src/components/ContactsTable.vue -->
<script setup lang="ts">
import { ref } from 'vue';
import { storeToRefs } from 'pinia';
import { VueGoodTable } from 'vue-good-table-next';
import 'vue-good-table-next/dist/vue-good-table-next.css';
import { useContactsStore } from '@/stores/contactsStore';

// 1. Pobieramy instancję magazynu Pinia
const contactsStore = useContactsStore();
const { contactsMode, contactsList } = storeToRefs(contactsStore);

// 2. Definiujemy kolumny tabeli
const columns = ref([
  { label: 'Imię i nazwisko', field: 'name' },
  { label: 'Telefon', field: 'phone' }
]);
</script>

<template>
  <div v-if="contactsMode" class="content">
    <h2>Książka kontaktowa ({{ contactsStore.totalContacts }}):</h2>
    <VueGoodTable
      :columns="columns"
      :rows="contactsList"
      :search-options="{ enabled: true }"
      :pagination-options="{ enabled: true, perPage: 5 }"
    />
  </div>
</template>
💡 Alternatywa: Integracja vue-good-table-next oraz Pinia w klasycznym Options API (mapState) (Kliknij, aby rozwinąć/zwinąć)

W starszych komponentach opartych na Options API do mapowania stanu z magazynu Pinia na właściwości obliczone komponentu wykorzystujemy funkcję pomocniczą mapState:

<script lang="ts">
import { defineComponent } from 'vue';
import { VueGoodTable } from 'vue-good-table-next';
import 'vue-good-table-next/dist/vue-good-table-next.css';
import { mapState } from 'pinia';
import { useContactsStore } from '@/stores/contactsStore';

export default defineComponent({
  components: { VueGoodTable },
  data() {
    return {
      columns: [
        { label: 'Imię i nazwisko', field: 'name' },
        { label: 'Telefon', field: 'phone' }
      ]
    };
  },
  computed: {
    ...mapState(useContactsStore, ['contactsMode', 'contactsList'])
  }
});
</script>

10. Nowoczesne Stylowanie Interfejsu z Tailwind CSS (v4 oraz v3)

Cel: Integracja frameworka CSS typu Utility-First (Tailwind CSS) z projektem Vue + Vite.

Tailwind CSS to najpopularniejszy framework CSS działający w paradygmacie Utility-First. Zamiast wymyślać dziesiątki nazw klas w osobnych plikach arkuszy stylów, budujemy nowoczesny, responsywny interfejs bezpośrednio w szablonie <template> za pomocą atomowych klas narzędziowych (np. flex items-center justify-between bg-emerald-600 text-white px-4 py-2 rounded-lg shadow-md hover:bg-emerald-700). Podczas budowania projektu kompilator skanuje pliki .vue i generuje wyłącznie te reguły CSS, których faktycznie użyliśmy w kodzie.

1. Konfiguracja najnowszej wersji Tailwind CSS v4 (z wtyczką @tailwindcss/vite)

W wersji Tailwind CSS v4 konfiguracja została radykalnie uproszczona – nie są już wymagane pliki postcss.config.js ani tailwind.config.js:

  1. Instalacja pakietów:
    npm install tailwindcss @tailwindcss/vite
  2. Dodanie wtyczki w pliku vite.config.ts:
    import { defineConfig } from 'vite';
    import vue from '@vitejs/plugin-vue';
    import tailwindcss from '@tailwindcss/vite';
    
    export default defineConfig({
      plugins: [
        vue(),
        tailwindcss()
      ]
    });
  3. Import w głównym pliku arkusza stylów ./src/assets/main.css (lub ./src/index.css):
    @import "tailwindcss";
  4. Zaimportowanie pliku CSS w ./src/main.ts:
    import { createApp } from 'vue';
    import App from './App.vue';
    import './index.css';
    
    createApp(App).mount('#app');
💡 Konfiguracja klasyczna (Tailwind CSS v3 z PostCSS – dla starszych projektów) (Kliknij, aby rozwinąć/zwinąć)

W projektach korzystających z linii Tailwind CSS v3 konfiguracja opiera się na procesorze PostCSS:

  1. Instalacja zależności:
    npm install -D tailwindcss@^3 postcss autoprefixer
    npx tailwindcss init -p
  2. Wskazanie ścieżek do szablonów w tailwind.config.js:
    /** @type {import('tailwindcss').Config} */
    export default {
      content: [
        "./index.html",
        "./src/**/*.{vue,js,ts,jsx,tsx}",
      ],
      theme: { extend: {} },
      plugins: [],
    };
  3. Dodanie dyrektyw w pliku ./src/index.css:
    @tailwind base;
    @tailwind components;
    @tailwind utilities;

11. Diagnostyka i Debugowanie Aplikacji Vue.js

Cel: Opanowanie narzędzi inspekcji i usuwania błędów w projektach Vue.js – od zintegrowanego panelu Vue DevTools, przez konsolę przeglądarki, po zaawansowane konfiguracje debuggera w Visual Studio Code.

1. Zintegrowane narzędzia Vue DevTools w Vite

Projekty utworzone przez create-vue posiadają wtyczkę vite-plugin-vue-devtools, która udostępnia zaawansowany inspektor bezpośrednio w przeglądarce bez konieczności instalowania zewnętrznych rozszerzeń z Chrome Web Store:

2. Debugowanie w konsoli przeglądarki i Haki Śledzenia Renderowania

W prostych aplikacjach korzystających z CDN możemy przypisać wynik app.mount('#app') do globalnej zmiennej window.vm = app.mount('#app') i badać stan bezpośrednio w konsoli DevTools (F12).

W komponentach <script setup> (oraz Options API) Vue 3 udostępnia ponadto dwa specjalne haki diagnostyczne cyklu życia, które pozwalają precyzyjnie ustalić, dlaczego dany komponent został ponownie wyrenderowany:

<script setup lang="ts">
import { onRenderTracked, onRenderTriggered } from 'vue';

// Wywoływany, gdy Vue rejestruje zależność reaktywną podczas renderowania:
onRenderTracked((event) => {
  console.log('Śledzona właściwość:', event.key, 'na obiekcie:', event.target);
});

// Wywoływany, gdy zmiana właściwości wymusza ponowne przerysowanie komponentu:
onRenderTriggered((event) => {
  console.log('Przerysowanie wywołane przez zmianę:', event.key, 'z:', event.oldValue, 'na:', event.newValue);
  // Możesz tu odkomentować instrukcję 'debugger;', aby zatrzymać wykonanie dokładnie w momencie mutacji!
  // debugger;
});
</script>

3. Konfiguracja Debuggera w Visual Studio Code (launch.json oraz Auto Attach)

Visual Studio Code pozwala stawiać punkty przerwania (Breakpoints – klawisz F9) bezpośrednio w plikach .vue oraz .ts:

  1. Tryb Auto Attach: Z palety poleceń (Ctrl + Shift + P) wybierz Debug: Toggle Auto Attach (tryb Smart lub Always). Od tej chwili uruchomienie skryptu w terminalu VS Code (Ctrl + \`` $\rightarrow$ npm run dev`) automatycznie podłączy debugger Node.js.
  2. Przycisk Debug w package.json: Po otwarciu pliku package.json nad sekcją "scripts" pojawia się przycisk ▷ Debug, pozwalający uruchomić wybrany skrypt w trybie śledzenia.
  3. Konfiguracja złożona (compounds) w pliku .vscode/launch.json (Zalecana):
    Pozwala jednym naciśnięciem klawisza F5 automatycznie uruchomić w tle serwer deweloperski Vite (npm run dev) oraz otworzyć powiązaną sesję debugowania przeglądarki Chrome/Edge z obsługą map źródłowych (Source Maps):
{
  "version": "0.2.0",
  "configurations": [
    {
      "type": "msedge",
      "request": "launch",
      "name": "Przeglądarka (Edge :5173)",
      "url": "http://localhost:5173",
      "webRoot": "${workspaceFolder}/src"
    },
    {
      "type": "node-terminal",
      "request": "launch",
      "name": "Serwer Vite (npm run dev)",
      "command": "npm run dev",
      "cwd": "${workspaceFolder}"
    }
  ],
  "compounds": [
    {
      "name": "Uruchom Full-Stack Debug (Vite + Edge)",
      "configurations": ["Serwer Vite (npm run dev)", "Przeglądarka (Edge :5173)"],
      "stopAll": true
    }
  ]
}

12. Kompleksowa Strategia Testowania: Vitest, Vue Test Utils i Playwright (Page Object Model)

Cel: Opanowanie trójpoziomowej piramidy testowania aplikacji Vue.js: testów jednostkowych (Vitest), testów integracyjnych komponentów (@vue/test-utils) oraz testów End-to-End w Playwright z zastosowaniem wzorca projektowego Page Object Model (POM).

                    /\
                   /  \          3. Testy E2E (Playwright + Page Object Model)
                  /────\            Prawdziwa przeglądarka, pełny przepływ użytkownika
                 /      \
                /────────\       2. Testy Komponentów (@vue/test-utils + Vitest)
               /          \         Montowanie komponentów, Props, Emits, Sloty, DOM
              /────────────\
             /              \    1. Testy Jednostkowe (Vitest)
            /________________\      Czyste funkcje TS/JS, Composables, Magazyny Pinia

1. Poziom I: Testy Jednostkowe (Vitest) i Testowanie Composables

Vitest to ultraszybki framework testowy stworzony przez zespół Vue/Vite. Korzysta z tej samej konfiguracji vite.config.ts i udostępnia API w pełni kompatybilne z Jest (describe, test, it, expect, vi).

A. Testowanie czystej funkcji (sum.ts oraz sum.spec.ts):
// src/utils/sum.ts
export function sum(a: number, b: number): number {
  return a + b;
}

// src/utils/__tests__/sum.spec.ts
import { expect, test } from 'vitest';
import { sum } from '../sum';

test('dodaje 1 + 2 zwracając wynik 3', () => {
  expect(sum(1, 2)).toBe(3);
});
B. Testowanie niezależnej funkcji Composable (useCounter.ts):

Jeśli funkcja Composable korzysta wyłącznie z reaktywności (ref, computed, watch) i nie wymaga haków cyklu życia DOM, możemy testować ją bezpośrednio jak zwykłą funkcję:

// src/composables/useCounter.ts
import { ref } from 'vue';

export function useCounter(initialValue = 0) {
  const count = ref(initialValue);
  const increment = () => count.value++;
  return { count, increment };
}

// src/composables/__tests__/useCounter.spec.ts
import { expect, test } from 'vitest';
import { useCounter } from '../useCounter';

test('useCounter poprawnie inicjalizuje i zwiększa stan licznika', () => {
  const { count, increment } = useCounter(10);
  expect(count.value).toBe(10);

  increment();
  expect(count.value).toBe(11);
});

2. Poziom II: Testy Komponentów (@vue/test-utils + Vitest)

Testy komponentów sprawdzają, czy komponent poprawnie renderuje szablon HTML na podstawie przekazanych props, reaguje na kliknięcia użytkownika i emituje odpowiednie zdarzenia emits. Testujemy zawsze publiczny kontrakt komponentu, a nie jego wewnętrzne zmienne prywatne.

Biblioteka @vue/test-utils udostępnia dwie metody montowania komponentu w wirtualnym środowisku DOM (jsdom / happy-dom):

// src/components/__tests__/WeatherHeader.spec.ts
import { describe, it, expect } from 'vitest';
import { shallowMount } from '@vue/test-utils';
import WeatherHeader from '../WeatherHeader.vue';

describe('Komponent WeatherHeader.vue', () => {
  it('renderuje przekazany tytuł w props oraz emituje zdarzenie odświeżenia po kliknięciu', async () => {
    // 1. Arrange & Act: Płytkie zamontowanie komponentu z przekazanym propsem 'title'
    const wrapper = shallowMount(WeatherHeader, {
      props: {
        title: 'Prognoza Pogody Vue Project'
      }
    });

    // 2. Assert: Weryfikacja wygenerowanego tekstu w drzewie DOM
    expect(wrapper.text()).toMatch('Prognoza Pogody Vue Project');

    // 3. Symulacja kliknięcia przycisku odświeżania i weryfikacja wyemitowanego zdarzenia
    const refreshBtn = wrapper.find('button.refresh-btn');
    if (refreshBtn.exists()) {
      await refreshBtn.trigger('click');
      expect(wrapper.emitted()).toHaveProperty('refresh');
    }
  });
});

3. Poziom III: Testy End-to-End (E2E) z użyciem Playwright i wzorca Page Object Model (POM)

Testy End-to-End (E2E) weryfikują działanie kompletnego, uruchomionego systemu z perspektywy prawdziwego użytkownika. Nie stosuje się tu atrap komponentów – narzędzie testowe uruchamia prawdziwy silnik przeglądarki (Chromium, Firefox oraz WebKit/Safari), otwiera adres aplikacji i wykonuje rzeczywiste kliknięcia, wypełnianie formularzy oraz zapytania sieciowe.

W nowoczesnych projektach Vue standardem przemysłowym do testów E2E jest Microsoft Playwright (@playwright/test), oferujący:

Dlaczego stosujemy wzorzec Page Object Model (Model Obiektu Strony – POM)?

Gdy piszemy dziesiątki testów E2E i w każdym z nich bezpośrednio powtarzamy te same selektory (await page.getByPlaceholder('Dodaj zadanie...').fill('...')), drobna zmiana tekstu w szablonie .vue powoduje awarię 50 plików testowych naraz!

Wzorzec Page Object Model (POM) rozwiązuje ten problem poprzez separację warstw:

  1. Klasa Page Object (e2e/pages/TodoPage.ts): Hermetyzuje wszystkie lokatory elementów danej podstrony oraz udostępnia metody wysokiego poziomu reprezentujące akcje użytkownika (np. addTodo('Kupić mleko'), toggleTodo(0)). Nie zawiera asercji expect!
  2. Plik specyfikacji testu (e2e/todo.spec.ts): Korzysta z metod obiektu strony i skupia się wyłącznie na scenariuszu biznesowym oraz asercjach sprawdzających wynik (expect). Jeśli w przyszłości zmieni się struktura HTML formularza, aktualizujemy lokator tylko w jednym miejscu – w klasie TodoPage!
  ┌─────────────────────────────────────────┐       ┌─────────────────────────────────────────┐
  │   TEST SPECYFIKACJI (e2e/todo.spec.ts)  │       │    PAGE OBJECT (e2e/pages/TodoPage.ts)  │
  │                                         │       │                                         │
  │  Scenariusz biznesowy i asercje expect: │       │  Lokatory i operacje na stronie:        │
  │  1. await todoPage.goto()               │ ────► │  - readonly input: Locator              │
  │  2. await todoPage.addTask('Raport')    │       │  - readonly submitBtn: Locator          │
  │  3. expect(todoPage.items).toHaveCount()│       │  + async addTask(title: string)         │
  └─────────────────────────────────────────┘       └────────────────────┬────────────────────┘
                                                                         │ Steruje przez CDP
                                                                         ▼
                                                    ┌─────────────────────────────────────────┐
                                                    │  Przeglądarka (Chromium/Firefox/WebKit) │
                                                    └─────────────────────────────────────────┘
Krok 1: Implementacja klasy modelu strony (e2e/pages/TodoPage.ts)
import { type Page, type Locator } from '@playwright/test';

export class TodoPage {
  readonly page: Page;
  readonly newTodoInput: Locator;
  readonly addButton: Locator;
  readonly todoItems: Locator;
  readonly completedCounter: Locator;

  constructor(page: Page) {
    this.page = page;
    // Definiujemy odporne lokatory semantyczne w jednym centralnym miejscu:
    this.newTodoInput = page.getByPlaceholder('Co jest do zrobienia?');
    this.addButton = page.getByRole('button', { name: 'Dodaj zadanie' });
    this.todoItems = page.getByTestId('todo-item');
    this.completedCounter = page.getByTestId('completed-count');
  }

  // Nawigacja do badanej podstrony aplikacji Vue
  async goto() {
    await this.page.goto('/todos');
  }

  // Akcja wysokiego poziomu: dodanie nowego zadania przez interfejs
  async addTask(taskTitle: string) {
    await this.newTodoInput.fill(taskTitle);
    await this.addButton.click();
  }

  // Akcja wysokiego poziomu: oznaczenie zadania o podanej nazwie jako wykonane
  async toggleTask(taskTitle: string) {
    const item = this.todoItems.filter({ hasText: taskTitle });
    await item.getByRole('checkbox').check();
  }
}
Krok 2: Implementacja czytelnego testu E2E korzystającego z POM (e2e/todo.spec.ts)
import { test, expect } from '@playwright/test';
import { TodoPage } from './pages/TodoPage';

test.describe('Moduł listy zadań (E2E z Page Object Model)', () => {
  test('pozwala użytkownikowi dodać nowe zadania i oznaczyć je jako ukończone', async ({ page }) => {
    // 1. Inicjalizacja obiektu Page Object Model
    const todoPage = new TodoPage(page);

    // 2. Otwarcie strony aplikacji
    await todoPage.goto();

    // 3. Wykonanie akcji użytkownika za pomocą czytelnych metod POM
    await todoPage.addTask('Przygotować sprawozdanie z laboratorium Vue 3');
    await todoPage.addTask('Skonfigurować testy Playwright z POM');

    // 4. Asercje (Playwright automatycznie poczeka na odświeżenie DOM przez Vue!)
    await expect(todoPage.todoItems).toHaveCount(2);
    await expect(todoPage.todoItems.first()).toContainText('Przygotować sprawozdanie');

    // 5. Oznaczenie pierwszego zadania jako ukończone i sprawdzenie licznika
    await todoPage.toggleTask('Przygotować sprawozdanie z laboratorium Vue 3');
    await expect(todoPage.completedCounter).toHaveText('Ukończono: 1 z 2');
  });
});
Uruchamianie testów Playwright z wiersza poleceń:
# Jednorazowe pobranie silników przeglądarek (Chromium, Firefox, WebKit):
npx playwright install

# Uruchomienie wszystkich testów E2E w trybie bezokienkowym (Headless):
npm run test:e2e

# Uruchomienie interaktywnego interfejsu graficznego Playwright UI Mode (zalecane podczas pisania testów!):
npx playwright test --ui