在 Livewire 中使用 CKEditor 所遇到的各種問題
中秋連假,一時心血來潮想要把部落格中所有頁面都改為使用 Livewire。
這次將部落格中新增文章與更新文章的部分改為使用 Livewire,原本以為很簡單,結果沒想到整合 Livewire 與 CKEditor 5 的問題比想像中還要來得多,花費了一些時間才理解問題並找到解決方法,決定寫一篇文章進行記錄。
編輯一下資料,怎麼 CKEditor 的編輯區就消失了 ?
我們可以使用以下的方式載入 CKEditor。
<head>
{{-- ... --}}
<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
會讓 Livewire 忽略元素本身以及內容的改變,這包含了元素的屬性,以及子元素。如果只想忽略元素本身屬性的改變,可以使用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 之後才加入事件監聽,透過 querySelector
取得 Editor 的元素之後,再用元素取得 Editor 實體,這個時候就可以用剛剛的方法加入事件監聽。
// 等待頁面載入完畢時在執行,避免 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
去監聽元素的變化,或是使用 input
與 paste
監聽使用者的輸入與貼上動作,效果都會相同。
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');
}