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

程式技術
sharkHead

中秋連假,一時心血來潮想要把部落格中所有頁面都改為使用 Livewire。

這次將部落格中新增文章更新文章的部分改為使用 Livewire,原本以為很簡單,結果沒想到整合 Livewire  與 CKEditor 5 的問題比想像中還要來得多,花費了一些時間才理解問題並找到解決方法,決定寫一篇文章進行記錄。

編輯一下資料,怎麼 CKEditor 的編輯區就消失了 ?

我們可以使用以下的方式載入 CKEditor。

...
    <script src="/public/js/ckedtiro.js"></script>
</head>

<body>
    <div class="mt-5 max-w-none">
        <div id="editor"></div>
    </div>

    <script>
        ClassicEditor
            .create(document.querySelector('#editor'))
            .catch(error => {
                console.error(error);
            });
    </script>
</body>

如果在 livewire component 中使用 wire:model 做資料綁定的話,這時候只要稍微編輯一下資料,CKEdtior 的編輯區塊會消失。

消失的原因在於 component 的重新整理,在頁面第一次載入完畢之後, CKEditor  的 JavaScript 腳本會在 <div id="editor"> 的後面加上兩個新的元素,用來顯示 CKEdtior 編輯區塊。

<div class="mt-5 max-w-none">
    <div id="editor"></div>

    <!-- 下面這兩個元素是 CKEditor 載入後才會加入的,也是主要顯示編輯器的部分 -->
    <!-- CKEditor 功能列 -->
    <div class="ck ck-editor__top ck-reset_all" role="presentation">...</div>
    <!-- CKEditor 編輯區塊 -->
    <div class="ck ck-editor__main" role="presentation">...</div>
</div>

因為  Livewire 在更新資料時,會重新整理整個 component ,因此原本不在 component 中的元素就會被移除,而重新整理後並不會再次執行 JavaScript 腳本,這也導致 CKEditor 的元素一移除就不會再出現。

<div id="editor"></div>

<!-- 因為原本就不包含在 livewire component 中,因此 component 重新整理後,下方的元素就會被移除 -->
<!-- CKEditor 功能列 -->
<div class="ck ck-editor__top ck-reset_all" role="presentation">...</div>
<!-- CKEditor 編輯區塊 -->
<div class="ck ck-editor__main" role="presentation">...</div>

為了避免  Livewire  移除由 JavaScript  加入的元素,Livewire 貼心的提供 wire:ignore 來避免移除被更動後的 DOM (Document Object Model)。

<!-- 加入 wire:ignore 就可以避免移除變更後的子元素 -->
<div wire:ignore class="mt-5 max-w-none">
    <div id="editor"></div>
</div>

wire:ignore 會涵蓋到元素"內容"的改變。如果只想涵蓋元素本身的改變,並忽略掉內容的改變,可以使用 wire:ignore.self

wire:model 怎麼沒有作用 !?

在 livewire component 中可以使用 wire:model 將頁面上資料與與後端的資料做雙向綁定,可以使用在常見的輸入元素 <input><select><textarea>

但文字編輯的值都是更新在 JavaScript 載入後才加入的元素中 ,因此加上 wire:model 是沒有用的。

<!-- wire:model 並不支援 div 元素 (div 元素也沒有 value) -->
<div wire:model="body"></div>

<!-- 換成 textarea 也沒用喔 -->
<textarea wire:model="body" id="editor" name="body" placeholder="hello world!"></textarea>

<!-- CKEditor 載入後才會加入的元素 -->
...
<!-- CKEditor 編輯區塊,這才是主要文字編輯時會更新的地方 -->
<div class="ck ck-editor__main" role="presentation">...</div>

根據 CKEditor 的文件,我們可以在建立 editor 時加入一個偵測值改變的 event listener,當值一改變,就使用 Livewire 提供的語法糖 @this.set() 更新後端的資料。

ClassicEditor
    .create(document.querySelector('#editor'))
    .then(editor => {
        // 加入 Event Listener,編輯值時就會觸發
        editor.model.document.on('change:data', () => {
            // Livewire 提供 @this.set(),可以在 Vanilla JS 中改變 Livewire 後端的資料
            @this.set('body', editor.getData());
        })
    })
    .catch(error => {
        console.error( error );
    });

也可以在建立 editor 之後才加入 event listener,透過 query selector 取得 editor 的元素之後,再用元素取得 editor 實體,這個時候就可以用剛剛的方法加入 event listener。

// 等待頁面載入完畢時在執行,避免 query selector 抓不到元素
window.addEventListener('load', () => {
    // 取得 CKEditor 元素
    const domEditableElement = document.querySelector('.ck-editor__editable');

    // 取得 CKEditor Instance
    const editorInstance = domEditableElement.ckeditorInstance;

    // 使用剛剛的方法監聽編輯區塊值的改變
    editorInstance.model.document.on('change:data', () => {
        @this.set('body', editorInstance.getData());
    });
});

