Hyvä en Alpine.js: interactieve componenten zonder framework-overhead
Terug naar blog

Hyvä en Alpine.js: interactieve componenten zonder framework-overhead

AuthorRuthger Idema
15 juni 20268 min leestijd

Een standaard Luma-checkout laadt voor honderden kilobytes aan JavaScript voordat een bezoeker iets ziet. Alpine.js, de motor achter de interactie in Hyvä, weegt ongeveer 15 KB gzipped. Dat verschil is niet cosmetisch.

Hyvä en Alpine.js: interactieve componenten zonder framework-overhead

Een standaard Luma-checkout laadt voor honderden kilobytes aan JavaScript voordat een bezoeker iets ziet. Alpine.js, de motor achter de interactie in Hyvä, weegt ongeveer 15 KB gzipped. Dat verschil is niet cosmetisch. Het bepaalt of je Largest Contentful Paint onder de 2,5 seconde blijft of er ruim overheen schiet.

In dit artikel duiken we de code in. Hoe werkt Alpine.js binnen Hyvä? Wat zijn de x-data patterns? Hoe bouw je een eigen component zoals een quantity-selector of mini-cart? En waarom is dit fundamenteel lichter dan Knockout of React?

Wat Alpine.js is, en wat het niet is

Alpine.js is geen framework. Het is een verzameling van 15 directives die je direct in je HTML schrijft. Geen build-step, geen virtual DOM, geen component-tree. Je markup blijft je markup.

Een component is gewoon een stuk HTML met een x-data attribuut:

html
<div x-data="{ open: false }">
  <button @click="open = !open">Toon details</button>
  <div x-show="open">Verborgen content</div>
</div>

Geen import. Geen registratie. Geen JSX-compilatie. De browser leest dit, Alpine pakt het op zodra de DOM klaar is, en de interactie werkt.

Dat is het mentale model. Alpine vult de gaten in server-gerenderde HTML met gedrag. Het probeert niet je hele UI te bezitten zoals React doet.

Het verschil met Knockout en React

Magento Luma draait op Knockout.js plus RequireJS. Die combinatie is het zwaarste deel van de Luma-frontend. RequireJS laadt asynchroon tientallen modules, Knockout bouwt observables op, en de virtuele DOM van Knockout-templates moet worden geparsed. We zien bij klanten dat alleen al de JS-payload van een Luma-pagina vaak 800 KB tot 1,2 MB bedraagt.

Hyvä gooit dat hele blok weg. Geen RequireJS, geen Knockout, geen jQuery. De volledige JavaScript-bundel van een Hyvä-pagina blijft doorgaans onder de 50 KB.

Luma (Knockout)Hyvä (Alpine)
JS-bibliothekenKnockout + RequireJS + jQueryAlpine.js
JS-payload (typisch)800 KB - 1,2 MB30 - 50 KB
Build-step nodigJa (Grunt/Webpack)Nee voor Alpine zelf
DOM-strategieVirtual DOM templatesDirecte DOM
LeercurveSteilVlak

React is in theorie sneller dan Knockout, maar lost het verkeerde probleem op. Een productpagina is geen single-page application. Je hebt geen hydratie van een complete component-tree nodig om een quantity-knop te laten werken. Alpine voegt precies genoeg toe, op precies de plek waar je het nodig hebt. Voor de meeste e-commerce-interactie is dat de juiste keuze.

Wanneer is Alpine NIET genoeg? Bij echt complexe state die over tientallen componenten gedeeld wordt, of een configurator met diepe afhankelijkheden, loop je tegen grenzen aan. Dan is een gerichte React-island een betere keuze. Wees daar eerlijk over.

We zetten de performance-cijfers naast elkaar in onze vergelijking Hyvä vs Luma.

De kern: x-data en reactive state

x-data is het hart van elk Alpine-component. Het definieert een scope met state en methodes. Alles binnen dat element heeft toegang tot die data.
html
<div x-data="{
  count: 1,
  increment() { this.count++ },
  decrement() { if (this.count > 1) this.count-- }
}">
  <button @click="decrement()">-</button>
  <span x-text="count"></span>
  <button @click="increment()">+</button>
</div>

De state is reactive. Verander count en elke x-text, x-show of x-bind die ervan afhangt update automatisch. Geen setState, geen re-render van een tree. Alpine houdt per expressie bij welke data wordt gebruikt en werkt alleen die plekken bij.

