编程 Document Picture-in-Picture API 实战:用 Web 标准 API 创建浮动桌面小组件

2026-09-06 23:16:36

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 窗口与主窗口之间可以通过多种方式通信:

  1. 直接 DOM 操作:主窗口可以直接操作 DPIP 窗口的 DOM
  2. postMessage:使用 window.postMessage 进行跨窗口通信
  3. BroadcastChannel:使用 BroadcastChannel API 进行广播通信
  4. SharedWorker:使用 SharedWorker 共享状态
  5. 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)通常也会保留
  • 但依赖于 windowdocument 的事件可能需要重新绑定
  • 建议在移动元素后重新检查和绑定关键事件

3. JavaScript 执行环境

DPIP 窗口中的 JavaScript 执行环境:

  • DPIP 窗口有自己的 windowdocument 对象
  • 在 DPIP 窗口中定义的变量和函数不会自动出现在主窗口
  • 使用 postMessageBroadcastChannel 进行跨窗口通信
  • 定时器(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/

推荐文章

程序员茄子在线接单