乐于分享
好东西不私藏

二代富文本编辑器框架ProseMirror入门

二代富文本编辑器框架ProseMirror入门

背景

富文本编辑器一直是前端开发的核心难点与技术硬骨头。原生contenteditable存在大量浏览器兼容乱象、光标异常、内容渲染不可控等问题,传统编辑器开发需要团队投入极高的人力成本,深耕浏览器底层细节,且各类交互、渲染、数据一致性bug层出不穷,长期维护压力极大。

第一代富文本编辑器以 UEditor、CKEditor 为代表,核心实现逻辑为直接操作原生 DOM。这类方案仅能满足基础富文本编辑需求,但存在先天性架构缺陷:无统一数据规范、依赖浏览器原生编辑机制,极易出现光标偏移、选区错乱、内容嵌套混乱、脏数据残留、粘贴内容清洗失效等问题,且DOM与业务数据强耦合,功能扩展与状态维护难度极高,架构层面无法根治各类兼容性问题。

随着 Vue、React 数据驱动开发范式成为前端主流,第二代富文本编辑器架构应运而生,其中 ProseMirror 是标杆性框架。它彻底颠覆传统DOM直操模式,实现了视图与数据解耦的架构革新。ProseMirror 摒弃直接修改DOM的逻辑,将所有编辑交互(输入、删除、样式修改、节点调整等)统一转化为状态变更事务

框架以不可变 State 状态为唯一真实数据源,所有内容变更都会生成全新的不可变JS状态对象,而非原地修改数据。框架通过 Transaction、Step 机制精准记录每一次数据变更,再由视图层根据最新State单向映射、自动更新DOM,形成用户操作 → 事务分发 → State更新 → 视图渲染的闭环数据流。这种架构从根源上规避了原生DOM编辑的各类乱象,解决了传统编辑器状态不同步、维护混乱、扩展受限的核心痛点,同时依托标准化文档模型与分层架构,为自定义节点、插件扩展、协同编辑等高阶能力提供了稳定支撑。

ProseMirror中的概念
概念定义
Schme: Schema 是 ProseMirror 编辑器的“法律和宪法”。 它用来定义你的文档允许包含哪些内容、这些内容如何嵌套、以及它们拥有哪些属性。
Command:点击某个MenuItem后的操作,可以作为工具菜单的回调,也可以在Plugin中使用
Plugin:用来改变State的一种钩子,第一个返回true的plugin将会阻止State对后续Plugin的调用
Transaction: 事务,任何在界面上的操作不会直接修改DOM,都会通过tr的方式去更新state,再通过state在View中展示变化
Step: 一个事务可以包含多个step,每个step描述了删除、插入、替换等行为
Mark: 针对于inline节点的一种样式控制的方式,每个Mark对应了inline字体的样式
Decoration: 一种在不改变底层文档数据(Doc Tree)的前提下,动态修改或扩展视图(View)展现形式的机制。
Selection:选取定义,选择了文档中的哪些部分,开始的节点,结束的节点
NodeView: 针对每个定义在Scheme上的节点可以应用自己的NodeView,即自己的展示
Doc:不可变对象,是DOM在内部state中的映射。
ProseMirror编辑器的概念之间的关系
概念之间的辨析:

Decoration 和 Marks:

Decoration(装饰器) 和 Marks(标记) 都可以用来改变编辑器中文本的视觉外观(比如把文字变红、加粗或高亮),但它们在底层的本质逻辑数据持久化以及适用场景上有巨大的区别

一句话总结:Marks 是属于文档数据的一部分(会保存到数据库);而 Decoration 只是视觉上的“特效滤镜”(只存在于当前屏幕,不污染数据)。

Command 和 Plugin:

在 ProseMirror 的设计架构中,Command(命令) 和 Plugin(插件) 是编辑器用来实现逻辑扩展和交互的两大核心支柱,但它们的工作角色生命周期完全不同