因為值一改變就會觸發 Livewire 發出 XHR (XMLHttpRequest) 請求,為了避免產生太多的請求對伺服器造成負擔,可以自己寫一個簡單的 debounce() 方法減少請求的次數,在停止觸發事件一段時間之後,請求才會送出。

let debounceTimer;

const debounce = (callback, time) => {
    // 取消上一次的 window.setTimeout
    window.clearTimeout(debounceTimer);
    debounceTimer = window.setTimeout(callback, time);
};

window.addEventListener('load', () => {
    const domEditableElement = document.querySelector('.ck-editor__editable');

    const editorInstance = domEditableElement.ckeditorInstance;

    editorInstance.model.document.on('change:data', () => {
        // 停止輸入一段時間後才會送出 XHR 請求
        debounce(() => {
            @this.set('body', editorInstance.getData())
        }, 500);
    });
});

其實要監聽編輯內容改變,除了 CKEditor 提供的方法,也可以使用 JavaScript 提供的事件監聽。

因為 CKEditor 的編輯是使用 Content Editable 當作編輯區塊 (很多富文本編輯器都會這樣使用),這允許使用者直接編輯元素 (輸入的字串都會包在 <p> 中,換行會直接產生 <br>)。

<div contenteditable="true">
  This text can be edited by the user.
</div>

因此可以使用 DOMSubtreeModified 去監聽元素的變化,或是使用 inputpaste 監聽使用者的輸入與貼上動作,效果都會相同。

const domEditableElement = document.querySelector('.ck-editor__editable');

const editorInstance = domEditableElement.ckeditorInstance;

// CKEditor 提供用來監聽內容的方法
editorInstance.model.document.on('change:data', () => {
    @this.set('body', editorInstance.getData());
});

// 可以使用 DOMSubtreeModified 監聽元素的變化
domEditableElement.addEventListener('DOMSubtreeModified', () => {
    @this.set('body', editorInstance.getData())
});

// 或是同時監聽使用者的輸入與貼上等行為
domEditableElement.addEventListener('input', () => {
    @this.set('body', editorInstance.getData())
});

domEditableElement.addEventListener('paste', () => {
    @this.set('body', editorInstance.getData())
});

使用 Livewire Lifecycle Hooks 實作自動儲存功能

Livewire 有提供一些 hook 方法,讓你可以在 component 載入 → 發出請求 → 更新資料的生命週期中加入你想額外處理的邏輯行為。

一般來說要實現自動儲存的功能,可以在文章資料一更動時,就發出 XHR 請求資料暫存至後端中,這種情況可以使用 Livewire 提供的 updated() 方法。範例中我選擇使用 redis 來暫存文章的資料。

// 將這個屬性的值會與 CKEditor 的編輯內容綁定在一起
// 只要 CKEditor 的編輯內容一有更動,就會更新 $body 的值
public string $body = '';

// ...

// updated() 方法在更新屬性的值時就會自動觸發
public function updated()
{
    // 就將資料暫存至 redis
    Redis::set('auto_save_unique_key', json_encode(
        [
            'title' => $this->title,
            'category_id' => $this->category_id,
            'tags' => $this->tags,
            'body' => $this->body,
        ], JSON_UNESCAPED_UNICODE)
    );

    // 設定 TTL (Time To Live) 為 7 天
    Redis::expire('auto_save_unique_key', 604_800);
}

之後就可以使用 mount() 方法中將資料從 redis 中取出,在頁面重新整理的時候,即使之前編輯的資料沒有儲存至資料庫也不會遺失。

// ...

// mount() 方法如同建構子,在重新整理頁面時執行,只會執行一次
public function mount()
{
    if (Redis::exists('auto_save_unique_key')) {
        $autoSavePostData = json_decode(Redis::get('auto_save_unique_key'), true);

        $this->title = $autoSavePostData['title'];
        $this->category_id = (int) $autoSavePostData['category_id'];
        $this->tags = $autoSavePostData['tags'];
        $this->body = $autoSavePostData['body'];
    }
}

在資料儲存到資料庫之後,就可以把暫存的資料從 redis 中清除。

public function store()
{
    // ...

    Redis::del('auto_save_unique_key');
}

參考資料

sharkHead
written by
sharkHead

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

2 則留言
sharkHead sharkHead 2023 年 10 月 25 日

Hello~

基本上你在某個元素上使用 wire:ignore 的話,那麼該元素底下所有子元素都不會被 livewire 的重新整理給移除,所以應該不需要在子元素上再加上 wire:ignore

訪客 2023 年 10 月 25 日

想要請教 外層元素用wire:ignore.self 那內層是否還可以用wire:ignore 或是 wire:ignore.self?