1. Prosemirror 的支柱:Schema
在介绍 轻松入门Prosemirror: 快速掌握其核心概念 一文中提到过 Schema 是 Prosemirror 中的重中之重,这里将其比作 Prosemirror 中的支柱,更是说明了 Schema 在 Prosemirror 中的地位。在我们之前的文章中也介绍了 Prosemirror 本质还是采用了 MVC 模式进行设计,其中 Schema 就是 Model 层,它严格定义了当前 Prosemirror 的文档结构,也是我们开发一个编辑器首要考虑的设计内容,当然,也可以在不断完善编辑器的过程中对 Schema 进行调整。
在上篇文中的示例中,我们也设计了一个非常简单的 Schema,并在注释中详细描述了每个字段代表的含义,也算对 Schema 有了一个初步认识,本文将会以 Schema 为核心,详细展开对 Prosemirror 的文档结构的深入探索。
2. Schema 详解
2.1 Schema 的构成
Prosemirror 中 Schema 的组成非常简单,只有 nodes 与 marks,文档整体由 nodes 构成,marks 通常是用来给 inline 类型的内容增加额外样式或信息的存在。以之前我们创建的 Schema 为例:
css
import { Schema } from 'prosemirror-model';
export const schema = new Schema({
nodes: {
// 整个文档
doc: {
// 文档内容规定必须是 block 类型的节点(block 与 HTML 中的 block 概念差不多) `+` 号代表可以有一个或多个(规则类似正则)
content: 'block+'
},
// 文档段落
paragraph: {
// 段落内容规定必须是 inline 类型的节点(inline 与 HTML 中 inline 概念差不多), `*` 号代表可以有 0 个或多个(规则类似正则)
content: 'inline*',
// 分组:当前节点所在的分组为 block,意味着它是个 block 节点
group: 'block',
// 渲染为 html 时候,使用 p 标签渲染,第二个参数 0 念做 "洞",类似 vue 中 slot 插槽的概念,
// 证明它有子节点,以后子节点就填充在 p 标签中
toDOM: () => {
return ['p', 0]
},
// 从别处复制过来的富文本,如果包含 p 标签,将 p 标签序列化为当前的 p 节点后进行展示
parseDOM: [{
tag: 'p'
}]
},
// 段落中的文本
text: {
// 当前处于 inline 分株,意味着它是个 inline 节点。代表输入的文本
group: 'inline'
},
// 1-6 级标题
heading: {
// attrs 与 vue/react 组件中 props 的概念类似,代表定义当前节点有哪些属性,这里定义了 level 属性,默认值 1
attrs: {
level: {
default: 1
}
},
// 当前节点内容可以是 0 个或多个 inline 节点
content: 'inline*',
// 当前节点分组为 block 分组
group: 'block',
// defining: 特殊属性,为 true 代表如果在当前标签内(以 h1 为例),全选内容,直接粘贴新的内容后,这些内容还会被 h1 标签包裹
// 如果为 false, 整个 h1 标签(包括内容与标签本身)将会被替换为其他内容,删除亦如此。
// 还有其他的特殊属性,后续细说
defining: true,
// 转为 html 标签时,根据当前的 level 属性,生成对应的 h1 - h6 标签,节点的内容填充在 h 标签中("洞"在)。
toDOM(node) {
const tag = `h${node.attrs.level}`
return [tag, 0]
},
// 从别处复制进来的富文本内容,根据标签序列化为当前 heading 节点,并填充对应的 level 属性
parseDOM: [
{tag: "h1", attrs: {level: 1}},
{tag: "h2", attrs: {level: 2}},
{tag: "h3", attrs: {level: 3}},
{tag: "h4", attrs: {level: 4}},
{tag: "h5", attrs: {level: 5}},
{tag: "h6", attrs: {level: 6}}
],
}
},
// 除了上面定义 node 节点,一些富文本样式,可以通过 marks 定义
marks: {
// 文本加粗
strong: {
// 对于加粗的部分,使用 strong 标签包裹,加粗的内容位于 strong 标签内(这里定义的 0 与上面一致,也念做 "洞",也类似 vue 中的 slot)
toDOM() {
return ['strong', 0]
},
// 从别的地方复制过来的富文本,如果有 strong 标签,则被解析为一个 strong mark
parseDOM: [
{ tag: 'strong' },
],
}
}
})
上面 schema 中定义了 doc 文档根节点,paragraph 段落结点,text 文本节点,heading 1-6 标题节点以及一个 strong 类型的文本加粗 mark,然后将下面两行内容分别复制到编辑器中。
Hello this is bold text
Hello this is bold text

