🎬 面试开场怎么引出这个话题
「在菲克斯的密评报告系统里,测评报告有大量表格嵌套的需求 — 表格里套表格、单元格合并、跨行跨列、对齐和样式排版都有。我用了 TipTap(基于 ProseMirror),印象最深的坑是 ContentEditable 在 Chrome 和 Safari 的光标行为不一样,嵌套表格里点击单元格光标会乱跳,排查了一周。另外从 Word 粘贴进来的内容带一堆 inline style 污染,我写了个 Schema 白名单过滤。」
✅ 抛出 4 个可深挖的点:① 表格嵌套业务复杂度 ② ContentEditable 浏览器差异 ③ Word 粘贴污染 ④ ProseMirror Schema。
富文本是前端最硬核的方向之一,能聊这个说明你不只是"调 API"。
1

选型:为什么选 TipTap 不选 TinyMCE / WangEditor

编辑器底层表格嵌套定制性体积结论
TipTapProseMirror原生支持,可深度定制★★★★★ Schema 自由按需引入✅ 最终选择
TinyMCE自研支持但定制难★★★ 配置驱动较大另一个项目用
WangEditor自研★★ 国产易用❌ 嵌套表格扛不住
Quill自研 Delta弱(Delta 模型限制)★★★❌ 复杂表格不行
选型关键: 测评报告的核心痛点是表格嵌套(表格里还有表格)+ 合并单元格。这需要底层的文档模型支持「递归的块级结构」。ProseMirror 的文档模型是一棵树(类似 DOM),天然支持嵌套;Quill 的 Delta 是扁平的线性结构,做嵌套表格非常痛苦。
🧠 类比理解
ProseMirror(TipTap) 的文档模型像 一棵真实的 DOM 树,节点可以无限嵌套,表格里放表格很自然。
Quill 的 Delta 模型像 一串珍珠项链,是线性的(op 数组),要表达"表格里有表格"得用很 hack 的方式,光标和选区都会出问题。
2

表格嵌套的核心坑

⚠️ 业务场景

测评报告里有这样的结构:一个"测评结论"表格,其中某个单元格内容本身又是一个"指标明细"表格。还要支持合并单元格、跨行跨列、单元格内富文本(加粗/列表/图片)。

技术难点: ProseMirror 默认的 table 扩展(NodeView)不允许表格嵌套(Schema 里 table 的 content 定义是 `tableRow+`,不允许再嵌 table)。要解开这个限制 + 处理嵌套后的选区、Tab 键跳转、合并计算。
🔑 解开嵌套限制 + 自定义 NodeView
// 1. 修改 Schema,允许 table 的 cell 里再放 table
const customTableCell = TableCell.extend({
  content: 'block+'  // 原本是 'block+',关键是允许 block 类型里包含 table
})

// 2. 注册时确保 table 在全局节点里可用,cell 的 content 自动允许
import { Table } from '@tiptap/extension-table'
import { TableCell } from '@tiptap/extension-table-cell'

const editor = new Editor({
  extensions: [
    StarterKit,
    Table.configure({ allowTableNodeSelection: true }),
    // 用自定义的 cell 替换默认的
    customTableCell,
    TableRow,
    TableHeader,
  ],
  content: '<table>...</table>'
})

// 3. 嵌套表格的 Tab 键导航:外层 Tab 到边界时,进入内层表格
// 需要重写 handleKeyDown,判断当前 selection 在哪个层级
⚠️ 嵌套后的具体坑
  • 选区跨层级错乱: 从外层表格拖选到内层表格,选区计算会把两层混在一起。→ ProseMirror 的 TextSelection / CellSelection 要区分层级。
  • 合并单元格计算: 嵌套表格里合并,rowspan/colspan 计算要把内层表格当成一个整体单元格。
  • 删除时的连带: 删掉一个包含内层表格的单元格,内层表格整个没了,撤销 redo 要能恢复整个子树。
  • 序列化/反序列化: 存库的 HTML 要正确还原嵌套结构,ProseMirror 的 DOMParser 对嵌套 table 有 bug,需要自定义 parseDOM rules。
3

ContentEditable 浏览器差异 — 最恶心的坑

⚠️ 真实事故:光标乱跳

现象: 嵌套表格里,Chrome 点击某个单元格光标正常,Safari 点同一个单元格光标跑到上一个单元格末尾;Firefox 又是另一种行为。研发用 Chrome 测一切正常,客户用 Mac 的 Safari 全报"光标乱跳"。

第一反应: 以为是 TipTap 的 bug,提了 issue。后来发现是 ContentEditable 本身的浏览器实现差异 — 这是 HTML5 规范里最"松"的部分,每个浏览器自己决定光标如何定位。

