Skip to content
全部文档

嵌套组件

Livewire 允许你在父组件内嵌套其他 Livewire 组件。这项能力非常强大,因为你可以把应用中共用的行为封装到可复用的 Livewire 组件里。

WARNING

你可能并不需要 Livewire 组件

在把模板的一部分抽成嵌套 Livewire 组件之前,先问自己:这部分内容是否需要「实时(live)」?如果不需要,建议改用简单的 Blade 组件。只有在组件能受益于 Livewire 的动态特性,或能直接带来性能收益时,才创建 Livewire 组件。

TIP

考虑用 islands 做局部更新

如果你只想把重渲染隔离到组件的特定区域,又不想承担创建独立子组件的开销,可以考虑改用 islands。Islands 能在单个组件内创建可独立更新的区域,无需管理 props、事件或子组件通信。

关于嵌套 Livewire 组件的性能、使用影响与约束,请参阅我们的深入技术解析:Livewire 组件嵌套

嵌套组件

要在父组件中嵌套 Livewire 组件,只需把它写进父组件的 Blade 视图。下面是父组件 dashboard 包含嵌套 todos 组件的示例:

php
<?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
<?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
<?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() 方法对你来说像多余的样板代码,只要属性和参数名一致,就可以省略:

php
public $todos; // [tl! highlight]

传递静态 props

上例中,我们用 Livewire 的动态 prop 语法向子组件传值,该语法支持 PHP 表达式,例如:

blade
<livewire:todo-count :todos="$todos" />

不过有时你可能只想传一个简单的静态值(比如字符串)。这时可以省略语句开头的冒号:

blade
<livewire:todo-count :todos="$todos" label="Todo Count:" />

布尔值可以只写键名。例如,要把值为 true$inline 传给组件,只需在组件标签上写 inline

blade
<livewire:todo-count :todos="$todos" inline />

简写属性语法

向组件传入 PHP 变量时,变量名和 prop 名常常相同。为避免写两遍名字,Livewire 允许你只在变量前加冒号:

blade
<livewire:todo-count :todos="$todos" /> <!-- [tl! remove] -->

<livewire:todo-count :$todos /> <!-- [tl! add] -->

在循环中渲染子组件

在循环中渲染子组件时,每次迭代都应提供唯一的 key

组件的 key 是 Livewire 在后续渲染中追踪每个组件的方式,尤其是在组件已经渲染过,或多个组件在页面上被重新排列时。

你可以在子组件上通过 :key prop 指定 key:

blade
<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 列表:

blade
<div>
    <h1>Todos:</h1>

    <livewire:todo-count :$todos />

    <!-- ... -->
</div>

接下来给 todo-count 组件的 $todos prop 加上 #[Reactive]。加上之后,父组件中任何 todos 的增删都会自动触发 todo-count 更新:

php
<?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
<?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 组件:

blade
<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
<?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
<?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
<?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 的具名插槽:

blade
<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 变量即可访问具名插槽:

blade
<div>
    <p>{{ $comment->author }}</p>
    <p>{{ $comment->body }}</p>

    <div class="actions">
        {{ $slots['actions'] }}
    </div>

    <div class="metadata">
        {{ $slot }}
    </div>
</div>

检查是否提供了插槽

可以用 $slots 上的 has() 方法检查父组件是否提供了某个插槽。这在你想根据插槽是否存在来条件渲染内容时很有用:

blade
<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 属性的示例:

blade
<livewire:comment :$comment class="border-b" />

在子组件中可用 $attributes 应用这些属性:

blade
<div {{ $attributes->class('bg-white rounded-md') }}>
    <p>{{ $comment->author }}</p>
    <p>{{ $comment->body }}</p>
</div>

与公开属性名匹配的属性会自动作为 props 传递,并从 $attributes 中排除。其余如 classiddata-* 等属性可通过 $attributes 使用。

