富文本编辑器深度话题
TipTap / TinyMCE — 测评报告里的表格嵌套 / 合并 / 对齐 / 样式排版
🎬 面试开场怎么引出这个话题
「在菲克斯的密评报告系统里,测评报告有大量表格嵌套的需求 — 表格里套表格、单元格合并、跨行跨列、对齐和样式排版都有。我用了 TipTap(基于 ProseMirror),印象最深的坑是 ContentEditable 在 Chrome 和 Safari 的光标行为不一样,嵌套表格里点击单元格光标会乱跳,排查了一周。另外从 Word 粘贴进来的内容带一堆 inline style 污染,我写了个 Schema 白名单过滤。」
✅ 抛出 4 个可深挖的点:① 表格嵌套业务复杂度 ② ContentEditable 浏览器差异 ③ Word 粘贴污染 ④ ProseMirror Schema。
富文本是前端最硬核的方向之一,能聊这个说明你不只是"调 API"。
富文本是前端最硬核的方向之一,能聊这个说明你不只是"调 API"。
1
选型:为什么选 TipTap 不选 TinyMCE / WangEditor
| 编辑器 | 底层 | 表格嵌套 | 定制性 | 体积 | 结论 |
|---|---|---|---|---|---|
| TipTap | ProseMirror | 原生支持,可深度定制 | ★★★★★ 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 层接管了大部分,但极端边界(嵌套 + 空单元格)还是会漏。
🔧 解决方案
| 问题 | Chrome | Safari | 解法 |
|---|---|---|---|
| 空单元格点击 | 光标进单元格 | 光标在上一个 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-xxx、color:windowtext、font-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 / toDOM | HTML ↔ 文档模型转换 | 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 秒。
② 避免在 decorations 里做重计算: 装饰器(语法高亮/批注)每次 transaction 都跑,缓存计算结果;
③ 分章节懒加载: 超长文档按章节存,只渲染当前可视章节(这个改动大,我们没做);
④ debounce 自动保存: 别每次输入都触发 onChange 存库,debounce 1-2 秒。
7
高频追问 & 容易翻车点
1为什么选 TipTap 不选 Quill / WangEditor? 基础
核心: 业务有表格嵌套需求。ProseMirror(TipTap 底层)的文档模型是树,天然支持节点递归嵌套;Quill 的 Delta 是线性结构,做嵌套表格非常痛苦。WangEditor 的表格功能弱,扛不住复杂合并。
加分: TipTap 基于 Schema 强约束,脏数据进不来;模块化按需引入体积可控。
加分: TipTap 基于 Schema 强约束,脏数据进不来;模块化按需引入体积可控。
💬 追问「ProseMirror 和 Delta 区别?」→ 树 vs 线性,前者适合复杂结构(表格/嵌套/协同),后者适合简单富文本(博客/评论)。
2ContentEditable 有什么问题? 中等
① 浏览器差异: 光标定位、选区、空节点处理各浏览器不一致(尤其是 Safari);
② 脏数据: 直接 contenteditable,浏览器随便插标签,style 污染;
③ 无约束: 能产生任何 DOM 结构,无法保证文档结构合法;
④ 撤销栈不可控: 浏览器的 undo 经常丢操作或乱跳。
ProseMirror 怎么解决: 用自己的 view 层接管,ContentEditable 只是个"显示层",真正的状态在 ProseMirror 的文档模型里,所有操作过 Schema 校验。这就是为什么 TipTap 比"裸 contenteditable"可靠。
② 脏数据: 直接 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 净化。
① 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 保证序列化还原。
② 嵌套表格的选区(CellSelection)要区分层级,不能跨层混选;
③ Tab 键导航重写,外层到边界进内层;
④ 合并单元格把内层表格当整体;
⑤ 自定义 parseDOM/toDOM 保证序列化还原。
💬 如果面试官追问细节到这一层,说明他很懂,可以反问「你们编辑器用的是什么方案?」转成技术交流。
6如果要做协同编辑(多人同时编辑)怎么做? 延伸
ProseMirror 的协同方案:Yjs + y-prosemirror。 Yjs 是 CRDT 库,每个编辑操作变成一个可合并的 operation,多人编辑自动合并不冲突。前端用 y-prosemirror 把 ProseMirror 的 transaction 同步给其他用户。
我们没有做协同(测评报告单人编辑),但如果要做,这是标准方案。比 OT(Operational Transformation,需要服务端集中处理)简单,CRDT 是去中心化的。
我们没有做协同(测评报告单人编辑),但如果要做,这是标准方案。比 OT(Operational Transformation,需要服务端集中处理)简单,CRDT 是去中心化的。
8
30 秒电梯陈述(背熟)
🎤 30 秒版
「在密评报告系统里,测评报告有大量表格嵌套和合并的需求,我用 TipTap,底层是 ProseMirror。印象最深的坑有两个:一是 ContentEditable 在 Chrome 和 Safari 的光标行为不一致,客户用 Mac 一直报光标乱跳,排查了一周才发现是浏览器差异,后来加了一个跨浏览器 E2E 测试专门测这个;二是从 Word 粘贴进来一堆 mso 样式污染,我用 Schema 白名单 + transformPasted 钩子做了净化。富文本这块我觉得踩的坑够深,聊细节没问题。」
这段话的优势:
① 有业务复杂度(表格嵌套/合并)→ 真实
② 有具体坑(浏览器差异)→ 有深度
③ 有解决方案(Schema/净化)→ 有结论
④ 提到底层(ProseMirror)→ 体现你懂原理不只是调 API
富文本是区分"会写前端"和"前端专家"的分水岭,这个话题一旦讲深,基本能拿下高级岗位。
① 有业务复杂度(表格嵌套/合并)→ 真实
② 有具体坑(浏览器差异)→ 有深度
③ 有解决方案(Schema/净化)→ 有结论
④ 提到底层(ProseMirror)→ 体现你懂原理不只是调 API
富文本是区分"会写前端"和"前端专家"的分水岭,这个话题一旦讲深,基本能拿下高级岗位。
面试亮点专题 03 — 富文本编辑器深度话题
TipTap / ProseMirror · 表格嵌套 · ContentEditable 浏览器差异 · Word 粘贴净化 · Schema 设计