调用摄像头最常见的示例只有一行:
js
const stream = await navigator.mediaDevices.getUserMedia({
video: true,
audio: false
});
但线上出现 NotAllowedError 时,"让用户去浏览器设置里点允许"往往不是一个合格的答案。
最近我排查过一个很有代表性的故障:摄像头与麦克风测试页在多台电脑、多个浏览器上全部失败,页面表现和用户拒绝授权几乎一样。最终原因不在前端按钮,也不在用户设置,而是 Nginx 返回了这条响应头:
http
Permissions-Policy: camera=(), microphone=()
空的允许列表意味着:当前文档中的摄像头和麦克风能力被服务器策略直接关闭。浏览器甚至没有必要把决定权交给用户。
把它修正为仅允许同源页面使用后,正常的权限提示才重新出现:
http
Permissions-Policy: camera=(self), microphone=(self)
这篇文章不会只停留在"复制一行配置"。我们从浏览器的权限模型开始,建立一套可以复用的排查顺序。
一、先建立四道"权限门"的模型
getUserMedia() 成功前,至少要依次通过四层检查:
text
安全上下文(HTTPS)
↓
文档级 Permissions Policy
↓
浏览器站点权限 + 操作系统权限
↓
设备存在、未被阻断,并且满足媒体约束
任何一层失败,最终都可能表现为"摄像头打不开"。如果没有分层思考,很容易在浏览器设置、前端代码和服务器配置之间反复试错。
第 1 层:页面是不是安全上下文
navigator.mediaDevices.getUserMedia() 只在安全上下文中可用。生产环境应使用 HTTPS;本地开发中的 localhost 通常被浏览器视为可信来源。
先检查能力,而不是直接调用:
js
if (!navigator.mediaDevices?.getUserMedia) {
throw new Error("getUserMedia 不可用:请检查 HTTPS 和浏览器支持");
}
注意:在不安全上下文中,问题可能不是 Promise 被拒绝,而是 navigator.mediaDevices 本身不可用。因此只写 .catch() 不足以覆盖这一层。
第 2 层:服务器是否允许当前文档使用能力
Permissions-Policy 是文档的能力开关。它在用户授权之前生效。
下面的策略禁止任何来源使用摄像头和麦克风:
http
Permissions-Policy: camera=(), microphone=()
下面的策略允许当前响应所属的源使用:
http
Permissions-Policy: camera=(self), microphone=(self)
这里的 self 是"同源",不是"同一个主域名的大概范围"。协议、主机名或端口不同,都可能构成不同的 origin。
第 3 层:用户和操作系统是否授权
服务器允许,只代表浏览器可以继续询问用户。用户仍然可以拒绝,浏览器也可能记住此前的"阻止"选择。Windows、macOS、Android 和 iOS 还有各自的系统级摄像头/麦克风开关。
因此应分别检查:
- 地址栏旁的站点权限;
- 浏览器全局隐私设置;
- 操作系统对浏览器应用的摄像头和麦克风权限。
第 4 层:设备和约束是否可满足
即使前三层都通过,也可能没有设备、设备被系统阻断,或指定的分辨率、采样率、deviceId 无法满足。
这时再检查硬件与约束,顺序才合理。
二、一个能正确释放设备的最小实现
先从最小请求开始,不要一上来就叠加 4K 分辨率、帧率、前后摄像头和精确设备 ID。
js
let stream = null;
async function startCamera() {
if (!navigator.mediaDevices?.getUserMedia) {
throw new Error("当前页面无法使用媒体设备 API");
}
stream = await navigator.mediaDevices.getUserMedia({
video: true,
audio: false
});
const video = document.querySelector("video");
video.srcObject = stream;
await video.play();
}
function stopCamera() {
stream?.getTracks().forEach(track => track.stop());
stream = null;
const video = document.querySelector("video");
video.srcObject = null;
}
window.addEventListener("pagehide", stopCamera);
video.pause() 或把预览区域隐藏起来,并不会停止摄像头。真正释放硬件的是对每条 MediaStreamTrack 调用 stop()。
页面离开时也应清理。否则摄像头指示灯可能继续亮一段时间,下一次请求或其他应用也可能受影响。
三、Nginx 应该怎么配,以及最容易漏掉什么
同源页面需要直接使用摄像头和麦克风时,可以这样设置:
nginx
add_header Permissions-Policy "camera=(self), microphone=(self)" always;
always 让该响应头也能出现在部分非 2xx 响应上,便于策略保持一致。不过,配置正确与否不能靠"我改过文件"判断,必须核对浏览器最终收到的响应。
1. 检查最终响应,而不是只读 Nginx 配置
在浏览器 DevTools 的 Network 面板中:
- 重新加载目标页面;
- 点开页面文档本身的请求;
- 查看 Response Headers;
- 搜索
permissions-policy; - 确认没有旧值、重复值或上游代理追加的值。
也可以在终端做只读检查:
bash
curl -I https://example.com/tools/camera-test
真实链路中,CDN、托管平台、反向代理和应用框架都可能写响应头。只看某一层配置,不等于知道客户端收到什么。
2. 注意 add_header 的作用域
Nginx 的 add_header 具有配置层级和继承规则。server 中看似正确的配置,可能被更具体的 location 块影响。
如果同一个页面在不同路由上的响应头不同,检查:
http、server、location三个层级;- 是否存在多个包含文件;
- 静态文件与应用代理是否走了不同的
location; - CDN 是否缓存了旧响应头。
不要盲目叠加第二条 Permissions-Policy。先找出最终响应中每一个同名头由谁产生。
3. 不要把 CSP 和 Permissions Policy 混为一谈
CSP 主要限制脚本、样式、图片、连接等资源的来源;Permissions-Policy 控制摄像头、麦克风、地理位置等浏览器能力是否可用。
调整 connect-src 通常不能修复一个被 camera=() 禁止的 getUserMedia() 请求,因为读取本地摄像头不是一次普通的 HTTP 上传请求。
四、跨域 iframe 比顶层页面多一道授权
如果摄像头工具运行在 iframe 中,仅修改子页面自己的 JavaScript 还不够。
假设:
- 顶层页面是
https://app.example.com; - iframe 是
https://camera.example.net。
父文档需要通过响应头允许目标来源,并在 iframe 上授予对应能力。例如:
http
Permissions-Policy: camera=(self "https://camera.example.net"), microphone=(self "https://camera.example.net")
html
<iframe
src="https://camera.example.net/test"
allow="camera; microphone"
></iframe>
响应头定义文档可以把能力委托给谁,iframe 的 allow 属性进一步声明这个嵌入内容可使用哪些能力。最终结果受两者共同约束;allow 不能突破响应头已经设置的上限。
此外,用户最终授权的对象与具体浏览器行为仍受 origin 和浏览器策略影响。跨域嵌入时不要用 * 图省事,应明确列出真正需要的来源。
五、不要把所有失败都显示成"请允许权限"
错误名是排障最有价值的线索之一。
js
function explainMediaError(error) {
switch (error?.name) {
case "NotAllowedError":
case "SecurityError":
return "访问被策略、浏览器权限或系统权限阻止";
case "NotFoundError":
return "没有找到符合条件的媒体设备";
case "OverconstrainedError":
return `设备无法满足约束:${error.constraint || "未知约束"}`;
case "NotReadableError":
return "设备存在,但浏览器无法读取;可能被系统或其他应用占用";
case "AbortError":
return "浏览器在启动设备时中止了操作";
default:
return `启动失败:${error?.name || "UnknownError"}`;
}
}
需要注意两点:
第一,NotAllowedError 并不等于"用户刚刚点了拒绝"。不安全上下文、Permissions Policy 和已保存的站点阻止设置都可能落到相近的拒绝结果。
第二,不同浏览器和操作系统对"设备被占用"的处理不完全一致。错误提示应该给出下一步检查方法,不要假装一个错误名可以定位所有根因。
推荐记录用于诊断的最少信息:错误 name、失败阶段、请求的约束。不要把设备标签、用户名、页面输入或媒体内容发进日志系统。
六、设备列表为什么一开始没有名称
enumerateDevices() 可以列出设备:
js
const devices = await navigator.mediaDevices.enumerateDevices();
const cameras = devices.filter(device => device.kind === "videoinput");
但在用户授权前,浏览器通常会隐藏或限制设备标签,以减少指纹识别风险。因此常见的正确流程是:
- 先用宽松约束请求一次媒体权限;
- 授权成功后调用
enumerateDevices(); - 展示可用设备列表;
- 用户切换时停止旧 track,再按
deviceId请求新设备。
js
async function switchCamera(deviceId) {
stopCamera();
stream = await navigator.mediaDevices.getUserMedia({
video: { deviceId: { exact: deviceId } },
audio: false
});
document.querySelector("video").srcObject = stream;
}
移动端还可以优先尝试 facingMode: "user" 或 "environment",但不要假定每台设备都提供相同的摄像头集合。
七、麦克风音量条不需要录音,也不需要上传
如果目标只是确认麦克风是否拾音,可以把 MediaStream 接入 Web Audio 的 AnalyserNode,读取时域采样并计算 RMS(均方根)。
js
function createVolumeMeter(stream, onLevel) {
const audioContext = new AudioContext();
const source = audioContext.createMediaStreamSource(stream);
const analyser = audioContext.createAnalyser();
analyser.fftSize = 512;
source.connect(analyser);
const samples = new Uint8Array(analyser.frequencyBinCount);
let frameId;
function draw() {
analyser.getByteTimeDomainData(samples);
let squareSum = 0;
for (const sample of samples) {
const normalized = (sample - 128) / 128;
squareSum += normalized * normalized;
}
const rms = Math.sqrt(squareSum / samples.length);
onLevel(Math.min(1, rms * 2.4));
frameId = requestAnimationFrame(draw);
}
draw();
return async function stop() {
cancelAnimationFrame(frameId);
stream.getTracks().forEach(track => track.stop());
await audioContext.close();
};
}
这段路径只在浏览器内实时分析采样。它没有创建 MediaRecorder,没有建立 RTCPeerConnection,也没有执行上传请求。
更准确的说法是:getUserMedia() 属于 Media Capture and Streams API,也常用于 WebRTC 应用;但"使用 getUserMedia"不等于"已经把音视频通过 WebRTC 发给远端"。是否离开设备,要看后续代码是否把流交给连接、录制或网络传输逻辑。
八、一套从快到慢的排查清单
遇到线上故障时,我按下面的顺序处理:
- 确认目标页面是 HTTPS,且
navigator.mediaDevices?.getUserMedia存在; - 在 Console 记录错误的
name和message; - 在 Network 面板检查主文档最终收到的
Permissions-Policy; - 如果在 iframe 中,检查响应头和 iframe
allow两层委托; - 检查地址栏站点权限和操作系统隐私权限;
- 关闭 Zoom、Teams、OBS 等可能占用设备的应用;
- 用
{ video: true }或{ audio: true }的最小约束重试; - 授权后再枚举设备、指定
deviceId或增加分辨率约束; - 停止时确认所有 tracks 都执行了
stop(); - 用 DevTools Network 验证测试期间是否存在不应有的媒体上传。
需要一个现成页面交叉验证时,可以使用这个浏览器端摄像头与麦克风测试页:它把摄像头预览、分辨率、设备切换和本地实时音量条放在同一页。把它当作对照工具,而不是结论;如果多个网站都失败,更应回到浏览器与系统层检查。
最后的判断原则
摄像头权限不是一个布尔开关,而是一条由服务器、文档、浏览器、系统、设备和约束共同决定的链路。
所以,看到 NotAllowedError 时不要立刻责怪用户;看到 HTTPS 也不要断定权限层没有问题;改完 Nginx 更不要跳过最终响应验证。
把故障拆成四道门,一次只验证一层,getUserMedia() 的排查就会从碰运气变成工程问题。