? 本文作者:抖音小程序前端业务平台团队 梓荣
引言
小程序社区作为外部开发者交流的平台,承载了许多内容,发帖功能是社区最重要的一个环节,所以社区具备一个功能较为丰富、用户体验友好的富文本编辑器,就成为了前提条件。
之前在小程序社区编辑器方案调研中,我们选择了公司内部团队开发的富文本编辑器,并针对社区需求进行插件化定制,其底层是使用了 prosemirror 来进行二次开发。
本文主要是结合自己的理解,对编辑器相关知识进行整理,跟大家分享。
1. 背景知识
1.1 编辑器分类
编辑器目前可以分为以下三类:
- textArea 远古时代的编辑器实现,起始阶段,最简单的如多行文本
- contentEditable 浏览器提供了基础功能
- draftjs (react)
- quilljs (vue)
- prosemirror(util)
- 脱离浏览器自带编辑能力,独立做光标和排版引擎
- office 、wps、 google doc
其中第一类不支持图片及视频等其它内容,第三类可能是一项浩大的工程,目前活跃的开源代码框架大多属于第二类,下面我们就来介绍一下浏览器自带的contentEditable及document.execCommand,以及它们跟编辑器的关系是什么。
1.2 关于 contentEditable
浏览器本身支持展示富文本,我们通过 html、css,就完成了图片、视频、文字和其它元素展示。
但是在contentEditable出现之后,浏览器才有了编辑富文本的能力。通过设置一个dom的contentediable属性为true,可以让这个dom变得可编辑,光标随之出现。
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 树。
但是操作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
但是当我们考虑标签嵌套的这样一种情形:
两者在浏览器上展示的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)创建,描述一个新的编辑器状态,随后用新状态来更新视图。从而来处理用户输入和视图交互的。这就构成了一个单向数据流,如下图所示。
我们可以借助事务来做一些有意思的事情,例如我们可以在初始化时,拦截事务的派发,打印日志后再更新状态
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初始化了整个编辑器视图。
现在让我们一起来理解官方提供的这段示例代码:
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. 参考资料
-
contenteditable developer.mozilla.org/en-US/docs/…
-
document.execCommand developer.mozilla.org/zh-CN/docs/…
-
为什么 ContentEditable 很恐怖 www.oschina.net/translate/w…
-
prosemirror prosemirror.net/docs/guide/
-
有道云笔记跨平台富文本编辑器的技术演进 www.cnblogs.com/163yun/p/92…
-
独立开发出一个文本编辑器需要多长时间 www.zhihu.com/question/26…
-
主流富文本编辑器有什么缺陷 www.zhihu.com/question/40…