Astro กับ Alpine.js: Lightweight Interactivity

#astro13 เม.ย. 2569

Astro กับ Alpine.js: Lightweight Interactivity

Alpine.js เป็น minimal JavaScript framework ที่เพิ่ม interactivity ให้กับ HTML โดยตรง ด้วยขนาดเพียง ~15KB และ syntax ที่คล้าย Vue.js ทำให้ Alpine.js เป็นตัวเลือกที่ยอดเยี่ยมสำหรับ Astro เมื่อต้องการ interactivity เล็กน้อยโดยไม่ต้องการ full framework

ทำไมต้องใช้ Alpine.js กับ Astro?

  • Tiny Size: ~15KB minified+gzipped
  • No Build Step: ใช้งานได้โดยตรงใน HTML
  • Familiar Syntax: คล้าย Vue.js
  • Progressive Enhancement: เพิ่ม interactivity ทีละน้อย
  • No Virtual DOM: อัปเดต DOM โดยตรง

การติดตั้ง

วิธีที่ 1: ผ่าน CDN (ง่ายที่สุด)

---
// src/layouts/BaseLayout.astro
---

<html>
  <head>
    <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js" is:inline></script>
  </head>
  <body>
    <slot />
  </body>
</html>

วิธีที่ 2: ผ่าน npm

npm install alpinejs
<script>
  import Alpine from 'alpinejs';
  window.Alpine = Alpine;
  Alpine.start();
</script>

Alpine.js Directives พื้นฐาน

---
// src/pages/demo.astro
---

<html lang="th">
  <body>
    <!-- x-data: กำหนด component state -->
    <div x-data="{ count: 0, name: 'นักพัฒนา' }">
      <p>สวัสดี, <span x-text="name"></span>!</p>
      <p>Count: <span x-text="count"></span></p>
      
      <!-- x-on: event handlers -->
      <button x-on:click="count++">เพิ่ม</button>
      <button @click="count--">ลด</button>
      <button @click="count = 0">Reset</button>
      
      <!-- x-show: แสดง/ซ่อน element -->
      <p x-show="count > 5" x-transition>Count มากกว่า 5!</p>
      
      <!-- x-if: render/remove element -->
      <template x-if="count === 0">
        <p>Count เป็น 0</p>
      </template>
      
      <!-- x-bind: bind attributes -->
      <button
        :disabled="count >= 10"
        :class="{ 'opacity-50': count >= 10 }"
        @click="count++"
      >
        เพิ่ม (max 10)
      </button>
    </div>
  </body>
</html>

Dropdown Menu

<div x-data="{ open: false }" class="relative">
  <button
    @click="open = !open"
    @keydown.escape="open = false"
    :aria-expanded="open"
    class="btn"
  >
    เมนู
    <svg
      :class="{ 'rotate-180': open }"
      class="w-4 h-4 transition-transform"
      fill="none"
      viewBox="0 0 24 24"
      stroke="currentColor"
    >
      <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
    </svg>
  </button>

  <div
    x-show="open"
    @click.outside="open = false"
    x-transition:enter="transition ease-out duration-200"
    x-transition:enter-start="opacity-0 scale-95"
    x-transition:enter-end="opacity-100 scale-100"
    x-transition:leave="transition ease-in duration-150"
    x-transition:leave-start="opacity-100 scale-100"
    x-transition:leave-end="opacity-0 scale-95"
    class="absolute right-0 mt-2 w-48 bg-white rounded-lg shadow-lg"
  >
    <a href="/profile" class="block px-4 py-2 hover:bg-gray-100">โปรไฟล์</a>
    <a href="/settings" class="block px-4 py-2 hover:bg-gray-100">ตั้งค่า</a>
    <button class="block w-full text-left px-4 py-2 hover:bg-gray-100 text-red-600">
      ออกจากระบบ
    </button>
  </div>
</div>

Modal Dialog

<div x-data="{ showModal: false }">
  <button @click="showModal = true" class="btn-primary">
    เปิด Modal
  </button>

  <!-- Modal Backdrop -->
  <div
    x-show="showModal"
    x-transition.opacity
    class="fixed inset-0 bg-black bg-opacity-50 z-40"
    @click="showModal = false"
  ></div>

  <!-- Modal Content -->
  <div
    x-show="showModal"
    x-transition:enter="transition ease-out duration-300"
    x-transition:enter-start="opacity-0 translate-y-4"
    x-transition:enter-end="opacity-100 translate-y-0"
    x-transition:leave="transition ease-in duration-200"
    x-transition:leave-start="opacity-100 translate-y-0"
    x-transition:leave-end="opacity-0 translate-y-4"
    class="fixed inset-0 z-50 flex items-center justify-center p-4"
    @keydown.escape.window="showModal = false"
  >
    <div class="bg-white rounded-xl shadow-xl max-w-md w-full p-6" @click.stop>
      <h2 class="text-xl font-bold mb-4">หัวข้อ Modal</h2>
      <p class="text-gray-600 mb-6">เนื้อหาของ modal</p>
      <div class="flex justify-end gap-3">
        <button @click="showModal = false" class="btn-secondary">ยกเลิก</button>
        <button @click="showModal = false" class="btn-primary">ยืนยัน</button>
      </div>
    </div>
  </div>
</div>

Alpine.js Components (x-data functions)

<script>
  document.addEventListener('alpine:init', () => {
    Alpine.data('searchBox', () => ({
      query: '',
      results: [],
      loading: false,
      open: false,

      async search() {
        if (!this.query.trim()) {
          this.results = [];
          return;
        }
        this.loading = true;
        try {
          const res = await fetch(`/api/search?q=${encodeURIComponent(this.query)}`);
          const data = await res.json();
          this.results = data.hits;
          this.open = true;
        } finally {
          this.loading = false;
        }
      },

      clear() {
        this.query = '';
        this.results = [];
        this.open = false;
      },
    }));

    Alpine.data('toast', () => ({
      messages: [],

      add(message, type = 'info', duration = 3000) {
        const id = Date.now();
        this.messages.push({ id, message, type });
        setTimeout(() => this.remove(id), duration);
      },

      remove(id) {
        this.messages = this.messages.filter((m) => m.id !== id);
      },
    }));
  });
</script>

<!-- ใช้งาน searchBox component -->
<div
  x-data="searchBox"
  class="relative"
>
  <input
    type="search"
    x-model="query"
    @input.debounce.300ms="search"
    @keydown.escape="clear"
    placeholder="ค้นหา..."
  />
  <div x-show="loading">กำลังค้นหา...</div>
  <div x-show="open && results.length > 0" @click.outside="open = false">
    <template x-for="result in results" :key="result.objectID">
      <a :href="`/blog/${result.slug}`" x-text="result.title"></a>
    </template>
  </div>
</div>

Alpine.js Stores

<script>
  document.addEventListener('alpine:init', () => {
    Alpine.store('cart', {
      items: [],

      get total() {
        return this.items.reduce((sum, item) => sum + item.price * item.qty, 0);
      },

      get count() {
        return this.items.reduce((sum, item) => sum + item.qty, 0);
      },

      add(product) {
        const existing = this.items.find((i) => i.id === product.id);
        if (existing) {
          existing.qty++;
        } else {
          this.items.push({ ...product, qty: 1 });
        }
      },

      remove(id) {
        this.items = this.items.filter((i) => i.id !== id);
      },

      clear() {
        this.items = [];
      },
    });
  });
</script>

<!-- ใช้งาน store -->
<div x-data>
  <span x-text="$store.cart.count">0</span> รายการ
  <span x-text="$store.cart.total">0</span> บาท
</div>

สรุป

Alpine.js กับ Astro เป็นคู่ที่ลงตัวสำหรับ websites ที่ต้องการ interactivity เล็กน้อยโดยไม่ต้องการ full JavaScript framework ด้วยขนาดที่เล็ก, syntax ที่เรียบง่าย, และการทำงานโดยตรงใน HTML ทำให้ developer สามารถเพิ่ม interactivity ได้อย่างรวดเร็วและมีประสิทธิภาพ