性能优化与可访问性(大文档 / 装饰 / NodeView / ARIA)
返回 🗂 富文本编辑专题 · 相关私有笔记
本篇是实战性能与可访问性收口篇:把前面 Plugin 与 Decoration、View 与 NodeView、State 与 Transaction 里讲过的内核机制,落到「大文档卡不卡」「装饰怎么维护才便宜」「NodeView 为什么重渲染」「contentEditable 对屏幕阅读器友不友好」这几件能直接量化的事情上。每一条优化都尽量锚回底层概念,而不是空喊「要快」。
进入时机与先修条件
不要把本篇当成“新建编辑器时从头套一遍”的配置清单。性能部分应在复现真实卡顿、量到瓶颈后按问题进入;无障碍部分应在产品交互稳定后用真实键盘、屏幕阅读器和 IME 验收,而不是只凭静态代码判断。
- 共同先修:读过 ProseMirror 入门,并能理解
Transaction → 新 state → View的更新闭环。 - 装饰与事务问题:再读 Plugin 与 Decoration,尤其是 plugin state、
tr.mapping与 meta。 NodeView问题:再读 View 与 NodeView;只有使用 React / VueNodeView时才需要“重渲染级联”与“同步渲染成本”两节。- 排版/分页问题:先确认是性能瓶颈还是文本布局问题;后者转到 排版与布局,不要用性能技巧替代排版设计。
推荐顺序:复现问题 → 用 profiler、性能 trace 或辅助技术实测定位 → 只读对应小节 → 改动后复测。这样能避免为了“可能的性能问题”提前牺牲编辑正确性。
第一部分:只在测到瓶颈后做性能优化
性能的核心心智模型:增量,而不是重建
ProseMirror 整个数据层是不可变的(见 PM 核心模型),性能的胜负手几乎都落在同一个动作上:每次 Transaction 之后,是「把旧结果增量映射到新文档」,还是「从头重算」。
- 文档树不可变 → 未改动的子树可被结构共享复用。
- 位置(position)是扁平整数,任意一处插入/删除都会让其后所有位置整体平移,这是后面
NodeView重渲染级联的根因。 DecorationSet与文档同构,也是持久化(immutable)结构,因此它能跟着文档一起做「只重建被触碰的分支」的增量映射。
一句话:凡是「每个 Transaction 都全量重算」的写法,在大文档下都会退化成准平方级开销。下面所有具体技巧都是这条原则的展开。
DecorationSet 的增量维护(THE 优化)
DecorationSet 是一棵与文档树同形的持久化结构。当 Transaction 到来时,用 set.map(tr.mapping, tr.doc) 把整套装饰沿映射前移——映射算法利用树形结构,只重建被文档改动触碰到的分支,未变子树直接复用。这就是大文档下装饰不卡的关键。
最常见也最致命的反模式:在 decorations(state) prop 里每次都 DecorationSet.create(...) 从头造。decorations() 在每次 view 更新时都会被调用,从头造等于把增量优势全丢了。正确姿势是把 DecorationSet 存进 plugin state,只在 apply 里 map,decorations() 只负责返回缓存。
import { Plugin, PluginKey } from "prosemirror-state";
import { DecorationSet } from "prosemirror-view";
const highlightKey = new PluginKey<DecorationSet>("highlight");
const highlightPlugin = new Plugin<DecorationSet>({
key: highlightKey,
state: {
init: () => DecorationSet.empty,
// apply(tr, value, oldState, newState) —— value 是上一份 DecorationSet
apply(tr, set, _oldState, newState) {
// 第一步永远是:跟着文档改动把旧装饰前移(增量复用未变子树)
let mapped = set.map(tr.mapping, tr.doc);
// 只有逻辑确实需要时才重算(用 meta 作为显式信号)
if (tr.getMeta("recalcHighlight")) {
mapped = createHighlights(newState.doc);
}
return mapped;
},
},
props: {
// 只取缓存,绝不在这里 create
decorations: (state) => highlightKey.getState(state),
},
});要点:apply 的第一行几乎总应该是 set.map(tr.mapping, tr.doc);真正昂贵的扫描(正则、语法分析)用 tr.getMeta(...) 标志位有条件触发。另外 DecorationSet.find(start?, end?, predicate?) 会遍历命中区间内的装饰——上万条装饰下频繁调用要自己缓存结果。
widget / inline / node:三类装饰怎么选
三类 Decoration 的成本与语义不同,选错就是白白多花钱:
Decoration.inline(from, to, attrs, spec?):给区间内所有 inline 节点加 CSS/属性,最轻,纯样式(高亮、拼写下划线)首选。注意它作用于区间内的 inline 节点,不改变块级结构。Decoration.node(from, to, attrs, spec?):给「正好被from/to这对位置包住的单个节点」加状态类(如选中态)。注意from/to必须精确对应那个节点的起止边界,不是任意区间。Decoration.widget(pos, toDOM, spec?):在某位置插入一个不属于文档的 DOM 节点(光标、评论锚点、协作者光标等)。最贵——多一个独立 DOM 插入点。
widget 的两个性能开关必须记住:
toDOM传函数,延迟渲染。Decoration.widget(pos, toDOM, spec?)里toDOM签名是(view, getPos) => DOMNode,只在真正绘制时被调用,视口外不构造 DOM;也可以直接传一个 DOM 节点,但那样就失去了延迟构造的好处。spec.key控制身份比较。key用来判断「这个 widget 还是不是原来那个」,默认按 DOM 节点身份比较。on-the-fly 生成 widget 却不给 key,即使内容完全一样也可能被当成新 widget 重绘——给同一逻辑实体一个稳定 key 就能避免。
import { Decoration } from "prosemirror-view";
const deco = Decoration.widget(
commentPos,
(view, getPos) => {
// 延迟渲染:仅在该位置真正进入绘制时调用;getPos() 可拿当前文档位置
const el = document.createElement("span");
el.className = "comment-anchor";
return el;
},
{
side: -1, // <0 画在光标前、插入内容落在 widget 之后;>=0 反之
key: commentId, // 用业务 id 做身份,避免相同内容重绘
}
);side 参数语义(官方原文):side < 0 时 widget 画在该位置光标之前,在此位置插入的内容会落到 widget 之后;side 为 0(默认)或正数时反过来。选错会导致打字时 widget 莫名漂移到光标另一侧。
别在 plugin.apply 里干重活:批处理与去抖
apply(tr, value, oldState, newState) 对每一个 Transaction 同步执行。在这里跑正则全文扫描、复杂装饰构造、甚至外部 API 调用,会直接卡住按键到渲染的链路,体感就是「打字延迟」。
正确的卸载路径有两条,都是把重活挪出按键关键路径:
appendTransaction(transactions, oldState, newState):在当前这批Transaction落定之后追加一个后续Transaction,用它携带「需要重算」的 meta,真正的昂贵计算在下一拍发生。注意appendTransaction自身追加的Transaction会再次进入状态更新流程并触发它,容易成环——必须用 meta 标志位做防护,避免无限循环。- plugin
view返回对象的update(view, prevState)里setTimeout去抖:语法高亮、搜索结果这类「停下来再算也不迟」的场景,用计时器把多次输入合并成一次重算(典型 150–250ms),到点后再dispatch一个带 meta 的Transaction。
import { Plugin } from "prosemirror-state";
const searchBatcher = new Plugin({
// appendTransaction(transactions, oldState, newState) → Transaction | null
appendTransaction(transactions, _oldState, newState) {
// 仅在用户实际输入后才触发(廉价信号)
const userTyped = transactions.some(
(tr) => tr.getMeta("uiEvent") === "input"
);
if (!userTyped) return null;
// 防环:若本就是我们追加的重算事务,别再追加
if (transactions.some((tr) => tr.getMeta("recalcSearch"))) return null;
return newState.tr.setMeta("recalcSearch", true);
},
});这与 Plugin 与 Decoration 里 plugin 状态机的设计是一致的:apply 要保持纯且快,跨 plugin 的「该干活了」信号靠 tr.setMeta() 传递,而不是在 apply 里直接同步算。
Transaction 是可变对象:把它当只读
一个容易踩的内核坑:Transaction 在 ProseMirror 里是可变的。任一 plugin 在 appendTransaction 链路中对 tr 调用 replaceRange 之类的变更,都会改变后续步骤看到的状态。
纪律:约定把 tr 当只读,需要从同一基点分叉时用 tr 的派生 API(如 state.tr 新建);跨 plugin 通信一律走 tr.setMeta() / tr.getMeta(),而不是直接改树。协同场景(如 协同编辑 里的 Yjs 绑定)尤其要注意:在 appendTransaction 之类的共享链路里,你对 tr 的变更会波及下游所有逻辑。位置/事务的底层语义见 State 与 Transaction。
NodeView 的重渲染级联:pos 作为 prop 是反模式
把 React 组件当 NodeView 用时,最隐蔽的性能炸弹是把文档位置 pos 当 prop 传。因为位置是相对的整数偏移,文档任意靠前处的一次插入/删除,都会让其后所有节点的 pos 改变;React 的 prop 比较随即把这些节点全部判为「变了」,触发全量重渲染——这正是社区文章里记录的大文档 NodeView 瓶颈来源。
解法是把「位置」从「身份」里解耦:
- 传
getPos回调而不是pos值。getPos()是一个闭包,按需从父级上下文算出当前位置,这样组件 props 本身不随位置漂移而变。 - 在多层嵌套上都套
React.memo,让「只有内容/外层装饰真正变化的节点」才重渲染。 - 用
node.eq(other)做结构相等判断(不是引用相等),作为 memo 的比较依据。
import { memo, useCallback } from "react";
import type { Node } from "prosemirror-model";
const NodeViewComponent = memo(function NodeViewComponent({
node,
getPos, // 回调,而非 pos 值 —— memo 才能生效
selected,
}: {
node: Node;
getPos: () => number;
selected: boolean;
}) {
return (
<div className={selected ? "is-selected" : ""}>{node.textContent}</div>
);
});
// 子层:用相对偏移现算位置,不让 pos 漂移污染 props
const Child = memo(function Child({
node,
getInnerPos,
offset,
}: {
node: Node;
getInnerPos: () => number;
offset: number;
}) {
const getPos = useCallback(
() => getInnerPos() + offset,
[getInnerPos, offset]
);
return <NodeViewComponent node={node} getPos={getPos} selected={false} />;
});原生 NodeView.update(node, decorations, innerDecorations) 也是同一套思想:在 update 里判断能否就地更新到新 node,能就返回 true 复用 DOM,不能才返回 false 让 View 销毁重建——尽量走「就地更新」分支。详见 View 与 NodeView。
TipTap React NodeView 的同步渲染成本
TipTap 的 React 集成会把 React 组件挂进 ProseMirror NodeView 的 DOM 容器里,而这个挂载是同步的:每个 NodeView 的 mount/update 都同步触发 React 渲染,ProseMirror 不会让出主线程。节点上千时,单帧预算很容易被这些同步挂载吃光。
TipTap 官方性能建议很直接:
- 能用纯 HTML/原生
NodeView就别用 React(文本、简单格式尤其如此);React 留给真正复杂的块(嵌入、可交互卡片)。 - 用 React 时激进地 memo +
useCallback,杜绝无谓的 prop 变化。 - 用 React DevTools Profiler 和
console.count("editor render")量化重渲染频率——「感觉变快了」不算数,要看计数。
实战细节与自定义 NodeView 的搭法见 TipTap 实战,架构层面的「Extension 如何编译成 PM plugins」见 TipTap 架构。
大文档:拼写检查与「虚拟滚动是分外事」
两个常被忽略却影响巨大的事实:
(1) 浏览器拼写检查是大文档头号杀手。 contentEditable 默认开启 spellcheck,在 Chromium 上对长文档、尤其非英文文本(如带变音符的德语)会触发昂贵的词法分析与级联回流,按键延迟可能直接翻倍(社区 issue 有详细记录)。通过 EditorView 的 attributes prop 设 { spellcheck: "false" } 关掉,大文档下常能显著降低按键延迟。
import { EditorView } from "prosemirror-view";
// attributes 可以是对象,也可以是 (state) => Object<string> 函数
const view = new EditorView(mount, {
state,
attributes: {
// 文档很大时直接关闭拼写检查(注意值是字符串)
spellcheck: doc.content.size > 100_000 ? "false" : "true",
},
});(2) 视口虚拟化/视口剔除是 ProseMirror core 的分外事。 由于高度缓存等复杂性,核心团队不在内核做 viewport culling(社区讨论里有定论)。所以「100+ 页要 <1ms 按键」别指望内核帮你裁——更务实的路线是分页(pagination)或拆成带 offset map 的子编辑器(sub-editors),而不是试图在单个 ProseMirror 实例里做遮挡剔除。
第二部分:从产品早期纳入的编辑体验与无障碍
contentEditable 的 IME / composition 与无障碍冲突
contentEditable 在 IME(中日韩等输入法)下天生脆弱:合成(composition)期间浏览器会渲染临时中间文本,此时若 ProseMirror 去同步状态或移动选区,就会撕裂正在输入的文本。
内核约定(也在 View 与 NodeView 里讲过):
- 合成期间不要干预:
compositionstart到compositionend之间不要程序化setSelection、不要改动会触碰光标处 DOM 的装饰。 - ProseMirror 在合成期间用一个不可见的 cursor wrapper(
view.cursorWrapper,存着当前位置的 marks)保住 bold/italic 等行内样式,合成结束再落定。 - Android IME 最脆:合成中改 DOM/装饰极易导致光标跳动,必须用真机配合真实输入法测,不能只在桌面 Chrome 验证。
- 合成文本在
compositionend之前对textContent/DOM 检查不可见——任何依赖读 DOM 的同步逻辑都必须等到compositionend。
import { EditorView } from "prosemirror-view";
const view = new EditorView(mount, { state });
// view.composing 是 EditorView 上的只读属性,合成进行中为 true。
// 任何程序化移动选区,先判断是否正在合成。
if (!view.composing) {
const tr = view.state.tr.setSelection(/* ... */);
view.dispatch(tr);
}无障碍冲突点:屏幕阅读器(NVDA/JAWS)对合成中间文本的播报常不可靠,IME + SR 的组合要单独测,不能假定「IME 正常 = SR 也正常」。
contentEditable 的可访问性陷阱:role / label / live region
contentEditable 默认没有任何 ARIA 语义——屏幕阅读器看到的只是一个无标签的可编辑块。四类常见错误与对策:
- 滥用
role="application"。这会关掉屏幕阅读器的文本浏览模式(H 跳标题、方向键逐行读),等于向 SR 承诺「我会自己实现全部键盘交互」——做不到就是破承诺。多数富文本编辑器更应保持默认的可编辑文档语义(必要时可显式role="textbox"并配aria-multiline="true"),让 SR 把它当可阅读/可编辑文本,而不是强行套application。 - 容器缺
aria-label。没有可见标题时,必须给可编辑区一个无障碍名,如aria-label="文章正文编辑器"(ProseMirror 也提供attributesprop 直接注入)。 - 动态变更不播报。文本插入等变化默认不被 SR 通报,需要用
aria-live="polite"区域承载状态(保存状态、字数等)。polite在 SR 空闲时再播报、不打断;assertive仅留给真正的警示(如「有未保存修改」)。 - 自定义
NodeView缺语义。没有语义化 HTML 或显式 ARIA 的自定义组件,对 SR 是隐形的——给它语义化标签或补 ARIA。
<div
contenteditable="true"
role="textbox"
aria-multiline="true"
aria-label="文章正文编辑器"
aria-describedby="editor-help"
>
<!-- 可编辑内容 -->
</div>
<div id="editor-help" hidden>支持 Markdown;Ctrl+B 加粗,Ctrl+I 斜体。</div>
<!-- 状态用 polite live region 播报 -->
<div aria-live="polite" id="editor-status"></div>live region 的一个隐性坑(MDN 也提示):它最好在页面加载时就已存在且为空,等无障碍 API 把它登记为 live region 之后再注入内容,否则首次注入可能不被播报。
键盘导航与焦点管理:roving tabindex
编辑器常是「复合控件(composite widget)」:一个可编辑区 + 工具栏/菜单等可聚焦子组件。W3C ARIA APG 的标准做法是 roving tabindex——整个复合控件在 Tab 序列里只占一个入口:当前活动元素 tabindex="0",其余 tabindex="-1";Tab 在控件之间跳,方向键在控件内部移动焦点。
const buttons = Array.from(
toolbar.querySelectorAll<HTMLButtonElement>("button")
);
buttons.forEach((btn, i) => {
btn.tabIndex = i === 0 ? 0 : -1; // 仅首个进入 Tab 序列
btn.addEventListener("keydown", (e) => {
const next =
e.key === "ArrowRight" ? i + 1 : e.key === "ArrowLeft" ? i - 1 : -1;
if (next < 0 || next >= buttons.length) return;
e.preventDefault();
buttons[i].tabIndex = -1;
buttons[next].tabIndex = 0;
buttons[next].focus();
});
});aria-activedescendant 是另一条路线(焦点留在容器、用属性标记活动项),但需要更多播报逻辑。两点纪律:别把键盘困在编辑器里却不给逃逸出口(Esc/Tab 要能出去);可见的焦点指示器不能被 CSS 抹掉。
选区与 DOM↔Doc 映射:用 API,别手撸 DOM
ProseMirror 用扁平字符偏移:每个节点边界、每个文本字符都让位置 +1(node.nodeSize 含开闭边界;文本节点 size = 文本长度,无边界)。View 提供两个映射 API:
view.posAtDOM(node, offset, bias?):DOM 位置 → 文档位置,常用于点击/聚焦事件里解析位置(bias默认-1)。view.domAtPos(pos, side?):文档位置 → DOM{node, offset},用于定位 widget 或测量布局。side:<0偏前、0(默认)浅层、>0偏后,用来消解「相邻文本节点之间」这类歧义位置。
边界情况(相邻文本节点之间、不透明 NodeView 内部)很微妙,优先用这两个 API,别直接遍历原始 DOM。另外注意:程序化移动焦点可能重置合成状态导致 IME 文本丢失——活跃编辑期间对焦点变更要保守。位置/映射的底层模型见 State 与 Transaction。
第三部分:发布前按场景验证
大文档性能优化清单(可操作)
- 关拼写检查:文档很大时用
attributesprop 设spellcheck: "false",Chromium 下常显著降低延迟。 - 缓存装饰、只 map:DecorationSet 存 plugin state,
apply第一行set.map(tr.mapping, tr.doc),decorations()只返回缓存,绝不create。 NodeView用getPos回调 + 多层 memo:杜绝pos作 prop;用node.eq做 memo 比较。- 重活移出 apply:正则/外部调用走
appendTransaction,或 pluginview().update里setTimeout去抖,并加防环 meta。 - widget 延迟渲染 + 设 key:
toDOM传函数、spec.key用业务 id、side选对。 - 频繁出现的节点用原生 DOM:React
NodeView同步挂载有成本,大量节点优先纯 DOM。 - 超大文档考虑分页/子编辑器:虚拟滚动是 PM 分外事,用 pagination 或带 offset map 的 sub-editors。
- 量化而非感觉:React DevTools Profiler +
console.count+ 检查 DOM 节点总数,用数据确认每一步收益。
可访问性检查清单(a11y checklist)
- role:可编辑容器优先保持默认可编辑语义(或显式
role="textbox"+aria-multiline="true"),慎用role="application"(除非真的实现了全部键盘交互)。 - name:容器有
aria-label或关联的可见标签;必要时aria-describedby指向用法说明。 - 状态播报:用
aria-live="polite"承载保存状态/字数;assertive仅留给警示;live region 页面加载时即存在且为空。 - 键盘:复合控件用 roving tabindex,Tab 进出、方向键内部移动;Esc/Tab 有逃逸出口,不困住键盘。
- 焦点可见:不移除焦点指示器,保证可见对比。
- 自定义
NodeView:语义化 HTML 或补显式 ARIA,别让 SR 看不见。 - IME × SR:用真机 IME(尤其 Android)+ NVDA/JAWS 实测合成文本播报,不靠桌面假设。
- 不在合成期干预:
compositionstart→compositionend间不程序化移动选区/改光标处 DOM。
参考
- ProseMirror Guide: Decorations & Efficient Updating — https://prosemirror.net/docs/guide
- ProseMirror Reference Manual: Decoration / DecorationSet / NodeView / EditorView — https://prosemirror.net/docs/ref
- Making React ProseMirror Really, Really Fast — https://handlewithcare.dev/blog/making_react_prosemirror_really_really_fast
- Why I Rebuilt ProseMirror’s Renderer in React — https://smoores.dev/post/why_i_rebuilt_prosemirror_view
- TipTap Integration Performance Guide — https://tiptap.dev/docs/guides/performance
- ProseMirror Performance Issues & Chrome Spellcheck(discuss)— https://discuss.prosemirror.net/t/performance-issues-with-prosemirror-and-chrome/2498
- ProseMirror 视口渲染/虚拟滚动属于分外事(discuss)— https://discuss.prosemirror.net/t/efficient-viewport-rendering-like-codemirror/577
- contentEditable HTML Specification — https://w3c.github.io/contentEditable
- ProseMirror & Android Composition Issues(discuss)— https://discuss.prosemirror.net/t/contenteditable-on-android-is-the-absolute-worst/3810
- W3C ARIA Authoring Practices: Keyboard Interface — https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface
- ARIA Live Regions(MDN)— https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Guides/Live_regions