2023年8月中旬文档画中画特性跟随chrome116版本发布,基于该功能特性,我们于2023年10月中旬启动了B站跨页面播放队列功能的开发与功能灰度,并于2024年6月中旬完成了灰度全量。目前该功能入口位于B站网页端的首页,点击首页右侧对应按钮即可打开跨页面播放队列小窗。队列中的视频为稍后再看列表中的视频,在小窗开启情况下,该列表内容会实时响应B站主场景网页中的添加/移除稍后再看操作。
文档画中画
跨页面播放队列功能是基于文档画中画特性实现的,所以在正式介绍具体实现之前,需要先了解一下文档画中画是什么。
文档画中画是一个由 documentPictureInPicture 的相关方法创建和销毁的置顶小窗口,与大家熟知的视频画中画的最大差异在于它支持开发者往其中创建任意文档内容。而想要在小窗中生成任意内容,则需要开发者通过类似于 document.body.append() 等操作页面节点的 api 将目标内容插入小窗中来实现。该小窗口依赖于创建它的父页面的存在而存在,当父页面被销毁时小窗口也会随之销毁。能否成功调出小窗口有两条主要限制:1、由用户主动触发;2、父页面使用 https 协议访问。
下文中的小窗用于代指文档画中画
以下为主要指令:
// 在父页面中执行documentPictureInPicture.window // 在父页面中对小窗window对象的引用,小窗未开启时引用为 nulldocumentPictureInPicture.requestWindow({ width: 200, height: 200, disallowReturnToOpener: false }) // 开启小窗documentPictureInPicture.window.close() // 关闭小窗documentPictureInPicture.onenter = (event) => { const pipWindow = event.window;} // 监听小窗开启事件 方式一documentPictureInPicture.addEventListener("enter", (event) => { const pipWindow = event.window;}) // 监听小窗开启事件 方式二documentPictureInPicture.window.addEventListener("pagehide", () => {})// 监听小窗关闭事件这里我给出一个基本示例,实现一个在小窗中展示“Hello World!”的功能:
// 打开小窗window.documentPictureInPicture.requestWindow()documentPictureInPicture.window.append('Hello World!')更具体的信息可以参考以下相关文章:
Picture-in-Picture for any Element, not just \ | Web Platform | Chrome for Developers(https://developer.chrome.com/docs/web-platform/document-picture-in-picture)
Document Picture-in-Picture Specification (wicg.github.io(https://wicg.github.io/document-picture-in-picture/)
具体实现
初版实现
最开始该功能是直接内嵌在B站首页项目中,使用了和首页相同的技术栈 vue3 + ts
实现的主体内容包含两部分:播放器和队列。播放器我使用的是B站通用的播放器,并由播放器的研发大佬提供了相应的小窗场景适配;队列则是一个支持响应播放器事件、能与播放器进行交互的视频队列,该队列同时需要支持一些编辑/更新功能,其中队列更新相关的功能涉及到小窗和父页面的通信、父页面与B站主场景页面的通信,具体原理将在下文进行阐述。
如上文所述,想要在小窗中生成任何内容都会涉及跨窗口的dom操作。所以在编写完要插入小窗中的`vue`单文件组件后,需要通过一些操作将 vue 组件实例生成的 DOM 子树移入小窗。具体的基本操作抽象如下:
import { createApp } from "vue";import WatchlaterPip from "path/to/WatchlaterPip.vue";// 创建一个挂载节点const containerDom = document.createElement('div');document.body.appendChild(containerDom)// 创建组件实例const pipInstantce = createApp(WatchlaterPip, { /* props */ }).mount(containerDom);documentPictureInPicture.window.document.body.appendChild(pipInstantce.$el)在初版功能实现过程中遇到了以下主要问题:
样式同步操作繁琐
在常规的 vue 组件打包配置下,组件模版和 js 逻辑与组件样式在打包过后的产物中是分离的,即一般生产环境的产物中样式文件是独立的。最后的表现就是样式相关的节点存在于 head 标签中,而对应内容的 DOM 节点树存在于 body 标签中,为了使内容移入小窗后能正常展示,样式节点的同步是必不可少的。所以当跨页面播放队列功能组件的相关样式被打包进了一个独立的 CSS 文件中时,为了能进行样式同步,需要将生成的独立 CSS 样式文件进行了上传,在执行 DOM 移入操作时同时做如下操作:
// 同步组件样式const link = document.createElement('link');link.href = `https://path/to/pip.css`;documentPictureInPicture.window.document.head.appendChild(link);另外由于使用的播放器也有自己的一套样式注入规则,播放器相关控件的样式会以懒加载的方式(因此还需要监听一个播放器的控件加载完成事件来确保能同步到完整样式)往一个带有特定节点标识的 style 标签中注入播放器所需的样式,也就是说,除了同步组件相关的 DOM 节点,还需要同步播放器的这组 style 标签样式。
总的来说,实现样式同步需要的操作为:1、独立打包组件样式并上传,创建资源地址指向对应样式文件的 link 标签并插入小窗;2、监听播放器控件加载完成事件,同步播放器 style 标签样式。
看到这里,想必见多识广的各位肯定能想到针对这个 vue 组件的样式节点同步问题,也许使用一些社区中已有的 CSS-in-JS 的方案就可以解决(类似 vue-styled-components 等方案)。但该实现方案导致的其他问题,让我放弃了继续基于该方案在优化样式同步方面做挣扎。
不可控因素过多
在以上移入操作完成后,小窗内容确实能正常展示了,但遇到了更大的问题:
挂载时机受限
在使用 vue 框架开发应用时,如果想要获取组件对应 DOM 节点的引用,需要在组件 DOM 生成和挂载完成后才能取到,也就是说上述的 DOM 子树移入操作最早只能发生在组件 mounted 声明周期函数执行的时候。
由于播放器的相关初始化操作也是属于这个组件初始化的一部分,在这个执行时机以前播放器可能早就完成了初始化并可以启播了,这就导致了如果不在播放器初始化时强制播放器禁止自动起播的话,那么在组件内容正式被插入小窗前的短暂时间里,可能会出现未有画面就出声音的奇异景象。
而如果强制播放器禁止自动起播的话,实践中发现,由于受浏览器的媒体启播策略的影响,小窗播放器在小窗内容正式初始化完成后再调用相关指令执行启播操作的话,成功启播的概率会相对较低,这对于一个具有播放功能的业务应用来说显然是不可接受的。
事件监听丢失
小窗内的如视频卡片的点击事件总会在某个时刻失去响应,这会导致用户无法正常切换视频等问题。
内存泄漏导致的页面崩溃
队列播放过程中放置一段时间或者执行频繁的稍后再看视频新增/删除操作,都能触发页面崩溃。
对于以上的事件监听丢失和内存泄漏问题,我并没有确切的定论,但我推测和 vue 的更新机制有关。vue 在对页面 DOM 进行正式更新前,会先过一层虚拟 DOM ,并且 vue 官方是不建议开发者在使用 vue 框架的情况下还直接对 DOM 进行操作的。而我在此场景下会在组件生成完毕之后再将其插入小窗的这种跨窗口的 DOM 操作显然是不符合使用规范的。将被监听的整个dom子树迁移到了一个新的窗口,导致整个更新机制出现不可预知的问题的概率那将是极大的。
预设方案
向小窗插入应用脚本
看到这,应该会有大佬想到,为什么就不打包一个独立的应用脚本,直接丢进小窗让它自行执行生成小窗内的应用呢?就像我们平时对普通网页应用所做的那样。这样在使用主流框架的同时,不就可以避开上述由于需要进行 DOM 移入操作导致的问题了吗?答案是:不行的。主要原因是任何从小窗内发起的接口请求,其请求头 referer 都为空。这点我们可以通过以下方式验证:
当在小窗中执行该指令,输出为 about:blank,即小窗本身地址信息为空:
// 在小窗中执行location.href // 会输出 about:blank非常不巧的是,B站的播放器用于拉取视频数据的相关接口,是会对 referer 进行校验的,如果直接在小窗内完成应用的所有初始化操作,将会导致视频无法播放。
既然提到了接口校验,那么常见的请求携带 cookie 进行的接口用户信息校验会受到影响吗?答案是:不会的。这是因为小窗的浏览器存储与父页面是互通的。当在小窗中执行以下指令,可以发现小窗的本地存储读写和父页面是同步的:
// 在小窗中执行document.cookie // 会输出父页面的cookie字符串localStorage // 会输出父页面的本地存储信息localStorage.setItem('test', 1) // 父页面中的本地存储信息也会新增这条记录显然,小窗通过本地存储记录的信息,可以保留在父页面域名下的浏览器存储中。小窗内发起的请求会携带父页面下的 cookie ,因此小窗发起请求时不会因缺失 cookie 而影响接口服务对用户信息的校验。
那么如果我们假设接口给我开绿灯,不需要 referer 校验,那么这个方案能满足跨页面播放队列功能实现的要求吗?这还需要验证一个东西:父页面能否和插入在小窗内的应用建立通信。
需要确保这一点的原因是跨页面播放队列需要实现一些功能:小窗需要根据父页面的指令实时更新列表内容,父页面需要监听小窗内的一些事件(最基本的切集操作等)来执行一些操作(比如上报埋点等)。如果父页面无法和小窗建立通信,那么在使用此方案的情况下,需要进行通信的相关功能都将无法实现。
类比`iframe`页面和父页面之间的通信,它们有两种主要的通信方式:
父子页面互相调用挂载在各自页面的全局方法,有同源限制;调用 postMessage 方法和监听 message 事件的方式实现通信的,适用于跨域的情况。以上两种方式实现的前提条件是父子页面能互相访问到对方的 window 对象引用。首先已知父页面可以通过 documentPictureInPicture.window 获取到小窗的 window 引用。小窗能否获取到父页面的 window 引用可以通过以下方式验证:
// 在小窗内执行window === window.parent // truewindow === window.top // true显然小窗内的顶层 window 对象其实就是它自己的 window 对象,所以在小窗内是无法直接获取到父页面的 window 引用的。因此直接照搬 iframe 页面和父页面的常用通信方式是行不通的。
但我们可以转变一下思路:既然父页面可以直接访问到小窗内的全局对象,那么我们可以将所有用于通信的方法或事件监听都放在小窗内进行。
下面我给出示例:
示例1,实现小窗向父页面发起通信:
// 在父页面内执行 - 打开小窗documentPictureInPicture.requestWindow()// 在父页面内执行 - 模拟父页面根据特定规则,向小窗预埋监听回调documentPictureInPicture.window.handlePipEvent = (e) => { console.log('pip event triggered!!!', e?.detail)}// 在小窗内执行 - 模拟应用脚本往小窗全局添加事件监听的操作window.addEventListener('pipevent', (e) => { window.handlePipEvent(e)})// 在小窗内执行 - 模拟应用脚本必要时触发事件发送消息window.dispatchEvent( new CustomEvent('pipevent', { detail: 'some data from pip' }))示例2,实现父页面向小窗发起通信
// 在父页面内执行 - 打开小窗documentPictureInPicture.requestWindow()// 在小窗内执行 - 模拟应用脚本往小窗全局对象挂载供外部调用的方法window.handleParentEvent = (e) => { console.log('parent event', e?.detail)}// 在父页面内执行 - 模拟父页面根据特定规则,调用小窗提供的能力window.addEventListener('parentevent', (e) => { documentPictureInPicture.window.handleParentEvent(e)})// 在父页面内执行 - nextTick 再添加事件监听window.dispatchEvent( new CustomEvent('parentevent', { detail: 'some data from parent' }))总的来说,该方案是可以通过一些曲折的方式实现父页面和小窗的通信的。
显然,只有当小窗业务功能使用的接口不涉及 referer 的校验时该方案才适用。
向小窗插入iframe页面
仔细看过上述向小窗插入应用脚本的方案后可能有人会问,如果小窗内发起的请求的请求头 referer 会为空,怕过不了接口校验,那是不是我往小窗里插入一个 iframe 页面就可以解决这个担忧。答案是:可以的。首先对于请求头 referer 为空这一点,确实可以通过直接插入一个 iframe 页面避免,因为`iframe`页面内的请求携带的地址信息正是 iframe 的页面地址,理论上只要确保该地址能通过接口校验即可。
而对于父页面和小窗的通信,可以基于上一个方案中的通信方式进行改造,来实现小窗中 iframe 和小窗父页面的通信。首先,我们需要基于常规的 iframe 页面和父级页面的通信方式,先建立起插入小窗的 iframe 和小窗的通信,再通过往小窗全局对象上挂载用于小窗和小窗父页面通信的方法和事件监听,即可建立起从小窗中 iframe 到小窗的父页面的通信链路。
下面我给出一个示例来验证可行性:
示例1,实现小窗 iframe 向小窗父页面发起通信
// 在小窗父页面内执行 - 向小窗插入目标 iframe// ......// 模拟小窗 iframe 直接调用小窗父页面预埋的方法,向小窗父页面发送消息(适用于小窗 iframe 和 小窗父页面是同源的情况下)// 在小窗父页面内执行 - 向小窗里预埋监听回调documentPictureInPicture.window.handlePipIframeEvent = (e) => { console.log('iframe event triggered!!!', e.detail)}// 在小窗 iframe 内执行 - 调用小窗父页面在小窗中挂载的全局方法window.top.handlePipIframeEvent({ detail: 'some data from iframe' })// 模拟小窗 iframe 通过调用小窗的 postMessage 方法触发小窗监听的 message 事件,向小窗父页面发送消息(适用于小窗 iframe 和 小窗父页面是跨域/同源的情况下)// 在小窗父页面内执行 - 小窗监听 message 事件documentPictureInPicture.window.addEventListener('message', (e) => { console.log('iframe event triggered!!!', e.data)})// 在小窗 iframe 内执行 - 小窗 iframe 通过调用小窗的 postMessage 方法向外发出信息window.top.postMessage('some data from iframe')示例2,实现小窗父页面向小窗`iframe`发起通信
// 在小窗父页面内执行 - 打开小窗// ......// 模拟小窗父页面调用小窗 iframe 在全局预埋的方法,向小窗 iframe 发送消息(适用于小窗 iframe 和 小窗父页面是同源的情况下)// 在小窗 iframe 内执行 - 在小窗 iframe 挂载供外部调用的全局方法window.handlePipParentEvent = (e) => { console.log('pip parent event triggerd!!!', e.detail)}// 在小窗父页面内执行 - 调用小窗 iframe 提供的外部方法const iframe = documentPictureInPicture.window.document.body.querySelector('iframe')iframe.contentWindow.handlePipParentEvent({ detail: 'some data from pip parent' })// 模拟小窗父页面通过调用小窗 iframe 的 postMessage 方法触发小窗 iframe 监听的 message 事件,向小窗 iframe 发送消息(适用于小窗 iframe 和 小窗父页面是跨域/同源的情况下)// 在小窗 iframe 内执行 - 小窗 iframe 监听 message 事件window.addEventListener('message', (e) => { console.log('pip parent event triggerd!!!', e.data)})// 在小窗父页面内执行 - 向小窗 iframe 发送消息const iframe = documentPictureInPicture.window.document.body.querySelector('iframe')iframe.contentWindow.postMessage('some data from pip parent')那看起来,这个方案已经满足了请求携带 referer 和支持小窗和父页面的通信两点要求,是行得通的。
但本次实现并没有采用这个方案,这是因为为了实现这个方案,我相当于需要特地给这个单一的跨页面播放队列功能配套一个完整的页面所需的一切条件,成本略高。那这个方案就一无是处了吗?那我倒没这么觉得,这个方案我觉得非常适用于强调快速迭代的活动、直播场景(类似于活动投票模块页面、直播礼物领取模块页面等)。在此类场景下,向外提供 iframe 页面似乎已经是家常便饭的事了,也早就形成了一套快速产出页面和对应访问地址的模式,主打一个“敏捷”。如果相关模块想要搭配文档画中画特性使用,这个方案就再适合不过了。
最终实现
那么如果上述方案都不用,首页的这个跨页面播放队列功能到底是怎么实现的?在给出最终解答之前,我觉得有必要总结一下实现这个功能的成本较低且性能较好的方案一些条件:
尽可能使用原生能力。因为只有这样才能避开在手动操作 DOM 时,避免诸如 vue 等框架的更新机制带来的不可控表现;降低初始化时的DOM操作成本 。尽可能避免需要同步多处页面节点的情况,尽可能简化生成小窗应用`DOM`子树的操作步骤;要确保接口请求的`referer`能够通过校验。这样才能确保播放器正常播放视频;要确保能给父页面和小窗提供通信的方式。因为这是实现跨页面播放队列实时更新等功能的必要条件。使用 web components
当提及通过原生的方式去实现一个业务应用时,无尽的 append 、 addClass 、 document.createElement 想必已经涌入各位的脑海中。当提到组件, vue 、 react 、 solid 等主流框架的用法想必各位已经烂熟于心?那如果把“原生”和“组件”加在一起,见多识广的人想必就能想到 web components !
首先让我来简述一下这个是个啥:
Web Components 是一系列不同技术的组合( Custom Elements , Shadow Dom , HTML templates ),它允许开发者创建可复用的自定义元素。它实现了将具体功能代码封装在你使用这个自定义标签元素的代码之外、通过原生能力使你的应用实现优雅的组件化。
Web Components - Web APIs | MDN (mozilla.org)(https://developer.mozilla.org/en-US/docs/Web/API/Web_components)
Web Components Tutorial for Beginners [2019] (robinwieruch.de)(https://www.robinwieruch.de/web-components-tutorial/)
这个东西使用起来,就类似于这样:
// 给这个元素整一个模版const template = document.createElement('template');template.innerHTML = ` <style>.boy {}</style> <div>上述给出的例子做了以下事情:
定义了一个 handsome-boy 自定义标签;使用了模版和插槽,通过模版内的 style 标签添加样式;把作为元素 property 的 hairColor 和作为元素 attribute 的 hair-color 进行映射,并对两者值的变化进行了响应。作为一个标签元素,它也可以很轻松的被放入小窗使用:
const hb = document.createElement('handsome-boy')documentPictureInPicture.window.document.body.appendChild(hb)Web Components 带来的组件化封装能力,可以将整个小窗应用模块直接变成一个单一自定义标签,那在开启小窗队列时,要做的 DOM 操作就只有插入这么一个标签。样式同步操作也是不需要的,因为模块的样式已经跟随标签内模版的 style 标签应用上去了。
Web Components 的样式应用有两种方式:声明式(在模版中通过 style 标签声明样式)和命令式(构造一个 CSSStyleSheet 样式表对象并应用到 Shadow Root ),两种方式都会将CSS样式封装到自定义元素内部。最终使用声明式的方式是因为涉及 DOM 在不同窗口(文档)中的操作,而 CSSStyleSheet 是必须发生在同一个页面文档中的,因此通过命令式的方式应用样式是不被允许的。
以下是最终实现中的小窗应用模块标签示例,最终实现中的样式存在于标签内的 style 标签中
那么到目前为止,“尽可能使用原生能力”的目标已经能通过使用 web components 达到,但这个实现方案真的降低了初始化DOM操作的成本了吗?至少在“移入DOM”这一步已经达到预期,但在生成完整 DOM 子树操作方面还没有。
其实通过上述"handsome-body"这个简单的例子就可以看出,组件编写成本略高。标签内容的初始化、数据更新到视图更新的操作、事件监听都有一定的编码量,这还是只需要完成一个属性的响应式和一个自定义事件的绑定的情况下。当我把这套编码实现套用到跨页面播放队列功能上时,除开标签内容初始化、自定义事件监听这些较为模版化的操作,数据的基本响应式的编写更让人头疼(无数的`getter`、`setter`函数飘在眼前~)。特别是在队列需要支持队列项的新增、删除操作以及响应各类用户交互操作(卡片点击、队列项新增/移除、视频播放结束等)触发的播放切集的情况下,会涉及到更多 DOM 元素的事件监听和状态更新。
那么有没有一种现成的解决方案,能让我在使用 web components 的同时,提供恰到好处的数据响应式功能和模版语法呢?这就不得不提 Lit 了。
引入Lit
Lit 是一个用于开发 Web Components 的库,基于 Web Components 相关技术提供了更高层级的封装,在支持开发者定制自己的可复用自定义元素的基础上,还支持了响应式属性、丰富的模版语法。在贴近原生保持轻量的同时,提高了使用 Web Components 的项目的迭代效率。
Lit(https://lit.dev/)
说白了就是用了Lit,我就可以使用声明式的方式编写组件而不需要再像上面一样编写很多的使用命令式代码,就可以亩产一万八。
将上文中的"handsome-boy"用 Lit 重写一遍,大概是这样的:
import { LitElement, html } from "lit";import { property } from "lit/decorators.js";class HandsomeBoy extends LitElement { // 简单的一步声明就完成了 property hairColor 和 attribute hair-color 的映射 @property({ type: String, attribute: 'hair-color' }) hairColor = 'black' constructor() { super() } protected render() { return html` <style>.boy {}</style> <div>显然基于 Lit 提供的数据响应式功能和丰富的模版语法去编写组件效率要更高,并且可维护性更好。
至此,“降低初始化时的DOM操作成本”的目的已经通过使用`web components`+`Lit`进行开发达到。
使用形式
那讲了这么多,最终这个小窗应用是怎么使用的呢?
还记得上述提及预期实践方案的后两个要求吗?"要确保能给父页面和小窗提供通信的方式"以及“要确保接口请求的 referer 能够通过校验”。
对于 referer 校验,是一定会通过的,这是因为应用标签是在父页面中创建再插入小窗的,而标签组件使用的请求方法大家可以简单理解为类似于挂载在父页面 window 对象上的 fetch 方法,因此发起请求时调用的是父页面的方法,等同于在父页面中发起, referer 不会为空,其值为父页面地址。
对于父页面和小窗的通信方式则是通过事件监听回调和小窗应用对外暴露方法实现,为此我编写了一个工具类,以下为该工具类的抽象示例:
// 仅为抽象代码// 假设这是一个定义好的小窗应用类class BiliPip { // 假设这里是数据定义 _isopen = false _app = null _pipoptions = {} constructor(options?: WatchlaterPipOptions) { if (options) { this._pipoptions = options } } // 创建小窗示例(一个自定义标签) _createApp() {} // 打开小窗 async open () { // 打开的前置操作 this._createApp() // ...... this._pipoptions?.onopen?.() // 外部传入的回调 - 父页面希望在小窗打开后做的事 } // 关闭小窗 async close () {} // 刷新操作 async refresh() {}}// 父页面使用该工具类就类似于这样const pip = new BiliPip({ onopen: () => {}, // 比如监听小窗打开 onswitchvideo: () => {} // 比如监听视频切换})pip.open() // 假设父页面想打开一下这个应用,则调用此方法pip.refresh() // 假设父页面像让小窗刷新一下列表,则调用此方法具体实现
以下将介绍部分具体功能的实现,这些实现都比较具有通用价值,也让我有一定收获,也期望能给各位带来一点参考价值和启发。
小窗开启状态管理
在实践过程中发现,同一个浏览器中,无论是视频画中画还是文档画中画,当创建任意一个画中画时,已存在的画中画会自动被销毁。这点可以通过打开B站视频播放页的视频画中画情况下,再创建一个文档画中画得到验证(或者其他能尝试使多个画中画出现的操作)。
在上述关于 documentPictureInPicture 的使用介绍中,提到了两个可用于监听小窗开启和关闭的事件( documentPictureInPicture 的 enter 事件和 documentPictureInPicture.window 的 pagehide 事件),这在同一个父页面仅有一个业务会触发文档画中画时,这种监听是没问题的。但当同一个父页面的多个业务都会触发创建小窗时,简单的监听处理会导致小窗内容出现错乱。
用以下代码实例举例:
// 在父页面中执行以下代码// 两个业务按钮document.body.insertAdjacentHTML('beforeend', ` <button id="pip1">open pip 1</button> <button id="pip2">open pip 2</button>`)// 业务一document.getElementById('pip1')?.addEventListener('click', () => { documentPictureInPicture.requestWindow()})documentPictureInPicture.addEventListener("enter", (event) => { const pipWindow = event.window; pipWindow.document.body.insertAdjacentHTML( 'beforeend', `<div style="color: green;font-size: 40px">业务一</div>` )})// 业务二document.getElementById('pip2')?.addEventListener('click', () => { documentPictureInPicture.requestWindow()})documentPictureInPicture.addEventListener("enter", (event) => { const pipWindow = event.window; pipWindow.document.body.insertAdjacentHTML( 'beforeend', `<div style="color: red;font-size: 40px">业务二</div>` )})上述模拟了两个业务,它们都对小窗开启事件进行了监听,当任一按钮被点击,两个业务都会向小窗插入业务内容。在实际场景中,这其实已经破坏了业务逻辑了。业务需要对开启的小窗进行识别,确认小窗是为自己开启再执行业务逻辑。为此,在实现跨页面播放队列时做了特别处理来实现这个目标。
特殊处理的核心代码逻辑如下,核心在于每个业务独立管理自己的开启状态:
// 编写一个工具类,核心在于每个实例独立管理自己的开启状态class BiliDocPip { _isopen = false get pipWindow() { if (!this._isopen) return null return window.documentPictureInPicture.window } async open() { // 如果已经开启了,就不再开启 if (this._isopen) return true // 需要使用一个独立的变量记录本实例创建的小窗引用,避免误判小窗开启状态 // 假如这里 requestWindow 失败了,并且外部早已存在一个小窗,此时 documentPictureInPicture.window 是个非空值,documentPictureInPicture.window 存在并不代表本实例就开启成功自己的小窗了 let curPipWindow = null try { curPipWindow = await window.documentPictureInPicture.requestWindow() } catch (e) {} // 开启失败 if (!curPipWindow) return false this._isopen = true // 需要在实例化出来的 window 上监听关闭事件 this.pipWindow.addEventListener('pagehide', () => { this._isopen = false }) return true } async close() { if (!this._isopen) return false this.pipWindow?.close?.() return true }}// 业务一const pip1 = new BiliDocPip(); // 使用独属于自己的开启和关闭状态document.getElementById('pip1')?.addEventListener('click', async () => { const res = await pip1.open() res && pip1.pipWindow.document.body.insertAdjacentHTML( 'beforeend', `<div style="color: green;font-size: 40px">业务一</div>` )})// 业务二const pip2 = new BiliDocPip(); // 使用独属于自己的开启和关闭状态document.getElementById('pip2')?.addEventListener('click', async () => { const res = await pip2.open() res && pip2.pipWindow.document.body.insertAdjacentHTML( 'beforeend', `<div style="color: red;font-size: 40px">业务二</div>` )})跨页面列表更新
跨页面播放页队列的“跨页面”有两层含义:队列浏览的跨页面、队列更新的跨页面。
队列浏览的跨页面的实现依赖于小窗始终置顶的特点,使得其很自然地可以在父页面离开视口的情况下,依然能够让用户浏览到队列内容。
队列更新的跨页面的实现依赖于父页面和其他主场景页面中接入的跨页面通信 SDK 实现,其底层原理是 BroadcastChannel + iframe。接入了这个SDK的多个页面会被插入一个统一的 iframe 页面,每个 iframe 页会向外暴露一个用于广播消息的方法供其父页面使用,同时每个 iframe 页面也会监听来自其他 iframe 页面的消息,当 iframe 页收到消息后,会将消息传递给父页面。iframe 页面之间的通信是通过 BroadcastChannel + message event 进行的,而 iframe 页面和父页面之间的通信,是通过 postMessage + message event 进行的。最终效果是当任一接入了该SDK的页面发生了稍后再看新增/删除操作时,其他接入了该SDK的页面都会收到消息,当小窗父页面收到消息时就可以通知小窗执行更新操作了,进而实现跨页面队列更新的效果。
Broadcast Channel API - Web APIs | MDN (mozilla.org)(https://developer.mozilla.org/en-US/docs/Web/API/Broadcast_Channel_API)
下面我给出一个具体例子来实现上述原理:
// 模拟跨页面通信方案// parent.html<button id="sendmsg">send msg</button><iframe src="https://path/to/iframe.html"></iframe><script> // 给父页面一个页面标识 const tag = Math.random() document.title = `I am a parent page ${tag}` window.addEventListener('message', (e) => { console.log(`parent says: message from my iframe is "${e.data}"`) }) const btn = document.getElementById('sendmsg') btn.addEventListener('click', () => { const iframe = document.querySelector('iframe') iframe.contentWindow.postMessage(`hello from parent ${tag}`) })</script>// iframe.html<script> const bc = new BroadcastChannel('mockCrossPage') window.addEventListener('message', (e) => { console.log(`iframe says: message from my parent is "${e.data}"`) bc.postMessage(e.data) }) bc.addEventListener('message', (e) => { console.log(`iframe says: message from outside is "${e.data}"`) window.top.postMessage(e.data) })</script>使用这么迂回的方式是因为B站的网页并非都是同源页面,比如 www.bilibili.com 、 search.bilibili.com 、space.bilibili.com 等,由于同源策略的限制,他们之间没什么办法可以直接通信,所以才采用了这种利用 iframe 页面进行同源页面间的通信,间接将跨页面消息同步给非同源父页面的方式。
window指向问题
可以注意到,上述出现的代码实例中,都强调了代码的执行应当发生在哪里(父页面or小窗)。这是因为父页面和小窗可以调用的api是完全一致的,但是最终的结果可能是有差异的,比如 location.href :
// 执行位置 - 父页面location.href // 返回父页面的地址信息documentPictureInPicture.window?.location.href // 返回小窗的地址信息// 执行位置 - 小窗// 无法获取父页面的地址信息,因为小窗内无法直接获取父页面window引用location.href // 返回小窗的地址信息这些类似情况也包括上述代码示例中的 console.log 、 alert ,可以注意到指令调用的结果总是发生在指令代码编写的窗口中。当涉及 DOM 的操作时(`DOM`的插入、窗口or文档事件监听等)也可能会有执行出现偏差的情况。下面通过一个特殊例子来展示:
// 在父页面中执行以下代码// 编写一个简单的能响应鼠标移动事件的自定义元素class MouseMove extends HTMLElement { constructor() { super() window.addEventListener('mousemove', (e) => { console.log('mouse move', e.pageX, e.pageY) }) }}customElements.define("mouse-move", PressMove)当这个"mouse-move"元素在小窗中时控制台是不会有任何打印内容的,这是因为 mousemove 事件的绑定是绑在了父页面的 window 对象上,小窗的 window 对象的 mousemove 事件当然就不会正常触发了。为了实现一个能在父页面以及小窗中都能正常运行的“mouse-move”元素,需要做一些特殊处理。以下是更正后的版本:
// fix 的版本class PressMove extends HTMLElement { _window = window _handleWindowMouseMove = (e) => { if (!this._active) return console.log('mouse move', e.pageX, e.pageY) } constructor() { super() this._window.addEventListener('mousemove', this._handleWindowMouseMove) } // 用于修改 window 指向,确保功能运行 setWindow(win) { this._window.removeEventListener('mousemove', this._handleWindowMouseMove) this._window = win this._window.addEventListener('mousemove', this._handleWindowMouseMove) }}上述 fix 版本给出了一个 setWindow 方法,只需要在将元素移入小窗时调用该方法修改 window 指向,就可以让 window 相关事件能够进行正确的绑定。其实上述两个例子模拟的就是开发过程中小窗播放器的进度条无法拖动的问题:因为播放器的初始化发生在父页面中,其中的全局事件绑定默认就使用了父页面的 window 对象,但是挂载的节点位于小窗内。这个问题本质上也是通过更新 window 事件绑定来解决。
小窗其实也是可以像父页面一样通过 window.open() 打开一个新页面的,但是调用的时候要注意使用的是小窗的 window 引用,否则会被拦截:
// 在父页面中执行documentPictureInPicture.requestWindow() .then(() => { documentPictureInPicture.window.handleOpenLink = (link) => { window.open(link) } })// 在小窗中执行window.handleOpenLink('https://www.bilibili.com')所以应当结合代码执行位置,使用正确的`window`全局对象引用来执行代码
其他功能
由于文档画中画特性属于较新的特性,因此该功能只覆盖在至少支持文档画中画的浏览器版本(chrome116以上),在一些功能的实现方案制定方面没有太多兼容性顾虑。本项目对浏览器兼容性近乎于无的要求,也给了我一个很好的契机去尝试将各种特性应用到实际生产中。
除了文档画中画这个较新的特性,开发过程中也尝试使用了以下浏览器版本要求较高的特性。
popover
小窗的提示浮层使用了原生的 popover ,仅靠 popover 相关属性和事件,加上 CSS Animation 就实现了浮层效果,无需引入任何外部工具。
Popover API - Web APIs | MDN (mozilla.org)(https://developer.mozilla.org/en-US/docs/Web/API/Popover_API)
scrollIntoView options
列表滚动定位依赖 scrollIntoView 的缓动选项参数实现,完全省去了缓动滚动定位的计算逻辑。
Element.scrollIntoView() - Web API | MDN (mozilla.org(https://developer.mozilla.org/zh-CN/docs/Web/API/Element/scrollIntoView#scrollintoviewoptions)
CSS Nesting
在本文编写时,该项目正在尝试将原生选择器嵌套(CSS Nesting)应用到css样式的管理中。
Using CSS nesting - CSS: Cascading Style Sheets | MDN (mozilla.org)(https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_nesting/Using_CSS_nesting/)
总的来说,整个项目最终所有功能完全依赖原生能力实现。
写在最后
经常搞 web 开发的朋友们应该都有一种感觉,基于web应用的跨页面、跨窗口操作上限略低。受浏览器如同源策略等安全策略的限制,每每要实现一些跨页面、跨窗口功能可能都要采用一些迂回的方式(比如上文提及的跨页面通信方案)。现在文档画中画特性的出现,我认为在对于web应用实现跨页面、跨窗口功能方面是个新的契机。或许我们可以发挥想象力,将更多功能搭配文档画中画“食用”,创造更多可能性。