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 实例,试着把它在浏览器控制台里打印出来:
很容易猜测出其中一些属性的含义,比如 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,