Voor herbruikbare componenten haal je de logica uit de HTML met Alpine.data():

javascript
document.addEventListener('alpine:init', () => {
  Alpine.data('quantitySelector', (initial = 1, max = 99) => ({
    count: initial,
    max: max,
    increment() { if (this.count < this.max) this.count++ },
    decrement() { if (this.count > 1) this.count-- }
  }))
})

In je Hyvä .phtml-template wordt het dan compact:

html
<div x-data="quantitySelector(1, <?= (int) $maxQty ?>)">
  <button @click="decrement()" :disabled="count <= 1">-</button>
  <input type="number" name="qty" x-model="count" min="1" :max="max">
  <button @click="increment()" :disabled="count >= max">+</button>
</div>
x-model koppelt het input-veld tweerichtings aan de state. :disabled is de korte schrijfwijze voor x-bind:disabled en zet de knop uit op de grenzen. Dit is volledig server-gerenderd en server-gevuld; Alpine voegt alleen het gedrag toe.

Hyvä's structuur: waar de magie zit

Hyvä registreert Alpine niet zomaar. Het levert Alpine via een centrale module en stelt het in view/frontend/web/js/alpine/ beschikbaar. Componenten en plugins worden gebundeld via hyva_modules en geladen in een enkel script vlak voor .

Belangrijk detail: Hyvä laadt Alpine met het defer-attribuut. De HTML rendert eerst volledig, daarna initialiseert Alpine. Dat is precies waarom de waargenomen laadtijd zo laag ligt. Niets blokkeert de eerste render.

Hyvä gebruikt ook x-cloak. Dat verbergt elementen tot Alpine ze heeft geïnitialiseerd, zodat je geen flits van ongestylede state ziet:

html
<div x-data="{ open: false }" x-cloak>
  <!-- pas zichtbaar zodra Alpine klaar is -->
</div>

De bijbehorende CSS-regel [x-cloak] { display: none !important; } staat standaard in de Hyvä-theme. Wil je dieper in de installatie en theme-structuur, lees dan Hyvä installeren op een bestaande Magento-shop.

Stores: gedeelde state tussen componenten

Lokale x-data is prima voor één component. Maar een mini-cart moet weten wanneer de quantity-selector een product toevoegt. Die twee leven in verschillende delen van de DOM. Daarvoor gebruikt Alpine een store: globale, reactive state die elk component kan lezen en schrijven.

Hyvä gebruikt stores intensief, onder meer voor de cart. Een vereenvoudigd voorbeeld:

javascript
document.addEventListener('alpine:init', () => {
  Alpine.store('cart', {
    items: [],
    count: 0,
    addItem(product, qty) {
      this.items.push({ ...product, qty })
      this.count += qty
    }
  })
})

Elk component benadert de store via $store:

html
<button x-data
        @click="$store.cart.addItem({ id: 42, name: 'Sneaker' }, 1)">
  In winkelwagen
</button>

<span x-data x-text="$store.cart.count"></span>

Wijzig de store en de teller in de header update direct. Geen event-bus die je handmatig moet bedraden, geen pub/sub-boilerplate. De reactiviteit doet het werk.

Een mini-cart bouwen met events

In de praktijk praat de Hyvä-cart met de Magento-backend via de standaard cart-endpoints en sectiedata. Het patroon is event-gedreven. Alpine luistert met @-listeners naar custom events op window.

html
<div x-data="miniCart()"
     @private-content-loaded.window="updateFromSections($event.detail.data)">

  <button @click="open = !open">
    Winkelwagen (<span x-text="itemCount"></span>)
  </button>

  <div x-show="open" x-transition x-cloak>
    <template x-for="item in items" :key="item.item_id">
      <div class="cart-line">
        <span x-text="item.product_name"></span>
        <span x-text="item.qty"></span>
      </div>
    </template>
    <p x-show="items.length === 0">Je winkelwagen is leeg.</p>
  </div>
</div>
javascript
Alpine.data('miniCart', () => ({
  open: false,
  items: [],
  itemCount: 0,
  updateFromSections(data) {
    if (!data.cart) return
    this.items = data.cart.items || []
    this.itemCount = data.cart.summary_count || 0
  }
}))

Wat hier gebeurt:

  • x-transition geeft een vloeiende open/dicht-animatie, declaratief, zonder een regel JavaScript.
  • x-for met