【技术干货】编辑器是天坑?来聊聊富文本编辑器

字节跳动

正文第一句——「本文已参与好文召集令活动,点击查看:后端、大前端双赛道投稿,2万元奖池等你挑战!

🌹 本文作者:抖音小程序前端业务平台团队 梓荣

引言

小程序社区作为外部开发者交流的平台,承载了许多内容,发帖功能是社区最重要的一个环节,所以社区具备一个功能较为丰富、用户体验友好的富文本编辑器,就成为了前提条件。

之前在小程序社区编辑器方案调研中,我们选择了公司内部团队开发的富文本编辑器,并针对社区需求进行插件化定制,其底层是使用了 prosemirror 来进行二次开发。

本文主要是结合自己的理解,对编辑器相关知识进行整理,跟大家分享。

1. 背景知识

1.1 编辑器分类

编辑器目前可以分为以下三类:

  1. textArea 远古时代的编辑器实现,起始阶段,最简单的如多行文本
  2. contentEditable 浏览器提供了基础功能
    • draftjs (react)
    • quilljs (vue)
    • prosemirror(util)
  3. 脱离浏览器自带编辑能力,独立做光标和排版引擎
    • office 、wps、 google doc

其中第一类不支持图片及视频等其它内容,第三类可能是一项浩大的工程,目前活跃的开源代码框架大多属于第二类,下面我们就来介绍一下浏览器自带的contentEditable及document.execCommand,以及它们跟编辑器的关系是什么。

1.2 关于 contentEditable

浏览器本身支持展示富文本,我们通过 html、css,就完成了图片、视频、文字和其它元素展示。

但是在contentEditable出现之后,浏览器才有了编辑富文本的能力。通过设置一个dom的contentediable属性为true,可以让这个dom变得可编辑,光标随之出现。 image.png image.png

1.3 关于 document.execCommand

如果说contentEditable使dom变得可编辑,那么execCommand则提供了从外部修改DOM的api。

当一段document被转为designMode(contentEditable 和 input框)的时候,document.execCommand的command会作用于光标选区(加粗,斜体等操作),也可能会插入一个新的元素,也可能会影响当前行的内容,取决于command是什么。

两者进行组合,一个是可编辑的DOM,一个是从外部修改DOM的能力,即可实现一个最简单的编辑器。

HTML
1.<div id="contentEditable" contenteditable style="height: 666px"></div>
复制代码
JavaScript
1. const editor = document.getElementById("contentEditable");
2. editor.focus();
3. document.execCommand("formatBlock", false, "h1"); // 格式化内容为标题
4. document.execCommand("bold", false); // 加粗选中文字
复制代码

但是问题在于,contentEditable只定义了操作,没有定义结果。这就导致了在不同浏览器上,相同的UI,可能对应了不同的DOM结构。

举个例子,对于“加粗字体”这个用户输入,在chrome上,是添加了<blod> 标签,ie11上则是添加了<strong> 标签。

2. prosemirror

前面提到,contentEditable有一些不合理之处,因此prosemirror 在contentEditable 的基础上,在 DOM之上加一层文档模型的抽象。contentEditable负责各浏览器上表现一致的操作,表现不一致的通过抽象层来处理。文档也具备了状态,所有修改编辑器内容的操作都是可记录的,通过状态的变更,来反映到视图的更新。

下面我们就来一起研究一下prosemirror。

2.1 介绍

prosemirror并不是一个开箱即用的富文本编辑器,它的官网介绍说明了,prosemirror更像是乐高积木,是一套工具包提供给开发者,方便开发者在此之上开发富文本编辑器的。

它的主要原则是开发者享有文档及事件变更的控制权。这里的文档是自定义的数据结构,只包含你允许的元素,用来描述内容本身及其变化,所以的变化都是可追溯到的。

它的核心是优先考虑模块化和可定制性,可以在此基础上再做二次开发,也正是我们下面讲到的四大modules。

2.2 基本原理

prosemirror是由四大modules组成的:

  • prosemirror-model :定义文档模型,描述编辑器内容的数据结构,是描述 state的一个部分
  • prosemirror-state: 描述编辑器状态的数据结构,包括选中、事务(指编辑器状态的一次变更)
  • prosemirror-view :编辑器的视图展示,用来挂载到真实DOM元素上,并提供用户交互
  • prosemirror-transform:记录及重放文档的修改,属于状态模块中事务的基础,用来做撤销回退、协作编辑等