只有能渲染为 HTML 属性的值才会被转发。数组和对象(除 HtmlString 这类 Htmlable 实例外)会被静默丢弃,不会进入 $attributes

Islands 与嵌套组件

构建 Livewire 应用时,你常会面临选择:该创建嵌套子组件,还是使用 island?两者都能把更新隔离到特定区域,但用途不同。

何时使用 islands

当你想要性能隔离、又不想增加架构复杂度时,islands 很合适。在以下情况使用 islands:

你需要性能优化,又不想增加额外开销

如果你的主要目标是避免昂贵计算无谓执行,islands 是更简单的方案:

blade
{{-- Island: Simple performance isolation --}}
@island
    <div>
        Revenue: {{ $this->expensiveRevenue }}
        <button wire:click="$refresh">Refresh</button>
    </div>
@endisland

这能获得与子组件相同的性能收益,但不必单独建组件文件、管理 props,或配置事件通信。

你想延迟或懒加载内容

Islands 擅长把昂贵操作推迟到首次页面加载之后:

blade
@island(lazy: true)
    <div>{{ $this->slowApiCall }}</div>
@endisland

你有多个相互独立的 UI 区域

当你有多个可独立更新、但不需要独立逻辑的区域时:

blade
@island(name: 'stats')
    <div>Stats: {{ $this->stats }}</div>
@endisland

@island(name: 'chart')
    <div>Chart: {{ $this->chartData }}</div>
@endisland

被隔离的区域不需要自己的生命周期

Islands 共享父组件的生命周期、状态和方法。当该区域在概念上属于同一组件时,这非常合适。

何时使用嵌套组件

当你需要真正的封装与可复用性时,嵌套组件更合适。在以下情况使用嵌套组件:

你需要可复用、自包含的功能

如果该组件会在多处使用,并拥有自己的逻辑与状态:

blade
{{-- This todo-item can be reused across the application --}}
<livewire:todo-item :$todo :wire:key="$todo->id" />

你需要独立的生命周期钩子

当子组件需要自己的 mount()updated() 或其他生命周期方法时:

php
public function mount($todo)
{
    $this->authorize('view', $todo);
}

public function updated($property)
{
    // Child-specific update logic
}

你需要封装的状态与逻辑

当子组件有需要隔离的复杂状态管理时:

php
// Child component with its own encapsulated state
public $editMode = false;
public $draft = '';

public function startEdit() { /* ... */ }
public function saveEdit() { /* ... */ }
public function cancelEdit() { /* ... */ }

你需要组件真正独立

嵌套组件是真正独立的,在父组件更新时仍保持自身状态。当你不希望父组件重渲染影响子组件时,这很有价值。

你在构建组件库

为团队或组织创建可复用组件时,嵌套组件能提供恰当的封装边界。

快速决策指南