一句话总结:Command 是“主动触发、一次性执行的动作”(动作派);而 Plugin 是“常驻后台、持续监听并管理状态的生命周期钩子”(守卫派)。

ProseMirror编辑器的架构
然而,Prosemirror并不是一个开箱即用的框架,要使用ProseMirror构建一个富文本编辑器仍然需要大量的开发工作,然而这些工作基于ProseMirror的架构之上,可以保证代码的有序组织,避免无序混乱的堆砌,此外Scheme的定义也保证对HTML结构的管控。
基于ProseMirror的编辑器架构:

下面是富文本编辑的核心逻辑发生地。它严格遵循不可变数据结构单向数据流闭环

 用户操作 (View) ──> 触发修改指令 (Transaction) ──> 计算生成新大脑 (State) ──> 刷新界面 (View)
典型的基于ProsemMirror的二次开发架构(Tiptap架构)
+-------------------------------------------------------------------------------+|                        1. 应用层 (Application Layer)                          ||  [ React / Vue / Vanilla JS ]  <--->  [ 你的自定义 UI: 工具栏、气泡菜单、侧边栏 ] |+------------------------------------+------------------------------------------+                                     | (通过 useEditor / editor.commands 调用)                                     v+-------------------------------------------------------------------------------+|                        2. Tiptap 抽象层 (Tiptap Layer)                         ||  +---------------------------+  +------------------------------------------+  ||  |     核心内核 (Core)        |  |          扩展生态 (Extensions)           |  ||  | - Editor 实例管理器        |  | - Nodes (Paragraph, Table, Heading...)  |  ||  | - 链式 Command API 封装    |  | - Marks (Bold, Italic, Link...)          |  ||  | - 框架桥接 (React/Vue UI)   |  | - Functionality (Collaboration, Gap...)  |  ||  +---------------------------+  +------------------------------------------+  |+------------------------------------+------------------------------------------+                                     | (解析并转换为底层的 State/Transaction/Props)                                     v+-------------------------------------------------------------------------------+|                      3. ProseMirror 底层大脑 (Core Layer)                      ||  +-------------------------+  +--------------------+  +--------------------+  ||  |      EditorView         |  |    EditorState     |  |       Schema       |  ||  |  (视图与原生DOM层)       |  |     (状态大脑)     |  |    (严格结构法律)  |  ||  | - 事件拦截 (EditorProps)  |  | - 文档树 (Doc)     |  | - 节点定义 (Nodes) |  ||  | - 节点渲染 (NodeViews)   |  | - 选区 (Selection) |  | - 样式定义 (Marks) |  ||  | - 视觉滤镜 (Decorations) |  | - 插件集 (Plugins) |  |                    |  ||  +-------------------------+  +--------------------+  +--------------------+  |+------------------------------------+------------------------------------------+                                     | (单向数据流闭环:Transaction 事务)                                     v+-------------------------------------------------------------------------------+|                      4. 浏览器原生层 (Browser DOM Layer)                       ||   [ ContentEditable DIV ]  <--->  [ 用户的物理输入 / 键盘鼠标事件 ]            |+-------------------------------------------------------------------------------+
核心基础代码入门
1. 在 Prosemirror 中,千万不要尝试直接修改数据。任何修改(插入文本、换行、删除)都必须通过 state.tr 产生一个事务,然后被 dispatch(派发)出去。
// 假设你已经拿到了 view 实例// 1. 【增】:在文档的最开头(位置 0)插入一段文字let tr1 = view.state.tr.insertText("【新内容】"0);view.dispatch(tr1);// 2. 【删】:删除当前光标选中的那段文字let { from, to } = view.state.selection// 获取当前选区的起止位置let tr2 = view.state.tr.delete(from, to);view.dispatch(tr2);// 3. 【改】:给当前选中的文字包裹一个超链接(Mark)let linkType = view.state.schema.marks.link;let tr3 = view.state.tr.addMark(from, to, linkType.create({ href"https://google.com" }));view.dispatch(tr3);
2. Prosemirror 的文档不是 HTML 字符串,而是一个扁平的索引树。当你拿到选区坐标(from 和 to)时,不能直接用原生 substring,必须通过 doc.slice 或 doc.nodeAt
// 监听每次用户选区的变化const selectionReaderPlugin = new Plugin({  view() {    return {      update(view) {        let { from, to, empty } = view.state.selection;        if (empty) return// 用户只是点了下光标,没有划选文字        // 1. 获取划选区域的切片数据(Slice)        let slice = view.state.doc.slice(from, to);        // 2. 将切片直接转换为标准的 JSON 对象(二次开发最常用的数据传输格式)        console.log("划选内容的 JSON:", slice.content.toJSON());        // 3. 获取划选区域对应的纯文本        console.log("划选纯文本:", view.state.doc.textBetween(from, to));      }    };  }});
3. 在定制高阶编辑器时,你经常需要实现连锁反应。例如:当用户删除了某个标题,你需要自动把它下面的副标题也删掉;或者当用户打完字,你要自动校验某种格式。appendTransaction 是 PluginSpec 中最强大的生命周期函数,它允许你在上一个事务完成后,无缝追加一个新的事务
const autoCorrectPlugin = new Plugin({  appendTransaction(transactions, oldState, newState) {    // 1. 检查刚刚发生的这一轮修改中,文档内容到底有没有变?(如果是单纯移动光标,则直接忽略)    let docChanged = transactions.some(tr => tr.docChanged);    if (!docChanged) return null;    // 2. 检查现在的文档,如果发现字数超过了 100 字,并且最后不是句号,我们自动帮它补个句号    let currentText = newState.doc.textContent;    if (currentText.length > 100 && !currentText.endsWith("。")) {      // 创建一个追加事务,在文档末尾(newState.doc.content.size)插入句号      let appendTr = newState.tr.insertText("。", newState.doc.content.size);      // 返回这个事务,Prosemirror 会把它和前一个用户的事务合并,一并刷新视图      return appendTr;     }    return null// 返回 null 代表不需要追加任何操作  }});
4. 二次开发往往要打破富文本的限制。比如在文档中间塞一个“插入图片/视频的快捷浮层”,或者像 Notion 一样在空行显示一个加号。这时候要用 Decoration.widget
const nativeUiPlugin = new Plugin({  props: {    decorations(state) {      const decos = [];      const { from, $from } = state.selection;      // 1. 检查当前光标是不是在一个完全空着的段落里      if ($from.parent.type.name === "paragraph" && $from.parent.content.size === 0) {        // 2. 纯手工用原生 JS(或者 React/Vue)创造一个完全属于你自己的 UI 按钮        let btn = document.createElement("button");        btn.textContent = "➕ 点击插入组件";        btn.style = "color: gray; margin-left: 10px; cursor: pointer;";        btn.onclick = () => alert("打开了你的自定义弹窗!");        // 3. 凭空把这个按钮“装饰”到当前光标所在的位置($from.pos)        decos.push(Decoration.widget($from.pos, btn));      }      return DecorationSet.create(state.doc, decos);    }  }});
结束语
最后对ProseMirror的核心概念再做一次简单的总结,以飨读者:
  • 不可变数据:State/Doc 不原地修改,变更生成全新副本,规避状态污染,便于撤销回溯。
  • 纯函数:Step/Command 无副作用,输入一致输出固定,保障编辑行为可预测、可复用。
  • 状态驱动:以 State 为唯一数据源,改状态自动同步视图,杜绝直接操作 DOM。
  • 文档树模型:用结构化 Doc 树描述内容,Schema 约束节点 / Mark,统一规范文档结构。
  • DOM 隔离:业务只操作内存 State,View 层单向渲染 DOM,隔绝原生 contenteditable 兼容 bug。
  • 插件生命周期:Plugin 常驻监听状态、视图事件,分层拦截事务,实现无侵入扩展。