Document Picture-in-Picture API 实战:用 Web 标准 API 创建浮动桌面小组件
CSS-Tricks 发表技术文章,由 Daniel Schwarz 撰写,详细介绍了如何使用 Document Picture-in-Picture API(DPIP)创建浮动在屏幕上的 Web 小组件。文章指出,Firefox 151 最近正式支持了 Document Picture-in-Picture API,这与传统的视频 Picture-in-Picture API 不同——传统 API 只能将视频放入浮动窗口,而 Document Picture-in-Picture API 可以将任何 HTML 内容放入浮动窗口,包括股票行情、实时聊天、播放列表、待办事项、笔记、电子表格等。本文基于 CSS-Tricks 的文章,系统解读 Document Picture-in-Picture API 的工作原理、使用方法、实际案例和注意事项。
背景:Picture-in-Picture 的演进
传统视频 Picture-in-Picture API
传统的 Picture-in-Picture API(PiP)是为视频播放设计的:
- 允许将视频从网页中"弹出"到一个浮动的小窗口中
- 浮动窗口始终置顶,即使用户切换浏览器标签或操作系统窗口
- 用户可以在做其他事情的同时继续观看视频
- 广泛应用于视频会议、在线教育、娱乐等场景
传统 PiP API 的使用方法:
const video = document.querySelector('video');
if (document.pictureInPictureEnabled) {
const pipWindow = await video.requestPictureInPicture();
}
传统 PiP 的局限:
- 只能用于视频元素,不能显示其他 HTML 内容
- 浮动窗口的内容完全由浏览器控制,开发者无法自定义
- 无法在浮动窗口中运行自定义的 JavaScript
- 无法添加交互元素(按钮、表单等)
- 样式和布局完全由浏览器决定
Document Picture-in-Picture API 的出现
Document Picture-in-Picture API(DPIP)解决了传统 PiP 的局限:
- 可以将任何 HTML 文档放入浮动窗口
- 开发者可以完全控制浮动窗口的内容、样式和交互
- 浮动窗口中可以运行 JavaScript
- 可以添加任何交互元素
- 可以创建真正的桌面级浮动小组件
DPIP 的核心思想:创建一个独立的浏览器窗口,将 HTML 文档渲染在其中,这个窗口始终置顶,可以自由调整大小和位置。
浏览器支持情况
- Chrome/Edge:较早支持,Chrome 111+ 开始实验性支持,后续版本稳定
- Firefox:Firefox 151 正式支持(2026 年)
- Safari:目前尚未支持,需要关注后续更新
- 移动端浏览器:目前主要支持桌面端,移动端支持有限
由于 Safari 尚未支持,使用 DPIP 时需要做好降级处理。
Document Picture-in-Picture API 核心概念
window.documentPictureInPicture
DPIP API 的入口是 window.documentPictureInPicture 对象:
// 检查浏览器是否支持 DPIP
if ('documentPictureInPicture' in window) {
console.log('DPIP 已支持');
}
documentPictureInPicture 对象提供以下属性和方法:
requestWindow(options):请求创建一个 DPIP 窗口window:当前活动的 DPIP 窗口(如果有)onenter:进入 DPIP 模式的事件onleave:离开 DPIP 模式的事件
requestWindow 方法
requestWindow 是创建 DPIP 窗口的核心方法:
const pipWindow = await window.documentPictureInPicture.requestWindow({
width: 400, // 窗口初始宽度
height: 300, // 窗口初始高度
});
参数说明:
width:浮动窗口的初始宽度(像素)height:浮动窗口的初始高度(像素)
返回值:一个 Promise,解析为 DPIP 窗口的 Window 对象。
重要限制:
requestWindow必须在用户手势(如点击事件)中调用,不能自动触发- 一次只能有一个 DPIP 窗口活动
- 窗口的最小尺寸由浏览器决定
DPIP 窗口的 Window 对象
requestWindow 返回的 Window 对象与普通浏览器窗口类似:
pipWindow.document:浮动窗口的文档对象pipWindow.window:浮动窗口的 window 对象- 可以使用标准 DOM API 操作浮动窗口的内容
- 可以在浮动窗口中添加样式、脚本和事件监听器
与主窗口的通信
DPIP 窗口与主窗口之间可以通过多种方式通信:
- 直接 DOM 操作:主窗口可以直接操作 DPIP 窗口的 DOM
- postMessage:使用
window.postMessage进行跨窗口通信 - BroadcastChannel:使用 BroadcastChannel API 进行广播通信
- SharedWorker:使用 SharedWorker 共享状态
- localStorage/sessionStorage:通过存储事件进行通信
基本使用方法
最简单的示例
将一个元素从主窗口移动到 DPIP 窗口:
<!DOCTYPE html>
<html>
<head>
<style>
.widget {
width: 100%;
height: 100%;
background: #1a1a2e;
color: #eee;
font-family: system-ui;
padding: 20px;
box-sizing: border-box;
}
.widget h2 { margin-top: 0; }
.open-btn {
padding: 10px 20px;
font-size: 16px;
cursor: pointer;
}
</style>
</head>
<body>
<button class="open-btn">打开浮动小组件</button>
<div id="widget" class="widget" style="display:none;">
<h2>我的浮动小组件</h2>
<p>这个窗口始终置顶,可以自由拖动和调整大小。</p>
<p>当前时间:<span id="time"></span></p>
</div>
<script>
const button = document.querySelector('.open-btn');
const widget = document.getElementById('widget');
button.addEventListener('click', async () => {
// 检查支持
if (!('documentPictureInPicture' in window)) {
alert('您的浏览器不支持 Document Picture-in-Picture API');
return;
}
try {
// 请求 DPIP 窗口
const pipWindow = await documentPictureInPicture.requestWindow({
width: 350,
height: 250,
});
// 复制样式到 DPIP 窗口
const style = document.createElement('style');
style.textContent = `
body { margin: 0; }
.widget { width: 100%; height: 100%; background: #1a1a2e; color: #eee; font-family: system-ui; padding: 20px; box-sizing: border-box; }
.widget h2 { margin-top: 0; }
`;
pipWindow.document.head.appendChild(style);
// 将小组件移动到 DPIP 窗口
pipWindow.document.body.appendChild(widget);
widget.style.display = 'block';
// 更新时间
const timeEl = widget.querySelector('#time');
setInterval(() => {
timeEl.textContent = new Date().toLocaleTimeString();
}, 1000);
// 监听窗口关闭
pipWindow.addEventListener('pagehide', () => {
// 将小组件移回主窗口
document.body.appendChild(widget);
widget.style.display = 'none';
});
} catch (err) {
console.error('无法打开 DPIP 窗口:', err);
}
});
</script>
</body>
</html>
克隆元素到 DPIP 窗口
如果不想移动原元素,可以克隆一个副本:
button.addEventListener('click', async () => {
const pipWindow = await documentPictureInPicture.requestWindow({
width: 400,
height: 300,
});
// 克隆所有样式表
[...document.styleSheets].forEach((styleSheet) => {
try {
const cssRules = [...styleSheet.cssRules].map(rule => rule.cssText).join('');
const style = document.createElement('style');
style.textContent = cssRules;
pipWindow.document.head.appendChild(style);
} catch (e) {
// 跨域样式表可能无法访问 cssRules
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = styleSheet.href;
pipWindow.document.head.appendChild(link);
}
});
// 克隆小组件
const clone = widget.cloneNode(true);
pipWindow.document.body.appendChild(clone);
});
实际案例:股票行情浮动小组件
CSS-Tricks 文章中以股票行情小组件为例,展示了 DPIP 的实际应用。
股票行情 HTML 结构
<div id="stock-ticker" class="stock-ticker">
<div class="ticker-header">
<h3>股票行情</h3>
<button class="refresh-btn">刷新</button>
</div>
<div class="ticker-list">
<div class="stock-item">
<span class="stock-symbol">AAPL</span>
<span class="stock-price">178.72</span>
<span class="stock-change up">+1.24%</span>
</div>
<div class="stock-item">
<span class="stock-symbol">GOOGL</span>
<span class="stock-price">141.80</span>
<span class="stock-change down">-0.56%</span>
</div>
<div class="stock-item">
<span class="stock-symbol">MSFT</span>
<span class="stock-price">378.91</span>
<span class="stock-change up">+0.89%</span>
</div>
</div>
</div>
CSS 样式
.stock-ticker {
width: 100%;
height: 100%;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
color: #eee;
font-family: 'SF Mono', 'Fira Code', monospace;
padding: 16px;
box-sizing: border-box;
overflow-y: auto;
}
.ticker-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid rgba(255,255,255,0.1);
}
.ticker-header h3 {
margin: 0;
font-size: 14px;
letter-spacing: 1px;
text-transform: uppercase;
color: #4ecca3;
}
.refresh-btn {
background: rgba(78, 204, 163, 0.2);
border: 1px solid #4ecca3;
color: #4ecca3;
padding: 4px 10px;
border-radius: 4px;
cursor: pointer;
font-size: 11px;
transition: all 0.2s;
}
.refresh-btn:hover {
background: rgba(78, 204, 163, 0.4);
}
.stock-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 0;
border-bottom: 1px solid rgba(255,255,255,0.05);
}
.stock-symbol {
font-weight: bold;
color: #e94560;
}
.stock-price {
font-size: 14px;
}
.stock-change.up {
color: #4ecca3;
}
.stock-change.down {
color: #e94560;
}
DPIP 特定的 CSS
使用媒体查询为 DPIP 窗口编写特定样式:
/* DPIP 窗口中的样式 */
@media (display-mode: picture-in-picture) {
.stock-ticker {
padding: 12px;
}
.ticker-header h3 {
font-size: 12px;
}
.stock-item {
padding: 6px 0;
}
}
/* 使用 :picture-in-picture 伪类(如果支持) */
.stock-ticker:picture-in-picture {
/* DPIP 窗口中的特定样式 */
}
JavaScript 实现
class StockTickerWidget {
constructor(element) {
this.element = element;
this.pipWindow = null;
this.refreshInterval = null;
this.init();
}
init() {
const openBtn = this.element.querySelector('.open-pip-btn');
if (openBtn) {
openBtn.addEventListener('click', () => this.openPip());
}
const refreshBtn = this.element.querySelector('.refresh-btn');
if (refreshBtn) {
refreshBtn.addEventListener('click', () => this.refresh());
}
// 自动刷新
this.refreshInterval = setInterval(() => this.refresh(), 30000);
}
async openPip() {
if (!('documentPictureInPicture' in window)) {
alert('浏览器不支持 DPIP');
return;
}
try {
this.pipWindow = await documentPictureInPicture.requestWindow({
width: 280,
height: 320,
});
// 复制样式
this.copyStylesToPip();
// 移动元素到 DPIP 窗口
this.pipWindow.document.body.appendChild(this.element);
// 重新绑定事件(因为元素移动到了新窗口)
this.rebindEvents();
// 监听关闭
this.pipWindow.addEventListener('pagehide', () => {
this.closePip();
});
} catch (err) {
console.error('打开 DPIP 失败:', err);
}
}
copyStylesToPip() {
// 复制内联样式
const styles = document.querySelectorAll('style');
styles.forEach(style => {
const clone = style.cloneNode(true);
this.pipWindow.document.head.appendChild(clone);
});
// 复制外部样式表链接
const links = document.querySelectorAll('link[rel="stylesheet"]');
links.forEach(link => {
const clone = link.cloneNode(true);
this.pipWindow.document.head.appendChild(clone);
});
}
rebindEvents() {
const refreshBtn = this.element.querySelector('.refresh-btn');
if (refreshBtn) {
refreshBtn.addEventListener('click', () => this.refresh());
}
}
closePip() {
// 将元素移回主窗口
document.body.appendChild(this.element);
this.pipWindow = null;
this.rebindEvents();
}
async refresh() {
// 模拟刷新股票数据
const items = this.element.querySelectorAll('.stock-item');
items.forEach(item => {
const priceEl = item.querySelector('.stock-price');
const changeEl = item.querySelector('.stock-change');
const currentPrice = parseFloat(priceEl.textContent);
const change = (Math.random() - 0.5) * 4;
const newPrice = (currentPrice * (1 + change / 100)).toFixed(2);
priceEl.textContent = newPrice;
changeEl.textContent = (change >= 0 ? '+' : '') + change.toFixed(2) + '%';
changeEl.className = 'stock-change ' + (change >= 0 ? 'up' : 'down');
});
}
}
// 初始化
const ticker = document.getElementById('stock-ticker');
if (ticker) {
new StockTickerWidget(ticker);
}
注意事项和最佳实践
1. 样式隔离问题
将 HTML 元素移动到 DPIP 窗口时,样式可能丢失:
- DPIP 窗口是一个独立的文档,不会继承主窗口的样式
- 需要手动复制样式表到 DPIP 窗口
- 跨域样式表可能无法通过
cssRules访问,需要使用<link>标签 - 建议为 DPIP 窗口编写独立的、内联的样式
2. 事件监听器问题
元素移动到 DPIP 窗口后,事件监听器的行为:
- 使用
addEventListener绑定的事件通常会保留 - 内联事件处理程序(
onclick)通常也会保留 - 但依赖于
window或document的事件可能需要重新绑定 - 建议在移动元素后重新检查和绑定关键事件
3. JavaScript 执行环境
DPIP 窗口中的 JavaScript 执行环境:
- DPIP 窗口有自己的
window和document对象 - 在 DPIP 窗口中定义的变量和函数不会自动出现在主窗口
- 使用
postMessage或BroadcastChannel进行跨窗口通信 - 定时器(setInterval/setTimeout)在 DPIP 窗口中正常工作
- DPIP 窗口关闭后,其中的定时器会被清除
4. 用户手势要求
requestWindow 必须在用户手势中调用:
- 不能在页面加载时自动打开 DPIP 窗口
- 必须在点击、触摸等用户事件处理程序中调用
- 这是浏览器的安全策略,防止滥用
- 如果需要自动打开,可以考虑其他方案(如浏览器扩展)
5. 单窗口限制
一次只能有一个 DPIP 窗口活动:
- 如果已经有一个 DPIP 窗口,再次调用
requestWindow会失败 - 需要先关闭现有窗口,或提示用户
- 可以通过
documentPictureInPicture.window检查是否有活动窗口 - 如果需要多个浮动窗口,考虑其他方案
6. 浏览器兼容性和降级
由于 Safari 不支持 DPIP,需要做好降级:
- 使用特性检测
'documentPictureInPicture' in window - 提供替代方案(如普通弹窗、新标签页、侧边栏)
- 在不支持的浏览器中隐藏或禁用 DPIP 按钮
- 考虑使用 polyfill(虽然完全模拟 DPIP 很困难)
- 关注浏览器支持情况的更新
7. 可访问性
DPIP 窗口的可访问性考虑:
- 确保 DPIP 窗口中的内容可以被屏幕阅读器访问
- 提供键盘导航支持
- 确保颜色对比度符合无障碍标准
- 为浮动窗口提供清晰的关闭方式
- 考虑用户可能有多个显示器的情况
8. 性能考虑
- DPIP 窗口是一个独立的浏览器窗口,会消耗额外的内存和 CPU
- 避免在 DPIP 窗口中运行过于复杂的动画或计算
- 注意 DPIP 窗口与主窗口之间的数据同步开销
- 窗口关闭后确保清理定时器和事件监听器
- 考虑使用
requestAnimationFrame进行动画优化
应用场景
1. 生产力工具
- 待办事项列表:始终可见的任务清单
- 笔记应用:快速记录想法的浮动笔记
- 计时器:番茄钟、倒计时等
- 计算器:随时可用的计算器
- 日历:迷你日历和日程提醒
2. 通信工具
- 聊天窗口:始终置顶的聊天界面
- 邮件预览:快速查看新邮件
- 通知中心:聚合各种通知
- 会议控制:视频会议的控制面板
- 状态更新:快速更新在线状态
3. 媒体和娱乐
- 音乐播放器:始终可见的播放控制
- 播放列表:浏览和选择音乐
- 直播聊天:观看直播时的聊天窗口
- 字幕显示:浮动的字幕窗口
- 游戏攻略:游戏时显示攻略
4. 开发工具
- 控制台:浮动的 JavaScript 控制台
- API 测试:快速测试 API 端点
- 颜色选择器:取色器工具
- 正则测试器:测试正则表达式
- JSON 格式化:快速格式化 JSON
5. 数据和金融
- 股票行情:实时股票价格
- 加密货币:数字货币行情
- 系统监控:CPU、内存使用情况
- 数据分析:迷你图表和指标
- 汇率转换:实时汇率
与相关技术的对比
| 特性 | Document PiP | 视频 PiP | 浏览器扩展 | 新窗口 | 侧边栏 |
|---|---|---|---|---|---|
| 任意 HTML 内容 | 是 | 否 | 是 | 是 | 部分 |
| 始终置顶 | 是 | 是 | 是 | 否 | 部分 |
| 无需安装 | 是 | 是 | 否 | 是 | 部分 |
| 用户手势要求 | 是 | 是 | 否 | 否 | 否 |
| 单窗口限制 | 是 | 多个视频 | 否 | 多个 | 部分 |
| 样式自定义 | 完全 | 无 | 完全 | 完全 | 部分 |
| 浏览器支持 | Chrome/Firefox | 全部 | 全部 | 全部 | 部分 |
总结
Document Picture-in-Picture API(DPIP)是 Web 平台的重要进步,它将 Picture-in-Picture 的能力从视频扩展到了任意 HTML 内容。与传统的视频 PiP API 只能显示视频不同,DPIP 允许开发者将任何 HTML 元素放入始终置顶的浮动窗口中,从而创建真正的桌面级 Web 小组件。DPIP 的核心 API 包括 window.documentPictureInPicture.requestWindow() 方法,它在用户手势中调用,返回一个独立的 Window 对象,开发者可以在其中渲染任意 HTML 内容。实际应用中,需要注意样式隔离、事件监听器、JavaScript 执行环境、用户手势要求、单窗口限制、浏览器兼容性和可访问性等问题。DPIP 的应用场景非常广泛,包括生产力工具、通信工具、媒体娱乐、开发工具、数据金融等多个领域。与视频 PiP、浏览器扩展、新窗口、侧边栏等相关技术相比,DPIP 具有无需安装、始终置顶、完全自定义 HTML 等独特优势。目前 Chrome 和 Firefox 已支持 DPIP,Safari 尚未支持,使用时需要做好特性检测和降级处理。随着 Web 应用越来越复杂,用户对于多任务处理和始终可见信息的需求不断增长,Document Picture-in-Picture API 为 Web 应用提供了创建桌面级浮动小组件的标准能力,将在未来的 Web 应用开发中发挥越来越重要的作用。对于前端开发者来说,掌握 DPIP API 可以为用户提供更高效、更便捷的多任务体验,是值得关注和学习的新 Web 标准。
来源:https://css-tricks.com/creating-web-widgets-using-the-document-picture-in-picture-api/