从“截图大法”到真实交互:B站专栏视频卡的技术革命

admin 2026-03-18 21:57:10 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

文章总结: 本文详述B站专栏编辑器从Quill迁移至ProseMirror的技术重构。针对旧方案Canvas伪造卡片的交互缺失与性能痛点,团队利用ProseMirror文档树与NodeView机制实现真实渲染。核心方案包括Schema原子化定义、编辑器与组件分离架构,并通过CardPlayer资源池与三级缓存策略解决性能瓶颈。最终实现了从静态截图到交互组件的跨越,提升了编辑体验与系统扩展性,并确保了历史数据兼容。 综合评分: 92 文章分类: 其他


cover_image

从“截图大法”到真实交互: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: 扁平的线性记录[&nbsp; {&nbsp;"insert":&nbsp;"Hello "&nbsp;},&nbsp; {&nbsp;"insert": {&nbsp;"video": {&nbsp;"id":&nbsp;"BV1xx..."&nbsp;} } },&nbsp;// 强行插入一个对象&nbsp; {&nbsp;"insert":&nbsp;"" }]

你想在这张薄薄的小票中间塞进一个立体、复杂的“视频播放器盒子”?太难了!Delta 天生就是扁平的,它很难描述复杂的嵌套结构。我们被迫使用的“截图大法”,其实就是在小票上画了个电视机的图案,而不是真的放了个电视机。

而 ProseMirror 使用的是 Document Model(文档树),它就像是乐高积木

// ProseMirror Tree: 结构化的树形数据{&nbsp;&nbsp;"type":&nbsp;"doc",&nbsp;&nbsp;"content":&nbsp;[&nbsp; &nbsp;&nbsp;{&nbsp;"type":&nbsp;"paragraph",&nbsp;"content":&nbsp;[{&nbsp;"type":&nbsp;"text",&nbsp;"text":&nbsp;"Hello"&nbsp;}]&nbsp;},&nbsp; &nbsp;&nbsp;{&nbsp; &nbsp; &nbsp;&nbsp;"type":&nbsp;"videoCard",&nbsp;// 独立的块级节点&nbsp; &nbsp; &nbsp;&nbsp;"attrs":&nbsp;{&nbsp;"bvid":&nbsp;"BV1xx..."&nbsp;},&nbsp; &nbsp; &nbsp;&nbsp;"content":&nbsp;[]&nbsp;// 可以继续嵌套其他节点&nbsp; &nbsp;&nbsp;}&nbsp;&nbsp;]}

你可以搭建一个名为”视频卡”的积木块,然后在里面随意嵌套”标题积木”、”封面积木”甚至”播放器积木”。这种树状结构天然就支持复杂的 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&nbsp;VideoCard&nbsp;=&nbsp;Node.create({&nbsp;&nbsp;name:&nbsp;'videoCard',&nbsp;&nbsp;group:&nbsp;'block', &nbsp; &nbsp;&nbsp;// 声明我是块级节点&nbsp;&nbsp;atom:&nbsp;true, &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;// 💡 关键点:原子化&nbsp;&nbsp;draggable:&nbsp;true, &nbsp; &nbsp;// 可拖拽&nbsp;&nbsp;&nbsp;&nbsp;// 定义数据属性&nbsp;&nbsp;addAttributes() {&nbsp; &nbsp;&nbsp;return&nbsp;{&nbsp; &nbsp; &nbsp;&nbsp;card_style: {&nbsp;default:&nbsp;CardStyle.NORMAL&nbsp;}, &nbsp;&nbsp;// 卡片风格&nbsp; &nbsp; &nbsp;&nbsp;info: {&nbsp;default: {} }, &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;// 业务数据&nbsp; &nbsp; &nbsp;&nbsp;status: {&nbsp;default:&nbsp;'loading'&nbsp;} &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;// loading | loaded | error&nbsp; &nbsp; }&nbsp; },&nbsp;&nbsp;&nbsp;&nbsp;// 解析规则:怎么从 HTML 读出来&nbsp;&nbsp;parseHTML() {&nbsp; &nbsp;&nbsp;return&nbsp;[{&nbsp;tag:&nbsp;'div[data-type="video-card"]'&nbsp;}]&nbsp; },&nbsp;&nbsp;&nbsp;&nbsp;// 渲染规则:怎么存成 HTML&nbsp;&nbsp;renderHTML({ node }) {&nbsp; &nbsp;&nbsp;return&nbsp;['div', {&nbsp;'data-type':&nbsp;'video-card',&nbsp;'data-bvid': node.attrs.bvid&nbsp;},&nbsp;0]&nbsp; }})

🧐 Code Review:

  • atom: true 是这里的神来之笔。它告诉 ProseMirror:“这个节点是一个整体,光标不能跑进去,要么选中整个卡片,要么不选”。这完美符合卡片的交互逻辑,避免了光标在卡片内部乱窜的尴尬。
  • addAttributes 定义了卡片的数据模型,这些数据会直接映射到 UI 组件的 Props 中。

3.3 核心设计 II:NodeView

(连接两个世界的桥梁)

接下来是重头戏 —— NodeView。它是连接 ProseMirror 数据层和 UI 渲染层的桥梁。我们要在这里把 UI组件挂载上去。

// NodeView:编辑器节点 ↔ UI 组件的桥接层abstract&nbsp;classBaseCardNodeView {&nbsp;&nbsp;// ProseMirror 调用生命周期方法&nbsp;&nbsp;constructor(node) &nbsp;// 节点创建 → 初始化组件&nbsp;&nbsp;update(node) &nbsp; &nbsp; &nbsp;&nbsp;// 节点更新 → 同步组件数据&nbsp;&nbsp;destroy() &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;// 节点销毁 → 清理组件资源
&nbsp;&nbsp;// 子类实现具体卡片类型&nbsp;&nbsp;abstract&nbsp;createCardComponent() &nbsp;// 创建对应的业务组件}
// 视频卡片实现class&nbsp;VideoCardNodeView&nbsp;extends&nbsp;BaseCardNodeView&nbsp;{&nbsp;&nbsp;createCardComponent() {&nbsp; &nbsp;&nbsp;// 🚀 核心动作:挂载真实组件&nbsp; &nbsp; returnnew&nbsp;VideoCard({&nbsp; &nbsp; &nbsp;&nbsp;data:&nbsp;this.node.attrs, &nbsp; &nbsp; &nbsp;// 编辑器数据&nbsp; &nbsp; &nbsp;&nbsp;isInEditor:&nbsp;true&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;// 标识编辑器环境&nbsp; &nbsp; })&nbsp; }
&nbsp;&nbsp;// 事件监听:组件 → 编辑器&nbsp;&nbsp;setupEventListeners() {&nbsp; &nbsp;&nbsp;// 监听组件内部的状态变更,同步回编辑器&nbsp; &nbsp;&nbsp;this.cardComponent.on('statusChange',&nbsp;(status) =>&nbsp;{&nbsp; &nbsp; &nbsp;&nbsp;this.updateNodeAttributes({ status }) &nbsp;&nbsp; &nbsp; })
&nbsp; &nbsp;&nbsp;this.cardComponent.on('delete',&nbsp;() =>&nbsp;{&nbsp; &nbsp; &nbsp;&nbsp;this.deleteFromEditor() &nbsp;// 从文档删除&nbsp; &nbsp; })&nbsp; }}

🧐 Code Review:

  • 这段代码实现了真正的“所编写即所得”。你在编辑器里看到的组件,就是发布后读者看到的组件,连代码都是同一份!
  • 通过事件监听,组件内部的操作(如点击删除、重试加载)可以反向控制编辑器的数据状态。

第四章:硬核填坑——从“能用”到“好用”

重构之路从不平坦,为了让这个系统真正“好用”,我们解决了不少棘手的工程问题。

4.1 隐秘的代价:插入极速,但运行态呢?

技术世界没有银弹。当我们为“极速插入”和“真实交互”欢呼时,隐秘的代价也随之而来——展示态(运行时)的性能崩盘风险。旧方案虽然插入慢,但在运行时只是一张死图,文章里塞入 50 个卡片依然能丝滑滚动。但新方案的每一个视频卡,都是一个包含了复杂 DOM 树、状态机、播放器的真实组件。如果放任不管,十几个播放器同时驻留内存,浏览器会直接崩溃 。

为了兜住这层底线,我们在架构上设计了两大“降落伞”:

4.2 把播放器“装”进编辑器

(CardPlayer 管理器)

我们引入了双视图自由切换模式与 CardPlayer 实例池 :

  • NORMAL 模式:普通小卡,仅展示封面和元信息,不播放视频 。
  • ADVANCED 模式:点击后直接展开内嵌播放器,通过改变 card_style 属性无缝切换,受 CardPlayer 管理器控制 。
// CardPlayer:全局播放器资源管理classCardPlayer {&nbsp;&nbsp;static&nbsp;MAX_PLAYERS&nbsp;=&nbsp;3&nbsp;&nbsp;// 🚦 最大实例数限制
&nbsp;&nbsp;// 互斥机制:播放时暂停其他&nbsp;&nbsp;static&nbsp;play(playerId) {&nbsp; &nbsp;&nbsp;pauseOthers(playerId) &nbsp; &nbsp;// 暂停其他播放器&nbsp; &nbsp;&nbsp;startPlay(playerId) &nbsp; &nbsp; &nbsp;&nbsp;// 启动当前播放器&nbsp; }
&nbsp;&nbsp;// 资源回收:超限时销毁最早的实例&nbsp;&nbsp;static&nbsp;enforceLimit() {&nbsp; &nbsp;&nbsp;if&nbsp;(count >&nbsp;MAX_PLAYERS) {&nbsp; &nbsp; &nbsp;&nbsp;destroyOldest() &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;// LRU 策略回收&nbsp; &nbsp; }&nbsp; }}

4.2 极致性能优化(批量解析 + 三级缓存)

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

// 三层缓存:验证结果 + 类型 + 卡片数据const&nbsp;cache = {&nbsp;&nbsp;validation:&nbsp;Map<url, boolean="">,&nbsp;&nbsp;type:&nbsp;Map<url, parsedtype="">,&nbsp;&nbsp;card:&nbsp;Map<url, carddata="">}
// 批量验证:防抖 + 队列合并async&nbsp;function&nbsp;validateLink(url) {&nbsp;&nbsp;// 1. 缓存命中 → 立即返回&nbsp;&nbsp;if&nbsp;(cache.has(url)) {&nbsp; &nbsp;&nbsp;return&nbsp;cache.get(url)&nbsp; }
&nbsp;&nbsp;// 2. 加入验证队列(防抖 100ms)&nbsp;&nbsp;addToQueue(url)&nbsp;&nbsp;await&nbsp;waitBatchProcess()
&nbsp;&nbsp;// 3. 批量请求完成后从缓存读取&nbsp;&nbsp;return&nbsp;cache.get(url)}

🧐 Code Review:

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

第五章:核心创新点总结

5.1 智能链接解析与双向转换

我们不仅支持从“链接”变“卡片”,还支持完美的逆向转换。通过 resource\_url 字段保存用户原始输入信息,确保数据 100% 完整。

// 核心数据结构:保存原始链接实现双向转换interface&nbsp;CardAttrs&nbsp;{&nbsp;&nbsp;resource_url:&nbsp;string&nbsp; &nbsp;&nbsp;// 🔑 关键:保存原始链接&nbsp;&nbsp;info:&nbsp;object&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;// 业务数据&nbsp;&nbsp;status:&nbsp;State&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;// 加载状态}
// 链接 → 卡片onPaste(url) {&nbsp;&nbsp;insertCard({&nbsp;resource_url: url,&nbsp;status:&nbsp;'loading'&nbsp;})}
// 卡片 → 链接convertToLink(card) {&nbsp;&nbsp;replaceWith(card.resource_url) &nbsp;// 恢复原始链接}

5.2 模板策略模式

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

// 抽象基类:定义统一的卡片生命周期abstract&nbsp;classBaseCard {&nbsp;&nbsp;// 状态机:合法的状态转换路径&nbsp; stateTransitions = {&nbsp; &nbsp; LOADING → [LOADED, ERROR], &nbsp;&nbsp;// 加载中 → 成功或失败&nbsp; &nbsp; ERROR → [LOADING], &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;// 失败 → 可重试&nbsp; &nbsp; LOADED → [LOADING] &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;// 已加载 → 可刷新&nbsp; }
&nbsp;&nbsp;// 子类必须实现的抽象方法&nbsp;&nbsp;abstract&nbsp;renderContent():&nbsp;void&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;// 渲染内容&nbsp;&nbsp;abstract&nbsp;getCardClassName():&nbsp;string&nbsp; &nbsp;// 返回样式类名
&nbsp;&nbsp;// 数据更新:触发状态转换和重新渲染&nbsp;&nbsp;async&nbsp;updateData(newData) {&nbsp; &nbsp;&nbsp;if&nbsp;(newData.status !==&nbsp;this.currentState) {&nbsp; &nbsp; &nbsp;&nbsp;if&nbsp;(!this.isValidTransition(newData.status))&nbsp;return&nbsp; &nbsp; &nbsp;&nbsp;this.currentState = newData.status&nbsp; &nbsp; &nbsp;&nbsp;this.updateUI()&nbsp; &nbsp; }&nbsp; &nbsp;&nbsp;this.renderContent()&nbsp; }}

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站专栏视频卡的技术革命》

评论:0   参与:  0