Plugin 系统与 Decoration(插件状态 / props / 装饰)
返回 🗂 富文本编辑专题 · 相关私有笔记
先做判断:这项能力该落在哪一层?
不要把所有“扩展编辑器”的需求都写成 Plugin。先问这项能力是否是文档本身、从文档派生的状态、临时视觉反馈,还是独立的交互 DOM:
| 需求 | 首选机制 | 原因 |
|---|---|---|
| 内容要保存、复制、导出、撤销、协同 | Schema 的 Node / Mark / attrs | 它是文档模型的一部分 |
| 状态要随每个 transaction 推进,但不写入文档 | Plugin state | 它随 EditorState 版本化 |
| 只给当前用户看的高亮、光标、占位提示 | Decoration | 它不污染 doc、undo 或协同数据 |
| 某个已有文档节点需要复杂的编辑期 DOM | NodeView | 它接管该节点的渲染,而不是定义新文档语义 |
| 工具栏、弹窗、网络请求、业务流程 | 应用层状态 / 组件 | 它们不属于 ProseMirror 状态机 |
本篇从第二、三行开始。先读 State 与 Transaction 的 apply 与 transaction,再读 View 与 NodeView 的 DOM 同步。首次阅读可先完成 Plugin state、props、Decoration 和组合范式;PluginView、跨插件读取与事务 hooks 是后续按需内容。
ProseMirror 把核心保持得很薄:文档结构由 PM Schema 管,状态变更由 State 与 Transaction 管,DOM 同步由 View 与 NodeView 管。Plugin 则承接需要随 state 更新的行为和临时 UI,例如历史记录、输入规则、协同光标、占位符和拼写检查。
本笔记讲清两件事:Plugin 如何成为一台“插件级不可变状态机”,以及 Decoration 如何在不污染文档模型的前提下给编辑器加上纯表现层的视觉效果。
一、Plugin 是什么:挂在 EditorState 上的三层结构
一个 Plugin 通过 new Plugin(spec) 创建,spec(PluginSpec)最多包含三类内容,对应三个互不相同的关注点:
- state:插件自己的私有状态字段(数据层),随
EditorState一起版本化、不可变。 - props:注入到 view 的行为与表现(交互层),如
decorations、handleKeyDown、handleClick。 - view:插件被挂载到具体
EditorView时的 DOM 生命周期钩子(副作用层),可持有可变 DOM/定时器。 - 另有两个事务拦截器:
filterTransaction/appendTransaction。
这三层的分工是 Plugin 设计的核心立意:数据是纯的(state),行为是反应式的(props),副作用被隔离(view)。理解了这条分界,后面所有 API 都各归其位。
import { Plugin, PluginKey } from "prosemirror-state"
const myPlugin = new Plugin({
key: new PluginKey("my-plugin"),
state: { init() { /* ... */ }, apply(tr, value) { /* ... */ } },
props: { decorations(state) { /* ... */ } },
view(editorView) { return { update() {}, destroy() {} } },
})二、插件级不可变状态机:state.{init, apply}
Plugin 的 state 字段(StateField)是整篇笔记最重要的概念。它声明了一个属于该插件的私有状态值,这个值不是游离在外的,而是被存进了 EditorState 内部——和 doc、selection 并列,享受同一套版本化机制。
它由两个纯函数定义:
state: {
// 创建 EditorState 时调用一次,返回插件状态的初始值
init(config, instance) { return /* 初始值 */ },
// 每个 transaction 都调用一次,由旧值 + tr 推导新值
apply(tr, value, oldState, newState) { return /* 新值 */ },
}签名要点:
init(config: EditorStateConfig, instance: EditorState) → T:状态初始化。config是传给EditorState.create()的配置,instance是一个半初始化的 state。apply(tr: Transaction, value: T, oldState: EditorState, newState: EditorState) → T:给定上一个值value和当前事务tr,确定性地算出下一个值。
为什么必须是「不可变 + 纯函数」
这是 PM 整体哲学在插件层的贯彻。apply 不允许原地改 value,而是返回一个新值——这使得状态转移满足三个关键性质:
- 确定性 / 可重放:同样的
(value, tr)永远得到同样的新值。这是 undo/redo 能工作的前提——history 插件本身就是一个 state 字段,撤销就是把状态指针挪回某个旧版本。 - 可参与协同:协同编辑需要把本地事务 rebase 到远端事务之后并重算,纯函数的
apply才能被反复重算而不出错(见 协同编辑)。 - time-travel 调试:状态是一串不可变快照,可以前后跳转检查。
换句话说:插件状态被纳入 EditorState 的版本流,而不是各自为政地用闭包变量乱存。这才是「插件级状态机」的真正含义——所有插件状态都跟着同一条不可变状态时间线走。
易错点:
init()拿到的instance是一个半初始化的EditorState——排在你后面的插件字段此时还没有值。不要在init里假设别的插件状态已就绪。
易错点:不可变是语义层的,不是引用层的。如果这次
tr跟你的状态无关,apply直接return value(同一引用)完全合法,而且是推荐做法(省去重建开销)。
三、PluginKey:跨插件读取状态、松耦合组合
插件状态既然存在 EditorState 里,就需要一个「取回」的句柄,这就是 PluginKey。
const myKey = new PluginKey("my-plugin")
const myPlugin = new Plugin({
key: myKey,
state: {
init: () => ({ count: 0 }),
apply: (tr, state) => ({ count: state.count + 1 }),
},
})
// 在另一个插件里,无需 import myPlugin,只凭 key 就能读到它的状态:
const otherPlugin = new Plugin({
props: {
handleKeyDown(view) {
const myState = myKey.getState(view.state)
console.log("count:", myState?.count)
return false
},
},
})关键 API:
new PluginKey(name?):创建一个句柄(name可选,默认"key")。key.get(state) → Plugin | undefined:从EditorState取回带此 key 的插件实例。key.getState(state) → PluginState | undefined:直接取回该插件的状态值(最常用)。plugin.getState(state):等价于key.getState,但需要持有插件实例本身。
为什么需要 PluginKey
它把「插件发现」和「插件引用」解耦了。A 插件想读 B 插件的状态时,不必 import B、不必拿到 B 的实例对象,只要双方约定一个 key 名即可。这带来真正的模块化组合:一个 collab 插件、一个 cursor 插件可以互相读状态而不形成代码依赖。
易错点:
PluginKey的 name 应当全局唯一。同一个 state 里有两个相同 key 的插件会冲突,只有一个能生效。
四、props:反应式的行为与表现
props(EditorProps)是插件注入到 view 的行为集合,常见的有:
decorations(state) → DecorationSet:返回该插件要叠加的装饰(见下半篇)。handleKeyDown(view, event):键盘事件处理。handleClick(view, pos, event):点击处理。handleDOMEvents:一个{ 事件名: 处理函数 }映射。
多个插件的同名 prop 会按插件在 state 中出现的顺序依次调用;某个处理函数返回 true 表示「已处理」,停止向后传播。
为什么 props 不用订阅、不用回调注册
最关键的性质:props 在每次 state 更新后都被重新求值。也就是说,decorations(state) 拿到的永远是「当前这一版」state,你不需要订阅变化、不需要手动 setState、不需要维护可变回调。这把「表现/交互」和「数据」彻底解耦——视图永远是 state 的纯函数。这一点和 View 与 NodeView 里「受控更新」的思路是同源的。
易错点:正因为每次更新都重算,如果某个 prop 很贵(例如全文扫描生成装饰),不要直接放在 prop 里硬算。应当把结果缓存在插件 state 中,用
apply增量维护、按需失效,prop 只负责把缓存好的DecorationSet读出来返回(见第八节示例)。
五、进阶:事务拦截器 filterTransaction 与 appendTransaction
这两个钩子让插件得以参与「事务是否能落地」「落地后是否要补一刀」的决策,而不必改动核心事务机制。它们依赖你已理解 apply、Step 与 Mapping;首次阅读可直接跳到第七节 Decoration,完成主线后再回来看 hooks。
filterTransaction:否决一个事务
new Plugin({
filterTransaction(tr, state) {
// 不允许删除第一段
const firstParaEnd = state.doc.firstChild!.nodeSize
for (const step of tr.steps) {
// 以 ReplaceStep 为例,其 from/to 标出被替换范围
if ((step as any).from < firstParaEnd && (step as any).to > 0) return false // 拦截
}
return true // 放行
},
})filterTransaction(tr: Transaction, state: EditorState) → boolean:返回 false 就整体阻止这个事务落地。用于校验、闸门、保护不可删区域。
注意:并非所有
Step子类都有from/to字段(只有ReplaceStep/ReplaceAroundStep这类位置替换才有)。要稳妥地判断某次事务改动了哪些范围,更通用的做法是遍历tr.mapping.maps并用StepMap.forEach取出受影响区间,而不是直接假设每个 step 都有from/to。
appendTransaction:在批次之后追加一个事务
new Plugin({
appendTransaction(transactions, oldState, newState) {
// 标题为空时自动补默认文本
const title = newState.doc.firstChild!
if (title.isBlock && title.content.size === 0) {
// 段落内部起始位置是 1
return newState.tr.insertText("Untitled", 1)
}
return null
},
})appendTransaction(transactions: readonly Transaction[], oldState: EditorState, newState: EditorState) → Transaction | null:在一批事务应用之后,允许你再排队一个额外事务。常用于派生次级状态、强制不变式(invariant)。如果其它插件也追加,它会被反复调用。
为什么要两个分开的钩子
它们是两种语义,不能混用:
- filter = 门禁:只能「放/不放」,不能改内容。想拦下用户的某次输入用它。
- append = 补刀:不能阻止已发生的事务,但能在其后追加修正,实现插件间组合与「保证文档始终满足某条规则」。
易错点:
filterTransaction返回false是把事务整个毙掉。如果你想要的是「保留用户操作,但顺便改一改」,应该用appendTransaction,而不是 filter。
它们保持了核心事务机制的简单——core 只管「应用 step」,复杂工作流外包给插件。这与 State 与 Transaction 中「事务是一等公民」的设计一脉相承。
六、PluginView:被隔离的 DOM 副作用出口
state 是不可变的,props 是反应式的——但现实里总有需要持有可变 DOM、定时器、第三方库实例的场景(浮动菜单、动画、IME 协调)。这些副作用被统一隔离到 view 钩子里。
new Plugin({
view(editorView) {
const dom = document.createElement("div")
dom.className = "my-plugin"
editorView.dom.parentNode!.appendChild(dom)
return {
update(view, prevState) {
// 每次 state 更新都被调用,用来把 DOM 同步到新 state
dom.textContent = `Doc size: ${view.state.doc.content.size}`
},
destroy() {
dom.parentNode!.removeChild(dom) // 清理
},
}
},
})view(editorView: EditorView) → PluginView:插件挂载到具体 view 时调用,返回一个对象。PluginView.update(view, prevState) → void:view 状态每次更新时调用,用来把可变 DOM 同步到新 state。PluginView.destroy() → void:view 销毁、或插件被替换时调用,用来清理定时器、事件监听、DOM。
为什么需要这个「逃生舱」
PM 把纯的(state/props)和脏的(DOM 副作用)刻意分开:纯的部分进 EditorState 参与版本化,脏的部分留在 PluginView 里、绝不进 state。这样模型保持可重放、可测试,而需要真实操作 DOM 的部分有一个受控的生命周期容器。
易错点:
destroy()不只在卸载时触发——插件被替换(传入 view 的 plugins 变了)也会触发旧 view 的destroy()。别假设它只在最终卸载时调用,清理逻辑要写得幂等且彻底。
七、Decoration:纯表现层,三种类型
下半篇转向 Plugin 最高频的产出物——Decoration(装饰)。一句话定义:装饰是叠加在文档之上的纯视觉层,它从不改变 doc.content。
import { Decoration, DecorationSet } from "prosemirror-view"三种类型对应三种「想加什么」:
1. Decoration.inline —— 给一段文本范围加样式/属性
Decoration.inline(from, to, { style: "background: yellow", class: "hl" })static inline(from, to, attrs, spec?) → Decoration:给 [from, to) 这段文本套上 style / class 等属性。典型用途:搜索高亮、语法着色、拼写检查下划线。可选的 spec 支持 inclusiveStart / inclusiveEnd 控制端点处插入的内容是否被纳入装饰。
2. Decoration.node —— 给整个节点加属性
Decoration.node(from, to, { class: "highlight-error" })static node(from, to, attrs, spec?) → Decoration:from / to 必须正好框住一个整节点(即 from 是该节点前位置、to 是其后位置),把这个节点包裹上属性。典型用途:把某个段落/代码块整体标红、给当前选中的块加边框。
3. Decoration.widget —— 在某位置插入「不属于文档」的 DOM
Decoration.widget(pos, (view, getPos) => {
const el = document.createElement("span")
el.className = "collaborative-cursor"
el.style.borderLeft = "2px solid blue"
return el
}, { side: -1 })static widget(pos, toDOM, spec?) → Decoration:在 pos 处插入一段 DOM,但这段 DOM 不占文档位置、不可选中、不可编辑。toDOM 既可以是一个 DOM 节点,也可以是 (view, getPos) => DOMNode 工厂函数。典型用途:协同远端光标、占位符提示、页面分隔符、行内 UI chrome。spec.side 控制它在该位置的左/右侧(负数偏左)。
为什么「装饰 = 表现层,绝不改文档」是关键设计
这是 Decoration 存在的全部理由,务必吃透:占位符、拼写下划线、协同光标、搜索高亮——它们都是「需要给用户看的反馈」,但都不应该写进文档模型。如果把它们写进 doc:
- 协同会出错——每个协作者的本地 UI(光标/选区/正在搜索什么)是不同的,而 doc 必须人人一致;
- undo/redo 会被污染——撤销不该把「拼写下划线」也撤掉;装饰不进 history,doc 才进。
- 测试会变脆——模型应当独立于视图反馈。
所以 PM 给出一条铁律:doc 是唯一权威的模型,装饰是与之正交的表现层。 凡是「只是给用户看、不是文档内容」的东西,一律走 Decoration。
易错点:widget 装饰不占文档空间、不可选中——这是有意设计,但在测试 NodeSelection / 位置计算时容易让人意外。
易错点:装饰不影响文档结构与 undo/redo。撤销永远作用于 doc,装饰是从新 doc 状态重新算出来的。
八、DecorationSet:装饰的高效维护
一个文档可能有成千上万条装饰(大文档的 lint 标记、整篇的协同 awareness)。如果每次文档变动都全量重建,性能会崩。DecorationSet 就是为「增量维护」而生的数据结构。
它是一棵镜像文档树的树形结构,把装饰按文档层级组织。核心 API:
// 创建
const set = DecorationSet.create(doc, [deco1, deco2, /* ... */])
// 文档变化后,把装饰位置随之前移/映射(只重算受影响的分支)
const next = set.map(tr.mapping, tr.doc)
// 增量增删
const added = set.add(doc, [newDeco])
const removed = set.remove([oldDeco])
// 查询某个范围内的装饰
const hits = set.find(from, to)DecorationSet.create(doc, decorations) → DecorationSetset.map(mapping, doc, options?) → DecorationSet:把装饰位置沿mapping(见 State 与 Transaction 的位置映射)前移到新 doc;options.onRemove可在装饰因映射而被丢弃时回调。set.add(doc, decorations) → DecorationSet/set.remove(decorations) → DecorationSetset.find(start?, end?, predicate?) → Decoration[]
为什么是树形:O(改动) 而非 O(全部)
树形让 map 只重算被改动触及的那几条分支,复杂度大致是 O(变化量) 而不是 O(装饰总数)。这正是大量装饰的插件(linting、协同光标群)仍能流畅运行的原因。详见 性能与可访问性。
易错点:
map()会把仍落在文档内的装饰更新位置;落到被删范围内的 inline/node 装饰会被丢弃,但若你想在装饰失效时做额外清理(例如同步插件 state 里的索引),要靠options.onRemove自己处理。
九、把两者合起来:状态机 + 装饰如何「不污染文档」地扩展编辑器
到这里,Plugin 状态机和 Decoration 的协作模式就清晰了。最经典的范式是:用插件 state 持有一个 DecorationSet,用 apply 增量维护它,用 props.decorations 把它读出来交给 view 渲染。
最小完整示例(给每隔几个字符加黄色斑点,源自官方 Guide):
let specklePlugin = new Plugin({
state: {
init(_, { doc }) {
let speckles = []
for (let pos = 1; pos < doc.content.size; pos += 4)
speckles.push(Decoration.inline(pos - 1, pos, { style: "background: yellow" }))
return DecorationSet.create(doc, speckles)
},
// 文档变了就把装饰位置 map 过去,O(改动)
apply(tr, set) { return set.map(tr.mapping, tr.doc) },
},
props: {
decorations(state) { return specklePlugin.getState(state) },
},
})进阶:在 apply 里增量地加/删装饰,而不是全量重建——把「贵」的扫描收敛到只发生在真正变化时:
apply(tr, decorationSet, oldState, newState) {
// 先把旧装饰映射到新 doc
let set = decorationSet.map(tr.mapping, tr.doc)
if (!tr.docChanged) return set
// 文档变了,补上新增的(此处简化为全量扫描;真实场景应只扫描受影响范围)
const toAdd = []
tr.doc.descendants((node, pos) => {
if (node.type.name === "error")
toAdd.push(Decoration.node(pos, pos + node.nodeSize, { class: "highlight-error" }))
})
return set.add(tr.doc, toAdd)
}这个范式回答了本笔记的核心问题——如何在不污染文档模型的前提下扩展编辑器:
- 文档结构永远只由 PM Schema 约束的 doc 决定,装饰一概不进 doc;
- 插件的「记忆」存在不可变的插件 state 里,跟着
EditorState一起版本化,天然兼容 undo/redo 与协同; - 视觉反馈用三类 Decoration 表达,经
DecorationSet高效维护; - 需要真实 DOM 副作用时,退到
PluginView这个被隔离的出口。
这套机制就是「占位符、拼写检查下划线、协同光标」全部都能只靠装饰实现、而文档模型岿然不动的根本原因。更上层的框架 TipTap 架构 也正是把 Extension 编译成这一套 Plugin + Decoration,再暴露给应用层(见 TipTap 实战)。
十、关键要点回顾
- 先决定数据归属:持久内容用 Schema,派生状态用 Plugin state,临时反馈用 Decoration,复杂节点 DOM 用 NodeView,应用 UI 留在应用层。
- 插件状态机(
init+apply)是 PM 可扩展性的内核:不可变、确定性、可与 undo/redo 和协同组合。 - Plugin 三层结构——state(数据)/ props(行为)/ view(DOM)——把模型保持纯、把视图保持反应式、把副作用隔离。
- Decoration 是与文档模型正交的表现层,永不改
doc.content;这正是协同、undo/redo、清晰分层得以成立的前提。 DecorationSet的树形设计让增量更新即便面对成千上万条装饰也高效(靠map只重建受影响分支)。PluginKey实现松耦合:一个插件无需 import 即可读另一个插件的状态。filterTransaction是门禁(否决),appendTransaction是补刀(追加修正)——两种语义不可混用。PluginView是 DOM 副作用、定时器、可变状态的逃生舱;保持插件 state 纯净,把 UI 协调交给PluginView.update。- widget 装饰适合「不属于可编辑文档」的 UI chrome(协同光标、页面分隔符);inline/node 装饰用于给文档内容本身着色/加属性。