背景:从"伪造"卡片到真实交互
回望 B 站富文本编辑器的演进史,我们经历了一个从"无"到"有",再从"有"到"优"的过程。在 UEditor 时代,我们解决了基本的文本编辑需求;在 Quill 时代,我们引入了 Delta 数据模型。
然而,在 Quill 时期,面对视频卡等复杂卡片,受限于 Quill 对 BlockNode 缺乏完善的支持,被迫采用" Canvas 绘图伪造卡片" 的障眼法。今天,拥抱 ProseMirror 生态,这套" 截图大法" 终于画上句号,取而代之的是支持真实交互的卡片渲染系统。
这场从"伪造"到"真实"的革命,不仅是一次技术栈的迁移,更是一次对技术债的降维打击。今天就带大家深入代码底层,看看我们是如何填平这个深坑的。
第一章:旧世界------那些年,我们用 Canvas "画"出来的视频卡
1.1 用户视角的"灵异"体验
你可能经历过这样的场景:在专栏里粘贴了一个视频链接,然后看着 Loading 转圈圈,心里默数两秒,"啪"的一下,编辑器里出现了一个视频卡片。
看起来很美?别急着夸。当你试图点击播放时,发现它毫无反应;当你试图修改标题时,发现根本选不中文字。这哪里是视频卡片,这分明就是一张死图!
是的,这就是我们不得不采用的 "Canvas 截图大法" 。
1.2 技术黑幕:Canvas 的"障眼法"
为了在 Quill 这个不支持复杂 Block Node 的编辑器里塞进一个视频卡,我们当年可是绞尽脑汁,最终设计了一套后续发现极其痛苦的 html2canvas 截图链路:
-
隐式渲染: 在浏览器可视区域外(看不见的地方),用 HTML 偷偷画一个临时的卡片 DOM。
-
Canvas 截图: 调用
html2canvas咔嚓一下,把这个 DOM 变成 Canvas。为了保证清晰度,通常需要设置scale: 4。 -
图片生成: 将 Canvas 导出为 Base64 图片。
-
上传替换: 把图片上传到 CDN,最后在编辑器里插一个静态的
<img>标签。

1.3 无法回避的四大痛点
说实话,每次写这段代码时,我的内心都是崩溃的。这种做法虽然暂时解决了跨平台兼容问题,但代价是沉重的:
-
交互性丧失(Interactive Loss): 这仅仅是一张死图。所谓"所见即所得"其实是"所见即图片"。
-
性能黑洞: 整个"API请求 → 绘制 → 截图 → 上传"的链路平均耗时 2秒 以上。严重打断写作心流。
-
数据死锁: 卡片上的播放量、弹幕数永远停留在插入的那一刻。如果视频后续爆火,卡片信息也不会更新,甚至误导读者。
-
存储浪费: 每一张生成的卡片图片都需要占用 CDN 空间,随着文章数量增长,这是巨大的隐形资源浪费。
第二章:病根诊断------当 Quill 遇上视频卡
为什么 Quill 做不好视频卡?这得从它的底层基因说起。
2.1 Delta 像"收银小票",ProseMirror 像"乐高积木"
Quill 使用的是 Delta 数据模型。Delta 本质上是一个线性的操作记录,就像一张长长的收银小票🧾。

你想在这张薄薄的小票中间塞进一个立体、复杂的"视频播放器盒子"?太难了!Delta 天生就是扁平的,它很难描述复杂的嵌套结构。我们被迫使用的"截图大法",其实就是在小票上画了个电视机的图案,而不是真的放了个电视机。
而 ProseMirror 使用的是 Document Model(文档树),它就像是乐高积木。
json
// ProseMirror Tree: 结构化的树形数据
{
"type": "doc",
"content": [
{ "type": "paragraph", "content": [{ "type": "text", "text": "Hello" }] },
{
"type": "videoCard", // 独立的块级节点
"attrs": { "bvid": "BV1xx..." },
"content": [] // 可以继续嵌套其他节点
}
]
}
你可以搭建一个名为"视频卡"的积木块,然后在里面随意嵌套"标题积木"、"封面积木"甚至"播放器积木"。这种树状结构天然就支持复杂的 Block Node(块级节点) 。

2.2 技术对比表:为什么我们要换枪?

2.3 选型博弈: 为什么是TipTap+ProseMirror?
在决定彻底抛弃 Quill 之前,我们对市面上的富文本技术方案进行了一次深度摸底。从底层技术演进来看,Web 富文本编辑器主要经历了三个维度的跃迁:
- Level 0(强依赖 DOM):完全基于原生的 contenteditable,典型如 UEditor。技术门槛低,但跨端表现极其不可控。
- Level 1(视图即数据):拥有自身抽象的数据模型,但依然依赖原生 DOM 渲染。典型如 Quill、Slate、Draft.js 及 ProseMirror。
- Level 2(自排版自渲染):彻底抛弃 contenteditable,利用 Canvas/SVG 自研排版引擎,典型如 Google Docs。
从 B 站图文生态(专栏、动态)的实际业务诉求出发,L2 方案属于严重的性能与研发成本过剩,而 L0 方案早已无法满足现代组件的交互需求。因此,我们的主战场锁定在了 L1 级别的抽象数据模型方案。
在 L1 的终极对决中,面对生态优秀的 Lexical 和老牌的 Draft.js(往往强绑定 React),以及底层极其强大但 API 学习曲线陡峭的 ProseMirror,我们最终选择了 Tiptap + ProseMirror 的组合拳。
Tiptap 作为基于 ProseMirror 构建的 Headless(无头)框架,完美继承了其强大的文档树(Document Tree)和 Schema 规范,同时提供了一层极其优雅的 API 封装。这套"底层稳健兜底,上层开发丝滑"的设计,斩断了特定 UI 框架的强依赖,成为我们完成这次降维打击的最优解。
第三章:ProseMirror 核心实战------架构重组
既然痛点找准了,那就开干。我们设计了全新的 "编辑器-组件分离" 架构,利用 ProseMirror 强大的 NodeView 机制,彻底重构了卡片系统。

3.1 架构革新:编辑器与组件的"分家"
在这个架构中,编辑器不再负责具体的 UI 渲染,而是专注于文档结构的管理。NodeView 充当了"桥接"的角色。
3.2 核心设计 I:Schema 定义(给积木定规矩)
首先,我们需要告诉编辑器,"视频卡"这个积木长什么样,有什么属性。
javascript
// schema/video-card.ts
const VideoCard = Node.create({
name: 'videoCard',
group: 'block', // 声明我是块级节点
atom: true, // 💡 关键点:原子化
draggable: true, // 可拖拽
// 定义数据属性
addAttributes() {
return {
card_style: { default: CardStyle.NORMAL }, // 卡片风格
info: { default: {} }, // 业务数据
status: { default: 'loading' } // loading | loaded | error
}
},
// 解析规则:怎么从 HTML 读出来
parseHTML() {
return [{ tag: 'div[data-type="video-card"]' }]
},
// 渲染规则:怎么存成 HTML
renderHTML({ node }) {
return ['div', { 'data-type': 'video-card', 'data-bvid': node.attrs.bvid }, 0]
}
})
🧐 Code Review:
atom: true是这里的神来之笔。它告诉 ProseMirror:"这个节点是一个整体,光标不能跑进去,要么选中整个卡片,要么不选"。这完美符合卡片的交互逻辑,避免了光标在卡片内部乱窜的尴尬。addAttributes定义了卡片的数据模型,这些数据会直接映射到 UI 组件的 Props 中。
3.3 核心设计 II:NodeView(连接两个世界的桥梁)
接下来是重头戏 ------ NodeView。它是连接 ProseMirror 数据层和 UI 渲染层的桥梁。我们要在这里把 UI组件挂载上去。

🧐 Code Review:
- 这段代码实现了真正的"所编写即所得"。你在编辑器里看到的组件,就是发布后读者看到的组件,连代码都是同一份!
- 通过事件监听,组件内部的操作(如点击删除、重试加载)可以反向控制编辑器的数据状态。
第四章:硬核填坑------从"能用"到"好用"
重构之路从不平坦,为了让这个系统真正"好用",我们解决了不少棘手的工程问题。
4.1 隐秘的代价:插入极速,但运行态呢?
技术世界没有银弹。当我们为"极速插入"和"真实交互"欢呼时,隐秘的代价也随之而来------展示态(运行时)的性能崩盘风险。旧方案虽然插入慢,但在运行时只是一张死图,文章里塞入 50 个卡片依然能丝滑滚动。但新方案的每一个视频卡,都是一个包含了复杂 DOM 树、状态机、播放器的真实组件。如果放任不管,十几个播放器同时驻留内存,浏览器会直接崩溃 。
为了兜住这层底线,我们在架构上设计了两大"降落伞":
4.2 把播放器"装"进编辑器(CardPlayer 管理器)
我们引入了双视图自由切换模式与 CardPlayer 实例池 :
-
NORMAL 模式:普通小卡,仅展示封面和元信息,不播放视频 。
-
ADVANCED 模式:点击后直接展开内嵌播放器,通过改变 card_style 属性无缝切换,受
CardPlayer管理器控制 。


4.2 极致性能优化(批量解析 + 三级缓存)
如果用户一次性粘贴 50 个链接怎么办?发 50 个 API 请求?服务器会报警的 !我们重构了链接解析层,引入了批量验证和共享缓存 。

🧐 Code Review:
- 这里基于防抖,100ms 内的粘贴操作会被合并为一个请求(Batch API)。
- 缓存是全局共享的。当用户在编辑器内反复撤销、重做或拖拽卡片时,直接命中缓存,实现 0 延迟渲染。


第五章:核心创新点总结
5.1 智能链接解析与双向转换
我们不仅支持从"链接"变"卡片",还支持完美的逆向转换。通过 resource_url 字段保存用户原始输入信息,确保数据 100% 完整。


5.2 模板策略模式
我们抽象了 BaseCard 基类,利用策略模式处理不同类型的卡片渲染。无论是视频卡、专栏卡还是投票卡,都复用了同一套生命周期管理逻辑(mount → load → update → destroy),代码复用率提升了 60% 。

5.3 历史包袱的优雅着陆:旧专栏兼容
新架构固然强大,但对于一个拥有海量存量数据的平台来说,绝不能以牺牲历史数据为代价。同时,新编辑器生产的内容也必须完美融入现有的内容分发基建。为此,我们围绕 Opus 协议(B站图文统一发布协议) 设计了一套向下兼容历史、向上打通分发的全局策略:
- 战略锚点:基于 Opus 图文统一发布协议的链路闭环
Opus 是我们内部定义的图文统一发布协议。为了无缝接入现有的动态分发渠道,确保高质量图文能够高效流转,新版编辑器在最终发布时,会将所有文档树数据全量转换为 Opus 格式。这不仅统一了底层标准,也让生产端到分发端的链路彻底打通。
- 首选路径:历史专栏优先转出 Opus 无损还原
针对过去沉淀的千万级历史专栏,我们已经在服务端优先尝试将其向 Opus 格式进行转出与迁移。由于 Opus 是我们的标准协议,当这些转换成功的数据进入新版编辑器时,能够通过 Schema 的精准映射,100% 无损还原为内部的 Document Tree,让老文章直接享受最纯粹的组件化编辑体验。
- 柔性兜底:不支持迁移场景的 H5 动态解析
然而,总有一些极其古老(例如夹杂着 UEditor 时代"野生标签")且无法安全迁移为 Opus 格式的富文本黑盒。面对这些"硬骨头",我们并没有采用高风险的"强洗数据",而是让新版编辑器利用加载 H5 内容的方式进行动态兜底。通过触发节点中预设的 parseHTML 规则,在浏览器端实时将陈旧的 HTML 代码"翻译"成全新的规范化 Block Node,确保再老的专栏也能在新编辑器中顺利"复活"并进行二次编辑。
第六章:效果实测与总结
通过这次架构升级,我们将"插卡"这一高频操作的体验提升到了新的维度 。但在亮眼的数据背后,我们也完成了一次经典的工程性能博弈。来看一组真实的对比数据 :


结论:走向"应用级"文档
从 Quill 到 ProseMirror 的迁移,不仅仅是更换了一个编辑器内核,更是我们对文档理解的一次升级。
文档不再只是静态内容的载体,而是动态应用的容器。
通过 Tiptap + ProseMirror 的现代化技术栈,我们成功将"低保真"的绘图式卡片,进化为具备完整生命周期、状态管理和复杂交互的"应用级"组件。这不仅解决了当下的性能痛点,更为未来引入投票、互动游戏等更复杂的业务卡片奠定了坚实的基础。
我们终于可以说:在 B 站的专栏编辑器里,你看到的,就是真实的 🎉(WYSIWYG)
-End-
作者丨泯泷