分享使用 Laravel Livewire 時曾遇到過的各種陷阱卡

程式技術
sharkHead

小弟是前端苦手,因此部落格是使用 Laravel Livewire 這個 Laravel 的全端框架,而部落格經營到現在也兩年多了,這兩年來我多次幫部落格進行各種小改版,希望可以讓自己寫文章的體驗越來越好。

而我也在這多次改版中,慢慢的熟悉 livewire 這個全端框架,藉此機會,我想要整理幾個過去使用 livewire 時曾經犯的錯誤或是踩過的坑,希望此篇文章可以幫助到同樣使用 livewire 的朋友們。

Single-Level Root Element

這是我當初使用 livewire 時,因為沒仔細看文件所犯的基本錯誤,livewire 的元件 (component) 一定要是 single-level root element,也就是只能有一個根元素。

<!-- livewire-component.blade.php -->

<div>
    <!-- 只能有一個根元素 -->
</div>

錯誤的元件範例。

<!-- livewire-wrong-component.blade.php -->

<div>
    <!-- 我是第一個根元素 -->
</div>

<div>
    <!-- 我是第二個根元素 -->
</div>

使用迴圈時,應該加上 Key 來追蹤元素的變化

Laravel livewire 有一個 DOM (Document Object Model) diffing/patching 系統,該系統會偵測元素的變化,並對元素進行新增、修改或移除。

但在某些狀況下,livewire 會無法追蹤元素的變化,例如在元件中使用迴圈。

<ul>
    @foreach ($items as $item)
        <li>{{ $item }}</li>
    @endforeach
</ul>

雖然上方可以正常顯示,但如果 $items 有更新的話,livewire 會無法正確更新列表的狀態,而解決方法就是幫元素加上 wire:key 屬性,幫助 livewire 去追蹤元素的變化。

<ul>
    @foreach ($items as $item)
        <li wire:key="item-{{ $item->id }}">{{ $item }}</li>
    @endforeach
</ul>

注意 key 必須使用唯一值,因此官方建議加上前綴 (prefix),應該避免使用 $loop->index 這種可能會重複的值。

如果迴圈中包的是另外一個 livewire 元件,key 的用法如下。

<ul>
    @foreach ($items as $item)
        @livewire('view-item', ['item' => $item], key('item-'.$item->id))

        <!-- key() using Laravel 7's tag syntax -->
        <livewire:view-item :item="$item" :wire:key="'item-'.$item->id">
    @endforeach
</ul>

Vue 的 v-for 也會要求使用 key 來追蹤元素的變化,詳細可以參考下方連結的文章,來了解 key 的用途。

How & Why to use the `:key` attribute in VueJS v-for loops

在更新父元件時,讓子元件的內容也一起更新

如剛剛提到的,我們會在迴圈中使用 前綴 + id 當作 key,來幫助 livewire 追蹤元素的變化,並對元素進行新增或是刪除。

假設資料的數量不變,但內部的內容有改變的話,livewire 是無法從 前綴 + id 的 key 值追蹤到這個元素的更新 (因為數量沒有變化)

簡單舉個留言板的例子

假設我們有一個顯示所有留言的 comment-list 元件,與顯示單一留言的 comment-item 元件。

comment-list 元件會從資料庫中讀取所有留言,並將留言內容傳入 comment-item 元件。

資料表

我們有一個資料表 comments,其結構與資料如下。

CREATE TABLE `comments` (
	`id` BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
	`body` TEXT NOT NULL COLLATE 'utf8mb4_unicode_ci',
	`created_at` TIMESTAMP NULL DEFAULT NULL,
	`updated_at` TIMESTAMP NULL DEFAULT NULL,

	PRIMARY KEY (`id`) USING BTREE
);

INSERT INTO `comments` (
  `body`, `created_at`, `updated_at`
)
VALUES
('comment 1', NOW(), NOW()),
('comment 2', NOW(), NOW()),
('comment 3', NOW(), NOW());

Comment List 元件

comment-list 元件的後端程式碼。

<?php

// CommentList.php

namespace App\Http\Livewire;

use App\Models\Comment;
use Livewire\Component;

class CommentList extends Component
{
    // 設定一個 event listener
    // 這個 event listener 可以用來重整 comment-list 元件
    protected $listeners = ['refreshCommentList' => '$refresh'];

    public function render()
    {
        return view('livewire.comment-list', ['comments' => Comment::all()]);
    }
}

comment-list 元件的前端程式碼。

<!-- comment-list.blade.php -->

<div class="space-y-6 w-1/2">
    @foreach ($comments as $comment)
        <livewire:comment-item
            :comment-id="$comment->id"
            :body="$comment->body"
            :created-at="$comment->created_at->format('Y 年 m 月 d 日')"
            :wire:key="'comment-'.$comment->id"
        />
    @endforeach
</div>

Comment Item 元件

comment-item 元件的後端程式碼。

需要注意的是,屬性不能夠使用 $id 命名,因為該屬性名稱已被 livewire 所使用。

<?php

// CommentItem.php

namespace App\Http\Livewire;

use Livewire\Component;

