相信很多同學即便沒有接觸過富文字編輯領域,也一定聽說過【富文字編輯是天坑,千萬不要碰】的說法——是的,富文字編輯是天坑,但 Slate 能很好地幫助你。下面會介紹富文字編輯的複雜度所在,以及 Slate 的解決方式。
背景
富文字編輯領域和常規的前端開發相比,有個非常微妙的區別:在這個領域裡,最流行的解決方案往往是相當【重】的。為什麼在一貫推崇【越輕越好】的前端社群,輕量級的編輯器沒有成為主流呢?這要從編輯器的實現原理說起。
在瀏覽器中,實現富文字編輯的原理大致可分為下面這三種:
- 在
<textarea>
上定位各種樣式。這是 Facebook 早期評論系統所使用的。 - 實現自己的佈局引擎,連閃爍的游標都是通過
<div>
控制的。這是 Google Docs 所使用的。 - 使用瀏覽器原生的 ContentEditable 編輯模式。這是絕大多數現有富文字編輯器所使用的。
三種方案中,第一種連加粗、斜體等操作都很難支援,已經基本棄用;第二種的工作量非常巨大,只有谷歌、微軟這樣能夠自己造瀏覽器的巨頭才能玩得好;對於最後一種,如果你不瞭解 contenteditable
,你可以開啟任意一個網站,在它的 <body>
標籤里加上這個屬性,然後看看它是怎樣變身為一個華麗的編輯器的?看起來這個方式比前兩種都要靠譜許多,瀏覽器已經替你處理好了快捷鍵、撤銷棧、游標、輸入法、相容性…很體貼啊!
ContentEditable 之殤
她那時候還太年輕,不知道所有命運贈予的禮物,早已在暗中標好了價格。
——茨威格《斷頭王后》
天下沒有免費的午餐,ContentEditable 也不例外。Medium Editor 的作者寫過一篇文章,介紹了 ContentEditable 的可怕之處。文中的批評可以歸結為一句話,即 ContentEditable 的資料結構和行為缺乏一致性。
比如,對一句【喜迎十九大】,下面的幾種 HTML 表示是完全等效的:
<!--正常-->
<p>喜迎<b>十九大</b></p>
<!--分離的 b 標籤-->
<p>喜迎<b>十</b><b>九大</b></p>
<!--巢狀的 b 標籤-->
<p>喜迎<b><b>十九大</b></b></p>
<!--空的 b 標籤-->
<p>喜迎<b>十</b><b></b><b>九大</b></p>
<!--span 代替 b 標籤-->
<p>喜迎<span style="font-weight: bold">十九大</span></p>複製程式碼
它們雖然看上去一樣,但對它們的編輯行為會產生顯著的區別。而在使用 ContentEditable 時,瀏覽器經常會自動插入這些垃圾標籤。
再比如,對於一句【喜迎十九大】,一次簡單的換行操作可能產生這樣的結果:
<p>喜迎<br/>十九大</p> <!--插入 br 標籤-->
<p>喜迎</p></p>十九大</p> <!--分割 p 標籤-->複製程式碼
不同瀏覽器哪怕對於簡單的換行操作,其行為也是存在各種分歧的。這樣一來,在 Chrome 中編輯的文件,在 Firefox 中開啟繼續編輯後,就很有可能出現 bug,而這些 bug 並不是簡單的樣式問題,而是會破壞資料結構的惡性 bug。
社群中有不少所謂的【超輕量級編輯器】,它們幾乎就只是 ContentEditable 加了一層美化的殼。這種編輯器基本完全依賴瀏覽器的原生行為,不會顧及 ContentEditable 對資料結構的破壞,基於它們去實現高階的編輯功能是十分困難的。如果抱著【輕量的東西更漂亮】的思路選擇它們,決定前請務必三思。
是應用,是類庫,還是框架?
另一個在富文字編輯領域較為尷尬的問題,是編輯器的定位。一般而言,前端領域接觸的各種專案,不外乎以下三種:
應用 Application
應用泛指包含了介面和互動邏輯的專案,比如各種管理後臺系統。
類庫 Library
類庫提供 API 供使用者呼叫來開發應用,但並不影響應用的程式碼架構,比如 jQuery 和 React:
jQuery: The Write Less, Do More, JavaScript Library
React is a JavaScript library for building user interfaces.
也許不少同學對 React 有【全家桶】的偏見,在這裡再強調一遍,React 本身僅僅是個檢視層,需要和許多類庫結合,才能用於開發應用。
框架 Framework
框架同樣提供 API,但它對應用程式碼有很強的侵入性,需要使用者按照框架的方式,提供程式碼供框架執行。Vue 和 Angular 都是典型的框架:
Vue.js – The Progressive
JavaScript FrameworkAngularJS — Superheroic JavaScript MVW Framework
那麼,富文字編輯器屬於上面的哪一種呢?每個編輯器專案都會說自己的定位是 Editor,但 Editor 是應用、是類庫、還是框架呢?許多主打【開箱即用】的編輯器,已經整合了許多樣式和互動邏輯,實際上已經是一個應用了。
這裡的問題在於,應用的定製性是最差的。因而,在需要定製不同的編輯體驗時,許多【開箱即用】的編輯器很難通過簡單的配置來滿足需求。這時,往往需要使用各種奇技淫巧,或再學習一套編輯器自身笨拙的外掛機制。
Vue 和 Angular 這樣的框架,在易用性上是有口皆碑的。那麼,富文字編輯領域,有沒有這樣的框架呢?有的,並且 Slate 還不是第一個。對編輯器有所瞭解的同學可能知道,Facebook 出品的 Draft.js 就是一個這樣的編輯框架,能讓你使用 React 技術棧定製自己的編輯器。既然 Draft.js 已經非常出色,那麼 Slate 與之相比,有什麼創新之處呢?而對於上文中 ContentEditable 的各種問題,Slate 又是如何解決的呢?讓我們來看看吧。
介紹 Slate
Slate 並非一個編輯器應用,而是一套在 React 和 Immutable 的堅實基礎上,用於操作富文字資料的強大框架。基於 Slate 實現一個富文字編輯器,只相當於使用 React(檢視層)+ Immutable(資料層)開發一個普通 Web 應用。下圖中展示了一個基於 Slate 實現的編輯器架構,資料的流動非常簡單易懂:
圖中,左側檢視層的 Toolbar 工具欄和 Editor 內的各種 Node 都是純粹的 React 元件,右側的模型層則大量應用了 Slate 所提供的支援。下面,我們簡單介紹一下這個架構中的幾個關鍵角色。
Immutable,迄今最理想的資料結構
我們知道,JS 物件的屬性是可以隨意賦值的,也就是 mutable 可變的。而相對地,不可變的資料型別不允許隨意賦值,每次通過 Immutable API 的修改,都會生成一個新的引用。
看起來這並不算什麼,和每次修改都全量複製一份資料比起來並沒有什麼區別。但 Immutable 的強大之處,在於不同引用之間,相同的部分是完全共享的。這也就意味著,對一棵基於 Immutable 的複雜文件樹,即便只改變了某一片葉子節點,也會生成一棵新樹,但這棵新樹除了那一片葉子節點外,所有內容都是和原有的樹共享的。
這和富文字編輯有什麼關係呢?我們知道,編輯器的【撤銷】其實是一個難度非常大的功能,許多定製了撤銷功能的編輯器,很容易出現撤銷前後的狀態不一致的情況。但有了 Immutable 後,每次編輯都會生成一個全新的編輯器狀態,只需簡單地在不同狀態之間切換,就能輕鬆地實現撤銷和重做操作。並且,Immutable 也完全支援複雜的巢狀來表達文件的樹形結構。可以說,Immutable 天生適合用於實現富文字編輯的模型層。在 Slate 和 Draft.js 中,富文字資料就是對 Immutable 的一層封裝,從而自帶了對撤銷操作的支援,不需額外編碼實現。在這方面,Slate 相比 Draft.js 的一個重要加分項是它支援巢狀的資料結構,對錶格等複雜內容的編輯提供了良好的支援。
React,迄今最合適的檢視層
說到 Immutable 就不能不提 React,目前 Immutable.js 這個不可變資料的 JS 庫就是 Facebook 自己實現的,並且一開始引入 Immutable 的目的也不是為了撤銷,而是為了優化 React 應用的效能。可以說,Immutable 和 React 有著天生的默契。
那麼,為什麼我們需要 React 呢?目前,除了 Slate 和 Draft.js 外幾乎所有的編輯器方案,在需要定製編輯節點(如公式、圖表等)時,要麼需要接觸和 DOM 緊密耦合的編輯器外掛概念,要麼只能使用編輯器內建的功能。這種做法在學習成本和效率上都不是最優的。
設想一下,如果編輯器中的編輯內容,全部都能以 React 元件的形式(如標題用 Heading 元件,段落用 Paragraph 元件等)來實現,那麼富文字編輯的門檻還會這麼高嗎?從 Immutable 資料對映到一個個 React 元件,是已經在許多 Web 應用中經歷過考驗的成熟模式。而在這種架構下,ContentEditable 那些令人望而生畏的問題也能得到很好的解決:只需要為 React 元件增加 contentEditable
屬性,而後對各種按鍵、點選等事件 preventDefault
,由框架決定事件對 Immutable 的變換,最後生成新狀態按需觸發重繪即可!
這種方案下,實現一個編輯器不再需要精通 DOM 的專家,難度大大降低了。即便像本文作者這樣僅僅熟悉 React,對前端只有一年多經驗的普通開發者,也有能力開發自己的編輯器了。在此稍微夾帶一些私貨:
在富文字編輯領域,React + Immutable 這種在全域性粒度全量地更改狀態,而後按需更新元件的方案,比起 Vue 這樣基於依賴追蹤細粒度地更新元件的方案,是更有優勢的。Vue 直接 mutate 資料的方式在原理上並不利於實現撤銷與回退,並且函式式元件 VNode 的 API 也沒有 React 這麼直觀易用(Vue 2.5 有改善,但差距仍然存在)。目前,Vue 社群還沒有類似的框架出現,這個場景也是 React 技術棧相比 Vue 的一個閃亮之處。
不過,Draft.js 和 Slate 都實現了對 React 的支援。雖然 Slate 定製節點的 API 更方便一些,但這也不是決定性的優勢。那麼 Slate 的特殊之處又哪呢?
Slate,迄今最靈活的 Controller
從前面的介紹中,我們看到相當多創新之處都是來自 Draft.js 的。那麼,Slate 又有什麼獨特之處呢?
Draft.js 有 Immutable 作為 Model,有 React 作為 View,但在使用它實現編輯器的過程中,你可能會感覺這比起一般的應用開發來,負擔還是有些沉重,或者說少了一點什麼東西。嗯,這個東西也許就是你熟悉的 Controller。
即便在前端輪子滿天飛的今天,UI 應用的架構 MVC 也不會過時,而是演化為了 MVVM 甚至 M-V-Whatever 的架構。編輯器應用同樣是個 UI 應用,我們同樣需要一種機制,將 Model 和 View 連線起來。
這可能不是 Draft.js 的閃光之處,它的文件變換 API 使用起來比較沉重,並且對 EditorState 的修改存在著較多限制。而 Slate 則提供了更加靈活的概念,來連線 Model 與 View。我們簡單介紹一下 Slate 中編輯操作發生時的處理流程:
- 使用者在編輯器游標所在的 Node 內按鍵,觸發事件。
- 根據按鍵的鍵值,分發不同的 Change,如換行、加粗等。
- Change 修改 State,生成新 State。
- 新 State 經過 Schema 校驗後,渲染到編輯器內,按需更新相應的 Node。
整個流程中最核心的機制可概括為一個公式:state.change().change()
,Change 是一個非常優雅的 API,所有的變換都是都通過 Change 物件實現的。比如,使用者先插入了文字,又刪除了另一個段落,這時對文件的變更就可以抽象為:
state.change().insertText().deleteBlock()複製程式碼
每個操作都是鏈式呼叫!在協同編輯的場景下,來自不同使用者的操作其實也可以歸結為這樣對 State 的鏈式呼叫,這也讓基於 Slate 實現協同編輯成為了可能。另一方面,每一個 Change 鏈式呼叫中的 API 都可實現為純函式,而後通過 Slate 的 call
API 來鏈式執行,這也讓編寫自己的 Change 並新增單元測試成為了可能。
這種優雅地處理編輯操作的方式,使得 Slate 能夠更簡單地將 Model 與 View 連線起來,實現對富文字資料的複雜操作。另外,Slate 支援自定義對狀態的 Schema 校驗規則,可以新增一些形如【第一個節點必須是 Heading 節點】或者【圖片節點必須包含 src 屬性】的校驗規則,並對異常資料進行過濾。
當然,Slate 中並沒有 Controller 的概念,不過實際上,基於 Slate 編寫的富文字編輯 Change 操作,和編寫傳統 MVC 應用中 Controller 邏輯的體驗有些接近。換句話說,Slate 把編寫複雜操作邏輯的難度,降低到了編寫 Change 函式的水平。在這一點上,Slate 的架構是十分易用的。
總結
在富文字編輯領域,Slate 是一個後起之秀。不過在推出迄今的短短一年內,它的社群貢獻者數量已經和 Draft.js 甚至 Vue 接近,達到了百人級別。並且,它的 Issue 和 PR 處理比 Draft.js 更加及時,作者對新想法也更加開放,迭代更加活躍。
Slate 的許多核心特性是從其他優秀編輯器專案中借鑑的,如其 Immutable 資料層與框架理念來自 Draft.js、Schema 與 Change 概念來自 ProseMirror 等。雖然它的許多閃光點單獨看來並非獨樹一幟,但在巨集觀層面上做到了博採眾長(聽起來和 Vue 有些接近?)。目前它還處於快速的迭代中,對有興趣參與的同學,成為貢獻者的機會很多哦。