根因: ContentEditable 的 selection(Selection API + Range)在不同浏览器里,对"空单元格""嵌套结构""block 边界"的 caret 定位算法不一致。ProseMirror 用自己的 view 层接管了大部分,但极端边界(嵌套 + 空单元格)还是会漏。
🔧 解决方案
问题ChromeSafari解法
空单元格点击光标进单元格光标在上一个 cell空 cell 塞个 <br> 占位
嵌套表格边界正常跳层自定义 NodeView 接管 mousedown
Shift+方向键选区跨 cell只在本 cell重写 keymap
IME 中文输入正常首字丢失compositionend 后手动 insertText
血的教训: 富文本编辑器必须在所有目标浏览器实测。光标问题在 Chrome 上完全暴露不出来,客户一旦用 Safari/Edge 就炸。我们后来加了一个 Playwright 跨浏览器 E2E 测试,专门测光标定位。
4

粘贴 Word/Excel 的样式污染

⚠️ 现象

用户从 Word 复制一段内容粘进来,HTML 里带了几百行 inline style(mso-xxxcolor:windowtextfont-family:宋体),还有一堆 <!-- [if gte mso]> 的条件注释垃圾。报告导出后样式全乱,体积暴增。

方案: 用 ProseMirror 的 transformPasted + clipboardTextParser 钩子,在内容进入文档前过滤。配合一个 样式白名单,只保留允许的标签和属性。
📋 Word 粘贴净化器
// 自定义扩展:拦截粘贴
const PasteCleaner = Extension.create({
  name: 'pasteCleaner',

  // 处理粘贴的 HTML
  addProseMirrorPlugins() {
    return [
      new Plugin({
        props: {
          // 粘贴 HTML 时调用
          transformPastedHTML(html) {
            return cleanWordHTML(html)
          },
          // 粘贴纯文本/片段时也处理
          transformPasted(slice) {
            return filterSlice(slice, allowedMarks)
          }
        }
      })
    ]
  }
})

function cleanWordHTML(html) {
  return html
    // 删 Word 条件注释垃圾
    .replace(/<!--\[if[\s\S]*?endif\]-->/g, '')
    // 删 mso 开头的 style 属性
    .replace(/style="[^"]*mso-[^"]*"/g, '')
    // 删 Word 特有的 class
    .replace(/class="Mso[^"]*"/g, '')
    // 把 <o:p> 等 Office 标签删掉
    .replace(/<\/?o:[^>]*>/g, '')
}

// 用 Schema 强约束:不在白名单的标签/属性,ProseMirror 自动丢弃
最佳实践: 白名单优于黑名单。 不要去想"Word 会带什么垃圾"(无穷无尽),而是定义"我允许什么标签和属性"(bold/italic/color/align 这些),其他全丢。ProseMirror 的 Schema 天然支持这个 — 不在 schema 里的节点解析时直接忽略。
5

ProseMirror Schema — 文档结构的"宪法"

🧠 类比
Schema 就像文档的 宪法 — 规定了"什么节点能存在、能包含什么、属性是什么"。任何不符合 Schema 的操作都会被拒绝(而不是产生脏数据)。这是 ProseMirror 比 ContentEditable 直接操作 DOM 安全得多的核心原因。
📋 Schema 三要素
要素作用例子
content定义能包含什么子节点'block+' 允许块级,不允许 inline 直接放
attrs节点属性colspan、rowspan、align
parseDOM / toDOMHTML ↔ 文档模型转换parseDOM 决定粘贴/加载时如何解析
⚠️ Schema 设计的坑
  • content 太严: 如果 cell 定义成 'paragraph'(只能一个段落),用户连按两次回车会失败。要 'block+'
  • attrs 默认值: colspan 默认必须是 1,不然新建单元格合并状态错乱。
  • parseDOM 顺序: 多个规则匹配同标签时,后面的覆盖前面的,顺序写反会解析错。
6

大文档性能

⚠️ 现象

一份测评报告 200+ 页,含几十个嵌套表格,内容多了之后打字有延迟(输入到显示差 300ms+),滚动卡顿。

根因: ProseMirror 每次输入都会触发一次 transaction → 重新 diff 整个文档 → 更新所有 NodeView。文档太大,diff 和 DOM patch 都慢。
优化手段:NodeView 复用: 自定义 NodeView 时,update 钩子里判断"这次更新是否影响我",不影响就 return true 跳过重渲染;
避免在 decorations 里做重计算: 装饰器(语法高亮/批注)每次 transaction 都跑,缓存计算结果;
分章节懒加载: 超长文档按章节存,只渲染当前可视章节(这个改动大,我们没做);
debounce 自动保存: 别每次输入都触发 onChange 存库,debounce 1-2 秒。
7

