PHP / Magento Dev Blog

  • Publikacje
  • O autorze
  • Kontakt

Hyvä Theme – architektura, Alpine.js, Tailwind, szablony PHTML, eventy

by Henryk Tews / poniedziałek, 22 stycznia 2024 / Opublikowano w Magento 2

Luma – domyślny motyw Magento 2 – ma poważny problem z wydajnością. Dziesiątki plików JS, jQuery, RequireJS, knockout.js i plik CSS liczący setki KB. Hyvä (wymawiane „hiuva”, po fińsku „dobry”) to alternatywny motyw który wyrzuca ten stack i zastępuje go Alpine.js i Tailwind CSS. Wyniki PageSpeed skaczą z 30-40 do 90+. Pokazuję architekturę i jak zacząć.

Co Hyvä zmienia w stosunku do Luma

Aspekt Luma Hyvä
JS Framework RequireJS + jQuery + knockout.js Alpine.js (13KB gzip)
CSS Framework LESS (kompilowany) Tailwind CSS (purge CSS = mały bundle)
Szablony PHTML + .html knockout PHTML + Alpine.js directives inline
PageSpeed (typowy) 30-50 85-95
Core Web Vitals LCP Często powyżej 4s Często poniżej 2.5s
Checkout Wbudowany (knockout.js) Hyvä Checkout (osobny moduł, płatny)
Licencja Open Source Płatna (jednorazowo ~1000 EUR)

Instalacja

# Hyvä wymaga dostępu do prywatnego repo po zakupie licencji
# Dodaj repozytorium do composer.json
composer config repositories.hyva-themes composer https://hyva-themes.repo.packagist.com/YOUR_TOKEN/

# Instalacja
composer require hyva-themes/magento2-default-theme
composer require hyva-themes/magento2-theme-module

# Utwórz własny child theme
mkdir -p app/design/frontend/Vendor/MyTheme
<!-- app/design/frontend/Vendor/MyTheme/theme.xml -->
<?xml version="1.0"?>
<theme xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xsi:noNamespaceSchemaLocation="urn:magento:framework:Config/etc/theme.xsd">
    <title>Vendor MyTheme</title>
    <parent>Hyva/default</parent>
</theme>
// app/design/frontend/Vendor/MyTheme/web/tailwind/tailwind.config.js
module.exports = {
    content: [
        // Skanuj PHTML i HTML pod kątem klas Tailwind
        '../**/*.phtml',
        '../../../../../../vendor/hyva-themes/magento2-default-theme/**/*.phtml',
        './src/**/*.js',
    ],
    theme: {
        extend: {
            colors: {
                primary: {
                    DEFAULT: '#1a56db',
                    lighter: '#3f83f8',
                    darker: '#1e3a5f',
                },
                secondary: {
                    DEFAULT: '#ff6b35',
                },
            },
            fontFamily: {
                sans: ['Inter', 'sans-serif'],
            },
        },
    },
    plugins: [],
}

Szablony PHTML z Alpine.js

W Hyvä każdy komponent interaktywny to Alpine.js zamiast knockout.js. Różnica jest ogromna w czytelności:

<?php
// app/design/frontend/Vendor/MyTheme/Magento_Catalog/templates/product/view/addtocart.phtml

/** @var \Magento\Catalog\Block\Product\View $block */
/** @var \Magento\Framework\Escaper $escaper */

$product     = $block->getProduct();
$productId   = $product->getId();
$productName = $escaper->escapeHtml($product->getName());
$maxQty      = (int) ($product->getExtensionAttributes()->getStockItem()->getMaxSaleQty() ?? 999);
?>

<div
    x-data="initAddToCart()"
    x-init="initProduct()"
    class="product-add-to-cart"
>
    <!-- Selector ilości z Alpine.js - bez knockout, bez RequireJS -->
    <div class="flex items-center gap-4 mb-4">
        <button
            @click="qty = Math.max(1, qty - 1)"
            :disabled="qty <= 1"
            class="w-10 h-10 border border-gray-300 rounded flex items-center justify-center
                   hover:bg-gray-100 disabled:opacity-50"
        >
            -
        </button>

        <input
            type="number"
            x-model.number="qty"
            min="1"
            :max="maxQty"
            class="w-16 text-center border border-gray-300 rounded h-10"
        />

        <button
            @click="qty = Math.min(maxQty, qty + 1)"
            :disabled="qty >= maxQty"
            class="w-10 h-10 border border-gray-300 rounded flex items-center justify-center
                   hover:bg-gray-100 disabled:opacity-50"
        >
            +
        </button>
    </div>

    <!-- Przycisk "Dodaj do koszyka" -->
    <button
        @click="addToCart()"
        :disabled="isLoading"
        class="w-full bg-primary hover:bg-primary-darker text-white font-semibold
               py-3 px-6 rounded transition-colors duration-200
               disabled:opacity-50 disabled:cursor-not-allowed"
    >
        <span x-show="!isLoading">
            <?= $escaper->escapeHtml(__('Add to Cart')) ?>
        </span>
        <span x-show="isLoading" class="flex items-center justify-center gap-2">
            <svg class="animate-spin w-5 h-5" fill="none" viewBox="0 0 24 24">
                <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
                <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.4 0 0 5.4 0 12h4z"></path>
            </svg>
            <?= $escaper->escapeHtml(__('Adding...')) ?>
        </span>
    </button>

    <!-- Komunikaty sukcesu/błędu -->
    <div
        x-show="message"
        x-transition
        :class="messageType === 'success' ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'"
        class="mt-3 p-3 rounded text-sm"
        x-text="message"
    ></div>
