尧图网络 高端网站定制 · 原创设计
免费咨询热线
400-888-6620
免费获取方案
基于ProseMirror+Tiptap重构电子病历编辑器:架构设计与踩坑实录
我大概有三年多时间一直在跟医疗信息化打交道2023年下半年接到一个让我失眠的活把运行了快十年的老电子病历编辑器推倒重做。整个项目反复对比了ProseMirror、Tiptap、Slate、Quill之后最后定下来用ProseMirror做文档内核、Tiptap做业务包装重构一整套电子病历编辑器。旧系统是开发商用ContentEditable逐层打补丁堆出来的CT室复制一段影像报告、护士站贴一张体温单、药房再粘一串药品名称整个文档结构就彻底失控。光标跳来跳去、格式层层嵌套、上下标全部丢失、保存到后端就是一大坨HTML质控科要的必填校验和修改留痕代码根本没法稳定支撑。文章把当初的架构设计、模型取舍和我踩过的坑完整复盘一遍希望能帮你少走点弯路。1. 先别急于写代码病历编辑器到底比普通富文本难在哪1.1 病历的底层逻辑是“结构化文书”不是“带格式的文本框”普通博客编辑器、Wiki编辑器要的是随便写、随便排、复制粘贴尽量保留格式。病历编辑器几乎完全相反病历有固定的段落结构——主诉、现病史、既往史、体格检查、辅助检查、初步诊断、治疗意见。患者姓名、年龄、病案号这些字段是从HIS自动带入的不允许医生随便改格式诊断结果要关联ICD编码药品剂量带上下标和单位危重患者要求必填项做质控打印时A4排版必须统一。这些需求放到普通富文本编辑器里基本是“反向优化”。我把约束理了一遍结构约束文档分成若干节、段不能随意嵌套出病态结构。字段约束部分内容来自外部系统是“只读可跳转”的原子数据。校验约束必填项、数值范围需要在输入过程中提示。审计约束每一次修改都要可追溯避免医疗纠纷说不清。输出约束打印成A4病案格式统一分页正确。这五类约束落到技术选型上意味着编辑器内核至少要回答几个问题文档结构能不能被Schema强制约束光标行为在复杂节点里能不能受控历史记录和撤销栈是不是可以自定义能否在文档变更链路上插入校验和审计逻辑这几个问题ContentEditable原生方案基本回答不了而ProseMirror这类“文档模型驱动”的编辑器天生就是按这个思路设计的。1.2 为什么ContentEditable撑不住而ProseMirror可以旧编辑器用ContentEditable表面上能“所见即所得”但浏览器把你丢进去的任意DOM交给自己的编辑逻辑之后内容就已经失控了。不同浏览器、不同版本、不同输入法环境下光标定位、撤销栈、粘贴清洗、富文本嵌套规则全都不一样。你以为在写编辑器其实是在跟五个浏览器的编辑内核搏斗。病历场景尤其放大这种失控一份内科大病历动辄三四千字中间还夹着体温单、检验结果、检查报告摘要医生经常从Word、网页、HIS其他模块里复制内容。浏览器自带的粘贴策略会把来源页面的内联样式、嵌套标签原封不动搬进来前端渲染直接就乱了。更麻烦的是ContentEditable下的修改是基于DOM Mutation的想准确知道“用户到底改了哪个字、值从多少变到多少”非常困难MutationObserver只能告诉你DOM变了不能告诉你业务上发生了什么。ProseMirror的做法是绕开浏览器原生编辑逻辑把编辑内容抽象成一颗由Schema定义的树。用户敲键盘、粘贴、删除都先转成Transaction经过State统一校验之后再驱动视图更新到DOM。DOM只是渲染层可以随时整体重建。这样光标、撤销、协作、校验都变成可控状态不再依赖浏览器自己的编辑内核。病历场景需要的“数据结构约束”“修改可追踪”“字段可锁定”在这个模型里都是基础能力而不是打补丁。1.3 Tiptap出现让我们省掉了哪些脏活直接用ProseMirror开发完全可行但团队学习曲线非常陡。你要自己组织命令系统自己管理NodeView和组件框架的通信自己设计InputRule和PasteRule还要处理Selection、Decoration、PluginState这些底层概念。如果整个团队从零开始啃官方文档前两周基本什么都交付不了。Tiptap相当于把这些能力做成了Extension模型Node、Mark、Extension各自独立支持Vue和React组件化接入。团队里新同学也能快速上手先跑通一个带工具栏的编辑器再逐步深入底层。我们最终确定的是“ProseMirror内核 Tiptap业务封装”的分层底层用ProseMirror的能力保证文档模型、事务机制、协作留痕稳定上层用Tiptap的扩展体系来承载不同科室、不同模板、不同校验规则。有一点要提前说清楚Tiptap不是银弹它本质还是ProseMirror遇到复杂问题最终还是要回到ProseMirror层排查。但它确实把业务团队的使用门槛拉低了很多这也是我们选它而不是直接裸写ProseMirror的核心原因。2. Schema是第一道架构决策病历文档树的建模方式2.1 顶层结构从“一坨HTML”到“强约束的树”Schema是整个编辑器的宪法。文档里允许出现什么节点、节点之间怎么嵌套、每个节点携带哪些属性都由Schema在编译期规定死。传统HTML文档里div套div、span嵌span怎么嵌套都合法但对病历来说这就是灾难。我们的顶层结构切得比较克制import { Schema } from prosemirror-model; const schema new Schema({ nodes: { doc: { content: section }, section: { content: block, attrs: { code: { default: }, title: { default: }, pageBreak: { default: auto } }, toDOM(node) { return [section, { data-code: node.attrs.code, class: hx-section }, [h3, node.attrs.title], 0]; } }, paragraph: { content: inline*, group: block, toDOM: () [p, 0] }, patient_field: { inline: true, atom: true, attrs: { fieldKey: { default: }, display: { default: } }, toDOM(node) { return [span, { data-field: node.attrs.fieldKey, class: hx-field }, node.attrs.display]; } }, text: { group: inline } }, marks: { strong: {}, sub: {}, sup: {} } });这里的section对应病历里的一个固定节比如“主诉”“现病史”。section下面只允许放block组节点普通用户不能随便拖一个表格进来当段落用。patient_field是内联原子节点用来放患者字段、生命体征、ICD诊断等外部数据。Schema约束住顶层结构以后后续的校验、打印、留痕全部基于这套结构展开不会再出现“一段病历里有七层嵌套div”这种事。2.2 用atom节点把患者字段和生命体征控件“焊死”在文档里病历里“患者姓名”“年龄”“病案号”“科室”这些字段必须做到不可随意改写。我一开始用普通span加class实现结果医生在编辑时能直接删掉一个字保存后患者姓名就缺了一角HIS同步回来又把用户手改的内容覆盖掉非常危险。后来统一改成atom原子节点。atom节点表示这个节点内部是不可进入编辑的医生能看到、能选中、能整体复制但不能把光标插进去修改内部文本。患者字段只保存fieldKey和display两个attrsdisplay用于展示真正的权威数据始终在HIS侧编辑器只负责展示和触发跳转。类似的生命体征控件、检验结果卡片、检查报告摘要也可以做成atom节点在NodeView里渲染成一个可交互的组件。比如体温单不需要医生手工填写一串数字而是点击控件后弹出趋势图选择器选择结果写回节点attrs文档里保存的始终是结构化数据。有个坑要提前提醒atom节点默认是可以被退格键和删除键整体删掉的。病历里“患者姓名”被医生误删之后虽然可以撤销但很多老医生不习惯撤销直接在空白处接着往下写。我们的做法是在Transaction层拦截当变更涉及关键patient_field节点被删除时dispatch一个阻止提示并恢复原节点。这一步写在appendTransaction或者自定义Plugin里不能依赖UI层去防。2.3 Mark体系的取舍上下标、单位、专有名词如何表达病历里的“cm²”“m³”“T1/2”“10⁶/L”到处都是这类表达式在编辑器里就是sub和sup两个Mark。我建议一开始只保留少量必要的Mark加粗、斜体、下标、上标以及一个专门的“受控词”Mark用来绑定诊断名称和ICD编码。不要一开始就上一堆字体颜色、字号、背景色Mark。病历的最终样式应该交给统一的CSS和打印样式表控制而不是让每个医生各自调字体颜色——否则打印出来的病案五花八门质控科第一个找你。Mark越少Schema越稳定后续导出PDF、病案归档时就越省事。受控词Mark可以做得很有价值。它内部存储一个code属性和对应的标准文本展示时高亮并带可点击角标点击后弹出ICD选择器。这样医生写诊断时看起来是在录入自由文本实际上每个关键诊断都绑定了标准编码后续做科研检索、病案质控、 DRG分组时数据直接可用。3. NodeView与Decoration病历里智能控件的两种实现路线3.1 NodeView适合承载复杂交互但要知道它的边界ProseMirror的NodeView允许我们把某个节点渲染成自定义DOM组件这是实现复杂交互控件的主要手段。病历里患者字段可以显示成“可点击跳转”的标签生命体征节点可以渲染成一个Vue组件检查报告摘要能渲染卡片式预览。基本接入方式是在Tiptap里扩展一个Node然后通过addNodeView返回组件import { Node, mergeAttributes } from tiptap/core; export const PatientField Node.create({ name: patientField, group: inline, inline: true, atom: true, addAttributes() { return { fieldKey: { default: }, display: { default: } }; }, parseHTML() { return [{ tag: span[data-field] }]; }, renderHTML({ HTMLAttributes }) { return [span, mergeAttributes(HTMLAttributes, { class: hx-field })]; }, addNodeView() { return ({ node, editor, getPos }) { const dom document.createElement(span); dom.className hx-field-view; dom.textContent node.attrs.display; // 绑定点击事件跳转到HIS患者详情 dom.addEventListener(click, () { // openPatientDetail(node.attrs.fieldKey) }); return { dom }; }; } });用NodeView要注意边界它只负责“视图”不要在里面直接改文档数据。要改节点attrs必须通过editor.view.dispatch(tr.setNodeMarkup(getPos(), undefined, newAttrs))走事务链路。另外NodeView内部的input、select等交互事件如果不需要编辑器接管一定要在stopEvent里返回true否则ProseMirror的全局事件处理器会干扰组件内部操作。3.2 Decoration怎样在不污染数据的前提下做校验高亮病历的必填校验、质控提醒、修改留痕提示不适合写成节点属性。因为校验状态是临时的、动态的一旦写进文档数据打印和归档时还得再清洗一遍。ProseMirror的Decoration机制就是为了解决这个问题。Decoration有三种Decoration.widget在指定位置插入一个DOM节点Decoration.inline给一段范围附加样式Decoration.node给整个节点附加class。我们做必填校验时遍历文档找到“值缺失的必填字段”生成一个DecorationSet通过PluginState管理并在文档变化或触发校验时更新。伪代码思路import { Decoration, DecorationSet } from prosemirror-view; function buildDecorations(doc) { const decorations []; doc.descendants((node, pos) { if (node.type.name patientField !node.attrs.display) { // 给缺失字段加红色边框和角标 decorations.push( Decoration.inline(pos, pos node.nodeSize, { class: hx-field-missing }) ); } }); return DecorationSet.create(doc, decorations); }这里的关键是DecorationSet是immutable的每次校验后要生成新实例替换旧的不能原地修改。这个状态放在PluginState里编辑器重绘时会自动应用。3.3 双重装饰冲突的真实排查过程我们在一个患者字段节点上同时使用了NodeView和Decoration做校验高亮结果遇到一个很诡异的问题高亮有时能显示有时应用不上偶尔还出现控制台警告“The decoration ... doesn‘t target a valid node”。排查链路是这样的第一步复现后打印出DecorationSet的range信息和目标节点的pos、nodeSize发现pos计算并没有错但警告仍然存在。第二步把NodeView返回的DOM结构打出来发现ProseMirror的NodeView渲染实际上分成了outerDOM和innerDOM两层编辑器对NodeView根节点的DOM结构有额外的处理逻辑。第三步确认问题出在inline decoration作用于NodeView内部子元素时和NodeView自己的DOM更新逻辑发生竞争有时内层子元素被NodeView重建decoration的挂载点就失效了。最终解决方式对NodeView包裹的原子节点统一用Decoration.node或者Decoration.widget来挂校验状态不要用Decoration.inline去装饰NodeView内部区域同时给NodeView根元素设置contenteditablefalse并实现ignoreMutation防止视图层来回干扰。这之后校验高亮稳定了再也没出过“提示消失”的问题。4. 每一次键盘敲击都是一笔“账”事务、Diff与修改留痕4.1 Transaction贯穿一切为什么所有修改必须走dispatchProseMirror里不许“直接改DOM”所有变更都是创建一个Transaction对象描述从哪个位置删了什么、插入了什么、设置了什么Mark或attrs然后dispatch到EditorView。视图根据Transaction重新计算文档状态迁移到新版本。这样做的收益是所有变更都变成一个可观察、可拦截、可记录的数据流。病历场景非常依赖这个特性。比如我们实现“锁定章节”当用户试图修改已经归档的“初步诊断”节时在appendTransaction里检查变更范围是否落在锁定节的pos区间如果是就直接返回原Transaction等效于拒绝修改。再比如“关键字段变更自动追加记录”当检测到某个治疗意见节点被修改时自动在文档末尾追加一条“某年某月某日由某医生修改”的元数据行完全不需要业务代码去手动处理DOM。4.2 用prosemirror-changeset做病历修订对比病案归档时最怕“改了哪里说不清”。我们要给医务科展示一份“修改前后对照表”这个不一定要从零实现diff算法。ProseMirror官方维护了一个prosemirror-changeset包专门用来计算两个文档状态之间的变更集。基本用法是维护一个ChangeSet实例每次dispatch Transaction时通过addSteps把新文档和映射信息喂进去ChangeSet会累积出结构化的变更描述。简单场景下也可以不引整个包自己在新旧doc JSON之间做递归diff但changeset能保留位置、插入文本、删除文本的精确关系更适合在线阅读。实际做修改历史页面时我在每条变更上额外挂了operator、actionType、timestamp元数据通过Transaction的meta字段传入。这样前端能渲染“张医生在14:23修改了主诉段落原文是‘咳嗽3天’改为‘咳嗽伴发热3天’”而不是仅仅展示一个diff补丁。4.3 留痕与审计病案归档后到底存了什么病历被打印归档后就进入“只读”状态不能再被编辑器修改。这个状态切换要在前端和后端同时控制。前端的做法是给编辑器设置editable: () false并销毁所有可编辑NodeView后端则保存一份归档JSON快照和一份最终HTML快照供在线预览和打印。多版本存储策略上我的建议是编辑过程中不保存全量快照只保存增量Transaction日志归档时才生成最终全量快照。这样既能满足“随时恢复到任意历史版本”又不会把数据库打爆。我们实测过一份复杂病历的Transaction日志平均每次编辑产生的增量数据只有几百字节比起每五分钟存一次全量JSON要省太多。5. 在Tiptap之上做业务封装既要效率也要可控5.1 Extension机制把专科模板、校验规则拆成独立模块Tiptap的Extension模型非常适合病历这种多科室复杂业务。我们的做法是把所有能力拆成独立扩展基础编辑加粗、斜体、上下标、患者字段、生命体征、诊断编码、必填校验、打印标记各占一个扩展包。每个扩展包含自己的Node/Mark/Command/InputRule/ProseMirrorPlugin互不感知通过编辑器的extensions数组装配。这样不同科室可以分别定制自己的模板扩展比如心内科的“心衰评估表”、急诊的“抢救记录模板”都是在上层通过补充Extension注册的不会动到底层Schema和核心代码。开发期专门准备几套不同专科的模板作为测试夹具特别能暴露Schema设计漏洞——儿科病历和妇科病历对字段结构的要求差异很大硬塞进同一套结构很容易出问题。5.2 输入规则与快捷键把临床录入速度提上来医生录入病历追求效率没有耐心用鼠标点一堆按钮。Tiptap的InputRule很适合做快捷输入输入//快速插入“现病史”标准段落输入患者插入患者姓名字段输入bp:插入血压控件输入diag:弹出诊断编码选择器快捷键同理比如CtrlEnter保存并进入质控校验Tab在病历节之间切换焦点。但这里有一个现场教训门诊、住院系统经常整机部署医生工作站可能有其他软件占用F1-F12甚至CtrlEnter快捷键配置之前一定要到现场环境调研一遍不要想当然。我们曾把CtrlS绑定为“保存病历”结果医生习惯性是CtrlS保存HIS页面弹窗冲突了好几次。5.3 打印分页与公式化输出病历不止在屏幕上存在病案要打印存档这驱动了很多架构决策。每个section我们加了pageBreak属性可选值auto、avoid、always对应CSS里的break-before和break-inside控制。打印样式完全走CSS media query不依赖编辑器内联样式。这里就能体现“校验高亮用Decoration而不是写入schema”的价值了。打印时只需要不把校验DecorationSet应用上去直接dispatch一个去掉高亮装饰的EditorState渲染就能输出干净的病案页面。如果当初把“必填”“缺失”这些标记写进节点attrs打印前还得再遍历清洗一遍非常容易漏。NodeView渲染的复杂控件打印时也需要专门处理。我们的经验是在print媒体样式下隐藏复杂组件显示其数据文本。比如生命体征NodeView在屏幕上是可点击的图表卡片打印时则通过media print规则隐藏卡片外壳显示“体温36.5℃ 脉搏78次/分”这样的纯文本保证纸质病案可读性。6. 复盘这套架构时总结出的几个关键教训6.1 别让业务需求反向侵蚀文档模型项目中期业务方提过“给患者字段支持任意颜色标记”“某科室的查体项目要多加一个层级”这类需求。如果为了省事直接在节点上堆attrs和样式Schema很快就会变成一个大杂烩校验、打印、协作、留痕都会连锁崩坏。后来我养成了一个习惯任何新需求先问一句“在文档模型里它到底是一个新节点、一个Mark、还是仅仅一个视图层Decoration”。有了这层抽象很多临时看起来很好的需求会被过滤掉或者被引导到更合理的实现路径上。6.2 升级依赖前先看ProseMirror和Tiptap的版本锁Tiptap 2与ProseMirror核心库的版本不是完全同步的。Tiptap的package.json里锁定了它依赖的ProseMirror版本范围如果你手痒单独把prosemirror-view升级到比Tiptap预期更高的版本很容易触发“duplicate prosemirror-model”这类问题编辑器直接白屏。我踩过一次当时为了修复一个光标bug把prosemirror-view升了一版结果Tiptap内部引用的prosemirror-model和我的应用引入的实例不是同一个所有类型判断全部失效连基本的输入都卡死。后来老老实实跟着Tiptap的依赖版本走不再自己单独升级ProseMirror家族包。6.3 编辑器在React/Vue里的生命周期比想象中敏感Tiptap实例初始化之后不要再在组件渲染过程中反复重建编辑器。正确做法是初始化一次后续通过editor.commands和外部状态同步。组件卸载时必须调用destroy()否则ProseMirror view的事件监听会残留在DOM上切路由再回来时会出现双重编辑器、输入卡顿。React 18的StrictMode会在开发环境额外挂载/卸载一次组件这个坑很隐蔽。我们团队一度以为是Tiptap的bug排查半天发现是StrictMode导致编辑器被创建了两次旧的view没有销毁干净。解决方案是在useEffect里做严格的cleanup并在useRef里保存editor实例。6.4 输入法与粘贴是永远的痛哪怕内核再稳中文医学录入对输入法依赖极大。ProseMirror/Tiptap对composition事件的处理相对完善但要注意不要在composition期间强制更新视图不要自己监听compositionstart/compositionend去做“智能补全”否则很容易把正在组词的中文打断。粘贴场景同样要下功夫。Word、网页里复制过来的内容带大量内联样式默认粘贴会绕过编辑器很多约束。我们最终实现了一套自己的PasteRule和ClipboardParser把粘贴内容统一剥离成文本受控Mark再走Schema校验后插入。这一步不做好Schema约束得再严格也会被一个CtrlV直接绕过。最后说一句大白话。别迷信ProseMirror和Tiptap这两个名字它们只是工具真正的架构价值在于你如何用Schema把业务规则显性化如何让事务贯穿所有变更如何把视图层的Decoration与数据层的模型彻底分离。我们做完这轮重构以后最明显的收益不是界面好看了而是质控科提的那些校验要求终于能在一个可控的模型上实现了。如果让我再做一次我会更早开始写Schema约束测试更早把diff留痕接进来——很多问题都是后续功能堆上来才暴露的前期架构留的每一分余量后面都会加倍回报你。
RELATED

相关推荐

C++ 中 double 转 string 的四种方法与精度控制实战指南

C++ 中 double 转 string 的四种方法与精度控制实战指南

C 中 double 转 string 的四种方法与精度控制实战指南 【免费下载链接】cosmos Worlds largest Contributor driven code dataset | Used in Quark Search Engine, OpenGenus IQ, OpenGenus Visual Project 项目地址: https://gitcode.com/gh_mirrors/co/cosmos 导读 在…

📅 2026/9/23 7:11:44
网络热词“cua”全解析:含义、用法与传播逻辑

网络热词“cua”全解析:含义、用法与传播逻辑

前段时间刷评论区,总能看到有人发“cua一下”“cua没了”“这波cua cua cua”。我一开始以为是谁键盘没打好,后来又以为是某种游戏技能音效。直到这个词连续出现在好几个不同圈子的聊天记录里,我才意识到:“cua”已经变成了一枚正…

📅 2026/9/23 7:06:44
STM32第一个工程从零搭建:工具链选型、时钟配置与调试链路打通

STM32第一个工程从零搭建:工具链选型、时钟配置与调试链路打通

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

📅 2026/9/23 7:06:44
MORE NEWS

更多资讯

📰

LangGPT 实践视角下的 AI Native 组织重构——从结构化提示词、概念锚点到时间折叠工作流

提示工程大模型人工智能AI 技能/插件Prompt 模板 【免费下载链接】LangGPT LangGPT: Empowering everyone to become a prompt expert! 🚀 📌 结构化提示词(Structured Prompt)提出者 📌 元提示词(Meta-Pro…

📰

GPS车辆定位监控系统中车辆资料设置全解析:从字段到排查

设备刚装完、卡也插好了,车却在平台上一片灰色,离线状态的红色标记看得人心里发毛。做GPS车辆定位监控这些年,我最常被问到的不是“定位准不准”,而是“师傅,车资料到底要怎么填才对”。很多人以为车辆资料设置就是把车…

📰

光伏逆变器故障诊断:Simulink建模与智能算法实践

1. 项目背景与核心价值光伏逆变器作为太阳能发电系统的"心脏",其可靠性直接影响整个电站的发电效率。而网侧整流器开路故障是最常见却又最难被及时发现的隐患之一——它不会立即导致系统停机,却会像慢性病一样逐渐侵蚀发电效率。传统基于硬件传…

📰

Cosmos 仓库二分查找(Binary Search)C++ 实战指南:原理、三种实现与源码级剖析

Cosmos 仓库二分查找(Binary Search)C 实战指南:原理、三种实现与源码级剖析 【免费下载链接】cosmos Worlds largest Contributor driven code dataset | Used in Quark Search Engine, OpenGenus IQ, OpenGenus Visual Project 项目地址:…

📰

Pelican 静态站点生成器完全指南:基于 Markdown 与 reStructuredText 的 Python 建站方案

【免费下载链接】pelican Static site generator that supports Markdown and reST syntax. Powered by Python. 项目地址: https://gitcode.com/gh_mirrors/pe/pelican 点击查看 免费下载 Pelican 是一个用 Python 编写的静态站点生成器(Static Site G…

📰

校园网Nginx高并发优化实战:从worker配置到缓存限流的避坑指南

一到选课季,“校园网Nginx高并发优化”就成了各个技术群里反复被问的话题。答案大家张口就能来几句:把worker_processes调到CPU核数、把worker_connections调成65535、开gzip、上缓存……但真到现场一看,很多服务器是被这些参数调崩的。这篇文…

TODAY

今日更新

THIS WEEK

本周精选

THIS MONTH

本月热门

读完文章,想聊聊您的网站?

告诉我们您的行业与需求,资深顾问一对一梳理方案与报价,全程免费。

📞 💬