PHP / Magento Dev Blog

  • Publikacje
  • O autorze
  • Kontakt

Hyvä zaawansowane wzorce – Alpine.js Store, eventy, lazy loading, reużywalne komponenty

by Henryk Tews / wtorek, 17 lutego 2026 / Opublikowano w Magento 2

Podstawy Hyvä – szablony PHTML z Alpine.js, Tailwind, eventy przez CustomEvent – opisałem w 2024. Po kilku latach wdrożeń mam zebrany zestaw wzorców które powtarzają się w zaawansowanych projektach: kompozycja wielu komponentów Alpine.js, komunikacja między izolowanymi widgetami, lazy loading danych przez Magento REST API i wzorzec store’u dla globalnego stanu koszyka. Pokazuję te wzorce z kodem.

Alpine.js Store – globalny stan bez Vuex

Gdy kilka komponentów na stronie musi dzielić ten sam stan (koszyk, ulubione, porównanie produktów), Alpine.js ma wbudowany mechanizm store:

// Inicjalizacja store w layout - jeden raz dla całej strony
// Magento PHTML layout: Magento_Theme/templates/root.phtml lub dedykowany blok

<script>
document.addEventListener('alpine:init', () => {
    // Globalny store koszyka
    Alpine.store('cart', {
        itemCount: 0,
        items: [],
        subtotal: 0,
        isOpen: false,
        isLoading: false,

        async init() {
            // Załaduj dane koszyka z Magento customer sections
            await this.reload();

            // Nasłuchuj na zmiany koszyka
            window.addEventListener('cart-updated', (e) => {
                this.itemCount = e.detail.summary_count ?? 0;
                this.subtotal  = e.detail.subtotal ?? 0;
            });
        },

        async reload() {
            this.isLoading = true;
            try {
                const response = await fetch('/customer/section/load?sections=cart', {
                    headers: { 'X-Requested-With': 'XMLHttpRequest' }
                });
                const data = await response.json();
                this.itemCount = data.cart?.summary_count ?? 0;
                this.items     = data.cart?.items ?? [];
                this.subtotal  = data.cart?.subtotal ?? 0;
            } finally {
                this.isLoading = false;
            }
        },

        open()  { this.isOpen = true; },
        close() { this.isOpen = false; },
    });

    // Store ulubionych produktów
    Alpine.store('wishlist', {
        productIds: JSON.parse(localStorage.getItem('wishlist') ?? '[]'),

        toggle(productId) {
            const idx = this.productIds.indexOf(productId);
            if (idx === -1) {
                this.productIds.push(productId);
            } else {
                this.productIds.splice(idx, 1);
            }
            localStorage.setItem('wishlist', JSON.stringify(this.productIds));
            window.dispatchEvent(new CustomEvent('wishlist-changed', {
                detail: { count: this.productIds.length }
            }));
        },

        has(productId) {
            return this.productIds.includes(productId);
        },

        get count() {
            return this.productIds.length;
        }
    });
});
</script>
<?php
// Użycie store w PHTML szablonach
?>

<!-- Header z ikonką koszyka - korzysta z globalnego store -->
<div x-data>
    <button
        @click="$store.cart.open()"
        class="relative p-2"
    >
        <!-- Ikona koszyka -->
        <svg>...</svg>

        <!-- Licznik - reaktywny na zmiany store -->
        <span
            x-show="$store.cart.itemCount > 0"
            x-text="$store.cart.itemCount"
            class="absolute -top-1 -right-1 bg-primary text-white text-xs rounded-full w-5 h-5 flex items-center justify-center"
        ></span>
    </button>
</div>

<!-- Ikona ulubionych na kafelku produktu -->
<div x-data>
    <button
        @click="$store.wishlist.toggle(getId() ?>)"
        :class="$store.wishlist.has(getId() ?>) ? 'text-red-500' : 'text-gray-400'"
        class="p-1 hover:text-red-500 transition-colors"
    >
        <svg>...</svg> <!-- ikona serca -->
    </button>
</div>

Komunikacja między komponentami przez eventy

<?php
// Wzorzec: event-driven komunikacja między Alpine.js widgetami
// Zamiast bezpośrednich referencji - luźne sprzężenie przez eventy
?>

<!-- Filtr cen - emituje event po zmianie -->
<div x-data="{
    min: 0,
    max: 1000,
    apply() {
        // Emituj event - nie wie kto nasłuchuje
        window.dispatchEvent(new CustomEvent('price-filter-changed', {
            detail: { min: this.min, max: this.max }
        }));
    }
}">
    <input type="range" x-model="min" @change="apply()" min="0" :max="max">
    <input type="range" x-model="max" @change="apply()" :min="min" max="1000">
    <span x-text="`${min} - ${max} PLN`"></span>
</div>

<!-- Lista produktów - nasłuchuje na event bez wiedzy o filtrze -->
<div x-data="{
    products: [],
    isLoading: false,

    init() {
        this.loadProducts();

        // Nasłuchuj na filtry
        window.addEventListener('price-filter-changed', (e) => {
            this.loadProducts(e.detail);
        });

        window.addEventListener('sort-changed', (e) => {
            this.loadProducts({ sort: e.detail.sort });
        });
    },

    async loadProducts(filters = {}) {
        this.isLoading = true;
        const params = new URLSearchParams({
            min_price: filters.min ?? 0,
            max_price: filters.max ?? 9999,
            sort:      filters.sort ?? 'position',
        });

        const response = await fetch(`/rest/V1/products?${params}`, {
            headers: { 'Content-Type': 'application/json' }
        });
        const data = await response.json();
        this.products  = data.items ?? [];
        this.isLoading = false;
    }
}">
    <template x-for="product in products" :key="product.id">
        <div x-text="product.name"></div>
    </template>
</div>

Lazy Loading komponentów – defer pattern

<?php
// Deferuj renderowanie drogich komponentów do momentu wejścia w viewport
// Przydatne dla "Polecane produkty", "Ostatnio oglądane" na dole strony
?>

<div
    x-data="{
        products: [],
        loaded: false,

        // Intersection Observer - ładuj dopiero gdy widoczny
        init() {
            const observer = new IntersectionObserver((entries) => {
                if (entries[0].isIntersecting && !this.loaded) {
                    this.loadRecommendations();
                    observer.disconnect();
                }
            }, { threshold: 0.1 });

            observer.observe(this.$el);
        },

        async loadRecommendations() {
            this.loaded = true;
            const currentSku = 'escapeJs($product->getSku()) ?>';

            const response = await fetch(`/rest/V1/products?searchCriteria[filterGroups][0][filters][0][field]=related_sku&searchCriteria[filterGroups][0][filters][0][value]=${currentSku}&searchCriteria[pageSize]=8`);
            const data = await response.json();
            this.products = data.items ?? [];
        }
    }"
    class="related-products"
>
    <!-- Skeleton loader podczas ładowania -->
    <div x-show="loaded && products.length === 0" class="grid grid-cols-4 gap-4">
        <template x-for="i in 4">
            <div class="animate-pulse bg-gray-200 h-48 rounded"></div>
        </template>
    </div>

    <!-- Produkty -->
    <div x-show="products.length > 0" class="grid grid-cols-4 gap-4">
        <template x-for="product in products" :key="product.id">
            <a :href="product.custom_attributes?.find(a => a.attribute_code === 'url_key')?.value ?? '#'"
               class="product-card">
                <span x-text="product.name"></span>
                <span x-text="`${product.price} PLN`"></span>
            </a>
        </template>
    </div>
</div>

Reużywalne komponenty Alpine.js przez x-data functions

// Zdefiniuj komponent raz, używaj wielokrotnie
// Umieść w pliku js ładowanym w head lub layout

function productCard(config) {
    return {
        qty: config.minQty ?? 1,
        maxQty: config.maxQty ?? 999,
        productId: config.productId,
        isAddingToCart: false,
        addedFeedback: false,

        increment() { this.qty = Math.min(this.qty + 1, this.maxQty); },
        decrement() { this.qty = Math.max(this.qty - 1, config.minQty ?? 1); },

        async addToCart() {
            this.isAddingToCart = true;

            try {
                const response = await fetch('/checkout/cart/add', {
                    method: 'POST',
                    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
                    body: new URLSearchParams({
                        product:  this.productId,
                        qty:      this.qty,
                        form_key: hyva.getFormKey(),
                    }),
                });

                if (response.ok) {
                    this.addedFeedback = true;
                    setTimeout(() => { this.addedFeedback = false; }, 2000);
                    window.dispatchEvent(new CustomEvent('reload-customer-section-data'));
                }
            } finally {
                this.isAddingToCart = false;
            }
        }
    };
}
<?php
// Użycie reużywalnego komponentu w PHTML
// Ten sam komponent na liście kategorii i stronie produktu
?>