高频追问 & 容易翻车点

1为什么选 TipTap 不选 Quill / WangEditor? 基础
核心: 业务有表格嵌套需求。ProseMirror(TipTap 底层)的文档模型是树,天然支持节点递归嵌套;Quill 的 Delta 是线性结构,做嵌套表格非常痛苦。WangEditor 的表格功能弱,扛不住复杂合并。
加分: TipTap 基于 Schema 强约束,脏数据进不来;模块化按需引入体积可控。
💬 追问「ProseMirror 和 Delta 区别?」→ 树 vs 线性,前者适合复杂结构(表格/嵌套/协同),后者适合简单富文本(博客/评论)。
2ContentEditable 有什么问题? 中等
浏览器差异: 光标定位、选区、空节点处理各浏览器不一致(尤其是 Safari);
脏数据: 直接 contenteditable,浏览器随便插标签,style 污染;
无约束: 能产生任何 DOM 结构,无法保证文档结构合法;
撤销栈不可控: 浏览器的 undo 经常丢操作或乱跳。
ProseMirror 怎么解决: 用自己的 view 层接管,ContentEditable 只是个"显示层",真正的状态在 ProseMirror 的文档模型里,所有操作过 Schema 校验。这就是为什么 TipTap 比"裸 contenteditable"可靠。
3怎么处理从 Word 粘贴的样式? 中等
白名单优于黑名单。 用 ProseMirror 的 transformPastedHTML 钩子拦截粘贴内容,先正则清掉 Word 的 mso 垃圾(条件注释、mso-xxx style、Office 自定义标签),然后靠 Schema 白名单 — 不在 schema 里的标签/属性自动丢弃。只保留 bold/italic/color/align 这些业务需要的。
💬 追问「为什么不直接用黑名单?」→ Word 带来的垃圾无穷无尽,黑名单永远列不全;白名单定义"允许什么",更安全。
4富文本数据怎么存?HTML 还是 JSON? 中等
两种都行,看需求:
HTML: 直观,直接渲染,SEO 友好。但容易带脏数据,版本升级 schema 变了不好迁移。
JSON(ProseMirror Doc): 结构化,schema 强约束,改 schema 后可以写迁移脚本。但渲染要回 DOM(慢一点)。
我们项目: 存 HTML(因为要导出 Word/PDF,HTML 转 docx 更直接),但入库前过 Schema 净化。
5表格嵌套具体怎么实现的? 深问
① 改 Schema:TableCell 的 content 允许包含 block(table 是 block 的一种);
② 嵌套表格的选区(CellSelection)要区分层级,不能跨层混选;
③ Tab 键导航重写,外层到边界进内层;
④ 合并单元格把内层表格当整体;
⑤ 自定义 parseDOM/toDOM 保证序列化还原。
💬 如果面试官追问细节到这一层,说明他很懂,可以反问「你们编辑器用的是什么方案?」转成技术交流。
6如果要做协同编辑(多人同时编辑)怎么做? 延伸
ProseMirror 的协同方案:Yjs + y-prosemirror。 Yjs 是 CRDT 库,每个编辑操作变成一个可合并的 operation,多人编辑自动合并不冲突。前端用 y-prosemirror 把 ProseMirror 的 transaction 同步给其他用户。
我们没有做协同(测评报告单人编辑),但如果要做,这是标准方案。比 OT(Operational Transformation,需要服务端集中处理)简单,CRDT 是去中心化的。
8

30 秒电梯陈述(背熟)

🎤 30 秒版
「在密评报告系统里,测评报告有大量表格嵌套和合并的需求,我用 TipTap,底层是 ProseMirror。印象最深的坑有两个:一是 ContentEditable 在 Chrome 和 Safari 的光标行为不一致,客户用 Mac 一直报光标乱跳,排查了一周才发现是浏览器差异,后来加了一个跨浏览器 E2E 测试专门测这个;二是从 Word 粘贴进来一堆 mso 样式污染,我用 Schema 白名单 + transformPasted 钩子做了净化。富文本这块我觉得踩的坑够深,聊细节没问题。」
这段话的优势:
① 有业务复杂度(表格嵌套/合并)→ 真实
② 有具体坑(浏览器差异)→ 有深度
③ 有解决方案(Schema/净化)→ 有结论
④ 提到底层(ProseMirror)→ 体现你懂原理不只是调 API
富文本是区分"会写前端"和"前端专家"的分水岭,这个话题一旦讲深,基本能拿下高级岗位。

面试亮点专题 03 — 富文本编辑器深度话题

TipTap / ProseMirror · 表格嵌套 · ContentEditable 浏览器差异 · Word 粘贴净化 · Schema 设计

← 返回专题首页