Blade + Livewire:打造互動式前端不需要寫 JavaScript
本篇是「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">© 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
這會建立兩個檔案:
app/View/Components/GroupBuyCard.php——PHP 類別(邏輯)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.livevswire: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" | 列表項目的 key | key={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 | 需要查資料庫 |
| 表單提交、CRUD | Livewire | 需要 server 處理 |
| 下拉選單、Modal | Alpine.js | 純 UI 狀態,不需要 server |
| Tab 切換 | Alpine.js | 純前端 |
| 計時器、動畫 | Alpine.js | 需要毫秒級響應 |
| 購物車數量 badge | Livewire + Alpine.js | Livewire 更新數據,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:model、wire: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 的取捨:
| Livewire | React/Vue/Svelte | |
|---|---|---|
| 學習曲線 | 低(只寫 PHP) | 高(需學 JS 框架) |
| 初始載入 | 快(server render) | 慢(需載入 JS bundle) |
| 互動延遲 | 略高(每次都跑 server) | 低(client-side) |
| SEO | 天生友好 | 需要 SSR |
| 適合場景 | 內容型、CRUD 為主 | 高互動、即時協作 |
對揪好買這種團購平台來說,Livewire 完全夠用。如果未來需要更豐富的即時互動(例如拖拉排序、即時聊天),第十一章的 API + Sanctum 可以讓你銜接任何前端框架。
下一章,我們要讓揪好買的使用者能夠註冊和登入——認證與授權系統。用 Laravel 12 的新版 Starter Kit 十分鐘搞定。