Draft.js 在知乎的實踐
Draft.js 是 Facebook 開源的用於構建富文本編輯器的 JavaScript 框架。
富文本
Draft.js 適合用來解決知乎 Web 端富文本相關的問題,場景包括:
- 提問/回答/寫文章這類帶格式、段落的文本;
- 支持 @、超鏈接的評論;
- 支持換行的個人簡介、私信。
Pure React
Draft.js 基於 React,Draft.js 提供的 Editor 對象是一個 React 組件,可以完美融入 React 項目之中。
例如,初始化一個自定義快捷鍵功能的富文本編輯器,使用非 React 編輯器,可能需要這麼寫:
Editor.init(document.getElementById(#myEditor), {keyBindingFn: myKeyBindingFn})n
或者更加原始的寫法:
document.getElementById(#myEditor).addEventListener(keydown, myKeyBindingFn)n
Draft.js:
<Editor keyBindingFn={myKeyBindingFn} />n
純 React 意味著函數式,而富文本的渲染適合在本質上被理解為函數。如果使用 Draft.js,富文本的狀態被封裝到一個 EditorState 類型的 immutable 對象中,這個對象作為組件屬性(函數參數)輸入給 Editor 組件(函數)。一旦用戶進行操作,比如敲一個回車,Editor 組件的 onChange 事件觸發,onChange 函數返回一個全新的 EditorState 實例,Editor 接收這個新的輸入,渲染新的內容。一切都是聲明式的,看上去就像傳統的 input 組件:
class MyEditor extends React.Component {n constructor(props) {n super(props)n this.state = {editorState: EditorState.createEmpty()} // 創建空的 EditorState 對象n this.onChange = (editorState) => this.setState({editorState})n }n render() {n const {editorState} = this.staten return <Editor editorState={editorState} onChange={this.onChange} />n }n}n
不是什麼
值得注意的是,我不傾向於把 Draft.js 理解為富文本編輯器,Draft.js 更應當被視為用於構建一個網站富文本內容和富文本編輯器的基礎設施。
試著運行一下上面的例子,就會發現頁面上呈現的是一塊可編輯的區域,而不像傳統的富文本編輯器(比如 TinyMCE),渲染出一個帶有工具欄的輸入框。如果我們給 Editor 傳入 readOnly 屬性,Editor 就會變成一個純粹的富文本渲染組件,可以用來渲染一篇文章。只要傳入 EditorState 類型對象作為輸入,Editor 組件就能渲染其中的富文本內容 。Editor 組件同時也包含一系列響應用戶操作的介面如 onChange,以及用於操作 EditorState 對象的工具函數/類。真正是富文本編輯器的應該是我們封裝後的 MyEditor 組件。
如果把富文本比作一幅畫,Draft.js 只提供了畫紙和畫筆,至於怎麼畫,開發者享有很大的自由。
EditorState 與 ContentState
那麼,EditorState 究竟是怎麼封裝富文本編輯器的狀態的呢?調用靜態方法 EditorState.createEmpty,就能得到一個最簡單的空 EditorState 實例,試著把它在瀏覽器控制台里列印出來:
很容易猜測出其中一些屬性的含義,比如 undoStack/redoStack 是「撤銷/重做」棧,selection 標識當前的選區,lastChangeType 記錄最後一次變更操作的類型。EditorState 提供一系列實例方法來獲取和操作這些屬性。
這裡的核心是 currentContent 屬性,currentContent 是 ContentState 類型的對象,ContentState規定了如何存儲具體的富文本內容,包括文字、塊級元素、行內樣式、元數據等。
結構化數據
Draft.js 提供 convertToRaw 方法,用於把 immutable 的 ContentState 對象轉為 plain JavaScript 對象,從而擁有作為 JSON 格式存儲的能力,對應地,convertFromRaw 方法能將轉化後的對象轉回 ContentState 對象。
在瀏覽器里列印下圖所示的內容經過 convertToRaw 轉化的結果:
可以看到的是輸出的對象有一個名為 blocks 的屬性,blocks 是一個數組,每一項代表當前內容中的一個塊級元素。
blocks 的第一項 type 是 unstyled,代表一個普通的段落,text 屬性存儲文字內容,inlineStyleRanges 也是一個數組,它的第一項表明該塊級元素第 7 個位置被添加了 BOLD 樣式,樣式長度為 5,因此,這一行文本的第 8 到第 12 個字元被添加了加粗的行內樣式。
第二項的 type 是 atomic,代表這是一個多媒體區塊,entityRanges 里值為 0 的 key 連接到數組 entityMap 的第 0 項,該 Entity 的類型 type 為 image,data.src 標明了圖片的 url,這是關於一張圖片的信息。Entity 概念在 Draft.js 中用於存儲元數據,圖片、視頻、@、超鏈接都可以依賴 Entity 進行存儲。
富文本內容的結構化存儲一個顯而易見的好處是表現力更強
以用 Python 判斷富文本中有沒有圖片為例。用傳統的 HTML 方式存儲富文本:
# 依賴用來渲染頁面的 HTML tag 及 CSS class,或許應該寫個更嚴謹的正則表達式,如果要取圖片地址之類的元信息則更麻煩nhasImage = <img class="RichText-image" in richContentn
Draft.js:
# 語義清晰,和渲染邏輯無關nhasImage = any(entity.type == image for entity in richContent.entityMap)n
富文本內容的結構化存儲的另一個好處是內容的存儲和渲染邏輯分離
分離能夠帶來更高的靈活性
例如知乎站上用 <a href="/people/s0s0">@李奇</a> 來存儲富文本中對 urlToken 為 s0s0 的用戶的 mention,當加入支持用戶修改自定義的 urlToken 的功能後,如果 urlToken 被修改,那麼原先的鏈接就失效了。解決方案是把鏈接的存儲方式改為 <a href="memberHash">@李奇</a>,其中 memberHash 是唯一的不變的值,為此我們不得不支持 /people/:memberHash 形式的個人主頁鏈接。
另一種思路是存 memberHash,在渲染之前根據 member_hash 去讀取現在的 urlToken。在 Draft.js 中為 mention 創建 entity 如下:
{n type: mention,n data: {n menberHash: abc,n }n}n
存儲和渲染的邏輯分離更容易保證渲染結果的確定性
以一段既加粗又傾斜的文本為例,對於一般的基於 HTML 存儲的富文本編輯器,如果先傾斜後加粗,很可能得到這個結果:
<b><i>我被加粗了,也被傾斜了</i></b>n
如果先加粗後傾斜,則是:
<i><b>我被加粗了,也被傾斜了</b></i>n
Draft.js:
{n "inlineStyleRanges": [n {"offset": 0, "length": 5, "style": "BOLD"},n {"offset": 0, "length": 5, "style": "ITALIC"}n ]n}n
<i> 和 <b> 標籤的順序由渲染邏輯中決定,我們甚至可以改用 CSS class 或者 inline style 來添加樣式(Draft.js 默認的做法)。
內容的存儲和渲染邏輯分離帶來的另一個可能的好處是多端復用
比如在 app 端做原生渲染,結構化數據比 HTML 更利於解析。
自定義
Draft.js 允許調用者自定義富文本的渲染和用戶輸入的處理方式,這些介面以 React prop 的形式暴露在 Editor 上:
<Editorn blockRendererFn={blockRendererFn}n blockStyleFn={blockStyleFn}n customStyleFn={customStyleFn}n keyBindingFn={keyBindingFn}n handleKeyCommand={this.handleKeyCommand}n/>n
const blockRendererFn = contentBlock => {n const type = contentBlock.getType()n let result = nullnn if (type === atomic) {n result = {n component: Media,n editable: false,n }n }nn return resultn}nnconst Media = props => {n const key = props.block.getEntityAt(0)n if (!key) {n return nulln }n const entity = Entity.get(key)n const data = entity.getData()n const type = entity.getType()nn let median if (type === image) {n media = (n <imgn className="content_image"n src={data.src}n alt="用戶上傳的圖片"n />n )n } else if (type === video) {n // ...n }nn return median}n
對於常見的 block 如普通段落、列表、代碼塊等,如果沒在 blockRendererFn 里特殊聲明,Draft.js 提供默認的渲染方式。blockStyleFn 提供輕量級的樣式上的定製,根據 block.type 添加對應的 CSS class。customStyleFn 則負責行內樣式如加粗、傾斜、下劃線的自定義。
keyBindingFn 和 handleKeyCommand 用於定義鍵盤事件的處理方式,下面是一個快捷鍵切換到 readOnly 模式的例子:
const myKeyBindingFn = (e) => {n // command + |n if (e.keyCode === 220 && KeyBindingUtil.hasCommandModifier(e)) {n return command-readonlyn }n return getDefaultKeyBinding(e)n}nnhandleKeyCommand(command) {n const {editorState, readOnly} = this.staten if (command === command-readonly) {n this.setState({readOnly: !readOnly})nt return truen }n const newState = RichUtils.handleKeyCommand(editorState, command)n if (newState) {n this.onChange(newState)nt return truen }n return falsen}n
keyBindingFn 規定了按鍵到 command 的映射,我們定義 command + | 對應的是 command-readonly,getDefaultKeyBinding 則是 Draft.js 的默認映射(包含撤銷、加粗、粘貼等)。
handleKeyCommand 則根據每個 command 做出具體的處理,我們在這裡改變了 state 的值。類似地,RichUtils.handleKeyCommand 提供了 Draft.js 對於 command 的默認處理,RichUtils.handleKeyCommand 接受當前 editorState 和 command 作為參數,返回一個新的 editorState,我們通過 this.onChange 把新的值更新進 state,從而傳給 Editor 對象。
Entity
如上所述,Entity 是 Draft.js 中用於存儲元數據的概念。block.getEntityAt 方法從 block 某個確定的位置得到其對應的 entity。
entity 具有 type 和 data,值得注意的是 entity 還有一個取值為 Immutable、Mutable 或 Segmented 的 mutability 屬性,這個屬性規定著對應著 entity 的文本將如何被修改/刪除。典型的場景是 mention,@xxx 中一旦有一個字元被修改或刪除,mention 應該整體被移除或替換,否則就會出現 @ 的名字和實際 @ 的用戶不一致的情形,因此,mention 這種類型的 entity 應該被聲明為 Immutable。
Decorator
除了 blockRendererFn、blockStyleFn、customStyleFn,Draft.js 還提供 Decorator 來豐富富文本的渲染。依舊以 mention 為例,一個 decorator 是一個以下形式的對象:
{n strategy: (contentBlock, callback) => {nt contentBlock.findEntityRanges(nt character => {nt const entityKey = character.getEntity()nt return (nt entityKey !== null &&nt Entity.get(entityKey).getType() === mentionnt )nt },nt callbacknt )n },n component: Mention,n}n
類似又不同於 blockRendererFn 自定義 block 的渲染,decorator 支持定義 block 內符合某種條件的文本的渲染,strategy 函數負責描述找到這段文本的方式,在這裡是找到所有對應類型為 mention 的 entity 的文字,然後用 Mention 組件進行渲染。
插件機制
draft-js-plugins 是基於 Draft.js 的插件框架,插件化的主要好處是讓富文本編輯器的各個功能相互獨立、易於插拔。相較於原生的 Draft.js Editor,draft-js-plugins-editor 的 Editor 多了一個 plugins的 prop,plugins 是每一項均為一個插件的數組。
每個插件都可以接受 Draft.js Editor 的 prop 作為參數,以此來定義插件的行為,如上文中提到的:
- blockRendererFn
- blockStyleFn
- handleKeyCommand
- decorators
以及沒有提到的:
- handleBeforeInput
- handlePastedText
- handlePastedFiles
- handleDroppedFiles
- handleDrop
- onEscape
- onTab
- onUpArrow
- onDownArrow
實現一個小插件——LinkTitlePlugin
通過 Entity、Decorator、插件機制的配合,我們可以比較簡單地實現一個小的功能插件,比如把粘貼進編輯器的鏈接自動替換為該鏈接對應網頁的標題,我把它命名為 LinkTitlePlugin:
// import ...nn// Link 組件,讀取 entity 中的 url,渲染鏈接nconst Link = ({entityKey, children}) => {n const {url} = Entity.get(entityKey).getData()nn return (n <an target="_blank"n href={url}n >n {children}n </a>n )n}nn// 創建插件的函數,因為插件可能可以接受不同的參數進行初始化。返回的對象就是一個 Draft.js 插件nconst linkTitlePlugin = () => {n return {n decorators: [n {n // 找到對應 type 為 link 的 entity 的文字位置n strategy: (contentBlock, callback) => {n contentBlock.findEntityRanges(n character => {n const entityKey = character.getEntity()n return (n entityKey !== null &&n Entity.get(entityKey).getType() === linkn )n },n callbackn )n },n component: Link,n },n ],n handlePastedText: (text, html, {getEditorState, setEditorState}) => {n n // 如果粘貼進來的不是鏈接,return false 告訴 Draft.js 進行粘貼操作的默認處理n const isPlainLink = !html && linkify.test(text)n if (!isPlainLink) return falsen n fetch(`/scraper?url=${text}`) // 抓取網頁標題的後端服務n .then((res) => res.json())n .then((data) => {n const title = data.titlen const editorState = getEditorState()n const contentState = editorState.getCurrentContent()n const selection = editorState.getSelection()n let newContentStaten if (title && title !== text) {n const entityKey = Entity.create(link, IMMUTABLE, {url: text}) // 創建新 entityn newContentState = Modifier.replaceText(contentState, selection, title, null,n entityKey) // 在當前選區位置插入帶 entity 的文字,文字內容為抓取到的 titlen } else {n newContentState = Modifier.replaceText(contentState, selection, text)n }n const newEditorState = EditorState.push(editorState, newContentState, insert-link) n if (newEditorState) {n setEditorState(newEditorState)n }n }, () => {n // 請求失敗,插入不帶 entity 的純文本,文字內容為粘貼來的原內容n const editorState = getEditorState()n const contentState = editorState.getCurrentContent()n const selection = editorState.getSelection()n const newContentState = Modifier.replaceText(contentState, selection, text)n const newEditorState = EditorState.push(editorState, newContentState, insert-characters)n if (newEditorState) {n setEditorState(newEditorState)n }n })n n // return true 告訴 Draft.js 我已經處理完畢這次粘貼事件,Draft.js 不必再進行處理n return truen },n }n}nnexport default linkTitlePluginn
數據兼容
一個比較麻煩的問題是,Draft.js 推薦的存儲方式是存儲 ContentState 對象經 convertToRaw 轉化後生成的 JSON(Draft.js 並不提供任何到 HTML 的轉換工具),然而對於過去使用基於 HTML 的富文本編輯器的網站(一般而言也會存儲 HTML)而言,這兩種數據格式是不兼容的。
較保守的方案:draft2HTML,存 HTML
依然使用舊的存儲方式,前端富文本編輯器輸出 JSON 後做一次到 HTML 的轉換,保證和老數據兼容。渲染時依舊使用老的方案,即直接讀取 HTML 輸出到頁面上,而不使用 Draft.js 渲染。
Pros:
- draft2HTML 成本較低,易於實現
- 老數據沒有任何風險
Cons:
- 新編輯器無法支持數據的修改,要支持的話還是要實現 HTML2draft
- 寫(新)和讀(老)的渲染方式不一致,如果需要完美地所見即所得,需要在樣式上進行兼容
較激進的方案:HTML2draft,存 draft
把所有過去用 HTML 進行存儲的數據進行一次轉換,統一成 Draft.js 規定的格式。所有的寫和讀都通過 Draft.js。
Pros:
- 理想情況下一旦完成不再需要兼容,寫讀一致
- 享受結構化存儲帶來的優勢
Cons:
- HTML2draft 成本較高,修改老數據風險較大
- 如果有多端(Web、iOS、Android),需要多端同時進行切換
一次嘗試
在做新版知乎 Web 個人頁的過程中,我們在整體視圖框架選用 React 的前提下,嘗試基於 Draft.js 來構建頂欄提問框內的富文本編輯器。
考慮到轉換老數據的風險和協同各端適配新數據格式的成本,決定先不做數據存儲層面的改動。恰巧的是提問功能只涉及到數據的增而不涉及到數據的修改,偏向保守的第一個方案可以滿足知乎新版 Web 個人頁的需求,同時把改版的風險和成本降到最低。
決定方案以後我們做了以下三件事來完成提問功能:
- 基於 Draft.js 實現滿足提問需求的富文本編輯器
- 實現 draft2HTML 函數,把富文本編輯器輸出的 ContentState 轉換為兼容老格式的 HTML 字元串用於存儲
- 在樣式上兼容富文本編輯器中基於 ContentState 渲染的內容和編輯器外基於 HTML 渲染的內容,做到所見即所得
下一步
當然,在未來我們不可避免地會涉及到數據(比如提問、回答)的修改。因此在上一步的基礎之上,我們去實現 HTML2draft 函數,支持新老數據在新編輯器中的修改。同樣出於成本和風險的考慮,我們打算繼續不改變數據存儲的方式。HTML 字元串從資料庫出來,轉換為 ContentState 對象傳入編輯器,編輯完畢後重新轉換回 HTML 存入資料庫,兩種格式的相互轉換在瀏覽器端進行。
至此,我們就可以完成一個支持增改、用於提問、回答、評論並且與老數據兼容的新的適用於 React 的富文本編輯器。這件事完成以後,我們也許再可以去考慮基於 Draft.js 的富文本結構化數據存儲方案。
相關鏈接
- https://github.com/nikgraf/awesome-draft-js
- https://medium.com/@rajaraodv/how-draft-js-represents-rich-text-data-eeabb5f25cf2#.q7vpkxmog
- https://draftjs-examples.herokuapp.com/
「知乎技術日誌」是知乎工程師運營的一個技術專欄,在這裡我們會陸續將知乎在 React、穩定性和安全管理、反作弊系統、微服務實踐、Docker、自動化運維、移動端網路優化等領域的技術思考和實踐分享給大家。希望各位大家給予關注,並提出你寶貴的意見和反饋。
推薦閱讀:
※SegmentFault 技術周刊 Vol.14 - 進階 Vue 2.0
※PYTHON如何控制網頁?
※天天演算法 | Medium | 5. 3Sum : 找出所有和為零的三元組(不重複)
※Atom 編輯器怎麼快速移除空白行?
※「Luy」CSS盒子模式還是很重要的