ProseMirror Schema(NodeSpec / MarkSpec / 内容表达式)
返回 🗂 富文本编辑专题 · 相关私有笔记
首读:Schema 是文档语言,不是文档本身
这篇没有前置阅读要求。若还没建立 DOM / State / Transaction / View 的总图,先花一分钟读 从 contentEditable 到一次编辑闭环;否则直接从这里开始。先把 Schema 当作一份文档语言(grammar):它声明编辑器允许哪些块、行内内容和格式,以及它们可以怎样组合;具体的一篇文章则是用这门语言写出的一个不可变 Node 值。浏览器 DOM 只是这个值的一个视图,不是唯一真相。
首读时只需分清下面五个词;不要先钻进 NodeType、MarkType、ContentMatch、Fragment 或底层工厂方法:
| 名词 | 先这样理解 | 现在不需要知道 |
|---|---|---|
Schema | 允许什么文档形状的声明 | 它如何编译内容表达式 |
| node | 文档里的结构单位,如段落、标题、文本 | NodeType 的运行时字段 |
| mark | 挂在行内内容上的格式或语义,如加粗、链接 | mark-set 的排序和互斥算法 |
content | 某种 node 可容纳的子节点规则 | ContentMatch 状态机 |
attrs | 节点或 mark 的数据,如标题层级、链接地址 | 属性验证的低层调用链 |
Node、Mark、Fragment、位置与切片的完整值模型在 PM 核心模型;那篇现在会假定你已经知道 Schema 是“文档语言”,但不会再要求你先掌握本页的进阶 API。两篇不再互为前置。
从一个最小文档语言开始
先不要从完整 NodeSpec 字段表开始。下面的 Schema 只表达一件事:文档至少有一个块,块目前只有段落,段落里只有文本;文本可以带一个 strong mark。
const schema = new Schema({
nodes: {
doc: { content: "block+" },
paragraph: { group: "block", content: "text*" },
text: {},
},
marks: {
strong: {},
},
})
const doc = schema.node("doc", null, [
schema.node("paragraph", null, [
schema.text("Hello "),
schema.text("world", [schema.mark("strong")]),
]),
])这段代码同时给出三个关键关系:
Schema声明doc、paragraph、text和strong这些名字及其规则。doc是一份具体文档值:doc → paragraph → text是树;strong是附在"world"上的 mark,而不是再包一层<strong>node。- Schema 不等于 HTML。之后可用
toDOM/parseDOM关联 DOM 表示,但文档模型仍以Node值为中心。
你可以把它类比为 TypeScript 类型声明和一个对象值,但不要把它理解为普通静态类型:Schema 同时参与编辑时的结构约束、粘贴解析、序列化和命令的可行性判断。
首读只记四种 content 写法
content 是“子节点序列的语法”,不是 HTML 选择器。第一次只需掌握:
text*:零个或多个 text;block+:一个或多个属于block分组的 node;image?:零个或一个 image;heading paragraph+:先一个 heading,再一个或多个 paragraph。
这四种写法已经足够读懂多数 Schema。并集、计数范围和 ContentMatch 自动机留到后半的参考节。
首读只分清 DOM 的两个方向
parseDOM:把粘贴或已有 HTML 导入为 Schema 允许的 node / mark;toDOM:把文档模型 投影为 DOM,用于默认渲染或导出。
两者描述的是边界映射,不改变“Node 值才是文档真相”这个分工。需要复杂交互 DOM 时,再到 View 与 NodeView 学 NodeView。
为什么先定义语言:让正常编辑路径保持结构合法
原生 contentEditable 会直接让浏览器改 DOM;它可以暂时形成空段落、错位嵌套或其他编辑器并不想保存的形状。ProseMirror 改成先声明文档语言,再让高层的 transform / transaction 在这门语言内变换文档。这样“段落里只能放文本”“文档至少有一个 block”之类的规则不必散落在每个命令中。
但这里有一个必须准确理解的边界:Schema 让正常编辑管线维持合法性,不表示 JavaScript 世界里绝对不可能构造出不合法的 Node。 手写节点、外部 JSON 和历史数据进入可信状态前,仍需要显式验证;具体低层 API 放在后面的“合法性边界与验证 API”参考节。
| 场景 | 应依赖什么 | 结论 |
|---|---|---|
| 用户正常输入、命令、transaction | 高层 transform / transaction 的结构约束 | 编辑器通常保持 schema-conformant 文档 |
| DOM 导入或粘贴 | 针对该 Schema 的 DOMParser | 解析器把可接受 DOM 映射为该语言中的内容 |
| 手写 node、外部 / 历史 JSON | 显式验证与迁移逻辑 | 不要假定 Schema 会自动拦下每一条低层 API 路径 |
这比“非法文档不可能存在”更有用:你会知道何时可以信任编辑器内的值,何时必须把校验当成数据边界的一部分。
首读路线与进阶边界
首读建议按下面顺序完成:
- 本节的最小 Schema;
- 本节的四种 content 写法和 DOM 双向映射;
- 下方参考节中按需查
NodeSpec、MarkSpec、完整内容表达式或 DOM API; - 然后进入 PM 核心模型,认识这些规则约束的文档值。
其余字段、有限状态自动机、细粒度 parse rule 和低层验证调用链都保留在本文后半作为参考。遇到“自定义 node / mark”“粘贴转换”“结构命令”时再回来查,不必把它们当成理解第一份编辑器的前置。
进阶参考:完整 NodeSpec 字段
一个 NodeSpec 描述一种 node 类型。最常用的字段:
content:内容表达式字符串,规定允许的子节点序列(下一节详述)。叶子节点(如 text、image)不写 content。group:把该 node 归入一个分组名,供内容表达式引用(如"block"、"inline")。inline:true表示这是行内 node(参与 inline 内容流)。text node 天然 inline。atom:true表示这是一个原子叶子——没有可直接编辑的内部内容(如 image、mention),在编辑器里作为整体被选中。attrs:属性定义(见 AttributeSpec)。marks:允许出现在该 node 内容上的 mark 集合。"_"表示允许全部,""表示禁止全部,也可空格分隔具体名字。parseDOM:从 DOM 解析成该 node 的规则数组。toDOM:把该 node 序列化为 DOM 的函数。
其余可选字段(控制编辑行为,而非结构合法性):selectable、draggable、code、whitespace("pre" | "normal")、defining / definingAsContext / definingForContent、isolating、toDebugString、leafText、linebreakReplacement。其中 isolating / defining 系列影响粘贴、删除、合并时编辑器如何对待这个 node 的边界,在 State 与 Transaction 的变更语义里更有体感。
const schema = new Schema({
nodes: {
doc: { content: "block+" },
paragraph: { group: "block", content: "inline*", marks: "_" },
heading: {
group: "block",
content: "inline*",
marks: "", // 标题内禁止任何 mark
attrs: { level: { default: 1 } },
toDOM: (node) => ["h" + node.attrs.level, 0],
parseDOM: [{ tag: "h1", attrs: { level: 1 } }],
},
text: { group: "inline" },
},
marks: {
strong: { toDOM: () => ["strong", 0], parseDOM: [{ tag: "strong" }] },
em: { toDOM: () => ["em", 0], parseDOM: [{ tag: "em" }] },
},
})设计上 NodeSpec 把几件事拆开:content 管结构合法性(编译成 ContentMatch 自动机),parseDOM / toDOM 管 DOM 双向映射,attrs 管节点自身数据,defining / isolating 管编辑时的边界行为。各字段职责单一,互不耦合。
进阶参考:MarkSpec(行内样式 / 标注)
mark 是附着在行内内容上的样式或标注(加粗、链接、行内代码),它独立于 node 内容单独建模,以便灵活组合。常用字段:
attrs:属性定义(如 link 的href)。inclusive:默认true,表示在被标记文本末尾继续输入时,新字符是否继承该 mark。excludes:列出本 mark 不能与之共存的其他 mark(空格分隔),"_"表示与所有 mark 互斥(包含自身)。group:mark 分组名。spanning:默认true,表示该 mark 能否跨越多个相邻 node。code:标记这是代码类 span。parseDOM/toDOM:与 NodeSpec 同义的双向映射。
const marks = {
strong: {},
em: {},
strikethrough: { excludes: "em" }, // 删除线与 em 互斥
code: { excludes: "_" }, // 行内代码与任何 mark 互斥
}
// strong + em ✅ 合法共存
// strikethrough + em ❌ strikethrough 声明 excludes em
// code + 任何 mark ❌ code excludes 全部inclusive 控制光标行为:打完一个链接后继续打字,通常希望延续(inclusive=true);某些临时高亮则希望 false。excludes 在「往 text node 上加 mark」时被强制执行,保证 mark 组合始终合法。
进阶参考:完整内容表达式语法
content 表达式是一种类正则的声明字符串,描述合法的子节点序列:
| 写法 | 含义 |
|---|---|
paragraph | 恰好一个 paragraph |
paragraph+ | 一个或多个 |
block* | 零个或多个 |
image? | 零个或一个 |
heading{2} | 恰好 2 个 |
heading{1,5} | 1 到 5 个 |
heading{2,} | 2 个或更多 |
block+ | 任意属于 block 分组的 node,一个或多个 |
(text | image)* | text 或 image 的并集,零或多个 |
heading paragraph+ | 一个 heading 紧跟一个或多个 paragraph |
const exprSchema = new Schema({
nodes: {
doc: { content: "block+" }, // 至少一个 block
list: { group: "block", content: "item+" }, // 至少一个 item
item: { content: "paragraph block*" }, // 恰好 1 段落,后跟 0+ block
paragraph: { group: "block", content: "inline*" },
image: {
group: "inline", inline: true, atom: true,
attrs: { src: {}, alt: { default: "" } },
},
text: { group: "inline" },
},
})关键内核:每条 content 表达式在 Schema 构造时被编译成一个有限状态自动机(ContentMatch)。表达式用类正则语法是为了书写直观,但底层是确定性状态机,使得「某个 node 能否插入到当前位置」是 O(1) 判定。可读的声明与高效的校验引擎由此解耦——这是 PM 能在每次按键时「免费」做结构校验的原因。
进阶参考:ContentMatch(内容校验状态机)
ContentMatch 是上述自动机的一个状态,代表「已经匹配了某个 node 内容的前缀,现在处于哪一步」。核心方法(签名取自 prosemirror-model 源码):
matchType(type):若该类型能出现在当前位置,返回下一个状态(ContentMatch);否则返回null。matchFragment(frag, start?, end?):校验一整段子节点序列,成功返回结果状态,失败返回null。fillBefore(after, toEnd?, startIndex?):尝试在after前插入哪些 node 才能让它合法(粘贴时把内容「塞进」合法容器,如自动补出必需的包裹层),返回插入用的Fragment(可能为空),无解返回null。findWrapping(target):返回为放下target类型所需的包裹 node 类型链(readonly NodeType[],可能为空数组表示直接放下),无解返回null。validEnd(布尔属性):当前位置是否允许「闭合」该 node。
const doc = schema.node("doc", null, [
schema.node("paragraph", null, schema.text("Hello")),
schema.node("paragraph", null, schema.text("World")),
])
doc.check() // 结构非法则抛异常
// 用 ContentMatch 测试插入合法性
const paraType = schema.nodes.paragraph
let match: ContentMatch | null = schema.nodes.doc.contentMatch
match = match.matchType(paraType) // paragraph 能否作为 doc 的开头
if (match) {
const next = match.matchType(paraType)
if (next?.validEnd) {
// 两个 paragraph 之后可以合法闭合 doc
}
}把内容规则编码为状态机后,编辑管线可以高效判断内容是否适配;fillBefore / findWrapping 还可为粘贴、智能换行等场景找到可行的补齐或包裹方案。它解释的是“正常编辑如何持续满足规则”,不是“任意低层构造永远不会产生不合法值”。
进阶参考:DOMParser(DOM → ProseMirror 导入)
DOMParser 依据各 NodeSpec / MarkSpec 的 parseDOM 规则,把 HTML 元素 / 样式映射成 node / mark,并产出符合 schema 的文档:
parser.parse(dom, options?):解析为完整Node(顶层需满足 schema 顶层约束)。parser.parseSlice(dom, options?):解析为Slice(两侧「开口」,用于粘贴这类不必满足顶层约束的场景)。DOMParser.fromSchema(schema):从 schema 里所有parseDOM规则自动构造解析器,按规则的priority排序。
const parser = DOMParser.fromSchema(schema)
const node = parser.parse(document.querySelector("article")!)设计要点:规则按优先级匹配,解析后强制走 schema 约束,因此导入的 HTML 一定 schema-conformant。parse 与 parseSlice 的区分让「粘贴」可以是开放片段,不必硬凑成一个完整顶层文档。
ParseRule:TagParseRule vs StyleParseRule
parseDOM 数组里的每条规则分两类,反映 HTML 「元素 vs 样式」的语义差异:
- TagParseRule:
tag(CSS 选择器)、node/mark(要创建的类型)、getAttrs(dom)(从元素抽取属性,返回false表示拒绝该规则)、contentElement(定位真正的内容 DOM)、preserveWhitespace。 - StyleParseRule:
style(CSS 属性名,可写成"property=value")、mark、getAttrs(从样式值抽取)、clearMark(移除某些 mark)。 - 二者都继承
GenericParseRule的priority、consuming、context字段。context可按父节点条件限制规则是否生效;consuming默认true,设为false则即便本规则匹配,后续规则仍有机会继续匹配该元素。
const linkMark = {
inclusive: true,
parseDOM: [{
tag: "a",
getAttrs: (dom: HTMLAnchorElement) => {
const href = dom.href
if (!href) return false // 没有 href 则拒绝
return { href, title: dom.title || "" }
},
}],
toDOM: (mark) => ["a", { href: mark.attrs.href, title: mark.attrs.title }, 0],
}
// <a href="#"></a> → 拒绝(href 为空)
// <a href="https://x.com" title="X"></a> → 解析出 href / titlegetAttrs 返回 false = 拒绝整条规则;返回 null / undefined = 「匹配,但属性为空/取默认值」——这是常见混淆点,务必区分。
进阶参考:DOMSerializer(ProseMirror → DOM 渲染 / 导出)
DOMSerializer 依据 toDOM 把文档转回 DOM:
serializer.serializeNode(node, options?):序列化单个 node。serializer.serializeFragment(fragment, options?, target?):序列化一个 Fragment(可传入target容器)。DOMSerializer.fromSchema(schema):从 schema 所有toDOM自动构造序列化器。DOMSerializer.renderSpec(doc, structure, xmlNS?)(静态方法):把一个DOMOutputSpec渲染为 DOM,返回{ dom, contentDOM? };structure含「洞」时,contentDOM指向含洞的那个元素。
DOMOutputSpec 与「洞」(hole)
toDOM 返回的 DOMOutputSpec 可以是:直接的 Node(DOM 节点);{ dom, contentDOM? } 对象(contentDOM 指明子内容插入处);或数组 [tag, attrs?, ...children]。数组里的数字 0 就是「洞」,标记子节点插入的位置:
const blockquoteSpec = {
group: "block",
content: "block+",
toDOM: () => ["blockquote", { class: "quote" }, 0], // 0 处插入子节点
parseDOM: [{ tag: "blockquote" }],
}
// renderSpec 会把洞所在元素识别为 contentDOM
const result = DOMSerializer.renderSpec(
document,
["div", { id: "wrapper" }, ["span", "Label: "]],
)
// result.dom = <div id="wrapper"><span>Label: </span></div>设计上 toDOM / serializeNode 与 parseDOM 形成对称的双向映射层。「洞」机制用结构化数组代替字符串拼接,从根上避免了拼 HTML 的转义/嵌套错误,保证子节点正确嵌套。fromSchema 则实现零配置:schema 声明一次,parser 与 serializer 自动构建。约束:0 必须是其父元素数组里唯一的子项(源码原文:“Content hole must be the only child of its parent node”),["div", "label", 0, "suffix"] 会抛异常——前后文要放进 children 数组里另行组织。
toDOM / parseDOM 也是 View 与 NodeView 默认渲染的依据;当 DOM 结构需要更复杂的交互时,才用 NodeView 接管渲染。
进阶参考:AttributeSpec(node / mark 的属性)
属性让 node / mark 携带自定义数据(heading 的 level、link 的 href)。字段:
default:默认值;没有 default 的属性即为必填(创建时必须显式给值)。validate:校验函数,或类型名字符串(形如"string"/"number"/"boolean",可用"|"组合)。
校验在 Node.check() 与 JSON 反序列化时运行,确保属性类型正确。把校验推迟到 check / 反序列化,是为了让运行时热路径保持快,同时仍在数据进出边界处兜住完整性问题。
进阶参考:Schema 的合法性边界与验证 API
把前面的结论说得精确一些:Schema 是高层编辑操作的共同约束,而不是 JavaScript 运行时的绝对防火墙。它把“哪些结构有意义”集中声明,正常的 transform、默认 DOM 解析与常用构造入口会据此工作;但 ProseMirror 也故意暴露了能创建开放片段或未校验节点的低层 API。
正常编辑与 DOM 解析
在标准编辑链路中,Transform / Step 会尝试应用结构变更;transform.step(step) 失败时抛 TransformError,maybeStep(step) 则返回带 failed 信息的 StepResult。transform.replace(...) 会生成 replace step 并走同一条应用路径。DOMParser.fromSchema(schema) 也以该 Schema 为目标,把可接受 DOM 转成相应的文档或 Slice。
const editorState = EditorState.create({ schema })
const tr = editorState.tr.replace(
0, 0,
new Slice(
Fragment.from(schema.node("paragraph", null, schema.text("New"))),
0, 0,
),
)
// 想检查而不抛异常:
// const result = editorState.tr.maybeStep(someStep)
// if (result.failed) { /* 处理失败,result.failed 是错误信息 */ }这里应把“正常管线保持合法”理解为一个工程约定:命令与 transaction 应从当前、已验证的文档出发,并使用高层变换 API;不要把它扩大成“任何传入的 Node 都天然合法”。
手写节点和外部 JSON 是信任边界
NodeType.create() 会检查并补齐属性,但不校验 content 表达式。Node.fromJSON() / schema.nodeFromJSON() 会恢复类型、mark 和属性,同样不会自动对整棵树调用 check()。这是为了支持开放 Slice 等底层场景,也是外部数据必须显式验证的原因。
const paragraph = schema.nodes.paragraph
// create() 允许低层调用者自行承担 content 合法性
const unsafe = paragraph.create(null, paragraph.create())
unsafe.check() // RangeError:paragraph 不能包含 paragraph
// 在构造点就要求合法:
const safe = paragraph.createChecked(null, schema.text("New"))
// 外部 / 历史 JSON 进入编辑器前:
const imported = schema.nodeFromJSON(externalJson)
imported.check()createAndFill() 会尝试补齐必需内容。为目标 node 提供了必需 attrs,且 Schema 能生成所需的默认子节点时,传 null 或 Fragment.empty 会得到最小合法值;给定一段无法包裹为合法内容的 fragment 时,它会返回 null。因此,正确的不变量是:经过验证的边界和正常编辑管线会维持 Schema 合法性;手写或导入的数据需要由调用方显式纳入这条保证。
进阶参考:inline node 与 atom
行内 node 可以设 atom: true,使其成为「行内但不可内部编辑」的整体单元(image、mention、公式),在文本流中占一个位置,被作为一个原子选中:
const mentionSpec = {
inline: true,
atom: true, // 作为原子单元
attrs: { id: {}, name: {} },
toDOM: (n) => ["span", { class: "mention", "data-id": n.attrs.id }, n.attrs.name],
}
const para = schema.node("paragraph", null, [
schema.text("Hello "),
schema.node("mention", { id: "123", name: "Alice" }),
schema.text(" how are you?"),
])常见坑(gotchas)
NodeSpec.marks的默认值:内容为 inline 的 node 默认允许全部 mark(等价"_"),其它(block)node 默认不允许。要在文本容器里禁所有 mark,显式写marks: ""。parseDOM的getAttrs:返回null/undefined视为「空属性 / 取默认」,返回false才是拒绝该规则。matchType失败返回null(无下一状态);matchFragment失败也只返回null不给细节——想要更好的错误信息,用matchType在循环里逐个判定。DOMOutputSpec的洞0必须是其父元素的唯一子项,否则renderSpec抛RangeError。Transform.step()校验失败会抛TransformError;想静默忽略用maybeStep()(返回StepResult,result.failed为错误)。务必二选一,别让异常裸奔。Node.check()会递归遍历整棵树,不是每次fromJSON、粘贴或普通输入都会自动执行;把它放在外部 / 历史数据进入编辑器的信任边界,不要放进热循环。- Node.js / SSR 下
DOMSerializer需要一个 DOM document,通过 options 传入,如serializeNode(node, { document: jsdomDoc })。 - content 表达式里的分组名必须有 node 声明它:没有任何 node 声明
group: "block"时,"block+"会构造失败。 excludes: "_"(排斥全部)会连同自身实例都互斥——通常是误用;要么留空,要么写具体 mark 名。
关键要点回顾
- Schema 是类型系统:NodeSpec 用 content 表达式(类正则的子节点规则)定义 node 类型,MarkSpec 定义 mark 类型;编辑器里出现的每个 node / mark 都必须在 schema 中声明。
- content 表达式编译成有限自动机(
ContentMatch),让编辑路径可直接查询“此处还能放什么、是否可闭合”,而不必每次重新解析表达式。 content/marks管结构合法,parseDOM/toDOM定义 DOM 导入 / 导出的映射。默认 View 可消费这些声明,但真正的状态—DOM 同步由 View 层负责,不能把 mapping 误认为双向同步机制本身。- Schema 把结构规则集中在前置声明中:高层变换和 DOM 解析依赖它维持合法性;
create()与fromJSON()这类低层入口则由调用方用createChecked()/check()显式补上验证。
这套结构约束是上层一切的地基:TipTap 把 Extension/Node/Mark 声明式地编译成 PM 的 schema + plugins + keymap(见 TipTap 架构 与 TipTap 实战);协同编辑中各端必须共享同一 schema,变更才能安全 rebase,见 协同编辑。
参考
- ProseMirror Guide — Schema:https://prosemirror.net/docs/guide/#schema
- ProseMirror Reference — Schema class:https://prosemirror.net/docs/ref/#model.Schema
- ProseMirror Reference — NodeSpec:https://prosemirror.net/docs/ref/#model.NodeSpec
- ProseMirror Reference — MarkSpec:https://prosemirror.net/docs/ref/#model.MarkSpec
- ProseMirror Reference — ContentMatch:https://prosemirror.net/docs/ref/#model.ContentMatch
- ProseMirror Reference — DOMParser:https://prosemirror.net/docs/ref/#model.DOMParser
- ProseMirror Reference — DOMSerializer:https://prosemirror.net/docs/ref/#model.DOMSerializer
- ProseMirror Reference — TagParseRule / StyleParseRule:https://prosemirror.net/docs/ref/#model.TagParseRule
- ProseMirror Reference — Node.check():https://prosemirror.net/docs/ref/#model.Node
- ProseMirror Reference — AttributeSpec:https://prosemirror.net/docs/ref/#model.AttributeSpec
- ProseMirror Reference — Transform:https://prosemirror.net/docs/ref/#transform.Transform