ProseMirror 概述
以前的 Web 编辑器其文档直接采用 HTML 结构,主要交互依靠含有 contentEditable 属性的元素,但是它处理用户交互的方式在一些边缘情况下行为不可控,很容易让开发者产生混乱。
参考
关于 ContentEditable 可以参考这篇文章 Why ContentEditable is Terrible (中译版)
ProseMirror 自定义了数据结构来表示文档,虽然借鉴了 DOM 结构但并不是完全相同(如果文档数据是通过网页展示出来的也是通过 DOM 表示),也是采用节点 node 来构成文档的骨架,节点之间可以嵌套构成树形结构,但是对于 inline content 的处理则不相同,它们并不是构成节点,而是使用一种称为 mark 标记以附加信息的形式添加到节点上
例如对于 <p>This is <strong>strong text with <em>emphasis</em></strong></p> 这段 HTML 内容
以 DOM 表示,则所有元素都转换为相应的节点,构成了一个嵌套的树结构

而使用 ProseMirror 来表示,则部分元素(在该示例中只有 <p> 元素)转换为节点,而部分元素转换为 mark 标记作为节点的附加信息,如果存在多个节点也可以构成一个嵌套的树结构(但是层级会更少、更简单)

ProseMirror 这种自定义的数据结构可定制性更强,而且更灵活通用,可以解析渲染为 DOM 树显示在网页中,也可以解析转换为 Markdown 文本,或是任何格式
四个核心模块
ProseMirror 由各种模块 modules 组成,它们就像乐高积木一样,用于完成特定的功能,只有将它们拼接起来才可以得到一个可用的编辑器。ProseMirror 并没有开箱即用的模块,这是它的「设计哲学」,将模块化和可定制化置于第一级(而不是易用性和简约性)
ProseMirror 有 4 个必要的模块:
prosemirror-model模块 设置编辑器的数据结构 schema,制定规则以约束编辑器可容纳哪些内容prosemirror-state模块 编辑器的状态(即具体的文档内容)prosemirror-view模块 用以搭建用户与编辑器状态(可以将其理解为「后台」数据)的桥梁,一方面视图可以将编辑器的状态显示到页面上(可以将其理解为「前台」显示),另一方面视图可以接受用户的交互指令prosemirror-transform模块 提供变更编辑器状态的方法(它们一般在 prosemirror-state 模块的 transaction 中提供),同时使得编辑器支持恢复、重做等功能,使得历史记录功能和协同编辑成为可能
参考
创建一个功能完整的基础编辑器,即编辑器有视图呈现文本内容,并可以正常处理用户的交互指令,都需要使用这 4 个模块(虽然不是每次都要完整导入这 4 个模块,但是或多或少会以各种方式触及到这 4 个模块所提供的功能),例如 prosemirror-transform 模块一般在 prosemirror-state 模块的 transaction 相关方法中实现
此外 ProseMirror 官方还有一些其他模块,以实现一些拓展功能:
prosemirror-commands模块 提供了基础的指令 command,可以通过 dispatch 这些指令(类似于分发事件)来创建节点,一般用于实现编程式更改文档内容,例如点击按钮一键修改文档等(相对应的是在编辑器中用户通过键盘输入手动来更改文档内容)prosemirror-keymap模块(一般与prosemirror-commands模块配置使用)可以让编辑器支持快捷键操作prosemirror-history模块 可以让编辑器支持历史记录,提供恢复、重做等功能prosemirror-inputrules模块 可以让编辑器支持输入规则,(类似于 Markdown 语法)当输入特殊的字符时会生成对应的节点prosemirror-collab模块 可以让编辑器支持协同编辑
此外还有提供了一些模板,可以让开发更简单,例如 prosemirror-schema-basic 模块 提供了一个 schema 对象,在里面定义了一些富文本编辑器常见的节点的数据结构
问题
ProseMirror 的 API 文档中问号 ? 标记,如果用于参数,则表示该参数是可选参数;如果用于返回值上,则表示该函数的返回值并不一定是所指类型(例如可能在不符合条件时,返回 null)
数据结构
ProseMirror 的数据结构中,既有树形的嵌套结构,也有扁平化的线性结构。
这是因为编辑器的文档是由节点构成的,而节点的内容也是一堆节点,它们之间构成了嵌套层级关系,构成了树形结构,可以通过父子节点的关系和祖孙节点的嵌套深度 offsets 进行文档位置的定位。
而对于 inline 内联节点(如文字节点),它们的内容一般是文字等,以线性排列的,则一般通过字符数量 tokens 来进行文档位置的定位更适合。其实ProseMirror 制定了一些规则,为文档的每一个位置计算出一个数字来表示,这样就可以不局限于 inline content,而是将整个文档看作扁平线性的结构。
根据具体的场景,选择合适的数据结构来描述/访问编辑器的文档内容,可以更高效地完成操作
位置系统
ProseMirror 使用两种定位系统来表示文档中的位置
对于树形结构,它是由节点之间嵌套而构成的,可以使用节点 node 的相关方法(例如 node.child(index) 或 node.descendants() 等),基于节点的父子层级关系来访问文档特定位置的节点
对于扁平结构,ProseMirror 制定了一些规则,可以使用数字来表示文档的每个位置
说明
虽然文档是通过节点来构成的,如果要遍历文档并不一定需要以树结构来看待节点,才可以访问到嵌套的节点,可与单纯通过「进入」和「离开」节点来遍历文档,这样就可以将文档从树结构看作为扁平化的线性结构
遵循以下规则来为文档中的一个位置计算出一个数值(以代表该位置):
- 文档的开始是位置
0 - 进入和离开一个非叶子节点(即包含
content内容的块级节点),索引值都会加1个 token - 在「文本节点」每增加一个字符索引值都会加
1token - 叶子节点(即那些不包含内容/子节点的节点,例如 Image 图像节点)索引值仅增加
1个 token
注意
表示文档每个位置的数字并没有实际创建或存储在内存中,只是一种约定的规则,以便在代码程序中高效地进行文档特定位置的计算和访问(可以借助 node.resolve 等方法获取相关位置信息)。
不过在节点对象中确实有一个相关属性 node.nodeSize(或通过片段对象的属性 fragment.size 来获取节点内容的大小,它们之间相差 2 个 token),它是根据以上规则计算一个节点的内容大小。
另外需要注意整个文档的大小是通过 doc.content.size 获取的(而不是 doc.size),因为根节点 doc 表示文档,而光标是无法定位到根节点之外,只能定位到它的内容上,所以文档的大小是指根节点内容的大小(而不是根节点的大小)
例如编辑器在页面相应的 DOM 结构如下
<p>One</p>
<blockquote><p>Two<img src="..."></p></blockquote>
则每一个元素(所对应的节点)及内容的定位如下