class CommentItem extends Component
{
    // 注意屬性不能使用 $id,因此這裡我們囉嗦一點,使用 $commentId
    public int $commentId;

    public string $body;

    public string $createAt;

    public function render()
    {
        return view('livewire.comment-item');
    }
}

comment-item 的前端程式碼。

<!-- comment-item.blade.php -->

<div class="ml-5">
    <div class="flex items-center justify-between border border-blue-400 p-5">
        <div>{{ $body }}</div>

        <div>{{ $createAt }}</div>

        <!-- 點擊這個按鈕可以觸發 CommentList.php 中的 refreshCommentGroup 事件 -->
        <button
            wire:click="$emit('refreshCommentList')"
            class="text-white bg-blue-500 hover:bg-blue-600 rounded-lg py-2 px-4"
        >
            重新整理
        </button>
    </div>
</div>

可以由上述的程式碼得知,comment-listcomment-item 的父元件。

我們測試一下,假設我們在 comments 資料表新增一筆資料,然後按下 comment-item.blade.php 中的重新整理按鈕 (不是重新整理頁面喔)。

可以發現畫面上多了剛剛在資料表中新增的留言

Livewire 是透過 XHR 請求取得新的資料,並根據資料的內容更新畫面上的 DOM,所以不用重新整理頁面,也能夠更新畫面上的資料

接下來我們不新增資料,而是只修改某一筆資料的 body 內容看看,然後按下重新整理按鈕。

你會發現…

資料沒有任何變化

因為 livewire 是從 key 值去判斷畫面如何更新,因為我們沒有新增資料,所以 id 數量不變,這會讓 livewire 以為資料沒變化,因此不會有任何動作。

如果想要在 body 被修改後觸發畫面更新,我們需要把 body 也加入 :wire:key 中,讓 livewire 知道 body 有被修改。

<!-- comment-list.blade.php -->

<div class="space-y-6 w-1/2">
    @foreach ($comments as $comment)
        <livewire:comment-item
            :body="$comment->body"
            :created-at="$comment->created_at->format('Y 年 m 月 d 日')"
            :wire:key="'comment-'.$comment->id.'-'.$comment->body"
        />
    @endforeach
</div>

在資料表中更動 body 資料後,再次按下 comment 中的重新整理按鈕,就會發現資料可以正常更新了。

根據這個思維,其實你可以在 :wire:key 設定一個每一次重新整理都會更動的值,這樣就能確保每次父元件更新資料時,子元件也會一起更新,例如 now()->toString()

<!-- comment-list.blade.php -->

<div class="space-y-6 w-1/2">
    @foreach ($comments as $comment)
        <livewire:comment-item
            :body="$comment->body"
            :created-at="$comment->created_at->format('Y 年 m 月 d 日')"
            :wire:key="now()->toString()"
        />
    @endforeach
</div>

雖然這也是個方法,但實際應該還是依照情況去設定 key 的值

屬性設定 Model 可能會產生的問題

Livewire 的屬性 (property) 類型是有限制的。

  • 與 javascript 相容的格式,例如 stringintarrayboolean
  • 部分 PHP 類型,例如 Stringable, Collection, DateTime, Model, EloquentCollection

雖然 Model 也可以當作 livewire 的屬性,但有幾點需要注意,第一個是資安問題,livewire 文件中已經明確提到不要將敏感資料儲存在屬性中,因為這些屬性的資料會存放在前端,因此屬性使用 Model ,等於是將 Model 的資料存在前端,有可能一不小心就洩漏敏感資料 (例如 User Model 的 email 資料)。

這部分的疑慮可以參考下方這篇文章。

Advanced Livewire: A better way of working with models

除了上述原因,還有另外一個我個人不太推薦在屬性上使用 Model 的原因。

奇怪的 404 問題

我們為剛剛的留言加上一個修改留言的功能,新增一個 edit-comment 元件。

Edit Comment 元件

edit-comment 元件的後端程式碼。

<?php

// EditComment.php

namespace App\Http\Livewire;

use App\Models\Comment;
use Livewire\Component;

class EditComment extends Component
{
    // 在屬性上使用 Comment Model
    public Comment $comment;

    // 新留言的內容
    public string $body = '';

    protected $listeners = ['setEditComment'];

    // 先透過 comment id 取得 comment 的完整資料,並更新 body 的資料
    public function setEditComment(Comment $comment)
    {
        $this->comment = $comment;
        $this->body = $this->comment->body;
    }

    public function update(): void
    {
        // 更新留言
        $this->comment->update(['body' => $this->body]);
    }

    public function render()
    {
        return view('livewire.edit-comment');
    }
}

edit-comment 元件的前端程式碼。

<!-- edit-comment.blade.php -->

<div>
    <form wire:submit.prevent="update">
        <label for="body"></label>

        <!-- 與後端的屬性 body 資料綁定在一起 -->
        <textarea
            wire:model="body"
            id="body"
            placeholder="寫下你的新留言"
        ></textarea>

        <button type="submit">更新</button>
    </form>
</div>

然後在剛剛的 comment-list 前端程式碼上面,加上 edit-comment 的元件。

<!-- comment-list.blade.php -->