结合这四大modules,我们可以实现一个基于prosemirror的简单编辑器:

HTML
<div id=editor style="margin-bottom: 23px"></div>
复制代码
JavaScript
1. import {schema} from "prosemirror-schema-basic"
2. import {EditorState} from "prosemirror-state"
3. import {EditorView} from "prosemirror-view"
4. import {baseKeymap} from "prosemirror-commands"
5. import {DOMParser} from "prosemirror-model"
6. 
7. let state = EditorState.create({schema})
8. let view = new EditorView(document.querySelector("#editor"), {
9.   state,
10.   plugins: [
11.     history(),
12.     keymap({"Mod-z": undo, "Mod-y": redo}),
13.     keymap(baseKeymap)
14.   ]
15. })
复制代码

2.3 模块化

2.3.1 prosemirror-model

在上述四个module中,文档模型是最基础的一个部分。 下面以一段富文本来介绍文档模型。

HTML
1. <p>This is <strong>strong text with <em>emphasis</em></strong></p>
复制代码

在浏览器中,是一棵如下图所示的 DOM 树。 image.png

但是操作DOM树本身是一件很复杂的事情。我们如果想把dom、state、view关联起来,更合理的方式应该是通过js对象来进行dom树的抽象,形成一份文档模型。

JavaScript
1. const model = {
2.     type: 'document',
3.     content: [
4.         {
5.             type: 'paragraph',
6.             content: [
7.                 {
8.                     text: 'This is',
9.                     type: 'text'
10.                 },
11.                 {
12.                     type: 'strong',
13.                     content: [
14.                         {
15.                             type: 'text',
16.                             text: 'strong text with'
17.                         },
18.                         {
19.                             type: 'em',
20.                             content: [
21.                                 {
22.                                     text: 'emphasis',
23.                                     type: 'text'
24.                                 }
25.                             ]
26.                         }
27.                     ]
28.                 }
29.             ]
30.         }
31.     ]
32. };
复制代码

那么有了这个模型对象,我们可以通过定位到具体某一段文本,假设定位到斜体emphasis的那段文本 model.content[0].content[1].content[2].content[0].text

但是当我们考虑标签嵌套的这样一种情形: image.png

两者在浏览器上展示的UI都是相同的,但是我们根据模型对象去定位到加粗斜体的文本,路径却在变化。

操作DOM树本身是十分不方便的,所以我们才抽象成文档模型,而文档模型的一个特点就是尽量扁平化。所以我们可以用这样的对象来解释上面这段文本

JavaScrpit
1. const model = {
2.     type: 'document',
3.     content: [
4.         {
5.             type: 'paragraph',
6.             content: [
7.                 {
8.                     text: 'This is',
9.                     type: 'text'
10.                 },
11.                 {
12.                     type: 'text',
13.                     text: 'strong text with',
14.                     marks: [{ type: 'strong' }]
15.                 },
16.                 {
17.                     type: 'text',
18.                     text: 'emphasis',
19.                     marks: [{ type: 'strong' }, { type: 'em' }]
20.                 }
21.             ]
22.         }
23.     ]
24. };
复制代码

我们引入 marks(标记),来表示附加到节点上的额外信息。这样寻找emphasis 的路径也就固定下来了。

所以类比于浏览器的DOM,编辑器维护了自己的一套节点层级结构。 不同点在于,对于内联元素,编辑器进行了扁平处理(flat),这降低树操作。

文档对象模型还包含了一个schema属性,指明了这份文档模型属于哪一个规则。一份文档schema,描述了文档中可能出现的节点类型以及它们嵌套的方式。

JavaScript
1. const schema = new Schema({
2.  nodes: {
3.   doc: {content: "block+"},
4.   paragraph: {group: "block", content: "text*", marks: "_"},
5.   heading: {group: "block", content: "text*", marks: ""},
6.   text: {inline: true}
7.  },
8.  marks: {
9.   strong: {},
10.   em: {}
11.  }
12. })
复制代码

这段代码定义了一个简单的骨架。通过使用group属性创建节点组,block+在内容表达式上相当于(paragraph | blockquote)+,marks为空字符串表示不允许有标记,"_"则代表通配符。所以这个schema规则可以表达为,其中文档可能包含一个或多个段落及标题,每个段落或标题包含任意数量的文本,支持段落中文本的strong(粗体)及emphasis(斜体)标记,但不支持标题。

schema 限制了标签嵌套的规则,以及某个节点下允许什么marks。因为用户的行为不可预测,但是schema为约束用户输入提供了一套规则,不符合schema的标签都会被移除掉。

