很多人一说“投屏”,第一反应就是买硬件盒子、装视频会议软件、或者让接收端电脑提前安装一个专用客户端。但如果你只是想把当前屏幕、某个应用窗口或浏览器标签页,实时投到另一台电脑的浏览器里,这套方案其实太重了。浏览器本身就是最好的投屏接收端,真正缺的只是一个轻量级的开源插件。
这篇文章不打算讲那些“听起来很美,装起来麻烦”的方案,而是会从技术原理拆开来看浏览器投屏到底是怎么实现的。我会给出一套可以直接跑起来的最小实现:一个 Socket.IO 信令服务、一个发送端页面、一个接收端页面,再加一份 Chrome / Edge 扩展配置。你按照步骤完成之后,就能在同一局域网内实现“一台电脑采集屏幕,另一台浏览器实时播放”的完整链路,并且可以基于这套代码二次开发成公司内部的投屏工具。
另外,我也想把这类方案的适用边界讲清楚:它适合教学、会议、演示、临时协作,但不适合低延迟串流游戏、大规模直播和 4K 高码率传输。先把边界搞清楚,再决定要不要在你的实际项目里使用,这是比“能跑通”更重要的一件事。
1. 浏览器投屏,为什么值得放弃“专业客户端”
传统投屏方案通常有两类。
一类是系统级投屏,比如 Windows 的 Miracast、macOS 的 AirPlay、手机上的 DLNA。这类方案依赖操作系统内置协议,接收端往往需要特定硬件或者开启了对应协议的系统,跨平台时经常出现“设备找不到”“画面延迟高”“音频不同步”的问题。
另一类是视频会议、远程控制软件自带的屏幕共享,比如企业微信、钉钉、腾讯会议、Zoom。优点是跨平台,缺点是必须有一个服务器作为中转,而且场景被锁定在“开会”这件事上。你只是想临时把本机页面投给旁边的同事看一下,登录一个视频会议软件显然有点夸张。
浏览器投屏其实是第三条路:以浏览器为接收端,以 WebRTC 为传输通道,以浏览器扩展或网页为控制入口。它的价值很明确:
- 接收端零安装:只要有一台装了 Chrome、Edge 或 Safari 的设备,打开一个网页就能开始接收。
- 跨平台能力强:Windows、macOS、Linux、Android、iOS 都能用现代浏览器解码播放。
- 架构简单:WebRTC 是点对点传输,不走大流量服务器,公司内部场景甚至可以直接走局域网。
- 开源可控:你可以完全掌控信令、权限、UI 和数据流,不需要依赖某个厂商的投屏协议。
所以,如果你正在做内部工具、远程演示、教室投屏、前端项目评审,或者经常需要把某个页面分享给同事看,浏览器投屏是一个值得认真考虑的轻量方案。
2. 技术原理拆解:getDisplayMedia、WebRTC 与信令服务
要实现“直接在浏览器投屏”,底层主要由三块技术组成。
2.1 屏幕采集:getDisplayMedia
浏览器做屏幕采集的标准 API 是navigator.mediaDevices.getDisplayMedia()。调用这个接口后,浏览器会弹出系统级选择框,用户可以选择共享整个屏幕、某个应用窗口或某个浏览器标签页。
这个 API 返回的是一个MediaStream,里面包含视频轨,部分场景还包含音频轨。拿到MediaStream之后,你可以把它交给一个<video>标签做本地预览,也可以把它交给 WebRTC 的RTCPeerConnection发送出去。
Safari 在部分历史版本里使用的是带前缀的window.webkitGetDisplayMedia,所以做跨浏览器兼容时,建议写一层兼容判断。
const getDisplayMedia = navigator.mediaDevices.getDisplayMedia || window.webkitGetDisplayMedia;2.2 传输通道:WebRTC
WebRTC 是浏览器之间进行实时音视频传输的标准能力,不需要安装任何插件。它完成的事情是:
- 通过
RTCPeerConnection建立点对点连接。 - 通过 SDP(Session Description Protocol)交换媒体能力。
- 通过 ICE 机制找到可用的网络路径。
- 通过 SRTP 加密传输媒体数据。
在投屏场景里,发送端调用pc.addTrack(track, stream)把屏幕流加入连接,接收端监听pc.ontrack就能拿到画面。
2.3 连接握手:信令服务
你可能会问,WebRTC 既然是点对点,为什么还需要一个服务器?
关键在于“连接建立之前,双方要互相告诉对方自己的 SDP 和 ICE 信息”。这就像两个人打电话,必须先有人帮你接通电话线。WebRTC 本身不规定这个“接通电话线”的通道怎么实现,所以我们需要一个信令服务来转发这些连接信息。
信令服务的实现方式很多,常见的有 WebSocket、Socket.IO、PeerJS、甚至简单的 HTTP 轮询。本文使用 Socket.IO,因为它在浏览器端写法简单,自动处理断线重连,非常适合快速搭建原型。
2.4 传统方案与浏览器投屏的对比
| 对比项 | 传统系统投屏 | 视频会议共享屏幕 | 浏览器投屏(WebRTC) |
|---|---|---|---|
| 接收端依赖 | 特定协议或硬件 | 安装客户端 | 现代浏览器 |
| 跨平台能力 | 一般 | 好 | 好 |
| 服务器成本 | 较低 | 高,视频流走服务器 | 低,P2P 为主 |
| 延迟表现 | 不稳定 | 较高 | 较好,受网络影响 |
| 可控性 | 低 | 低 | 高,开源可改 |
| 使用场景 | 家庭/会议室 | 远程会议 | 内部演示、教学、协作 |
3. 方案选型:Chrome、Edge、Safari 的兼容性与扩展形态
很多人一听“开源插件”,第一反应是做一个浏览器扩展,然后分别提交到 Chrome Web Store、Edge Add-ons 和 Safari Extensions。这是一个方向,但并不是唯一方向。
3.1 纯网页方案
直接把 sender.html 和 receiver.html 部署在一个内网服务器上,接收端只需要打开网页并输入房间号,就能接收画面。这种方式不需要安装任何扩展,受众最广,适合临时演示和内部协作。
缺点是用户需要手动打开网页、输入房间号。如果想做到“一键投屏”,就需要扩展辅助。
3.2 浏览器扩展方案
如果你做的是公司内部工具,希望员工安装一个扩展后点击按钮就能开始投屏,那么可以在 WebRTC 核心代码外面包一层 Chrome Extension Manifest V3。
Chrome 和 Edge 基于 Chromium,扩展代码可以直接复用。Safari 则需要通过 Xcode 转换成 Safari App Extension,并且上线要经过 App Store 审核,链路更重。如果团队没有 macOS 开发经验,建议第一版只做 Chrome / Edge 扩展,Safari 用纯网页方式兜底。
3.3 各浏览器对屏幕采集的支持情况
从技术能力上看,Chrome、Edge、Safari 的较新版本都支持getDisplayMedia和 WebRTC。但是,Safari 存在几个需要注意的差异点:
- 部分旧版本需要兼容
window.webkitGetDisplayMedia。 - macOS 系统要求浏览器本身拥有“屏幕录制”权限,否则采集画面会是黑屏。
- iOS 上的 Safari 不支持屏幕采集,iPadOS 的支持也有严格限制。
- 音频采集行为与 Chrome 不一致,
audio: true在 Safari 上可能被忽略。
因此,如果你的投屏接收端需要部署在 iPad 或 iPhone 上,不建议依赖 Safari 接收。更好的方式是让移动端用户安装一个基于 WebRTC 的本地客户端或使用支持该能力的原生 App 壳。
4. 环境准备与前置条件
开始写代码之前,先把环境准备好。本文示例依赖 Node.js,因为你需要在本地启动信令服务。
4.1 基础工具
- Node.js:推荐使用 18 及以上 LTS 版本,版本具体以你的开发环境为准。
- npm:Node.js 自带,用来安装依赖。
- Chrome 或 Edge:用于测试发送端和接收端。
- Safari:可选,用于测试 macOS 下的兼容情况。
4.2 安全上下文要求
getDisplayMedia必须在安全上下文(Secure Context)中调用。这里有一个容易被新手忽略的点:
http://localhost和http://127.0.0.1被认为是安全上下文。http://192.168.x.x局域网 IP 不被认为是安全上下文。- Chrome 和 Edge 可能会在控制台输出警告,并阻止屏幕采集。
因此,开发阶段建议直接用http://localhost:3000访问页面。如果一定要用局域网 IP 进行多设备测试,就需要为信令服务配置 HTTPS 证书。
4.3 网络要求
WebRTC 默认使用 STUN 服务器帮助设备发现自己的公网映射地址。本文示例使用 Google 的公共 STUN 服务,适合内网或 NAT 条件友好的场景。
如果发送端和接收端分别在严格的防火墙或对称 NAT 后面,就必须额外部署 TURN 服务器(例如 coturn)。TURN 服务器会中转媒体流,需要一定的带宽成本。
5. 核心流程拆解:从屏幕采集到浏览器播放
整体流程并不复杂,但每个环节都有容易踩坑的地方。
5.1 第一步:建立信令通道
发送端和接收端都连接到同一个 Socket.IO 服务器,并加入同一个“房间号”。服务端记录每个 socket 所属的房间和角色。
5.2 第二步:接收端声明就绪
接收端进入房间后,广播一条receiver-ready消息,告诉局域网内的发送端“我已经准备好接收画面了”。
5.3 第三步:发送端采集屏幕
用户点击“开始投屏”按钮后,发送端调用getDisplayMedia采集屏幕流,并创建RTCPeerConnection,把屏幕流的轨道通过addTrack加入连接。
5.4 第四步:SDP 协商与 ICE 交换
发送端收到receiver-ready后,创建 Offer,通过信令服务发给接收端。接收端收到 Offer 后,设置远端描述,创建 Answer,再通过信令服务回传。同时,两端通过onicecandidate互相交换 ICE 候选,打通网络路径。
5.5 第五步:接收端播放画面
接收端的pc.ontrack触发后,把拿到的MediaStream赋给<video>标签的srcObject,画面就出来了。
这里的核心难点是信令状态机:谁先加入、谁先发送、事件丢失怎么办。下面用代码完整跑一遍。
6. 完整代码实现:跑通一个浏览器投屏最小示例
这是一个可以直接运行的完整示例。我尽量把代码写得工程化一点,方便你进行二次开发。
6.1 项目结构与依赖
先创建项目目录:
browser-screen-share/ ├── package.json ├── server.js └── public/ ├── sender.html ├── receiver.html └── popup/ # Chrome/Edge 扩展壳示例 ├── manifest.json ├── popup.html └── popup.js在根目录执行初始化命令:
mkdir browser-screen-share cd browser-screen-share npm init -y npm install express socket.iopackage.json中需要包含以下依赖(版本以 npm 实际安装结果为准,下面是兼容性较好的参考范围):
{ "name": "browser-screen-share", "version": "1.0.0", "description": "浏览器投屏开源示例", "main": "server.js", "scripts": { "start": "node server.js" }, "dependencies": { "express": "^4.19.2", "socket.io": "^4.7.5" } }6.2 信令服务端 server.js
这个文件负责托管静态页面和转发信令消息。
// 文件路径:server.js const path = require('path'); const express = require('express'); const http = require('http'); const { Server } = require('socket.io'); const app = express(); const server = http.createServer(app); const io = new Server(server); // 托管 public 目录下的静态页面 app.use(express.static(path.join(__dirname, 'public'))); // 房间数据:room -> Map<socketId, role> const rooms = new Map(); // 已就绪的接收端:room -> Set<socketId> const readyReceivers = new Map(); io.on('connection', (socket) => { // 加入房间 socket.on('join', ({ room, role }) => { socket.join(room); socket.data.room = room; socket.data.role = role; if (!rooms.has(room)) { rooms.set(room, new Map()); } rooms.get(room).set(socket.id, role); // 广播房间内成员变化 const clients = [...rooms.get(room).entries()]; io.to(room).emit('peers', { clients: clients.map(([id, r]) => ({ id, role: r })), }); // 如果发送端加入时,房间内已有就绪接收端,则立即通知发送端 if (role === 'sender' && readyReceivers.has(room)) { for (const receiverId of readyReceivers.get(room)) { socket.emit('receiver-ready', { from: receiverId }); } } }); // 接收端声明就绪 socket.on('receiver-ready', ({ room }) => { if (!readyReceivers.has(room)) { readyReceivers.set(room, new Set()); } readyReceivers.get(room).add(socket.id); socket.to(room).emit('receiver-ready', { from: socket.id }); }); // 转发 WebRTC 信令 socket.on('signal', ({ room, signal, to }) => { if (to) { io.to(to).emit('signal', { signal, from: socket.id }); } else { socket.to(room).emit('signal', { signal, from: socket.id }); } }); // 断开连接时清理 socket.on('disconnect', () => { const room = socket.data.room; if (!room) return; if (rooms.has(room)) { rooms.get(room).delete(socket.id); if (rooms.get(room).size === 0) { rooms.delete(room); } } if (readyReceivers.has(room)) { readyReceivers.get(room).delete(socket.id); if (readyReceivers.get(room).size === 0) { readyReceivers.delete(room); } } }); }); const PORT = process.env.PORT || 3000; server.listen(PORT, () => { console.log(`浏览器投屏信令服务已启动:http://localhost:${PORT}`); });服务端逻辑有三个关键点:
- 每个 socket 会记录自己所在的房间和角色,后续清理依赖这些数据。
receiver-ready状态被缓存下来,避免“发送端后加入时错过事件”。signal消息支持定向转发和广播转发两种模式,发送端给接收端回消息时带to参数,避免消息扩散到无关客户端。
6.3 发送端页面 sender.html
发送端的主要逻辑是:采集屏幕流、创建RTCPeerConnection、等待接收端就绪、发送 Offer。
<!-- 文件路径:public/sender.html --> <!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>浏览器投屏 - 发送端</title> <style> body { font-family: system-ui, sans-serif; max-width: 800px; margin: 40px auto; padding: 0 20px; } video { width: 100%; border-radius: 12px; background: #000; } button { padding: 10px 24px; font-size: 16px; cursor: pointer; } :disabled { opacity: 0.6; cursor: not-allowed; } </style> </head> <body> <h1>浏览器投屏发送端</h1> <div> <input id="room" placeholder="房间号" value="demo-room" /> <button id="startBtn">开始投屏</button> </div> <video id="preview" autoplay muted playsinline></video> <div id="status"></div> <script src="/socket.io/socket.io.js"></script> <script> const socket = io(); const roomInput = document.getElementById('room'); const startBtn = document.getElementById('startBtn'); const preview = document.getElementById('preview'); const statusDiv = document.getElementById('status'); let pc = null; let receiverId = null; function setStatus(text) { statusDiv.textContent = text; console.log('[sender]', text); } // 兼容 Safari 部分版本的 API 前缀 const getDisplayMedia = navigator.mediaDevices.getDisplayMedia || window.webkitGetDisplayMedia; startBtn.addEventListener('click', async () => { const room = roomInput.value.trim(); if (!room) { setStatus('请先输入房间号'); return; } try { // 1. 采集屏幕流 const stream = await getDisplayMedia.call(navigator.mediaDevices, { video: true, audio: true, // Chrome 中可以在系统弹窗里选择共享标签页音频 }); preview.srcObject = stream; setStatus('已获取屏幕流,等待接收端...'); // 2. 创建 RTCPeerConnection pc = new RTCPeerConnection({ iceServers: [ { urls: 'stun:stun.l.google.com:19302' }, ], }); // 3. 将屏幕流轨道加入连接 stream.getTracks().forEach((track) => { pc.addTrack(track, stream); }); // 4. 本地 ICE 候选发送给对端 pc.onicecandidate = (event) => { if (event.candidate && receiverId) { socket.emit('signal', { room, to: receiverId, signal: { type: 'ice', candidate: event.candidate }, }); } }; // 5. 先注册事件,再加入房间,避免错过接收端就绪事件 socket.on('receiver-ready', async ({ from }) => { if (receiverId) return; receiverId = from; setStatus('接收端已就绪,正在建立连接...'); const offer = await pc.createOffer(); await pc.setLocalDescription(offer); socket.emit('signal', { room, to: receiverId, signal: { type: 'offer', sdp: { type: pc.localDescription.type, sdp: pc.localDescription.sdp, }, }, }); }); socket.on('signal', async ({ signal, from }) => { if (signal.type === 'answer' && !pc.remoteDescription) { await pc.setRemoteDescription(signal.sdp); setStatus('连接已建立,投屏中...'); } else if (signal.type === 'ice') { try { await pc.addIceCandidate(signal.candidate); } catch (err) { console.warn('[sender] addIceCandidate 失败', err); } } }); socket.emit('join', { room, role: 'sender' }); // 停止投屏时释放资源 stream.getVideoTracks()[0].addEventListener('ended', () => { if (pc) { pc.close(); pc = null; receiverId = null; preview.srcObject = null; setStatus('投屏已结束'); } }); } catch (err) { console.error(err); setStatus('获取屏幕流失败:' + err.message); } }); </script> </body> </html>这里有一个容易踩坑的地方:getDisplayMedia必须在用户点击事件里调用。如果放在页面加载后的异步回调里,浏览器会拒绝弹出屏幕采集选择框。
6.4 接收端页面 receiver.html
接收端的逻辑更简单:加入房间、声明就绪、收到 Offer 后创建 Answer、接收媒体流播放。
<!-- 文件路径:public/receiver.html --> <!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>浏览器投屏 - 接收端</title> <style> body { font-family: system-ui, sans-serif; max-width: 800px; margin: 40px auto; padding: 0 20px; } video { width: 100%; border-radius: 12px; background: #000; } button { padding: 10px 24px; font-size: 16px; cursor: pointer; } :disabled { opacity: 0.6; cursor: not-allowed; } </style> </head> <body> <h1>浏览器投屏接收端</h1> <div> <input id="room" placeholder="房间号" value="demo-room" /> <button id="connectBtn">连接并接收</button> </div> <video id="screenVideo" autoplay muted playsinline></video> <div id="status"></div> <script src="/socket.io/socket.io.js"></script> <script> const socket = io(); const roomInput = document.getElementById('room'); const connectBtn = document.getElementById('connectBtn'); const screenVideo = document.getElementById('screenVideo'); const statusDiv = document.getElementById('status'); let pc = null; function setStatus(text) { statusDiv.textContent = text; console.log('[receiver]', text); } connectBtn.addEventListener('click', async () => { const room = roomInput.value.trim(); if (!room) { setStatus('请先输入房间号'); return; } // 1. 创建 RTCPeerConnection pc = new RTCPeerConnection({ iceServers: [ { urls: 'stun:stun.l.google.com:19302' }, ], }); // 2. 收到远端媒体流时播放 pc.ontrack = (event) => { if (event.streams && event.streams[0]) { screenVideo.srcObject = event.streams[0]; } else { screenVideo.srcObject = new MediaStream([event.track]); } setStatus('已接收到投屏画面'); }; // 3. 本地 ICE 候选发给发送端 pc.onicecandidate = (event) => { if (event.candidate) { socket.emit('signal', { room, signal: { type: 'ice', candidate: event.candidate }, }); } }; // 4. 收到 Offer 后创建 Answer socket.on('signal', async ({ signal, from }) => { if (signal.type === 'offer' && !pc.remoteDescription) { await pc.setRemoteDescription(signal.sdp); const answer = await pc.createAnswer(); await pc.setLocalDescription(answer); socket.emit('signal', { room, to: from, signal: { type: 'answer', sdp: { type: pc.localDescription.type, sdp: pc.localDescription.sdp, }, }, }); setStatus('连接协商完成,等待画面...'); } else if (signal.type === 'ice') { try { await pc.addIceCandidate(signal.candidate); } catch (err) { console.warn('[receiver] addIceCandidate 失败', err); } } }); // 5. 加入房间并声明就绪 socket.emit('join', { room, role: 'receiver' }); socket.emit('receiver-ready', { room }); setStatus('已连接信令服务,等待发送端...'); }); </script> </body> </html>注意,接收端在点击“连接并接收”时才创建RTCPeerConnection和注册事件监听。这样避免页面一打开就建立连接,既节省资源,也符合用户主动操作的预期。
6.5 Chrome / Edge 扩展壳:manifest.json
如果希望把发送端封装成一个浏览器扩展,可以创建一个 Manifest V3 扩展。Chrome 和 Edge 都支持这种格式。
{ "manifest_version": 3, "name": "Browser Screen Share", "version": "1.0.0", "description": "在浏览器中直接投屏的开源示例", "permissions": ["activeTab"], "host_permissions": ["<all_urls>"], "action": { "default_title": "浏览器投屏", "default_popup": "popup.html" } }配套的 popup 页面:
<!-- 文件路径:public/popup/popup.html --> <!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8" /> <style> body { width: 220px; padding: 16px; font-family: system-ui, sans-serif; } button { width: 100%; padding: 10px; font-size: 16px; cursor: pointer; } </style> </head> <body> <button id="openSender">打开投屏发送页</button> <script src="popup.js"></script> </body> </html>// 文件路径:public/popup/popup.js document.getElementById('openSender').addEventListener('click', () => { chrome.tabs.create({ url: chrome.runtime.getURL('../sender.html'), }); });加载方式:打开 Chrome / Edge 的扩展管理页面,开启“开发者模式”,选择“加载已解压的扩展程序”,选中 `