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

背景:从"伪造"卡片到真实交互

回望 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 本质上是一个线性的操作记录,就像一张长长的收银小票🧾。

你想在这张薄薄的小票中间塞进一个立体、复杂的"视频播放器盒子"?太难了!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-

作者丨泯泷

相关推荐
Elaine3362 小时前
【通过 Vue 实例劫持突破 Web 编辑器的粘贴限制】
前端·javascript·vue.js·chrome devtools·前端逆向
程序员讲BPM工作流2 小时前
npm非全局方式安装小龙虾OpenClaw
前端·npm·node.js
阿成学长_Cain2 小时前
Linux alias 命令详解:从入门到高级用法
linux·前端·chrome
程序员敲代码吗2 小时前
探索数字转换与计算机存储基础
前端·python
SuperEugene2 小时前
Vant 4 实战教程:Vue3 移动端后台管理系统从选型到开发|Vue生态精选篇
前端·javascript·vue.js·前端框架·vant
xuankuxiaoyao2 小时前
VUE.JS 实践 第一章
前端·javascript·vue.js
梦想的旅途22 小时前
企业微信消息回调开发指南:如何实时接收并处理企微消息?
前端·机器人·自动化·企业微信
何中应2 小时前
CentOS7安装高版本Node.js
前端·centos·node.js
じ星不离月か2 小时前
【记录】 跑马灯无限滚动
前端·css·跑马灯·无限滚动