我们将 editorView 挂载到 window 对象上,通过 editorView.state.doc.nodeSize
可以查看到当前整个文档的大小都是 27,但明显上面有加粗的内容对应的 html 中多了strong 标签。这也就对应到我们上面所说,文档的整体是由 nodes 构成(这里没说有 marks),而 mark 只是附加在 inline 内容(例如上方的 text 文本节点)上用来增加样式与额外信息的存在,在对文档大小的计算过程中,它并不参与计算,这对于后续理解光标系统 以及文档中的位置信息有着重要作用。
2.2 Schema 中的特殊节点
在 Schema 设计过程中,不知道大家有没有与笔者相同的疑惑,Schema 是如何知道 doc 就是整个文档的根节点,text 为什么刚好代表输入的文本节点?如果在 Schema 定义的过程中给他们换换名字行不行?
这里就要提到特殊节点了,在 Schema 的构造函数中,除了上述代码中的提供的 nodes 与 marks,还有一个属性,topNode,它可以规定我们的根节点名称到底是什么(默认值是 doc
),例如,我们将 topNode 名称设置为 document
,此时需要将 nodes 中的 doc 改名为 document,否则会报错。但此时只是节点名称发生了变化,在实例化的 state 数据中,访问根节点依然是 state.doc
来进行访问。
css
const schema = new Schema({
nodes: {
//...
// 名称改为 document
document: {
content: 'block+'
},
},
marks: {
//...
},
topNode: 'document'
})
除此之外,另外一个特殊的节点是 text
,它是 prosemirror 最最最基础的文本节点,且目前只能通过 text
名称来定义,假如修改名称为 char
,在初始化过程中也会报错,所以它无法自定义,且 text 类型为 inline 类型,如果改为 block 会报错,在定义 text 时,只需要定义其中的 group 为 inline
即可。在 prosemirror 中,节点的 group 默认有 block
与 inline
两个分组,其概念与 html 节点的 inline 节点与 block 节点一致,一般一个节点如果是 span
包裹的,则归为 inline 组比较,对于标题,段落等,则归为 block 组。除了默认的分组,我们也可以自定义一些组的名称,这将在后续探索 Node 的过程中详细描述。
arduino
const schema = new Schema({
nodes: {
//...
text: {
group: 'inline'
}
}
//...
})
2.3 别乱定义 schema
虽然在 schema 中我们可以定义很多不同的节点,但是节点的定义也要遵循一定规律,例如上述的两个特殊节点,根节点与文本节点,就是必须要存在的,另外还需要至少存在一个可以承载内容的节点,在我们创建的第一个 prosemirror 编辑器中,尽管没有填充任何内容,但查看 editor.state.doc
,默认还是会生成最简单的空白内容,或者在 dom 层面,我们的内容默认有一个 p 标签,对应的是 paragraph 元素。
prosemirror 不允许有空文本节点,我们看到在 p 标签中, 文本内容为空时,会填入一个 br 标签。

但如果定义 schema 时,我们将 paragraph
删除,或者移动到 heading
后面,刷新浏览器,则会看到默认内容是 h1 包裹的了:

