跳至主要內容
技術

Blade + Livewire:打造互動式前端不需要寫 JavaScript

Blade + Livewire:打造互動式前端不需要寫 JavaScript
PHP/Laravel 完全指南 第 5 / 15 篇

本篇是「PHP/Laravel 完全指南」系列的第 5 / 15 篇。你可以從系列總覽開始閱讀,也可以直接接著看本文。

「用 Laravel 做前端?不是應該接 React 或 Vue 嗎?」——這是很多人的第一反應。確實,Laravel 有完整的 API 支援可以搭配任何前端框架。但 Laravel 社群在過去幾年發展出了另一條路線:用純 PHP 寫前端互動。這條路的核心就是 Blade 模板引擎加上 Livewire。

Blade 是 Laravel 內建的模板引擎,讓你在 HTML 裡面嵌入 PHP 邏輯,類似 JSX 之於 React 或 Jinja 之於 Python。有了 Blade,你可以用 @if@foreach 等指令描述頁面邏輯,同時保持 HTML 的可讀性。

Livewire 3 更進一步:它讓你用 PHP 類別定義前端元件的狀態和行為,使用者的點擊、輸入、篩選等互動動作,全部由 Livewire 透過 AJAX 在背景處理,頁面局部更新,完全不需要你手寫 JavaScript。再搭配 Alpine.js 處理一些純前端的小互動(下拉選單、Modal),你就有了一套完整的全 PHP 前端開發體驗。

這一章我們要幫揪好買打造使用者看得到的介面:團購列表頁(支援即時搜尋和篩選)、團購詳情頁(顯示即時參與人數)、可重用的 UI 元件。你會發現不寫 JavaScript 也能做出流暢的互動體驗——而且程式碼比你想像的少很多。

Blade 模板引擎:Laravel 的 HTML 超能力

第二章我們已經碰過 Blade 的基本語法——{{ }}@if@foreach@extends。這一節我們再補充幾個進階但很常用的功能。

條件渲染

{{-- 顯示/隱藏 --}}
@if($groupBuy->status === 'open')
<span class="badge-green">開團中</span>
@elseif($groupBuy->status === 'confirmed')
<span class="badge-blue">已成團</span>
@else
<span class="badge-gray">已結束</span>
@endif

{{-- 更簡潔的語法 --}}
@unless($groupBuy->isFull())
<button>我要跟團</button>
@endunless

{{-- 有/沒有資料 --}}
@forelse($groupBuys as $groupBuy)
<div>{{ $groupBuy->title }}</div>
@empty
<p>目前沒有開團中的團購</p>
@endforelse

認證相關

@auth
<p>歡迎回來,{{ auth()->user()->name }}!</p>
@endauth @guest
<a href="/login">登入</a>
<a href="/register">註冊</a>
@endguest

引入子 View

{{-- 引入另一個 Blade 檔案 --}} @include('partials.navbar') {{-- 引入時傳資料 --}}
@include('partials.group-buy-card', ['groupBuy' => $groupBuy])

Layout 與 Component:可重用的 UI 積木

第二章用的是 @extends + @yield 的傳統 Layout 方式。現代 Laravel 更推薦用 Blade Component——語法更像 HTML,組合性更強。

Component Layout

建立 resources/views/components/layouts/app.blade.php

<!DOCTYPE html>
<html lang="zh-TW">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>{{ $title ?? '揪好買' }} | 揪好買 JiuHaoMai</title>
    @vite(['resources/css/app.css', 'resources/js/app.js'])
  </head>
  <body class="bg-gray-50 min-h-screen">
    <nav class="bg-indigo-900 text-white px-6 py-4">
      <div class="max-w-5xl mx-auto flex justify-between items-center">
        <a href="/" class="text-xl font-bold text-emerald-400">🛒 揪好買</a>
        <div class="space-x-4">
          <a href="/group-buys" class="hover:text-emerald-300">所有團購</a>
          @auth
          <a href="/my-groups" class="hover:text-emerald-300">我的團購</a>
          @endauth
        </div>
      </div>
    </nav>

    <main class="max-w-5xl mx-auto px-6 py-8">{{ $slot }}</main>

    <footer class="text-center py-6 text-gray-400 text-sm">&copy; 2026 揪好買 JiuHaoMai</footer>
  </body>
