嵌套组件
Livewire 允许你在父组件内嵌套其他 Livewire 组件。这项能力非常强大,因为你可以把应用中共用的行为封装到可复用的 Livewire 组件里。
WARNING
你可能并不需要 Livewire 组件
在把模板的一部分抽成嵌套 Livewire 组件之前,先问自己:这部分内容是否需要「实时(live)」?如果不需要,建议改用简单的 Blade 组件。只有在组件能受益于 Livewire 的动态特性,或能直接带来性能收益时,才创建 Livewire 组件。
TIP
考虑用 islands 做局部更新
如果你只想把重渲染隔离到组件的特定区域,又不想承担创建独立子组件的开销,可以考虑改用 islands。Islands 能在单个组件内创建可独立更新的区域,无需管理 props、事件或子组件通信。
关于嵌套 Livewire 组件的性能、使用影响与约束,请参阅我们的深入技术解析:Livewire 组件嵌套。
嵌套组件
要在父组件中嵌套 Livewire 组件,只需把它写进父组件的 Blade 视图。下面是父组件 dashboard 包含嵌套 todos 组件的示例:
<?php // resources/views/components/⚡dashboard.blade.php
use Livewire\Component;
new class extends Component {
//
};
?>
<div>
<h1>Dashboard</h1>
<livewire:todos /> <!-- [tl! highlight] -->
</div>页面初次渲染时,dashboard 组件会遇到 <livewire:todos /> 并就地渲染它。之后对 dashboard 的网络请求中,嵌套的 todos 会跳过渲染,因为它已成为页面上的独立组件。关于嵌套与渲染背后的技术概念,请参阅文档:嵌套组件是独立的。
关于渲染组件的语法,请参阅 渲染组件。
向子组件传递 props
从父组件向子组件传递数据很直接,实际上很像向普通 Blade 组件 传递 props。
例如,来看一个 todos 组件,它把 $todos 集合传给名为 todo-count 的子组件:
<?php // resources/views/components/⚡todos.blade.php
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\Computed;
use Livewire\Component;
new class extends Component {
#[Computed]
public function todos()
{
return Auth::user()->todos,
}
};
?>
<div>
<livewire:todo-count :todos="$this->todos" />
<!-- ... -->
</div>可以看到,我们用 :todos="$this->todos" 这种语法把 $this->todos 传给 todo-count。
$todos 已传给子组件后,可以通过子组件的 mount() 方法接收:
<?php // resources/views/components/⚡todo-count.blade.php
use Livewire\Attributes\Computed;
use Livewire\Component;
use App\Models\Todo;
new class extends Component {
public $todos;
public function mount($todos)
{
$this->todos = $todos;
}
#[Computed]
public function count()
{
return $this->todos->count(),
}
};
?>
<div>
Count: {{ $this->count }}
</div>TIP
省略 mount() 的更简洁写法
如果上面例子里的 mount() 方法对你来说像多余的样板代码,只要属性和参数名一致,就可以省略:
public $todos; // [tl! highlight]传递静态 props
上例中,我们用 Livewire 的动态 prop 语法向子组件传值,该语法支持 PHP 表达式,例如:
<livewire:todo-count :todos="$todos" />不过有时你可能只想传一个简单的静态值(比如字符串)。这时可以省略语句开头的冒号:
<livewire:todo-count :todos="$todos" label="Todo Count:" />布尔值可以只写键名。例如,要把值为 true 的 $inline 传给组件,只需在组件标签上写 inline:
<livewire:todo-count :todos="$todos" inline />简写属性语法
向组件传入 PHP 变量时,变量名和 prop 名常常相同。为避免写两遍名字,Livewire 允许你只在变量前加冒号:
<livewire:todo-count :todos="$todos" /> <!-- [tl! remove] -->
<livewire:todo-count :$todos /> <!-- [tl! add] -->在循环中渲染子组件
在循环中渲染子组件时,每次迭代都应提供唯一的 key。
组件的 key 是 Livewire 在后续渲染中追踪每个组件的方式,尤其是在组件已经渲染过,或多个组件在页面上被重新排列时。
你可以在子组件上通过 :key prop 指定 key:
<div>
<h1>Todos</h1>
@foreach ($todos as $todo)
<livewire:todo-item :$todo :wire:key="$todo->id" />
@endforeach
</div>可以看到,每个子组件的 key 都设为对应 $todo 的 ID。这样即便 todos 被重新排序,key 也仍唯一且可被追踪。
WARNING
Key 不是可选的
如果你用过 Vue 或 Alpine 这类前端框架,会熟悉在循环嵌套元素上加 key。不过在那些框架里,key 并非_必须_:没有 key 时项目仍会渲染,只是重排可能追踪不准。而 Livewire 对 key 的依赖更强,没有 key 时行为会不正确。
响应式 props
刚接触 Livewire 的开发者常以为 props 默认是「响应式(reactive)」的——也就是说,父组件改了传给子组件的 prop 值后,子组件会自动更新。但默认情况下,Livewire 的 props 并不是响应式的。
在 Livewire 中,每个组件都是独立的。这意味着父组件触发更新并发出网络请求时,只有父组件的状态会发到服务器重新渲染,子组件的状态不会。这样设计是为了只在服务器与客户端之间传输最少数据,尽可能提升更新性能。
不过,如果你希望或需要某个 prop 是响应式的,可以用 #[Reactive] 属性轻松开启:
例如,下面是父组件 todos 的模板。其中渲染了 todo-count,并传入当前的 todos 列表:
<div>
<h1>Todos:</h1>
<livewire:todo-count :$todos />
<!-- ... -->
</div>接下来给 todo-count 组件的 $todos prop 加上 #[Reactive]。加上之后,父组件中任何 todos 的增删都会自动触发 todo-count 更新:
<?php // resources/views/components/⚡todo-count.blade.php
use Livewire\Attributes\Reactive;
use Livewire\Attributes\Computed;
use Livewire\Component;
use App\Models\Todo;
new class extends Component {
#[Reactive] // [tl! highlight]
public $todos;
#[Computed]
public function count()
{
return $this->todos->count(),
}
};
?>
<div>
Count: {{ $this->count }}
</div>响应式属性非常强大,让 Livewire 更接近 Vue、React 这类前端组件库。但务必理解其性能影响,只在适合你的场景时再加 #[Reactive]。
TIP
Islands 可以省去响应式 props
如果你创建子组件主要是为了隔离更新,并用 #[Reactive] 保持同步,可以考虑改用 islands。Islands 能在单个组件内提供隔离的重渲染,无需响应式 props 或子组件通信。
用 wire:model 绑定子组件数据
在父子组件之间共享状态的另一种强大模式,是借助 Livewire 的 Modelable 特性,直接在子组件上使用 wire:model。
把输入元素抽成独立 Livewire 组件、同时又要在父组件里访问其状态时,这种用法非常常见。
下面是父组件 todos 的示例,其中 $todo 属性跟踪用户即将添加的当前待办:
<?php // resources/views/components/⚡todos.blade.php
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\Computed;
use Livewire\Component;
use App\Models\Todo;
new class extends Component {
public $todo = '';
public function add()
{
Todo::create([
'content' => $this->pull('todo'),
]);
}
#[Computed]
public function todos()
{
return Auth::user()->todos,
}
};在 todos 模板中可以看到,wire:model 被用来把 $todo 直接绑定到嵌套的 todo-input 组件:
<div>
<h1>Todos</h1>
<livewire:todo-input wire:model="todo" /> <!-- [tl! highlight] -->
<button wire:click="add">Add Todo</button>
<div>
@foreach ($this->todos as $todo)
<livewire:todo-item :$todo :wire:key="$todo->id" />
@endforeach
</div>
</div>Livewire 提供 #[Modelable] 属性,你可以加在子组件的任意属性上,使其可从父组件进行 modelable 绑定。
下面是 todo-input 组件:在 $value 上方加了 #[Modelable],告诉 Livewire:若父组件在该组件上声明了 wire:model,应绑定到这个属性:
<?php // resources/views/components/⚡todo-input.blade.php
use Livewire\Attributes\Modelable;
use Livewire\Component;
new class extends Component {
#[Modelable] // [tl! highlight]
public $value = '';
};
?>
<div>
<input type="text" wire:model="value" >
</div>这样,父组件 todos 就可以把 todo-input 当作普通输入元素,用 wire:model 直接绑定其值。
WARNING
目前 Livewire 只支持一个 #[Modelable] 属性,因此只会绑定第一个。
插槽
插槽允许你把 Blade 内容从父组件传给子组件。当子组件既要渲染自身内容,又要允许父组件在特定位置注入自定义内容时,这很有用。
下面是父组件渲染评论列表的示例。每条评论由子组件 Comment 渲染,但父组件通过插槽传入一个「Remove」按钮:
<?php
use Livewire\Attributes\Computed;
use Livewire\Component;
use App\Models\Post;
new class extends Component {
public Post $post;
#[Computed]
public function comments()
{
return $this->post->comments;
}
public function removeComment($id)
{
$this->post->comments()->find($id)->delete();
}
};
?>
<div>
@foreach ($this->comments as $comment)
<livewire:comment :$comment :wire:key="$comment->id">
<button wire:click="removeComment({{ $comment->id }})">
Remove
</button>
</livewire:comment>
@endforeach
</div>内容已传给子组件 Comment 后,可以用 $slot 变量渲染:
<?php
use Livewire\Component;
use App\Models\Comment;
new class extends Component {
public Comment $comment;
};
?>
<div>
<p>{{ $comment->author }}</p>
<p>{{ $comment->body }}</p>
{{ $slot }}
</div>当 Comment 渲染 $slot 时,Livewire 会注入父组件传入的内容。
务必理解:插槽是在父组件上下文中求值的。也就是说,插槽内引用的任何属性或方法都属于父组件,而不是子组件。上例中,removeComment() 调用的是父组件,而不是子组件 Comment。
具名插槽
除默认插槽外,你还可以向子组件传入多个具名插槽。当你想为子组件的多个区域提供内容时,这很有用。
下面示例同时向 Comment 传入默认插槽和名为 actions 的具名插槽:
<div>
@foreach ($this->comments as $comment)
<livewire:comment :$comment :wire:key="$comment->id">
<livewire:slot name="actions">
<button wire:click="removeComment({{ $comment->id }})">
Remove
</button>
</livewire:slot>
<span>Posted on {{ $comment->created_at }}</span>
</livewire:comment>
@endforeach
</div>在子组件中,把插槽名传给 $slots 变量即可访问具名插槽:
<div>
<p>{{ $comment->author }}</p>
<p>{{ $comment->body }}</p>
<div class="actions">
{{ $slots['actions'] }}
</div>
<div class="metadata">
{{ $slot }}
</div>
</div>检查是否提供了插槽
可以用 $slots 上的 has() 方法检查父组件是否提供了某个插槽。这在你想根据插槽是否存在来条件渲染内容时很有用:
<div>
<p>{{ $comment->author }}</p>
<p>{{ $comment->body }}</p>
@if ($slots->has('actions'))
<div class="actions">
{{ $slots['actions'] }}
</div>
@endif
{{ $slot }}
</div>转发 HTML 属性
与 Blade 组件一样,Livewire 组件支持用 $attributes 变量把 HTML 属性从父组件转发给子组件。
下面是父组件向子组件传递 class 属性的示例:
<livewire:comment :$comment class="border-b" />在子组件中可用 $attributes 应用这些属性:
<div {{ $attributes->class('bg-white rounded-md') }}>
<p>{{ $comment->author }}</p>
<p>{{ $comment->body }}</p>
</div>与公开属性名匹配的属性会自动作为 props 传递,并从 $attributes 中排除。其余如 class、id 或 data-* 等属性可通过 $attributes 使用。
只有能渲染为 HTML 属性的值才会被转发。数组和对象(除 HtmlString 这类 Htmlable 实例外)会被静默丢弃,不会进入 $attributes。
Islands 与嵌套组件
构建 Livewire 应用时,你常会面临选择:该创建嵌套子组件,还是使用 island?两者都能把更新隔离到特定区域,但用途不同。
何时使用 islands
当你想要性能隔离、又不想增加架构复杂度时,islands 很合适。在以下情况使用 islands:
你需要性能优化,又不想增加额外开销
如果你的主要目标是避免昂贵计算无谓执行,islands 是更简单的方案:
{{-- Island: Simple performance isolation --}}
@island
<div>
Revenue: {{ $this->expensiveRevenue }}
<button wire:click="$refresh">Refresh</button>
</div>
@endisland这能获得与子组件相同的性能收益,但不必单独建组件文件、管理 props,或配置事件通信。
你想延迟或懒加载内容
Islands 擅长把昂贵操作推迟到首次页面加载之后:
@island(lazy: true)
<div>{{ $this->slowApiCall }}</div>
@endisland你有多个相互独立的 UI 区域
当你有多个可独立更新、但不需要独立逻辑的区域时:
@island(name: 'stats')
<div>Stats: {{ $this->stats }}</div>
@endisland
@island(name: 'chart')
<div>Chart: {{ $this->chartData }}</div>
@endisland被隔离的区域不需要自己的生命周期
Islands 共享父组件的生命周期、状态和方法。当该区域在概念上属于同一组件时,这非常合适。
何时使用嵌套组件
当你需要真正的封装与可复用性时,嵌套组件更合适。在以下情况使用嵌套组件:
你需要可复用、自包含的功能
如果该组件会在多处使用,并拥有自己的逻辑与状态:
{{-- This todo-item can be reused across the application --}}
<livewire:todo-item :$todo :wire:key="$todo->id" />你需要独立的生命周期钩子
当子组件需要自己的 mount()、updated() 或其他生命周期方法时:
public function mount($todo)
{
$this->authorize('view', $todo);
}
public function updated($property)
{
// Child-specific update logic
}你需要封装的状态与逻辑
当子组件有需要隔离的复杂状态管理时:
// Child component with its own encapsulated state
public $editMode = false;
public $draft = '';
public function startEdit() { /* ... */ }
public function saveEdit() { /* ... */ }
public function cancelEdit() { /* ... */ }你需要组件真正独立
嵌套组件是真正独立的,在父组件更新时仍保持自身状态。当你不希望父组件重渲染影响子组件时,这很有价值。
你在构建组件库
为团队或组织创建可复用组件时,嵌套组件能提供恰当的封装边界。
快速决策指南
仍不确定?问问自己:
- 这是否需要可复用? → 嵌套组件
- 这是否需要自己的生命周期方法? → 嵌套组件
- 我是否只是想优化性能? → Island
- 我是否想延迟加载昂贵内容? → Island(配合
lazy或defer) - 这是否只会在一处使用? → 多半用 island
- 这是否需要复杂、隔离的状态? → 嵌套组件
记住:你可以先用 island 保持简单,之后若需要更强封装,再重构为嵌套组件。
监听来自子组件的事件
另一种强大的父子通信方式是 Livewire 的事件系统:你可以在服务器或客户端派发事件,由其他组件拦截。
我们的 Livewire 事件系统完整文档 有更详细的说明;下面用一个简单示例演示如何用事件触发父组件更新。
来看一个可展示并删除 todos 的 todos 组件:
<?php // resources/views/components/⚡todos.blade.php
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\Computed;
use Livewire\Component;
use App\Models\Todo;
new class extends Component {
public function remove($todoId)
{
$todo = Todo::find($todoId);
$this->authorize('delete', $todo);
$todo->delete();
}
#[Computed]
public function todos()
{
return Auth::user()->todos,
}
};
?>
<div>
@foreach ($this->todos as $todo)
<livewire:todo-item :$todo :wire:key="$todo->id" />
@endforeach
</div>要从子组件 todo-item 内部调用 remove(),可以在 todos 上通过 #[On] 属性添加事件监听:
<?php // resources/views/components/⚡todos.blade.php
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\Computed;
use Livewire\Attributes\On;
use Livewire\Component;
use App\Models\Todo;
new class extends Component {
#[On('remove-todo')] // [tl! highlight]
public function remove($todoId)
{
$todo = Todo::find($todoId);
$this->authorize('delete', $todo);
$todo->delete();
}
#[Computed]
public function todos()
{
return Auth::user()->todos,
}
};
?>
<div>
@foreach ($this->todos as $todo)
<livewire:todo-item :$todo :wire:key="$todo->id" />
@endforeach
</div>把属性加到 action 上之后,就可以从子组件 todo-item 派发 remove-todo 事件:
<?php // resources/views/components/⚡todo-item.blade.php
use Livewire\Component;
use App\Models\Todo;
new class extends Component {
public Todo $todo;
public function remove()
{
$this->dispatch('remove-todo', todoId: $this->todo->id); // [tl! highlight]
}
};
?>
<div>
<span>{{ $todo->content }}</span>
<button wire:click="remove">Remove</button>
</div>现在,在 todo-item 内点击「Remove」按钮时,父组件 todos 会拦截派发的事件并执行删除。
父组件删除 todo 后,列表会重新渲染,派发 remove-todo 事件的那个子组件也会从页面移除。
通过客户端派发提升性能
上例虽然可行,但完成一个操作需要两次网络请求:
- 第一次来自 `todo-item` 的网络请求触发 `remove` action,并派发 `remove-todo` 事件。
- 第二次是在 `remove-todo` 于客户端派发后,由 `todos` 拦截并调用其 `remove` action。
你可以在客户端直接派发 remove-todo,从而完全避免第一次请求。下面是更新后的 todo-item:派发 remove-todo 时不会触发网络请求:
<?php // resources/views/components/⚡todo-item.blade.php
use Livewire\Component;
use App\Models\Todo;
new class extends Component {
public Todo $todo;
};
?>
<div>
<span>{{ $todo->content }}</span>
<button wire:click="$dispatch('remove-todo', { todoId: {{ $todo->id }} })">Remove</button>
</div>经验法则:能在客户端派发时,优先在客户端派发。
从子组件直接访问父组件
事件通信增加了一层间接性:父组件可能监听从未被子组件派发的事件,子组件也可能派发从未被父组件拦截的事件。
这种间接性有时是可取的;但在另一些情况下,你可能更希望从子组件直接访问父组件。
Livewire 通过在 Blade 模板中提供魔法变量 $parent 实现这一点,你可以从子组件直接访问父组件的 actions 和属性。下面把上面的 TodoItem 模板改写为通过 $parent 直接调用父组件的 remove():
<div>
<span>{{ $todo->content }}</span>
<button wire:click="$parent.remove({{ $todo->id }})">Remove</button>
</div>事件与直接访问父组件,是父子双向通信的几种方式。理解它们的权衡,能帮助你在具体场景中做出更合适的选择。
动态子组件
有时要到运行时才知道页面该渲染哪个子组件。因此 Livewire 允许用 <livewire:dynamic-component ...> 在运行时选择子组件,它接收 :is prop:
<livewire:dynamic-component :is="$current" />动态子组件适用于多种场景;下面用动态组件渲染多步表单中不同步骤的示例:
<?php // resources/views/components/⚡steps.blade.php
use Livewire\Component;
new class extends Component {
public $current = 'step-one';
protected $steps = [
'step-one',
'step-two',
'step-three',
];
public function next()
{
$currentIndex = array_search($this->current, $this->steps);
$this->current = $this->steps[$currentIndex + 1];
}
};
?>
<div>
<livewire:dynamic-component :is="$current" :wire:key="$current" />
<button wire:click="next">Next</button>
</div>此时若 steps 的 $current 设为「step-one」,Livewire 会渲染名为「step-one」的组件,例如:
<?php // resources/views/components/⚡step-one.blade.php
use Livewire\Component;
new class extends Component {
//
};
?>
<div>
Step One Content
</div>如果你更喜欢,也可以用另一种语法:
<livewire:is :component="$current" :wire:key="$current" />WARNING
别忘了给每个子组件分配唯一 key。虽然 Livewire 会为 <livewire:dynamic-child /> 和 <livewire:is /> 自动生成 key,但同一个 key 会应用到_所有_子组件,导致后续渲染被跳过。
关于 key 如何影响组件渲染,详见强制子组件重新渲染。
递归组件
多数应用很少需要,但 Livewire 组件可以递归嵌套,即父组件把自身作为子组件渲染。
设想一份问卷,其中有可挂载子问题的 survey-question 组件:
<?php // resources/views/components/⚡survey-question.blade.php
use Livewire\Attributes\Computed;
use Livewire\Component;
use App\Models\Question;
new class extends Component {
public Question $question;
#[Computed]
public function subQuestions()
{
return $this->question->subQuestions,
}
};
?>
<div>
Question: {{ $question->content }}
@foreach ($this->subQuestions as $subQuestion)
<livewire:survey-question :question="$subQuestion" :wire:key="$subQuestion->id" />
@endforeach
</div>WARNING
当然,递归组件也要遵守递归的常规规则。最重要的是,模板里要有逻辑防止无限递归。上例中,若某个 $subQuestion 又把原问题当作自己的 $subQuestion,就会进入死循环。
强制子组件重新渲染
在底层,Livewire 会为模板中每个嵌套 Livewire 组件生成一个 key。
例如,看下面这个嵌套的 todo-count:
<div>
<livewire:todo-count :$todos />
</div>Livewire 内部会给组件挂上一个随机字符串 key,类似:
<div>
<livewire:todo-count :$todos wire:key="lska" />
</div>父组件渲染时遇到如上子组件,会把该 key 存进父组件关联的子组件列表:
'children' => ['lska'],后续渲染时,Livewire 用这份列表判断子组件是否已在先前请求中渲染过。若已渲染过则跳过。记住,嵌套组件是独立的。若子组件的 key 不在列表中(说明尚未渲染),Livewire 会新建组件实例并就地渲染。
这些细节大多是后台行为,多数用户不必关心;但给子组件设置 key,是控制子组件渲染的有力手段。
据此,若要强制组件重新渲染,只需改变它的 key。
下面示例中,若传入的 $todos 发生变化,我们可能希望销毁并重新初始化 todo-count:
<div>
<livewire:todo-count :todos="$todos" :wire:key="$todos->pluck('id')->join('-')" />
</div>如上所示,我们根据 $todos 的内容生成动态 :key 字符串。这样,todo-count 会正常渲染并存在,直到 $todos 本身变化;那时组件会从零重新初始化,旧组件会被丢弃。
另见
- 事件 — 在嵌套组件之间通信
- 组件 — 了解如何渲染与组织组件
- Islands — 用于局部更新的嵌套替代方案
- 理解嵌套 — 深入了解嵌套的性能与行为
- Reactive 属性 — 让嵌套组件中的 props 变为响应式