二代富文本编辑器框架ProseMirror入门
富文本编辑器一直是前端开发的核心难点与技术硬骨头。原生contenteditable存在大量浏览器兼容乱象、光标异常、内容渲染不可控等问题,传统编辑器开发需要团队投入极高的人力成本,深耕浏览器底层细节,且各类交互、渲染、数据一致性bug层出不穷,长期维护压力极大。
第一代富文本编辑器以 UEditor、CKEditor 为代表,核心实现逻辑为直接操作原生 DOM。这类方案仅能满足基础富文本编辑需求,但存在先天性架构缺陷:无统一数据规范、依赖浏览器原生编辑机制,极易出现光标偏移、选区错乱、内容嵌套混乱、脏数据残留、粘贴内容清洗失效等问题,且DOM与业务数据强耦合,功能扩展与状态维护难度极高,架构层面无法根治各类兼容性问题。
随着 Vue、React 数据驱动开发范式成为前端主流,第二代富文本编辑器架构应运而生,其中 ProseMirror 是标杆性框架。它彻底颠覆传统DOM直操模式,实现了视图与数据解耦的架构革新。ProseMirror 摒弃直接修改DOM的逻辑,将所有编辑交互(输入、删除、样式修改、节点调整等)统一转化为状态变更事务。
框架以不可变 State 状态为唯一真实数据源,所有内容变更都会生成全新的不可变JS状态对象,而非原地修改数据。框架通过 Transaction、Step 机制精准记录每一次数据变更,再由视图层根据最新State单向映射、自动更新DOM,形成用户操作 → 事务分发 → State更新 → 视图渲染的闭环数据流。这种架构从根源上规避了原生DOM编辑的各类乱象,解决了传统编辑器状态不同步、维护混乱、扩展受限的核心痛点,同时依托标准化文档模型与分层架构,为自定义节点、插件扩展、协同编辑等高阶能力提供了稳定支撑。


Decoration 和 Marks:
Decoration(装饰器) 和 Marks(标记) 都可以用来改变编辑器中文本的视觉外观(比如把文字变红、加粗或高亮),但它们在底层的本质逻辑、数据持久化以及适用场景上有巨大的区别。
一句话总结:Marks 是属于文档数据的一部分(会保存到数据库);而 Decoration 只是视觉上的“特效滤镜”(只存在于当前屏幕,不污染数据)。
Command 和 Plugin:
在 ProseMirror 的设计架构中,Command(命令) 和 Plugin(插件) 是编辑器用来实现逻辑扩展和交互的两大核心支柱,但它们的工作角色和生命周期完全不同。
一句话总结:Command 是“主动触发、一次性执行的动作”(动作派);而 Plugin 是“常驻后台、持续监听并管理状态的生命周期钩子”(守卫派)。
下面是富文本编辑的核心逻辑发生地。它严格遵循不可变数据结构与单向数据流闭环:
用户操作 (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);
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));}};}});
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 代表不需要追加任何操作}});
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);}}});
- 不可变数据:State/Doc 不原地修改,变更生成全新副本,规避状态污染,便于撤销回溯。
- 纯函数:Step/Command 无副作用,输入一致输出固定,保障编辑行为可预测、可复用。
- 状态驱动:以 State 为唯一数据源,改状态自动同步视图,杜绝直接操作 DOM。
- 文档树模型:用结构化 Doc 树描述内容,Schema 约束节点 / Mark,统一规范文档结构。
- DOM 隔离:业务只操作内存 State,View 层单向渲染 DOM,隔绝原生 contenteditable 兼容 bug。
- 插件生命周期:Plugin 常驻监听状态、视图事件,分层拦截事务,实现无侵入扩展。
夜雨聆风