<div x-data="productCard({
    productId: getId() ?>,
    minQty: getExtensionAttributes()->getStockItem()?->getMinSaleQty() ?? 1) ?>,
    maxQty: getExtensionAttributes()->getStockItem()?->getMaxSaleQty() ?? 999) ?>
})">
    <div class="flex items-center gap-2">
        <button @click="decrement()" :disabled="qty <= 1">-</button>
        <span x-text="qty"></span>
        <button @click="increment()" :disabled="qty >= maxQty">+</button>
    </div>

    <button
        @click="addToCart()"
        :disabled="isAddingToCart"
        :class="addedFeedback ? 'bg-green-500' : 'bg-primary'"
        class="w-full py-2 text-white rounded transition-colors"
    >
        <span x-show="!isAddingToCart && !addedFeedback">Dodaj do koszyka</span>
        <span x-show="isAddingToCart">Dodaję...</span>
        <span x-show="addedFeedback">Dodano! ✓</span>
    </button>
</div>

Podsumowanie

Alpine.js Store eliminuje potrzebę przekazywania danych między komponentami przez atrybuty – globalny stan koszyka jest dostępny wszędzie z $store.cart. Eventy przez CustomEvent dają luźne sprzężenie między filtrami a listą produktów – każdy komponent robi swoje bez wiedzy o innych. Reużywalne funkcje x-data pozwalają pisać komponent raz i używać na liście kategorii, w sliderze i na stronie produktu. Razem te wzorce dają architekturę która rośnie bez bałaganu.

About Henryk Tews

Co możesz przeczytać następne

Wzorce GoF w Magento 2 – gdzie je znaleźć i jak działają
B2B – Company, Shared Catalog, Negotiable Quote, Requisition List, własne pluginy
Własny carrier wysyłkowy – collectRates, śledzenie paczek, generowanie etykiet
  • Publikacje
  • O autorze
  • Kontakt

© 2026 Created by

GÓRA
Zarządzaj zgodą
Aby zapewnić jak najlepsze wrażenia, korzystamy z technologii, takich jak pliki cookie, do przechowywania i/lub uzyskiwania dostępu do informacji o urządzeniu. Zgoda na te technologie pozwoli nam przetwarzać dane, takie jak zachowanie podczas przeglądania lub unikalne identyfikatory na tej stronie. Brak wyrażenia zgody lub wycofanie zgody może niekorzystnie wpłynąć na niektóre cechy i funkcje.
Funkcjonalne Zawsze aktywne
Przechowywanie lub dostęp do danych technicznych jest ściśle konieczny do uzasadnionego celu umożliwienia korzystania z konkretnej usługi wyraźnie żądanej przez subskrybenta lub użytkownika, lub wyłącznie w celu przeprowadzenia transmisji komunikatu przez sieć łączności elektronicznej.
Preferencje
Przechowywanie lub dostęp techniczny jest niezbędny do uzasadnionego celu przechowywania preferencji, o które nie prosi subskrybent lub użytkownik.
Statystyka
Przechowywanie techniczne lub dostęp, który jest używany wyłącznie do celów statystycznych. Przechowywanie techniczne lub dostęp, który jest używany wyłącznie do anonimowych celów statystycznych. Bez wezwania do sądu, dobrowolnego podporządkowania się dostawcy usług internetowych lub dodatkowych zapisów od strony trzeciej, informacje przechowywane lub pobierane wyłącznie w tym celu zwykle nie mogą być wykorzystywane do identyfikacji użytkownika.
Marketing
Przechowywanie lub dostęp techniczny jest wymagany do tworzenia profili użytkowników w celu wysyłania reklam lub śledzenia użytkownika na stronie internetowej lub na kilku stronach internetowych w podobnych celach marketingowych.
  • Zarządzaj opcjami
  • Zarządzaj serwisami
  • Zarządzaj {vendor_count} dostawcami
  • Przeczytaj więcej o tych celach
Zobacz preferencje
  • {title}
  • {title}
  • {title}