仍不确定?问问自己:

  • 这是否需要可复用?嵌套组件
  • 这是否需要自己的生命周期方法?嵌套组件
  • 我是否只是想优化性能?Island
  • 我是否想延迟加载昂贵内容?Island(配合 lazydefer
  • 这是否只会在一处使用?多半用 island
  • 这是否需要复杂、隔离的状态?嵌套组件

记住:你可以先用 island 保持简单,之后若需要更强封装,再重构为嵌套组件。

监听来自子组件的事件

另一种强大的父子通信方式是 Livewire 的事件系统:你可以在服务器或客户端派发事件,由其他组件拦截。

我们的 Livewire 事件系统完整文档 有更详细的说明;下面用一个简单示例演示如何用事件触发父组件更新。

来看一个可展示并删除 todos 的 todos 组件:

php
<?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
<?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
<?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 事件的那个子组件也会从页面移除。

通过客户端派发提升性能

上例虽然可行,但完成一个操作需要两次网络请求:

  1. 第一次来自 `todo-item` 的网络请求触发 `remove` action,并派发 `remove-todo` 事件。
  2. 第二次是在 `remove-todo` 于客户端派发后,由 `todos` 拦截并调用其 `remove` action。

你可以在客户端直接派发 remove-todo,从而完全避免第一次请求。下面是更新后的 todo-item:派发 remove-todo 时不会触发网络请求:

php
<?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>

经验法则:能在客户端派发时,优先在客户端派发。

TIP

Islands 可消除事件通信开销

如果你创建子组件主要是为了通过事件触发父组件更新,可以考虑改用 islands。Islands 共享同一组件上下文,可直接调用组件方法,无需事件间接层。

从子组件直接访问父组件

事件通信增加了一层间接性:父组件可能监听从未被子组件派发的事件,子组件也可能派发从未被父组件拦截的事件。

这种间接性有时是可取的;但在另一些情况下,你可能更希望从子组件直接访问父组件。

Livewire 通过在 Blade 模板中提供魔法变量 $parent 实现这一点,你可以从子组件直接访问父组件的 actions 和属性。下面把上面的 TodoItem 模板改写为通过 $parent 直接调用父组件的 remove()

blade
<div>
    <span>{{ $todo->content }}</span>

    <button wire:click="$parent.remove({{ $todo->id }})">Remove</button>
</div>

事件与直接访问父组件,是父子双向通信的几种方式。理解它们的权衡,能帮助你在具体场景中做出更合适的选择。

动态子组件

有时要到运行时才知道页面该渲染哪个子组件。因此 Livewire 允许用 <livewire:dynamic-component ...> 在运行时选择子组件,它接收 :is prop:

blade
<livewire:dynamic-component :is="$current" />

动态子组件适用于多种场景;下面用动态组件渲染多步表单中不同步骤的示例:

php
<?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
<?php // resources/views/components/⚡step-one.blade.php

use Livewire\Component;

new class extends Component {
    //
};
?>

<div>
    Step One Content
</div>

如果你更喜欢,也可以用另一种语法:

blade
<livewire:is :component="$current" :wire:key="$current" />

WARNING

别忘了给每个子组件分配唯一 key。虽然 Livewire 会为 <livewire:dynamic-child /><livewire:is /> 自动生成 key,但同一个 key 会应用到_所有_子组件,导致后续渲染被跳过。

关于 key 如何影响组件渲染,详见强制子组件重新渲染

递归组件

多数应用很少需要,但 Livewire 组件可以递归嵌套,即父组件把自身作为子组件渲染。

设想一份问卷,其中有可挂载子问题的 survey-question 组件:

php
<?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

blade
<div>
    <livewire:todo-count :$todos />
</div>

Livewire 内部会给组件挂上一个随机字符串 key,类似:

blade
<div>
    <livewire:todo-count :$todos wire:key="lska" />
</div>

父组件渲染时遇到如上子组件,会把该 key 存进父组件关联的子组件列表:

php
'children' => ['lska'],

后续渲染时,Livewire 用这份列表判断子组件是否已在先前请求中渲染过。若已渲染过则跳过。记住,嵌套组件是独立的。若子组件的 key 不在列表中(说明尚未渲染),Livewire 会新建组件实例并就地渲染。

这些细节大多是后台行为,多数用户不必关心;但给子组件设置 key,是控制子组件渲染的有力手段。

据此,若要强制组件重新渲染,只需改变它的 key。

下面示例中,若传入的 $todos 发生变化,我们可能希望销毁并重新初始化 todo-count

blade
<div>
    <livewire:todo-count :todos="$todos" :wire:key="$todos->pluck('id')->join('-')" />
</div>

如上所示,我们根据 $todos 的内容生成动态 :key 字符串。这样,todo-count 会正常渲染并存在,直到 $todos 本身变化;那时组件会从零重新初始化,旧组件会被丢弃。

另见

  • 事件在嵌套组件之间通信
  • 组件了解如何渲染与组织组件
  • Islands用于局部更新的嵌套替代方案
  • 理解嵌套深入了解嵌套的性能与行为
  • Reactive 属性让嵌套组件中的 props 变为响应式