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、自动化运维、移动端网络优化等领域的技术思考和实践分享给大家。希望各位大家给予关注,并提出你宝贵的意见和反馈。

编辑于 2017-01-20 15:36