</html>

使用方式——像 HTML 標籤一樣包裹內容:

<x-layouts.app title="團購列表">
  <h1>所有團購</h1>
  <p>這裡的內容會填入 Layout 的 {{ $slot }}</p>
</x-layouts.app>

{{ $slot }} 是預設的內容插槽。所有 <x-layouts.app> 標籤之間的東西都會自動填入 $slot$title 則是傳給 Component 的屬性。

自訂 Blade Component

把重複出現的 UI 抽成 Component:

php artisan make:component GroupBuyCard

這會建立兩個檔案:

  1. app/View/Components/GroupBuyCard.php——PHP 類別(邏輯)
  2. resources/views/components/group-buy-card.blade.php——Blade 模板(UI)
// app/View/Components/GroupBuyCard.php
<?php

namespace App\View\Components;

use App\Models\GroupBuy;
use Illuminate\View\Component;

class GroupBuyCard extends Component
{
    public function __construct(
        public GroupBuy $groupBuy,
    ) {}

    public function participantCount(): int
    {
        return $this->groupBuy->participants()->count();
    }

    public function render()
    {
        return view('components.group-buy-card');
    }
}
<!-- resources/views/components/group-buy-card.blade.php -->
<div class="bg-white rounded-xl shadow-sm border p-5 hover:shadow-md transition">
  <div class="flex justify-between items-start">
    <h3 class="font-bold text-lg">{{ $groupBuy->title }}</h3>
    <span
      class="text-sm px-2 py-1 rounded
            {{ $groupBuy->status === 'open' ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500' }}"
    >
      {{ $groupBuy->status === 'open' ? '開團中' : '已結束' }}
    </span>
  </div>

  <p class="text-gray-500 mt-2 text-sm">{{ Str::limit($groupBuy->description, 60) }}</p>

  <div class="mt-4 flex justify-between items-center text-sm">
    <span>💰 ${{ number_format($groupBuy->price_per_unit / 100) }} / 份</span>
    <span>👥 {{ $participantCount() }} / {{ $groupBuy->min_participants }} 人</span>
    <span>⏰ {{ $groupBuy->deadline->diffForHumans() }}</span>
  </div>

  <a
    href="/group-buys/{{ $groupBuy->id }}"
    class="block mt-4 text-center bg-indigo-600 text-white py-2 rounded-lg hover:bg-indigo-700"
  >
    查看詳情
  </a>
</div>

使用——就像 HTML 標籤:

@foreach($groupBuys as $groupBuy)
<x-group-buy-card :group-buy="$groupBuy" />
@endforeach

:group-buy="$groupBuy" 的冒號前綴表示「這是一個 PHP 表達式」,不加冒號就是純字串。

匿名 Component(不需要 PHP class)

如果 Component 只有模板沒有邏輯,可以只建 Blade 檔案:

<!-- resources/views/components/badge.blade.php -->
@props(['color' => 'gray', 'label'])

<span class="px-2 py-1 rounded text-sm bg-{{ $color }}-100 text-{{ $color }}-700">
  {{ $label }}
</span>
<x-badge color="green" label="開團中" /> <x-badge color="red" label="已截止" />

@props 宣告這個 Component 接受哪些屬性,以及預設值。

Livewire 3:用 PHP 寫前端互動

到目前為止,我們的頁面還是傳統的 server-side rendering——使用者每次操作都要整頁重新載入。Livewire 改變了這件事:它讓你用 PHP 寫有狀態的元件,使用者互動時只重新渲染有變化的部分。

安裝 Livewire

composer require livewire/livewire

就這樣。Livewire 會自動注入需要的 JavaScript(透過 @livewireStyles@livewireScripts,但如果你用 @vite 它會自動處理)。

Livewire 的運作原理

使用者輸入/點擊

Livewire JS 攔截事件

送 AJAX 到 server,帶上 component 狀態

Server 端 PHP 處理邏輯、更新 state

Server 回傳 HTML diff

Livewire JS 局部更新 DOM

對使用者來說,體驗就像 SPA;對開發者來說,你只寫 PHP。

React/Vue 開發者的理解方式: Livewire component ≈ React component,public 屬性 ≈ state,PHP 方法 ≈ event handler。差別是 state 存在 server,不在 client。

建立第一個 Livewire Component

php artisan make:livewire Counter

產生兩個檔案:

// app/Livewire/Counter.php
<?php

namespace App\Livewire;

use Livewire\Component;

class Counter extends Component
{
    public int $count = 0;  // 狀態(state)

    public function increment(): void
    {
        $this->count++;
    }

    public function decrement(): void
    {
        $this->count--;
    }

    public function render()
    {
        return view('livewire.counter');
    }
}
<!-- resources/views/livewire/counter.blade.php -->
<div>
  <h2 class="text-2xl font-bold">{{ $count }}</h2>
  <div class="space-x-2 mt-2">
    <button wire:click="decrement" class="px-4 py-2 bg-red-500 text-white rounded">-</button>
    <button wire:click="increment" class="px-4 py-2 bg-green-500 text-white rounded">+</button>
  </div>
</div>

在任何 Blade 頁面裡嵌入:

<livewire:counter />

點按鈕,數字會即時更新——沒寫任何 JavaScript。

wire:model、wire:click——最常用的指令

Livewire 的指令(directive)讓你把使用者互動綁定到 PHP 方法和屬性。

wire:click

把點擊事件綁定到 PHP 方法:

<button wire:click="addToCart({{ $product->id }})">加入購物車</button>
public function addToCart(int $productId): void
{
    // PHP 邏輯:加入購物車
}

wire:model

雙向資料綁定——輸入框的值同步到 PHP 屬性:

<!-- 即時同步(每次按鍵都觸發) -->
<input wire:model.live="search" type="text" placeholder="搜尋團購..." />

<!-- 離開輸入框才同步(預設行為) -->
<input wire:model.blur="email" type="email" />

<!-- 表單送出才同步 -->
<input wire:model="name" type="text" />
public string $search = '';

// 每次 $search 改變,render() 就會重新執行
// 搭配下面的 render,就能實現即時搜尋
public function render()
{
    return view('livewire.group-buy-list', [
        'groupBuys' => GroupBuy::open()
            ->when($this->search, fn($q) => $q->where('title', 'like', "%{$this->search}%"))
            ->latest()
            ->paginate(12),
    ]);
}

wire:model.live vs wire:model .live 修飾符讓每次按鍵都觸發同步(適合即時搜尋),沒有修飾符的要等表單 submit。

先講一個我希望早點有人提醒我的代價: Livewire 的即時搜尋很爽,但它跟 React/Vue 那種前端即時過濾完全不是同一回事。client-side 框架打字時是在瀏覽器記憶體裡 filter 陣列,零後端負擔;Livewire 是每按一個鍵 → 發一個 AJAX request → server 跑一次帶 like "%keyword%" 的查詢。所以 .debounce.300ms 我不會叫它「優化」,它是必要的防線——沒加的話,使用者打「衛生紙」三個字你的 server 就吃了好幾發查詢,幾十個人同時搜你就知道痛。同理,分頁大小(這裡 paginate(12))、search 欄位有沒有索引,都是成本問題不是有空再說的事。而且要老實講:like "%keyword%" 開頭那個 % 會讓 MySQL 索引直接失效、只能全表掃,資料量小無感,幾十萬筆以上就該認真考慮 Laravel Scout + Meilisearch 之類的全文檢索了。寫起來只是一個 PHP 檔案,但它背後是真的在打 DB,這點別忘。

wire:submit

攔截表單提交:

<form wire:submit="save">
  <input wire:model="title" type="text" />
  <input wire:model="price" type="number" />
  <button type="submit">儲存</button>
</form>
public string $title = '';
public int $price = 0;

public function save(): void
{
    $this->validate([
        'title' => 'required|min:3',
        'price' => 'required|integer|min:100',
    ]);

    GroupBuy::create([
        'title' => $this->title,
        'price_per_unit' => $this->price,
        // ...
    ]);

    $this->redirect('/group-buys');
}

wire:loading

顯示載入狀態:

<button wire:click="save">
  <span wire:loading.remove>儲存</span>
  <span wire:loading>處理中...</span>
</button>

<!-- 整個區塊顯示 loading overlay -->
<div wire:loading.class="opacity-50 pointer-events-none">{{-- 內容 --}}</div>

常用指令速查

指令用途React 類比
wire:click="method"點擊觸發 PHP 方法onClick={handler}
wire:model.live="prop"即時雙向綁定value={state} onChange={set}
wire:submit="method"表單提交onSubmit={handler}
wire:loading載入狀態顯示/隱藏loading state + conditional render
wire:confirm="確定?"確認對話框if (confirm(...))
wire:poll.5s每 5 秒自動重新渲染useEffect + setInterval
wire:key="unique"列表項目的 keykey={id}

Volt:單檔案 Livewire Component

Livewire 的標準做法是 PHP class + Blade 模板兩個檔案。Volt 讓你把兩者合在一個 .blade.php 裡——類似 Vue 的 SFC(Single File Component)。

⚠️ Livewire 4 版本注意(2026-01-15 釋出):Livewire 4 已將單檔元件(SFC)內建進核心,不再需要單獨安裝 Volt 套件。php artisan make:livewire 預設即產生單檔格式,命名空間也從 Livewire\Volt\Component 改為 Livewire\Component。若你使用的是 Livewire 3,繼續依下方指令安裝 Volt;若已升級至 Livewire 4,直接用 make:livewire 即可,Volt 套件可移除。

# Livewire 3 才需要:
composer require livewire/volt
php artisan volt:install

建立一個 Volt component:

<!-- resources/views/livewire/participant-counter.blade.php -->
<?php
use App\Models\GroupBuy;
use Livewire\Volt\Component;

new class extends Component {
    public GroupBuy $groupBuy;
    public int $count;

    public function mount(GroupBuy $groupBuy): void
    {
        $this->groupBuy = $groupBuy;
        $this->count = $groupBuy->participants()->count();
    }

    public function refresh(): void
    {
        $this->count = $this->groupBuy->participants()->count();
    }
}; ?>

<div wire:poll.10s="refresh" class="flex items-center gap-2">
  <span class="text-2xl font-bold">{{ $count }}</span>
  <span class="text-gray-500">人已跟團</span>
</div>

PHP 邏輯和 HTML 模板在同一個檔案裡——適合小型、單一職責的元件。

什麼時候用 Volt? 小元件(計數器、狀態切換、簡單表單)用 Volt 很方便。複雜元件(多步驟表單、帶分頁的列表)還是拆成兩個檔案比較好維護。

Alpine.js:Livewire 的最佳搭檔

有些互動是純前端的:下拉選單展開/收合、Modal 彈出/關閉、Tab 切換。這些不需要跑到 server,用 Alpine.js 就好。

Alpine.js 隨 Livewire 3 自動安裝,不用額外設定。

基本語法

<!-- 下拉選單 -->
<div x-data="{ open: false }">
  <button @click="open = !open">選單</button>
  <ul x-show="open" @click.away="open = false" x-transition>
    <li><a href="/profile">個人資料</a></li>
    <li><a href="/settings">設定</a></li>
    <li><a href="/logout">登出</a></li>
  </ul>
</div>

<!-- Modal -->
<div x-data="{ showModal: false }">
  <button @click="showModal = true">開團規則</button>

  <div
    x-show="showModal"
    x-transition.opacity
    class="fixed inset-0 bg-black/50 flex items-center justify-center"
  >
    <div class="bg-white rounded-xl p-6 max-w-md" @click.away="showModal = false">
      <h3 class="font-bold text-lg">開團規則</h3>
      <p class="mt-2 text-gray-600">最低 3 人成團,截止時間前未達人數自動取消。</p>
      <button @click="showModal = false" class="mt-4 px-4 py-2 bg-gray-200 rounded">關閉</button>
    </div>
  </div>
</div>

Alpine.js vs Livewire 分工

場景用誰原因
即時搜尋、篩選Livewire需要查資料庫
表單提交、CRUDLivewire需要 server 處理
下拉選單、ModalAlpine.js純 UI 狀態,不需要 server
Tab 切換Alpine.js純前端
計時器、動畫Alpine.js需要毫秒級響應
購物車數量 badgeLivewire + Alpine.jsLivewire 更新數據,Alpine 做動畫

簡單記:需要資料庫或 PHP 邏輯 → Livewire;純 UI 互動 → Alpine.js

Tailwind CSS 整合:快速美化介面

Laravel 12 新建專案預設就有 Tailwind CSS 的設定。如果你從其他框架來可能已經用過——它是 utility-first 的 CSS 框架,直接在 HTML 上加 class 來寫樣式。

# 安裝前端依賴
npm install

# 啟動 Vite dev server(編譯 CSS 和 JS)
npm run dev

確保 Layout 裡有引入 Vite:

<head>
  @vite(['resources/css/app.css', 'resources/js/app.js'])
</head>

常用 Tailwind 速查

<!-- 容器和間距 -->
<div class="max-w-5xl mx-auto px-6 py-8">
  <!-- Grid 佈局 -->
  <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
    <!-- 卡片 -->
    <div class="bg-white rounded-xl shadow-sm border p-5 hover:shadow-md transition">
      <!-- 按鈕 -->
      <button class="bg-indigo-600 text-white px-4 py-2 rounded-lg hover:bg-indigo-700 transition">
        <!-- 文字 -->
        <h1 class="text-2xl font-bold text-gray-900">
          <p class="text-sm text-gray-500 mt-2">
            <!-- Badge -->
            <span class="bg-green-100 text-green-700 text-sm px-2 py-1 rounded">
              <!-- Responsive -->
              <div class="text-sm md:text-base lg:text-lg"></div
            ></span>
          </p>
        </h1>
      </button>
    </div>
  </div>
</div>

不習慣 utility class? 一開始覺得 HTML 很亂是正常的。但搭配 Blade Component,你把樣式封裝在 Component 裡,使用端看到的就是乾淨的 <x-group-buy-card :group-buy="$gb" />

實作:揪好買團購列表與即時搜尋

把所有東西串起來——用 Livewire 做一個有即時搜尋和分類篩選的團購列表頁。

Step 1:建立 Livewire Component

php artisan make:livewire GroupBuyList

app/Livewire/GroupBuyList.php

<?php

namespace App\Livewire;

use App\Models\GroupBuy;
use Livewire\Component;
use Livewire\WithPagination;

class GroupBuyList extends Component
{
    use WithPagination;

    public string $search = '';
    public string $status = '';
    public string $sortBy = 'latest';

    // 搜尋條件改變時重置頁碼
    public function updatedSearch(): void
    {
        $this->resetPage();
    }

    public function updatedStatus(): void
    {
        $this->resetPage();
    }

    public function render()
    {
        $groupBuys = GroupBuy::query()
            ->with('organizer')            // Eager loading 避免 N+1
            ->withCount('participants')     // 載入參與者數量
            ->when($this->search, fn($q) =>
                $q->where('title', 'like', "%{$this->search}%")
                  ->orWhere('product_name', 'like', "%{$this->search}%")
            )
            ->when($this->status === 'open', fn($q) =>
                $q->where('status', 'open')->where('deadline', '>', now())
            )
            ->when($this->status === 'confirmed', fn($q) =>
                $q->where('status', 'confirmed')
            )
            ->when($this->sortBy === 'latest', fn($q) => $q->latest())
            ->when($this->sortBy === 'deadline', fn($q) => $q->orderBy('deadline'))
            ->when($this->sortBy === 'popular', fn($q) => $q->orderByDesc('participants_count'))
            ->paginate(12);

        return view('livewire.group-buy-list', [
            'groupBuys' => $groupBuys,
        ]);
    }
}

Step 2:Livewire Blade 模板

resources/views/livewire/group-buy-list.blade.php

<div>
  {{-- 搜尋與篩選列 --}}
  <div class="flex flex-col md:flex-row gap-4 mb-8">
    <div class="flex-1">
      <input
        wire:model.live.debounce.300ms="search"
        type="text"
        placeholder="🔍 搜尋團購名稱..."
        class="w-full px-4 py-3 rounded-lg border focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
      />
    </div>

    <select wire:model.live="status" class="px-4 py-3 rounded-lg border">
      <option value="">全部狀態</option>
      <option value="open">開團中</option>
      <option value="confirmed">已成團</option>
    </select>

    <select wire:model.live="sortBy" class="px-4 py-3 rounded-lg border">
      <option value="latest">最新</option>
      <option value="deadline">即將截止</option>
      <option value="popular">最多人跟</option>
    </select>
  </div>

  {{-- 團購卡片 Grid --}}
  <div
    wire:loading.class="opacity-50"
    class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 transition"
  >
    @forelse($groupBuys as $groupBuy)
    <x-group-buy-card :group-buy="$groupBuy" wire:key="gb-{{ $groupBuy->id }}" />
    @empty
    <div class="col-span-full text-center py-12 text-gray-400">
      <p class="text-4xl mb-2">🍃</p>
      <p>找不到符合條件的團購</p>
    </div>
    @endforelse
  </div>

  {{-- 分頁 --}}
  <div class="mt-8">{{ $groupBuys->links() }}</div>
</div>

Step 3:頁面路由與 View

routes/web.php

Route::get('/group-buys', function () {
    return view('group-buys.index');
});

resources/views/group-buys/index.blade.php

<x-layouts.app title="所有團購">
  <div class="flex justify-between items-center mb-6">
    <h1 class="text-2xl font-bold">🛒 所有團購</h1>
    @auth
    <a
      href="/group-buys/create"
      class="bg-indigo-600 text-white px-4 py-2 rounded-lg hover:bg-indigo-700"
    >
      + 我要開團
    </a>
    @endauth
  </div>

  <livewire:group-buy-list />
</x-layouts.app>

Step 4:看看效果

# 確保有測試資料
php artisan migrate:fresh --seed

# 啟動兩個終端
php artisan serve    # 後端
npm run dev          # 前端(Vite)

打開 http://localhost:8000/group-buys,你會看到:

  • 搜尋框——輸入文字後 300ms 自動篩選(debounce),不用按 Enter
  • 狀態篩選——切換「開團中」「已成團」即時過濾
  • 排序——最新、即將截止、最多人跟
  • 分頁——超過 12 筆自動分頁
  • Loading 狀態——篩選時卡片區域半透明

完全沒寫 JavaScript。所有邏輯都在 GroupBuyList.php 這一個 PHP 檔案裡。

效能注意:wire:key

注意每個列表項目的 wire:key="gb-{{ $groupBuy->id }}"。這跟 React 的 key prop 一樣——幫助 Livewire 的 DOM diffing 算法正確辨識哪些項目被新增/移除/移動。列表裡沒加 wire:key 會導致奇怪的渲染 bug。

小結:全 PHP 技術棧的前端開發

這一章我們走過了 Laravel 前端開發的完整工具鏈:

  • Blade Component——用 <x-component> 語法建立可重用的 UI 積木,比 @include 更好組合
  • Livewire 3——用 PHP 寫有狀態的前端元件,wire:modelwire:click 處理使用者互動
  • Volt——單檔案 Livewire Component,適合小型元件
  • Alpine.js——純前端互動(下拉選單、Modal、Tab),不需要跑到 server
  • Tailwind CSS——utility-first CSS,搭配 Blade Component 封裝樣式

我們也為揪好買做了第一個互動頁面:

  • 團購列表——Livewire 即時搜尋 + 篩選 + 排序 + 分頁
  • GroupBuyCard Component——可重用的團購卡片 UI

Livewire vs React/Vue/Svelte 的取捨:

LivewireReact/Vue/Svelte
學習曲線低(只寫 PHP)高(需學 JS 框架)
初始載入快(server render)慢(需載入 JS bundle)
互動延遲略高(每次都跑 server)低(client-side)
SEO天生友好需要 SSR
適合場景內容型、CRUD 為主高互動、即時協作

對揪好買這種團購平台來說,Livewire 完全夠用。如果未來需要更豐富的即時互動(例如拖拉排序、即時聊天),第十一章的 API + Sanctum 可以讓你銜接任何前端框架。

下一章,我們要讓揪好買的使用者能夠註冊和登入——認證與授權系統。用 Laravel 12 的新版 Starter Kit 十分鐘搞定。

留言討論

esc
輸入關鍵字搜尋文章...
查看收藏 →