</div>

<script>
function initAddToCart() {
    return {
        qty: 1,
        maxQty: <?= (int) $maxQty ?>,
        productId: <?= (int) $productId ?>,
        isLoading: false,
        message: '',
        messageType: 'success',

        initProduct() {
            // Nasłuchuj na zmiany opcji produktu (warianty)
            window.addEventListener('configurable-selection-changed', (e) => {
                if (e.detail.productId !== this.productId) return;
                this.maxQty = e.detail.maxQty || 999;
            });
        },

        async addToCart() {
            this.isLoading = true;
            this.message   = '';

            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(),
                    }),
                });

                const data = await response.json();

                if (data.success) {
                    this.message     = '<?= $escaper->escapeHtml(__("Product added to cart")) ?>';
                    this.messageType = 'success';

                    // Aktualizuj licznik koszyka przez event
                    window.dispatchEvent(new CustomEvent('reload-customer-section-data'));
                } else {
                    this.message     = data.error || '<?= $escaper->escapeHtml(__("Could not add product")) ?>';
                    this.messageType = 'error';
                }
            } catch (error) {
                this.message     = '<?= $escaper->escapeHtml(__("An error occurred")) ?>';
                this.messageType = 'error';
            } finally {
                this.isLoading = false;
            }
        }
    };
}
</script>

Eventy w Hyvä – zamiast pubsub knockout

Hyvä używa natywnych DOM events zamiast knockout pubsub. To prostsze i bardziej przewidywalne:

// Wysyłanie eventu (np. po dodaniu do koszyka)
window.dispatchEvent(new CustomEvent('reload-customer-section-data'));
window.dispatchEvent(new CustomEvent('cart-updated', {
    detail: { itemCount: 3, total: '99.99' }
}));

// Nasłuchiwanie (np. licznik koszyka w headerze)
// x-data w template PHTML
{
    cartCount: 0,
    init() {
        window.addEventListener('cart-updated', (event) => {
            this.cartCount = event.detail.itemCount;
        });
        // Załaduj dane koszyka przy inicjalizacji
        window.addEventListener('private-content-loaded', (event) => {
            const cart = event.detail.data.cart;
            this.cartCount = cart?.summary_count || 0;
        });
    }
}

Moduły zewnętrzne a kompatybilność z Hyvä

Główne wyzwanie przy wdrożeniu Hyvä to kompatybilność modułów zewnętrznych – każdy który ma własne szablony PHTML z knockout.js wymaga przepisania lub użycia trybu kompatybilności:

# Sprawdź które zainstalowane moduły mają szablony kompatybilne z Hyvä
# Społeczność utrzymuje listę: https://www.hyva-themes.com/magento-2-hyvae-compatibility

# Moduł Hyvä Compatibility - fallback dla modułów bez natywnego wsparcia
# (używa iframe dla komponentów knockout - działa ale wolniej niż natywne Hyvä)
composer require hyva-themes/magento2-compat-module-fallback

Podsumowanie

Hyvä rozwiązuje realny problem wydajności frontendu Magento 2. Zamiana RequireJS + knockout.js na Alpine.js + Tailwind to nie tylko lepsza wydajność – to też prostszy, bardziej czytelny kod szablonów. Główna bariera to koszt licencji i konieczność przepisania lub sprawdzenia kompatybilności każdego modułu zewnętrznego. Dla nowych projektów Magento 2.4.x warto rozważyć Hyvä jako domyślny wybór frontendu zamiast Lumy.

About Henryk Tews

Co możesz przeczytać następne

Magento 2.4.8 – PHP 8.4 wsparcie, migracja Elasticsearch → OpenSearch, checklist upgrade
AI-assisted optymalizacja SQL – LLM + EXPLAIN + Blackfire, 5x przyspieszenie
Redis Streams – Consumer Groups, pending messages, dead letter, integracja z Magento queue
  • 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}