大家好,我是程序员柏风,AI 最全的 Vibecoding 学习地图 http://coderoadmap.cn 作者。
Blade 模板引擎与视图
这一课你将能做到
- 用 Blade 输出变量、条件渲染、循环渲染,并理解转义与安全
- 用
@extends/@section组织布局,用组件(class-based + anonymous)封装可复用 UI - 在 Blade 中处理表单、CSRF、验证错误回显和分页链接
为什么要学
虽然很多新项目采用前后端分离,但服务端模板从未消失:管理后台、邮件模板、PDF 渲染、内容站、SEO 敏感的落地页,仍然首选服务端渲染。Laravel 的 Blade 是这一层的工具,定位相当于 Spring 生态的 Thymeleaf(而不是 JSP):语法简洁、支持继承与组件、编译为纯 PHP 缓存执行。
学会 Blade 还有一个隐性收益:Breeze、Jetstream、大部分后台界面包的代码你都能读懂并改造。本课用一个「订单管理后台」把布局、列表、表单、错误回显完整串起来。
先建立图像
Controller
│ return view('orders.index', ['orders' => $orders]);
▼
resources/views/
├── layouts/app.blade.php ← 母版:<head>、导航、@yield('content')
├── components/
│ ├── alert.blade.php ← 匿名组件:<x-alert type="success"/>
│ └── stat-card.blade.php
└── orders/
├── index.blade.php ← @extends('layouts.app') + @section
├── create.blade.php
└── show.blade.php
编译机制:Blade 文件第一次被访问时编译为 storage/framework/views/*.php(纯 PHP),原文件改动后自动重编译。等价 Thymeleaf 的模板缓存机制,但开发期零配置自动失效。
跟着做
步骤 1:渲染第一个视图
新建 resources/views/hello.blade.php:
<h1>你好,{{ $name }}</h1>
<p>当前时间:{{ now()->format('Y-m-d H:i') }}</p>
在 routes/web.php:
Route::get('/hello', function () {
return view('hello', ['name' => '小李']);
});
要点:{{ $name }} 等价 htmlspecialchars($name, ENT_QUOTES),输出自动转义,防 XSS。确认不需要转义(内容本身就是受信 HTML)才用 {!! $html !!}。
步骤 2:条件与循环
{{-- 这是 Blade 注释,不会出现在 HTML 源码里 --}}
@if($orders->isEmpty())
<x-alert type="info">还没有订单,去<a href="{{ route('orders.create') }}">下一单</a>吧。</x-alert>
@else
<p>共 {{ $orders->total() }} 笔订单。</p>
@endif
@isset($user)
<p>欢迎,{{ $user->name }}</p>
@endisset
@unless(auth()->check())
<a href="/login">登录</a>
@endunless
<ul>
@foreach($orders as $order)
<li class="@if($loop->first) font-bold @endif">
{{ $loop->iteration }}.
<a href="{{ route('orders.show', $order) }}">{{ $order->order_no }}</a>
— ¥{{ number_format($order->amount / 100, 2) }}
@switch($order->status)
@case('paid') <span class="text-green">已支付</span> @break
@case('cancelled') <span class="text-gray">已取消</span> @break
@default <span>{{ $order->status }}</span>
@endswitch
</li>
@else
<li>无数据</li>
@endforeach
</ul>
{{-- 复杂循环用 forelse 更省心(空态内置) --}}
@forelse($orders as $order)
...
@empty
<p>暂无订单</p>
@endforelse
$loop 变量提供 index/iteration/first/last/remaining/depth/parent,比手写计数器清晰。
步骤 3:布局继承
母版 resources/views/layouts/app.blade.php:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>@yield('title', '商家后台') - Shop</title>
<link rel="stylesheet" href="{{ asset('css/app.css') }}">
</head>
<body>
<nav>
<a href="{{ route('home') }}">首页</a>
<a href="{{ route('orders.index') }}">订单</a>
@auth
<form method="POST" action="{{ route('logout') }}">
@csrf
<button type="submit">退出({{ auth()->user()->name }})</button>
</form>
@endauth
</nav>
{{-- 闪存消息 --}}
@if(session('success'))
<x-alert type="success">{{ session('success') }}</x-alert>
@endif
<main>
@yield('content')
</main>
</body>
</html>
子页面 resources/views/orders/index.blade.php:
@extends('layouts.app')
@section('title', '订单列表')
@section('content')
<h1>订单列表</h1>
{{-- 列表内容见步骤 2 --}}
@endsection
步骤 4:组件封装
匿名组件(推荐简单 UI):新建 resources/views/components/alert.blade.php:
@props([
'type' => 'info', // success / danger / warning / info
])
<div {{ $attributes->merge(['class' => "alert alert-{$type}"]) }}>
{{ $slot }}
</div>
使用:
<x-alert type="danger" class="mt-4">库存不足,请补货</x-alert>
带类的组件(需要数据查询逻辑时):
php artisan make:component StatCard
app/View/Components/StatCard.php:
<?php
namespace App\View\Components;
use Closure;
use Illuminate\Contracts\View\View;
use Illuminate\View\Component;
class StatCard extends Component
{
public function __construct(
public string $label,
public int|float $value,
public ?string $trend = null,
) {}
public function render(): View|Closure|string
{
return view('components.stat-card');
}
}
resources/views/components/stat-card.blade.php:
<div class="stat-card">
<div class="label">{{ $label }}</div>
<div class="value">{{ $value }}</div>
@isset($trend)<div class="trend">{{ $trend }}</div>@endisset
</div>
使用:<x-stat-card label="今日成交额" :value="number_format($todayAmount / 100, 2)" trend="+12%"/>,注意属性前加 : 表示按 PHP 表达式传参。
步骤 5:表单、CSRF 与错误回显
resources/views/orders/create.blade.php:
@extends('layouts.app')
@section('content')
<form method="POST" action="{{ route('orders.store') }}">
@csrf
{{-- 用 PUT/PATCH 时:@method('PUT') --}}
<div>
<label>支付渠道</label>
<select name="channel">
@foreach(['alipay' => '支付宝', 'wechat' => '微信'] as $value => $text)
<option value="{{ $value }}" @selected(old('channel') === $value)>{{ $text }}</option>
@endforeach
</select>
@error('channel') <p class="error">{{ $message }}</p> @enderror
</div>
<div>
<label>备注</label>
<input type="text" name="remark" value="{{ old('remark') }}">
@error('remark') <p class="error">{{ $message }}</p> @enderror
</div>
<button type="submit">提交订单</button>
</form>
@endsection
关键点:
@csrf生成隐藏的 token 输入框,缺失会得到 419。old('field')在验证失败重定向后回填用户上次输入,避免用户重填。@error('field')直接拿到该字段的第一条错误信息;$errors是所有页面自动可用的错误包(ViewErrorBag)。@checked、@selected、@disabled是 Laravel 9+ 的便捷指令。
步骤 6:分页与 URL 资源
{{-- 控制器:Order::paginate(20);模板输出分页条 --}}
{{ $orders->withQueryString()->links() }}
{{-- 静态资源(带版本 query 防缓存) --}}
<img src="{{ asset('images/logo.png') }}" alt="logo">
{{-- 混合 Vite 构建资源(Laravel 官方前端方案) --}}
@vite(['resources/css/app.css', 'resources/js/app.js'])
{{-- 引入子视图(可传数据) --}}
@include('orders._filters', ['status' => $status ?? null])
{{-- 一次性渲染:每个 item 都 include(循环集合) --}}
@each('orders._card', $orders, 'order', 'orders._empty')
讲清楚要点
- Blade 编译为纯 PHP:指令是编译期语法糖,没有运行时模板解释开销。改文件自动失效,部署时可用
view:cache预热。 - 转义默认开启:
{{ }}转义,{!! !!}不转义;永远不要对用户输入使用后者。这是 XSS 的最后一道防线。 - 布局两种体系:继承(
@extends/@yield/@section)适合整页骨架;组件(<x-*>)适合可复用小块。新项目更推荐组件化,Blade 组件是 Laravel 7 后的首选。 - 指令覆盖面:认证态
@auth/@guest、权限@can/@cannot(第 7 课)、语言@lang、注入@inject、栈@push/@stack(子页面向母版追加脚本)。 $attributes是组件的生命线:外部传入的非 props 属性(class、id、data-*)都收集在这里,merge()可做 CSS 类智能合并。- 分页样式可换:默认使用 Tailwind 分页视图,可用
php artisan vendor:publish --tag=laravel-pagination发布后改 Bootstrap 风格。 - 邮件模板也是 Blade:
php artisan make:mail OrderShipped --markdown生成 Markdown + Blade 邮件,通知类内容复用同一套技能。
你可能会懵的地方
@yield、@section、@stack、@include到底怎么选?@yield定义可被子页面覆盖的内容位;@section在子页面填充,配合@parent可追加;@stack收集多处@push(适合页底脚本,列表页每行都可能 push);@include是直接引入一个固定子视图。- 为什么
old()有时返回 null? 只有验证失败后back()的请求才有闪存的旧输入;直接 GET 访问表单页本来就没有。默认值写法:old('remark', $order->remark ?? '')。 - 组件名和文件路径如何对应?
resources/views/components/stat-card.blade.php→<x-stat-card>;子目录components/form/input.blade.php→<x-form.input>。 - Blade 里能写业务逻辑吗? 简单展示逻辑可以(格式化、条件);但凡涉及查库、多步计算都应放控制器、Service 或视图组件类里,保持模板可扫读。
- 和 Vue/React 是什么关系? Blade 负责首屏 HTML;Vue/React 可作为页面内的交互岛(Inertia 或直接挂载),Blade 仍然输出容器与初始数据。Livewire 则是「用 Blade 写交互」的另一流派,不需要写 JS。
错法对照
| 错法 | 问题 | 正解 |
|---|---|---|
{!! $userInput !!} 渲染用户内容 | XSS 漏洞 | {{ $userInput }} |
表单漏写 @csrf | POST 返回 419 | 所有非 GET 表单加 @csrf |
| 验证失败后用户要重填全部字段 | 体验差 | value 绑定 old() |
每个页面重复写 <head> 和导航 | 维护灾难 | 母版 + @extends |
在 Blade 里直接 DB::table(...) | 数据层泄漏到视图 | 控制器准备好数据再传入 |
| 组件 props 与普通属性混用不清 | 取值意外 | 显式声明 @props,其余走 $attributes |
卡点急救
- 页面显示「Invalid argument supplied for foreach()」:传入的不是集合。控制器用
collect()包装;模板里用@forelse并在调试时dd($orders)确认类型。 - 改了 Blade 没生效:检查是否改错文件(路径与
view()名称要对应点斜杠规则);删storage/framework/views下编译文件;确认storage可写。 - 组件报
Unable to locate a class or view component:确认组件路径/命名;类组件执行过php artisan view:clear;匿名组件文件必须在resources/views/components下。
面试常问
-
Blade 如何防止 XSS?什么场景会绕过防护?
{{ }}编译为htmlspecialchars(ENT_QUOTES | UTF-8);风险场景是显式使用{!! !!}输出不可信内容,以及在 JavaScript 上下文(<script>内拼 JSON)——此时应使用@json($data)或JSON_HEX_TAG等安全标志输出,不能直接拼字符串。 -
Blade 组件和布局继承有什么区别,如何取舍? 继承是整页层级的模板复用(母版占位 + 子页面填充);组件是带属性与插槽的独立 UI 单元,可跨页面复用、可携带类逻辑。现代实践以组件为复用主力,继承仅保留一两层页面骨架;与 React/Vue 的组件心智一致。
-
服务端渲染与前后端分离各自的适用场景? SSR 利于首屏与 SEO、部署简单、适合后台和内容站;分离架构利于交互复杂度、前后端独立伸缩、多端复用 API。选型看团队结构与产品形态,没有绝对优劣;也可用 Inertia 取中间路线(路由与控制器仍在 Laravel,页面组件用 Vue/React)。
自测三问
- 要把每个页面相同的导航和
<head>抽出来,你会创建什么文件、子页面如何声明? - 用户提交表单后某个字段校验失败,怎样让错误提示显示在该字段下方、并保留用户刚输入的值?
- 为什么
{{ $content }}是安全的而{!! $content !!}可能不安全?各举一个合理使用场景。
小结一句话
Blade 用编译型指令把变量输出、布局继承、组件插槽和表单脚手架收拢成一套默认安全的服务端渲染方案,写后台和邮件模板的效率远超裸 PHP。
延伸
- Blade 模板文档
- Blade 组件
- Vite 前端资源
- Thymeleaf 对照文档
- 下一课:数据库与 Eloquent ORM——进入数据层,学习迁移、模型关系、查询构造器与 N+1 治理。