<div class="space-y-6 w-1/2">
    @foreach ($comments as $comment)
        <livewire:comment-item
            :body="$comment->body"
            :created-at="$comment->created_at->format('Y 年 m 月 d 日')"
            :wire:key="'comment-'.$comment->id.'-'.$comment->body"
        />
    @endforeach

    <!-- 將 edit-comment 加在這裡 -->
    <livewire:edit-comment />
</div>

接下來,我們先幫 comment-item 元件加上一個刪除留言的功能。

<?php

// CommentItem.php

namespace App\Http\Livewire;

use App\Models\Comment;
use Livewire\Component;

class CommentItem extends Component
{
    public int $commentId

    public string $body;

    public string $createAt;

    // 加上一個刪除留言的功能
    public function destroy(Comment $comment)
    {
        $comment->delete();

        // 刪除後重新整理留言列表
        $this->emitUp('refreshCommentList');
    }

    public function render()
    {
        return view('livewire.comment-item');
    }
}

然後在 comment-item 前端程式碼加上刪除與修改的按鈕。

<!-- comment-item.blade.php -->

<div class="ml-5">
    <div class="flex items-center justify-between border border-blue-400 p-5">
        <div>{{ $body }}</div>

        <div>{{ $createAt }}</div>

        <button
            wire:click="$emit('refreshCommentList')"
            class="text-white bg-blue-500 hover:bg-blue-600 rounded-lg py-2 px-4"
        >
            重新整理
        </button>

        <!-- 點擊這個按鈕可以觸發 EditComment.php 中的 setEditComment 事件 -->
        <button
            wire:click="$emit('setEditComment', {{ $commentId }})"
            class="text-white bg-blue-500 hover:bg-blue-600 rounded-lg py-2 px-4"
        >
            修改
        </button>

        <button
            wire:click="destroy({{ $commentId }})"
            class="text-white bg-blue-500 hover:bg-blue-600 rounded-lg py-2 px-4"
        >
            刪除
        </button>
    </div>
</div>

一切準備就緒之後,接下來請按照下方步驟依序執行,讓我們嘗試觸發一個奇怪的問題。

  1. 我們先按畫面上第一個留言的修改按鈕。
  2. 此時 edit-comment.blade.php 的內容就會更新,<textarea> 會出現第一個留言內容的資料,這時先不要做任何動作。
  3. 我們按下第一個留言的刪除按鈕,將第一個留言從資料庫中刪除,並重新整理留言列表。
  4. 接下來按下第二個留言的修改按鈕。

你可能會預期 edit-comment 前端程式碼的 <textarea> 會更新上第二個留言的內容,但你的畫面應該出現了 …

一個彈跳視窗顯示 404 且資料找不到的錯誤

關於這個問題,我很推薦拜讀下面這篇由 livewire 作者所寫的文章,文章中詳細的說明了 livewire 的執行方式。

Livewire isn't actually "live"

當你按下第一個留言的修改按鈕時,edit-comment 元件的 $comment 屬性會被載入第一個留言的資料,並且將這個狀態儲存在前端中

接下來我們故意將第一個留言從資料庫中刪除。

隨後我們按下第二個留言的修改按鈕,一般來說,我們預期 livewire 會幫我們載入第二個留言的資料,但在這之前,livewire 會先從前端發出的 XHR 請求中取得剛剛的狀態,根據狀態的內容,livewire 會先幫我們載入第一個留言的資料。

但我們先前已經刪除了第一個留言,因此 livewire 在無法取得正確的資料後,就會出現錯誤。

要修正這個問題,我們可以避免在屬性上使用 Model,而是在呼叫方法後再執行一次查詢取得留言的資料。

<?php

// EditComment.php

namespace App\Http\Livewire;

use App\Models\Comment;
use Livewire\Component;

class EditComment extends Component
{
    // 新留言的內容
    public string $body = '';

    protected $listeners = ['setEditComment'];

    // 先透過 comment id 取得 comment 的完整資料,並更新 body 的資料
    public function setEditComment(Comment $comment)
    {
        $this->body = $comment->body;
    }

    public function update(Comemnt $comment): void
    {
        // 更新留言
        $comment->update(['body' => $this->body]);
    }

    public function render()
    {
        return view('livewire.edit-comment');
    }
}

這個問題曾在 GitHub Issue 上被討論過。

404 Not Found Modal after delete · Issue #2135 · livewire/livewire (github.com)

編輯器失蹤事件

這個問題之前有專門寫一篇文章討論過。

在 Livewire 中使用 CKEditor 所遇到的各種問題

基本上由 JavaScript 產生的 DOM 在 livewire 重新整理元件後都會消失,我們可以使用 wire:ignore 來解決這個問題,詳細的原由可以參考上面的文章。

參考資料

sharkHead
written by
sharkHead

後端打工仔,在下班後喜歡研究各種不同的技術。稍微擅長 PHP,並偶爾涉獵前端開發。個性就像動態語言般隨興,但渴望做事能像囉嗦的靜態語言那樣嚴謹。

1 則留言
訪客 2023 年 02 月 18 日

感謝這用心的分享!