Document Picture-in-Picture API 是一项相对较新的浏览器能力,它允许开发者将页面中的任意 DOM 元素(比如一个自定义播放器、一个侧边工具面板)投射到一个独立的置顶小窗口中运行。与传统的视频画中画不同,这个窗口里承载的是真实的文档,因此它拥有自己的 title 属性、自己的样式表,甚至可以独立监听事件。问题也随之而来:这个小窗口的标题默认继承自请求时的快照,一旦源页面的标题发生变化,或者媒体会话(Media Session)中的元数据更新,小窗口标题就可能和实际内容脱节。这篇文章就在 TypeScript 的视角下,把这件事的类型定义和同步逻辑彻底讲清楚。

一、Document Picture-in-Picture API 的类型结构
先从入口对象说起。这套 API 挂载在 window.documentPictureInPicture 上,TypeScript 官方库在较新的版本中已经内置了部分声明,但如果我们使用的是旧版本的 lib.dom 或者希望覆盖更精确的类型,就需要自己补齐。核心接口有两个:一个是 DocumentPictureInPicture,它提供 requestWindow 方法;另一个是 WindowInPictureInPicture,即请求成功后返回的窗口对象。
requestWindow 接受一个配置对象,其中 width 和 height 是可选的窗口尺寸,disallowReturnToOpener 用于控制是否显示返回源页面的按钮。返回值是一个 Promise,resolve 出来的窗口除了常规的 Window 属性外,还多了一个关键成员 window.document,它就是画中画窗口内部的文档对象,标题同步的所有操作都围绕它展开。
// 手动补充的完整类型声明(适用于未内置该 API 的 TS 环境)
interface DocumentPictureInPictureOptions {
width?: number;
height?: number;
disallowReturnToOpener?: boolean;
}
interface DocumentPictureInPicture {
window: Window | null;
requestWindow(options?: DocumentPictureInPictureOptions): Promise<Window>;
}
declare global {
interface Window {
documentPictureInPicture?: DocumentPictureInPicture;
}
}
需要特别注意的是 declare global 的写法。因为 API 可能不存在(浏览器兼容性问题),我们把 documentPictureInPicture 声明为可选属性,这样在使用时 TypeScript 会强制我们做空值检查,避免在 Safari 等尚未支持的浏览器里直接报运行时错误。这是一种类型驱动防御性编程的典型实践。
二、Media Session API 的类型约束与元数据关联
画中画窗口标题的理想来源往往不是源页面的 document.title,而是媒体会话中的元数据。想象一个音乐播放场景:用户在主页面切歌,画中画小窗的标题应该显示当前曲目名,而不是页面原本的站点名称。Media Session API 的核心是 navigator.mediaSession,它的 metadata 属性是一个 MediaMetadata 实例,包含 title、artist、album、artwork 等字段。
TypeScript 对 Media Session 的内置支持比较完整,我们可以直接定义一个从 MediaMetadata 提取标题的工具类型,并将其作为同步函数的输入。关键设计在于:同步函数不应该直接读取全局状态,而是接受一个结构化的元数据参数,这样便于测试,也让依赖关系更清晰。
// 标题来源的类型定义
type TitleSource =
| { kind: 'media'; metadata: MediaMetadata | null }
| { kind: 'document'; title: string };
function resolveTitle(source: TitleSource): string {
if (source.kind === 'media' && source.metadata) {
const parts = [source.metadata.title, source.metadata.artist]
.filter(Boolean) as string[];
return parts.join(' - ') || '正在播放';
}
return source.title || '画中画窗口';
}
用可辨识联合(Discriminated Union)定义 TitleSource 是这段代码的点睛之笔。当 kind 为 media 时 TypeScript 会自动收窄类型,保证 metadata 属性存在;当为 document 时则只关心字符串。这种模式避免了用一个松散的对象同时携带两种来源的字段,编译期就能拦住拼写错误和属性误用。
三、实现标题同步的完整方案与清理逻辑
有了类型基础,接下来实现同步。思路分三层:请求窗口成功后立即写入初始标题;用 MutationObserver 监听源页面 document.title 的变化;监听画中画窗口的 pagehide 事件,在窗口关闭时断开观察器并清理回调。整个流程封装在一个函数里,返回一个清理函数,符合常见的资源管理模式。
async function openPlayerInPip(player: HTMLElement): Promise<() => void> {
if (!('documentPictureInPicture' in window)) {
throw new Error('当前浏览器不支持 Document Picture-in-Picture');
}
const pipWindow = await window.documentPictureInPicture!.requestWindow({
width: 420,
height: 260,
});
// 初始标题:优先取媒体会话元数据
const applyTitle = () => {
const source: TitleSource = navigator.mediaSession.metadata
? { kind: 'media', metadata: navigator.mediaSession.metadata }
: { kind: 'document', title: document.title };
pipWindow.document.title = resolveTitle(source);
};
applyTitle();
// 监听源文档标题变化,同步到画中画窗口
const observer = new MutationObserver(applyTitle);
const titleElement = document.querySelector('title');
if (titleElement) {
observer.observe(titleElement, { childList: true, characterData: true });
}
// 监听媒体会话元数据更新
navigator.mediaSession.addEventListener?.('metadatachange', applyTitle);
// 把播放器移动进画中画文档
pipWindow.document.body.append(player);
// 返回清理函数
return () => {
observer.disconnect();
navigator.mediaSession.removeEventListener?.('metadatachange', applyTitle);
if (pipWindow.document.body.contains(player)) {
document.body.append(player); // 归还元素
}
};
}
这段实现里有两个容易踩的坑值得展开。第一,MutationObserver 必须挂在 <title> 元素上而不是 document 上,且要同时开启 childList 和 characterData,因为有些框架修改标题是替换整个文本节点,有些则是修改节点内容。第二,metadatachange 事件在部分类型定义里还没有收录,所以代码用了可选链调用 addEventListener?.,如果当前环境的类型声明不支持,可以扩展 MediaSession 接口来补上这个事件签名。
四、兼容性判断与降级策略的类型安全写法
最后谈一下工程层面的稳健性。生产环境中不能假设 API 一定存在,类型系统恰恰是表达这种不确定性的最佳工具。推荐用类型收窄配合特性检测,让不支持的环境走隐藏的降级路径,比如提示用户改用视频元素原生的 requestPictureInPicture 方法。
type PipCapability =
| { supported: true; open: (el: HTMLElement) => Promise<() => void> }
| { supported: false; fallback: 'video-pip' | 'none' };
function detectPipCapability(): PipCapability {
if ('documentPictureInPicture' in window) {
return { supported: true, open: openPlayerInPip };
}
if (document.createElement('video').requestPictureInPicture) {
return { supported: false, fallback: 'video-pip' };
}
return { supported: false, fallback: 'none' };
}
这种能力对象模式的好处是调用方代码完全不需要写 if-else 嵌套,TypeScript 会根据 supported 字段自动区分两条分支。此外还要记得处理 requestWindow 被用户拒绝的情形——它抛出的 NotAllowedError 应该被捕获并给出友好提示,而不是让页面崩溃。综合来看,标题同步虽然只是一个小功能,但把它做扎实需要贯穿类型声明、事件监听、资源清理和兼容降级四个层面,这也是用 TypeScript 编写现代浏览器 API 封装的通用套路。
TypeScriptDocument Picture-in-Picture APIMedia Session API修改时间:2026-09-04 12:02:44