提示
如果需要操作整个节点,则可以使用树形结构来看待文档,并调用 node 的相关方法;如果要操作内容或不完整的节点,则可以使用扁平结构来看待,使用上述的约定规则来计算位置
注意
要区分不同的概念:
- 子节点的索引值,例如
node.childCount - 整个文档范围下的位置
- 相对节点内部的偏移量
更新文档
文档使用的是一个持久化/不可变 immutable 的数据结构,在修改文档时会创建一个新的文档对象,而不是改变旧的对象,并创建一个位置映射,这样可以通过文档差异算法,按需更新 DOM,且可以实现文档历史记录。而且对于协同编辑很有利。
在更新文档时,符合单向数据流的架构,通过分发事务的方式来触发文档的状态更新,而不是直接响应用户的交互对文档进行修改。
而在交互方面,对文档所做的任何实际修改都是通过捕获相应的浏览器事件,并转换为 ProseMirror 对这些修改的表述(描述和操作对文档的编辑),这样我们就可以通过将文档的编辑抽象为标准的行为,可以对文档有完全的控制。而且通过抽象标准化的方式来表示文档的修改,在协同编辑时可以准确、统一地处理多个用户的修改。例如:
- 通过监听按键事件,来捕获输入的文本以及退格、回车之类的操作 💡 可以配置快捷键,它们被称为「命令」 Command,除了可以通过用户按下键盘上相应的按键触发,
还可以通过调用(该命令已过期 ❓)execCommand方法(传递相应的「命令」参数)以编程的方式来触发 - 通过监听剪贴板事件,来控制复制、剪切和粘贴行为
- 通过监听拖放事件,来控制文本编辑器的元素的拖拽行为
- 通过监听
CompositionEvent事件,甚至可以控制使用输入法输入时的行为
参考
而且通过 inputrules 模块,可以通过匹配正在输入的内容,触发特定的行为,例如监测到输入 ## 再按下空格键后,创建一个二级标题
单向数据流
这 4 个模块通过一个单向数据流构建起编辑器,EditorView 视图展示编辑器的一个状态,并接受用户的交互指令。当用户操作页面(以修改文档)触发 DOM 事件分发时 ➡️ 通过 Transaction 来生成新的 EditorState ➡️ 然后基于这个新的编辑器状态,通过 updateState() 方法来更新页面的视图

此外 ProseMirror 支持插件系统,用于扩展编辑器的功能,通过 plugin 的一些 hook (在编辑器的特定状态时间点执行的函数)可以控制 state 的行为。