Draft.js 在知乎的實踐


Draft.js 在知乎的實踐

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 實例,試着把它在瀏覽器控制台里打印出來:

image

很容易猜測出其中一些屬性的含義,比如 undoStack/redoStack 是「撤銷/重做」棧,selection 標識當前的選區,lastChangeType 記錄最后一次變更操作的類型。EditorState 提供一系列實例方法來獲取和操作這些屬性。

這里的核心是 currentContent 屬性,currentContent 是 ContentState 類型的對象,ContentState規定了如何存儲具體的富文本內容,包括文字、塊級元素、行內樣式、元數據等。

結構化數據

Draft.js 提供 convertToRaw 方法,用於把 immutable 的 ContentState 對象轉為 plain JavaScript 對象,從而擁有作為 JSON 格式存儲的能力,對應地,convertFromRaw 方法能將轉化后的對象轉回 ContentState 對象。

在瀏覽器里打印下圖所示的內容經過 convertToRaw 轉化的結果:

image image

可以看到的是輸出的對象有一個名為 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, entityKey) // 在當前選區位置插入帶 entity 的文字,文字內容為抓取到的 title  } else { newContentState = Modifier.replaceText(contentState, selection, text) } const newEditorState = EditorState.push(editorState, newContentState, 'insert-link') if (newEditorState) { setEditorState(newEditorState) } }, () => { // 請求失敗,插入不帶 entity 的純文本,文字內容為粘貼來的原內容  const editorState = getEditorState() const contentState = editorState.getCurrentContent() const selection = editorState.getSelection() const newContentState = Modifier.replaceText(contentState, selection, text) const newEditorState = EditorState.push(editorState, newContentState, 'insert-characters') if (newEditorState) { setEditorState(newEditorState) } }) // return true 告訴 Draft.js 我已經處理完畢這次粘貼事件,Draft.js 不必再進行處理  return true }, } } export default linkTitlePlugin 

數據兼容

一個比較麻煩的問題是,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 個人頁的需求,同時把改版的風險和成本降到最低。

決定方案以后我們做了以下三件事來完成提問功能:

  1. 基於 Draft.js 實現滿足提問需求的富文本編輯器
  2. 實現 draft2HTML 函數,把富文本編輯器輸出的 ContentState 轉換為兼容老格式的 HTML 字符串用於存儲
  3. 在樣式上兼容富文本編輯器中基於 ContentState 渲染的內容和編輯器外基於 HTML 渲染的內容,做到所見即所得

下一步

當然,在未來我們不可避免地會涉及到數據(比如提問、回答)的修改。因此在上一步的基礎之上,我們去實現 HTML2draft 函數,支持新老數據在新編輯器中的修改。同樣出於成本和風險的考慮,我們打算繼續不改變數據存儲的方式。HTML 字符串從數據庫出來,轉換為 ContentState 對象傳入編輯器,編輯完畢后重新轉換回 HTML 存入數據庫,兩種格式的相互轉換在瀏覽器端進行。

至此,我們就可以完成一個支持增改、用於提問、回答、評論並且與老數據兼容的新的適用於 React 的富文本編輯器。這件事完成以后,我們也許再可以去考慮基於 Draft.js 的富文本結構化數據存儲方案。

相關鏈接



知乎技術日志」是知乎工程師運營的一個技術專欄,在這里我們會陸續將知乎在 React、穩定性和安全管理、反作弊系統、微服務實踐、Docker、自動化運維、移動端網絡優化等領域的技術思考和實踐分享給大家。希望各位大家給予關注,並提出你寶貴的意見和反饋。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM