文章总结: 本文详述B站专栏编辑器从Quill迁移至ProseMirror的技术重构。针对旧方案Canvas伪造卡片的交互缺失与性能痛点,团队利用ProseMirror文档树与NodeView机制实现真实渲染。核心方案包括Schema原子化定义、编辑器与组件分离架构,并通过CardPlayer资源池与三级缓存策略解决性能瓶颈。最终实现了从静态截图到交互组件的跨越,提升了编辑体验与系统扩展性,并确保了历史数据兼容。 综合评分: 92 文章分类: 其他
从“截图大法”到真实交互:B站专栏视频卡的技术革命
原创
大前端 大前端
哔哩哔哩技术
2026年3月12日 12:04 上海
背景:从“伪造”卡片到真实交互
回望 B 站富文本编辑器的演进史,我们经历了一个从“无”到“有”,再从“有”到“优”的过程。在 UEditor 时代,我们解决了基本的文本编辑需求;在 Quill 时代,我们引入了 Delta 数据模型。
然而,在 Quill 时期,面对视频卡等复杂卡片,受限于 Quill 对 BlockNode 缺乏完善的支持,被迫采用“ Canvas 绘图伪造卡片” 的障眼法。今天,拥抱 ProseMirror 生态,这套“ 截图大法” 终于画上句号,取而代之的是支持真实交互的卡片渲染系统。
这场从“伪造”到“真实”的革命,不仅是一次技术栈的迁移,更是一次对技术债的降维打击。今天就带大家深入代码底层,看看我们是如何填平这个深坑的。
第一章:旧世界——那些年,
我们用 Canvas “画”出来的视频卡
1.1 用户视角的“灵异”体验
你可能经历过这样的场景:在专栏里粘贴了一个视频链接,然后看着 Loading 转圈圈,心里默数两秒,“啪”的一下,编辑器里出现了一个视频卡片。
看起来很美?别急着夸。当你试图点击播放时,发现它毫无反应;当你试图修改标题时,发现根本选不中文字。这哪里是视频卡片,这分明就是一张死图!
是的,这就是我们不得不采用的“Canvas 截图大法”。
1.2 技术黑幕:Canvas 的“障眼法”
为了在 Quill 这个不支持复杂 Block Node 的编辑器里塞进一个视频卡,我们当年可是绞尽脑汁,最终设计了一套后续发现极其痛苦的 html2canvas 截图链路:
1. 隐式渲染:在浏览器可视区域外(看不见的地方),用 HTML 偷偷画一个临时的卡片 DOM。
2. Canvas 截图:调用 html2canvas 咔嚓一下,把这个 DOM 变成 Canvas。为了保证清晰度,通常需要设置 scale: 4。
3. 图片生成:将 Canvas 导出为 Base64 图片。
4. 上传替换:把图片上传到 CDN,最后在编辑器里插一个静态的 <img> 标签。
1.3 无法回避的四大痛点
说实话,每次写这段代码时,我的内心都是崩溃的。这种做法虽然暂时解决了跨平台兼容问题,但代价是沉重的:
- 交互性丧失(Interactive Loss):这仅仅是一张死图。所谓“所见即所得”其实是“所见即图片”。
- 性能黑洞:整个“API请求 → 绘制 → 截图 → 上传”的链路平均耗时 2秒 以上。严重打断写作心流。
- 数据死锁:卡片上的播放量、弹幕数永远停留在插入的那一刻。如果视频后续爆火,卡片信息也不会更新,甚至误导读者。
- 存储浪费:每一张生成的卡片图片都需要占用 CDN 空间,随着文章数量增长,这是巨大的隐形资源浪费。
第二章:病根诊断——
当 Quill 遇上视频卡
为什么 Quill 做不好视频卡?这得从它的底层基因说起。
2.1 Delta 像“收银小票”,
ProseMirror 像“乐高积木”
Quill 使用的是Delta数据模型。Delta 本质上是一个线性的操作记录,就像一张长长的收银小票🧾。
// Quill Delta: 扁平的线性记录[ { "insert": "Hello " }, { "insert": { "video": { "id": "BV1xx..." } } }, // 强行插入一个对象 { "insert": "" }]
你想在这张薄薄的小票中间塞进一个立体、复杂的“视频播放器盒子”?太难了!Delta 天生就是扁平的,它很难描述复杂的嵌套结构。我们被迫使用的“截图大法”,其实就是在小票上画了个电视机的图案,而不是真的放了个电视机。
而 ProseMirror 使用的是 Document Model(文档树),它就像是乐高积木。
// 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 定义(给积木定规矩)
首先,我们需要告诉编辑器,“视频卡”这个积木长什么样,有什么属性。
// schema/video-card.tsconst 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组件挂载上去。
// NodeView:编辑器节点 ↔ UI 组件的桥接层abstract classBaseCardNodeView { // ProseMirror 调用生命周期方法 constructor(node) // 节点创建 → 初始化组件 update(node) // 节点更新 → 同步组件数据 destroy() // 节点销毁 → 清理组件资源
// 子类实现具体卡片类型 abstract createCardComponent() // 创建对应的业务组件}
// 视频卡片实现class VideoCardNodeView extends BaseCardNodeView { createCardComponent() { // 🚀 核心动作:挂载真实组件 returnnew VideoCard({ data: this.node.attrs, // 编辑器数据 isInEditor: true // 标识编辑器环境 }) }
// 事件监听:组件 → 编辑器 setupEventListeners() { // 监听组件内部的状态变更,同步回编辑器 this.cardComponent.on('statusChange', (status) => { this.updateNodeAttributes({ status }) })
this.cardComponent.on('delete', () => { this.deleteFromEditor() // 从文档删除 }) }}
🧐 Code Review:
- 这段代码实现了真正的“所编写即所得”。你在编辑器里看到的组件,就是发布后读者看到的组件,连代码都是同一份!
- 通过事件监听,组件内部的操作(如点击删除、重试加载)可以反向控制编辑器的数据状态。
第四章:硬核填坑——从“能用”到“好用”
重构之路从不平坦,为了让这个系统真正“好用”,我们解决了不少棘手的工程问题。
4.1 隐秘的代价:插入极速,但运行态呢?
技术世界没有银弹。当我们为“极速插入”和“真实交互”欢呼时,隐秘的代价也随之而来——展示态(运行时)的性能崩盘风险。旧方案虽然插入慢,但在运行时只是一张死图,文章里塞入 50 个卡片依然能丝滑滚动。但新方案的每一个视频卡,都是一个包含了复杂 DOM 树、状态机、播放器的真实组件。如果放任不管,十几个播放器同时驻留内存,浏览器会直接崩溃 。
为了兜住这层底线,我们在架构上设计了两大“降落伞”:
4.2 把播放器“装”进编辑器
(CardPlayer 管理器)
我们引入了双视图自由切换模式与 CardPlayer 实例池 :
- NORMAL 模式:普通小卡,仅展示封面和元信息,不播放视频 。
- ADVANCED 模式:点击后直接展开内嵌播放器,通过改变 card_style 属性无缝切换,受
CardPlayer管理器控制 。
// CardPlayer:全局播放器资源管理classCardPlayer { static MAX_PLAYERS = 3 // 🚦 最大实例数限制
// 互斥机制:播放时暂停其他 static play(playerId) { pauseOthers(playerId) // 暂停其他播放器 startPlay(playerId) // 启动当前播放器 }
// 资源回收:超限时销毁最早的实例 static enforceLimit() { if (count > MAX_PLAYERS) { destroyOldest() // LRU 策略回收 } }}
4.2 极致性能优化(批量解析 + 三级缓存)
如果用户一次性粘贴 50 个链接怎么办?发 50 个 API 请求?服务器会报警的 !我们重构了链接解析层,引入了批量验证和共享缓存 。
// 三层缓存:验证结果 + 类型 + 卡片数据const cache = { validation: Map<url, boolean="">, type: Map<url, parsedtype="">, card: Map<url, carddata="">}
// 批量验证:防抖 + 队列合并async function validateLink(url) { // 1. 缓存命中 → 立即返回 if (cache.has(url)) { return cache.get(url) }
// 2. 加入验证队列(防抖 100ms) addToQueue(url) await waitBatchProcess()
// 3. 批量请求完成后从缓存读取 return cache.get(url)}
🧐 Code Review:
- 这里基于防抖,100ms 内的粘贴操作会被合并为一个请求(Batch API)。
- 缓存是全局共享的。当用户在编辑器内反复撤销、重做或拖拽卡片时,直接命中缓存,实现 0 延迟渲染。
第五章:核心创新点总结
5.1 智能链接解析与双向转换
我们不仅支持从“链接”变“卡片”,还支持完美的逆向转换。通过 resource\_url 字段保存用户原始输入信息,确保数据 100% 完整。
// 核心数据结构:保存原始链接实现双向转换interface CardAttrs { resource_url: string // 🔑 关键:保存原始链接 info: object // 业务数据 status: State // 加载状态}
// 链接 → 卡片onPaste(url) { insertCard({ resource_url: url, status: 'loading' })}
// 卡片 → 链接convertToLink(card) { replaceWith(card.resource_url) // 恢复原始链接}
5.2 模板策略模式
我们抽象了 BaseCard 基类,利用策略模式处理不同类型的卡片渲染。无论是视频卡、专栏卡还是投票卡,都复用了同一套生命周期管理逻辑(mount → load → update → destroy),代码复用率提升了 60%。
// 抽象基类:定义统一的卡片生命周期abstract classBaseCard { // 状态机:合法的状态转换路径 stateTransitions = { LOADING → [LOADED, ERROR], // 加载中 → 成功或失败 ERROR → [LOADING], // 失败 → 可重试 LOADED → [LOADING] // 已加载 → 可刷新 }
// 子类必须实现的抽象方法 abstract renderContent(): void // 渲染内容 abstract getCardClassName(): string // 返回样式类名
// 数据更新:触发状态转换和重新渲染 async updateData(newData) { if (newData.status !== this.currentState) { if (!this.isValidTransition(newData.status)) return this.currentState = newData.status this.updateUI() } this.renderContent() }}
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-
作者丨泯泷
往期精彩指路
- 全链路压测改造之全链自动化测试实践
- 哔哩哔哩⼤数据建设之路—实时DQC篇
- Apache Kyuubi 在B站大数据场景下的应用实践
通用工程丨大前端丨业务线
大数据丨AI丨多媒体
免责声明:
本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。
任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。
本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我。
本文转载自:哔哩哔哩技术 大前端 大前端《从“截图大法”到真实交互:B站专栏视频卡的技术革命》
版权声明
本站仅做备份收录,仅供研究与教学参考之用。
读者将信息用于其他用途的,全部法律及连带责任由读者自行承担,本站不承担任何责任。










评论