从现象看,prosemirror 会寻找到我们第一个定义的 block 元素来初始化默认的 state。因此,在定义 schema 的时候,需要注意两点:
- Schema 中必要的节点要定义
- 节点的定义顺序可能会影响编辑器初始内容,如果遇到类似问题,可以排查 schema 中 node 的定义顺序
2.4 Schema 对象长什么样?
上面说了半天 Schema 的定义,但我们还没有看到 new
之后的 schema 对象究竟长什么样子,我们可以通过直接打印 new
之后的 schema,也可以通过 editorView.state.schema
来查看 schema 对象结构,这里也可以了解一下,schema 实例化后,在编辑器内部,存放的位置是 state 上。在程序中,很多时候我们要用到 schema,也可以通过 state.schema
来获取。

基本属性
实例化后的 schema 中 spec
是我们 new Schema 时传进来的参数,也叫做 SchemaSpec
(shcema 描述对象,其中的传递的 nodes 与 marks 对应 Node 与 Mark 的 spec 描述对象),nodes
与 marks
则是根据传入的描述对象创建的 node 与 mark 实例。
cached 缓存
除此之外,还需提到的是 shcema 中的 cached
对象,就是一个保存额外数据的地方,通过 state.schema
可以看到,默认帮我们缓存了 domParser
,domSerializer
对象,分别用于解析 dom 元素以及将 node 序列化为 dom。当然,你可能看到 schema 的 cached 中只有 domParser,只要你在编辑器中输入内容, cached 中就会有 domSerializer, 这是因为 prosemirror 在用到 domSerializer 的时候才会将其缓存在 cached 上。
我们也可以在该对象上存储我们自己的一些数据或方法:
javascript
const schema = new Schema({
//...
})
// 在 cached 上绑定 sayHello 方法,这样在后续访问 schema 时,可以通过 schema.cached.sayHello 来调用自己定义的方法
// 如果有其他需求,也可以缓存自己需要的内容
schema.cached.sayHello = function(name: string) {
console.log('hello: ', name)
}
要注意的是,使用缓存的时候,先判断一下缓存中是否已经有了对应的内容。
2.4 Schema 中的一些工具方法
通过上述打印出来的 schema 实例,我们发现在 shcema 上还存在许多帮助我们创建 node 的方法:
schema.node()
创建节点,创建的节点必须是 shcema 中定义的类型,通过节点对象的 toJSON 可以看到转为 JSON 后的数据格式。

schema.text(content)
可以创建文本节点

schema.mark()
可以创建 mark 实例

schema.nodeFromJSON()
可以通过 json 内容创建 node 节点

schema.markFromJSON()
可以通过 json 内容创建 mark 实例(可自行尝试)
除此之外,schema.cached 中绑定的 domParser 可以将 dom 节点解析为 node 节点,domSerializer 可以将 node 内容序列化为 dom 节点。
后续在实际使用过程中,创建节点或mark,这些方法都会用到。将文档内容转为 html,也可以通过 domSerializer 直接将 state.doc 根节点序列化为 html, 这可以作为后续编辑器内容的输出,当然如果你想输出 json 格式的数据,也可以通过 state.doc.toJSON()
来。
3. 小结
本文详细讲解了 prosemirror 的 Schema 定义,以及其中的一些方法与属性。在创建 schema 时候需要注意必须要存在的特殊节点,以及节点顺序;schema 对象中 cached 对象上可以缓存我们需要的数据,我们可以手动将一些数据或方法绑定在 cached 中,方便后续使用,默认 prosemirror 也在上面绑定了 domParser 与 domSerializer,但 domSerializer 只有第一次内部调用的时候才会绑定进来;schema 上也存在需要操作节点的方法,可以快速帮我们创建节点,后续在编辑器的开发中会经常使用。
为了控制每篇文章的篇幅,关于 Node 的详细探索将会放在后面。
期待与你下次相见!
See you next time!