2.3.2 prosemirror-state、prosemirror-view、prosemirror-transform

将schema以某种形式注入到 state的生成过程中,编辑器就将只出现符合定义规则的内容。 除了文档对象外,组成编辑器state的内容还有selection(选区信息)、plugin system(插件系统)等。selection包含了光标位置、选中区域等信息,plugin system则为编辑器提供了额外功能如键盘绑定。

当我们初始化state之后,promisemirror的视图模块就会根据已有的state进行展示,并开始处理用户的输入。由于prosemirror的state在设计上是不可变的,只能通过触发事务(transaction)创建,描述一个新的编辑器状态,随后用新状态来更新视图。从而来处理用户输入和视图交互的。这就构成了一个单向数据流,如下图所示。

image.png

我们可以借助事务来做一些有意思的事情,例如我们可以在初始化时,拦截事务的派发,打印日志后再更新状态

JavaScript
1. // 光标选区变化、用户输入时拦截事务派发
2. dispatchTransaction(transaction) {
3.      console.log("文档内容大小变化由", transaction.before.content.size, "变化为", transaction.doc.content.size);
4.      let newState = view.state.apply(transaction);
5.      view.updateState(newState);
6.  }
复制代码

或者我们可以手动往编辑器里注入内容

JavaScript
1. // 往编辑器插入 hello world
2. let tr = view.state.tr;
3. tr.insertText("hello world");
4. let newState = view.state.apply(tr);
5. view.updateState(newState);
复制代码

2.3.3 模块间的关系

结合上面的分析,可以看到prosemirror每个模块都不是独立的。prosemirror-model构成了编辑器的基础,是prosemirror-state的组成部分,prosemirror-transform负责处理state的变更,prosemirror-state初始化了整个编辑器视图。

image.png

现在让我们一起来理解官方提供的这段示例代码:

HTML
1. <div id=editor style="margin-bottom: 23px"></div>
2. 
3. <div style="display: none" id="content">
4.   <p>这是一段编辑器初始内容</p>
5. </div>
复制代码
JavaScript
1. import {EditorState} from "prosemirror-state"
2. import {EditorView} from "prosemirror-view"
3. import {Schema, DOMParser} from "prosemirror-model"
4. import {schema} from "prosemirror-schema-basic"
5. import {addListNodes} from "prosemirror-schema-list"
6. import {exampleSetup} from "prosemirror-example-setup"
7. 
8. // Mix the nodes from prosemirror-schema-list into the basic schema to
9. // create a schema with list support.
10. const mySchema = new Schema({
11.   nodes: addListNodes(schema.spec.nodes, "paragraph block*", "block"),
12.   marks: schema.spec.marks
13. })
14. 
15. window.view = new EditorView(document.querySelector("#editor"), {
16.   state: EditorState.create({
17.     doc: DOMParser.fromSchema(mySchema).parse(document.querySelector("#content")), // 获取html,按照schema规则生成文档对象模型
18.     plugins: exampleSetup({schema: mySchema})
19.   })
20. })
复制代码

这段代码可以解释为:

  • 首先是根据某个浏览器DOM,按照某个指定规则(schema)抽象文档模型后,创建document
  • 这将创建出一个遵循该schema的空document,并且光标指在文档的start起始点
  • 文档对象模型及官方默认的插件系统构成了初始编辑器状态(state)
  • 根据编辑器状态(state)创建编辑器视图组件(view),挂载到真实DOM节点上
  • 这就将有状态的文档渲染成了一个可编辑的dom node 节点,并且无论何时只要有用户输入就有对应的状态事务(state transactions)创建
  • 状态事务描述了state的更改,并且应用于创建新state,这将用来更新视图。
  • 后续每个编辑器的更新都是通过dispatch a transaction,派发一个事务。

3. 后记

编辑器向来是前端领域的一个难点,也是一个天坑,从头开始做一个编辑器将是一项巨大的工程,因此我对prosemirror、quilljs等开源编辑器作者充满了敬佩。站在巨人的肩膀上,了解编辑器的基本架构和模型,对编辑器有一个初步的认知,可能对我们是有一定的启发作用的,这也是写这篇文章的一个初衷。

ps:你能在这里体验到我们的编辑器 (forum.microapp.bytedance.com/mini-app/po…)

欢迎来小程序社区(forum.microapp.bytedance.com/mini-app) 逛一逛~

4. 参考资料