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})
或者更加原始的寫法:
document.getElementById('#myEditor').addEventListener('keydown', myKeyBindingFn)
Draft.js:
<Editor keyBindingFn={myKeyBindingFn} />
純 React 意味着函數式,而富文本的渲染適合在本質上被理解為函數。如果使用 Draft.js,富文本的狀態被封裝到一個 EditorState 類型的 immutable 對象中,這個對象作為組件屬性(函數參數)輸入給 Editor 組件(函數)。一旦用戶進行操作,比如敲一個回車,Editor 組件的 onChange 事件觸發,onChange 函數返回一個全新的 EditorState 實例,Editor 接收這個新的輸入,渲染新的內容。一切都是聲明式的,看上去就像傳統的 input 組件:
class MyEditor extends React.Component { constructor(props) { super(props) this.state = {editorState: EditorState.createEmpty()} // 創建空的 EditorState 對象 this.onChange = (editorState) => this.setState({editorState}) } render() { const {editorState} = this.state return <Editor editorState={editorState} onChange={this.onChange} /> } }
不是什么
值得注意的是,我不傾向於把 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,或許應該寫個更嚴謹的正則表達式,如果要取圖片地址之類的元信息則更麻煩 hasImage = '<img class="RichText-image"' in richContent
Draft.js:
# 語義清晰,和渲染邏輯無關 hasImage = any(entity.type == 'image' for entity in richContent.entityMap)
富文本內容的結構化存儲的另一個好處是內容的存儲和渲染邏輯分離
分離能夠帶來更高的靈活性
例如知乎站上用 <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 如下:
{ type: 'mention', data: { menberHash: 'abc', } }
存儲和渲染的邏輯分離更容易保證渲染結果的確定性
以一段既加粗又傾斜的文本為例,對於一般的基於 HTML 存儲的富文本編輯器,如果先傾斜后加粗,很可能得到這個結果:
<b><i>我被加粗了,也被傾斜了</i></b>
如果先加粗后傾斜,則是:
<i><b>我被加粗了,也被傾斜了</b></i>
Draft.js:
{ "inlineStyleRanges": [ {"offset": 0, "length": 5, "style": "BOLD"}, {"offset": 0, "length": 5, "style": "ITALIC"} ] }
<i> 和 <b> 標簽的順序由渲染邏輯中決定,我們甚至可以改用 CSS class 或者 inline style 來添加樣式(Draft.js 默認的做法)。
內容的存儲和渲染邏輯分離帶來的另一個可能的好處是多端復用
比如在 app 端做原生渲染,結構化數據比 HTML 更利於解析。
自定義
Draft.js 允許調用者自定義富文本的渲染和用戶輸入的處理方式,這些接口以 React prop 的形式暴露在 Editor 上:
<Editor blockRendererFn={blockRendererFn} blockStyleFn={blockStyleFn} customStyleFn={customStyleFn} keyBindingFn={keyBindingFn} handleKeyCommand={this.handleKeyCommand} />
通過 blockRendererFn 自定義渲染當前 block 的方式,例如指定調用 Media 組件去渲染 type 為 atomic 的 block,當前 block 會被注入到組件的 props 中:
const blockRendererFn = contentBlock => { const type = contentBlock.getType() let result = null if (type === 'atomic') { result = { component: Media, editable: false, } } return result } const Media = props => { const key = props.block.getEntityAt(0) if (!key) { return null } const entity = Entity.get(key) const data = entity.getData() const type = entity.getType() let media if (type === 'image') { media = ( <img className="content_image" src={data.src} alt="用戶上傳的圖片" /> ) } else if (type === 'video') { // ... } return media }
對於常見的 block 如普通段落、列表、代碼塊等,如果沒在 blockRendererFn 里特殊聲明,Draft.js 提供默認的渲染方式。blockStyleFn 提供輕量級的樣式上的定制,根據 block.type 添加對應的 CSS class。customStyleFn 則負責行內樣式如加粗、傾斜、下划線的自定義。
keyBindingFn 和 handleKeyCommand 用於定義鍵盤事件的處理方式,下面是一個快捷鍵切換到 readOnly 模式的例子:
const myKeyBindingFn = (e) => { // command + | if (e.keyCode === 220 && KeyBindingUtil.hasCommandModifier(e)) { return 'command-readonly' } return getDefaultKeyBinding(e) } handleKeyCommand(command) { const {editorState, readOnly} = this.state if (command === 'command-readonly') { this.setState({readOnly: !readOnly}) return true } const newState = RichUtils.handleKeyCommand(editorState, command) if (newState) { this.onChange(newState) return true } return false }
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 是一個以下形式的對象:
{ strategy: (contentBlock, callback) => { contentBlock.findEntityRanges( character => { const entityKey = character.getEntity() return ( entityKey !== null && Entity.get(entityKey).getType() === 'mention' ) }, callback ) }, component: Mention, }
類似又不同於 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 ... // Link 組件,讀取 entity 中的 url,渲染鏈接 const Link = ({entityKey, children}) => { const {url} = Entity.get(entityKey).getData() return ( <a target="_blank" href={url} > {children} </a> ) } // 創建插件的函數,因為插件可能可以接受不同的參數進行初始化。返回的對象就是一個 Draft.js 插件 const linkTitlePlugin = () => { return { decorators: [ { // 找到對應 type 為 link 的 entity 的文字位置 strategy: (contentBlock, callback) => { contentBlock.findEntityRanges( character => { const entityKey = character.getEntity() return ( entityKey !== null && Entity.get(entityKey).getType() === 'link' ) }, callback ) }, component: Link, }, ], handlePastedText: (text, html, {getEditorState, setEditorState}) => { // 如果粘貼進來的不是鏈接,return false 告訴 Draft.js 進行粘貼操作的默認處理 const isPlainLink = !html && linkify.test(text) if (!isPlainLink) return false fetch(`/scraper?url=${text}`) // 抓取網頁標題的后端服務 .then((res) => res.json()) .then((data) => { const title = data.title const editorState = getEditorState() const contentState = editorState.getCurrentContent() const selection = editorState.getSelection() let newContentState if (title && title !== text) { const entityKey = Entity.create('link', 'IMMUTABLE', {url: text}) // 創建新 entity newContentState = Modifier.replaceText(contentState, selection, title, null,