性能优化与可访问性(大文档 / 装饰 / NodeView / ARIA)

返回 🗂 富文本编辑专题 · 相关私有笔记

本篇是实战性能与可访问性收口篇:把前面 Plugin 与 DecorationView 与 NodeViewState 与 Transaction 里讲过的内核机制,落到「大文档卡不卡」「装饰怎么维护才便宜」「NodeView 为什么重渲染」「contentEditable 对屏幕阅读器友不友好」这几件能直接量化的事情上。每一条优化都尽量锚回底层概念,而不是空喊「要快」。

进入时机与先修条件

不要把本篇当成“新建编辑器时从头套一遍”的配置清单。性能部分应在复现真实卡顿、量到瓶颈后按问题进入;无障碍部分应在产品交互稳定后用真实键盘、屏幕阅读器和 IME 验收,而不是只凭静态代码判断。

  • 共同先修:读过 ProseMirror 入门,并能理解 Transaction → 新 state → View 的更新闭环。
  • 装饰与事务问题:再读 Plugin 与 Decoration,尤其是 plugin state、tr.mapping 与 meta。
  • NodeView 问题:再读 View 与 NodeView;只有使用 React / Vue NodeView 时才需要“重渲染级联”与“同步渲染成本”两节。
  • 排版/分页问题:先确认是性能瓶颈还是文本布局问题;后者转到 排版与布局,不要用性能技巧替代排版设计。

推荐顺序:复现问题 → 用 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 的两个性能开关必须记住:

  1. toDOM 传函数,延迟渲染Decoration.widget(pos, toDOM, spec?)toDOM 签名是 (view, getPos) => DOMNode,只在真正绘制时被调用,视口外不构造 DOM;也可以直接传一个 DOM 节点,但那样就失去了延迟构造的好处。
  2. 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 之后;side0(默认)或正数时反过来。选错会导致打字时 widget 莫名漂移到光标另一侧。

别在 plugin.apply 里干重活:批处理与去抖

apply(tr, value, oldState, newState)每一个 Transaction 同步执行。在这里跑正则全文扫描、复杂装饰构造、甚至外部 API 调用,会直接卡住按键到渲染的链路,体感就是「打字延迟」。

正确的卸载路径有两条,都是把重活挪出按键关键路径:

  1. appendTransaction(transactions, oldState, newState):在当前这批 Transaction 落定之后追加一个后续 Transaction,用它携带「需要重算」的 meta,真正的昂贵计算在下一拍发生。注意 appendTransaction 自身追加的 Transaction 会再次进入状态更新流程并触发它,容易成环——必须用 meta 标志位做防护,避免无限循环。
  2. 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 有详细记录)。通过 EditorViewattributes 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 里讲过):

  • 合成期间不要干预:compositionstartcompositionend 之间不要程序化 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 语义——屏幕阅读器看到的只是一个无标签的可编辑块。四类常见错误与对策:

  1. 滥用 role="application"。这会关掉屏幕阅读器的文本浏览模式(H 跳标题、方向键逐行读),等于向 SR 承诺「我会自己实现全部键盘交互」——做不到就是破承诺。多数富文本编辑器更应保持默认的可编辑文档语义(必要时可显式 role="textbox" 并配 aria-multiline="true"),让 SR 把它当可阅读/可编辑文本,而不是强行套 application
  2. 容器缺 aria-label。没有可见标题时,必须给可编辑区一个无障碍名,如 aria-label="文章正文编辑器"(ProseMirror 也提供 attributes prop 直接注入)。
  3. 动态变更不播报。文本插入等变化默认不被 SR 通报,需要用 aria-live="polite" 区域承载状态(保存状态、字数等)。polite 在 SR 空闲时再播报、不打断;assertive 仅留给真正的警示(如「有未保存修改」)。
  4. 自定义 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

第三部分:发布前按场景验证

大文档性能优化清单(可操作)

  1. 关拼写检查:文档很大时用 attributes prop 设 spellcheck: "false",Chromium 下常显著降低延迟。
  2. 缓存装饰、只 map:DecorationSet 存 plugin state,apply 第一行 set.map(tr.mapping, tr.doc),decorations() 只返回缓存,绝不 create
  3. NodeViewgetPos 回调 + 多层 memo:杜绝 pos 作 prop;用 node.eq 做 memo 比较。
  4. 重活移出 apply:正则/外部调用走 appendTransaction,或 plugin view().updatesetTimeout 去抖,并加防环 meta。
  5. widget 延迟渲染 + 设 key:toDOM 传函数、spec.key 用业务 id、side 选对。
  6. 频繁出现的节点用原生 DOM:React NodeView 同步挂载有成本,大量节点优先纯 DOM。
  7. 超大文档考虑分页/子编辑器:虚拟滚动是 PM 分外事,用 pagination 或带 offset map 的 sub-editors。
  8. 量化而非感觉:React DevTools Profiler + console.count + 检查 DOM 节点总数,用数据确认每一步收益。

可访问性检查清单(a11y checklist)

  1. role:可编辑容器优先保持默认可编辑语义(或显式 role="textbox" + aria-multiline="true"),慎用 role="application"(除非真的实现了全部键盘交互)。
  2. name:容器有 aria-label 或关联的可见标签;必要时 aria-describedby 指向用法说明。
  3. 状态播报:用 aria-live="polite" 承载保存状态/字数;assertive 仅留给警示;live region 页面加载时即存在且为空。
  4. 键盘:复合控件用 roving tabindex,Tab 进出、方向键内部移动;Esc/Tab 有逃逸出口,不困住键盘。
  5. 焦点可见:不移除焦点指示器,保证可见对比。
  6. 自定义 NodeView:语义化 HTML 或补显式 ARIA,别让 SR 看不见。
  7. IME × SR:用真机 IME(尤其 Android)+ NVDA/JAWS 实测合成文本播报,不靠桌面假设。
  8. 不在合成期干预:compositionstartcompositionend 间不程序化移动选区/改光标处 DOM。

参考