用原生Web API实现专业级拍照功能,告别传统file input
最近项目里碰到个有意思的需求。产品经理找到我:“咱们APP的身份验证页面,用户反馈说每次上传照片都要从相册里翻半天,能不能直接拍照上传?”
我心想这不简单吗,改个<input type="file" accept="image/*" capture="camera">不就完了?
结果产品补充道:“最好能切换前后摄像头,拍完能预览重拍,体验要和原生APP一样流畅。“
实现效果
先看看最终效果,功能包括:
- 直接调用手机摄像头(不是选择相册图片)
- 前后摄像头切换
- 拍照后预览
- 重拍或保存
- 完全适配移动端

技术原理详解
1. getUserMedia - Web摄像头的入场券
整个功能的基础是navigator.mediaDevices.getUserMedia()这个API。第一次接触的朋友可能会被这个长名字吓到,其实理解起来很简单:
// 告诉浏览器:我要用摄像头了const constraints = { video: { facingMode: { ideal: 'environment' }, // 后置摄像头 width: { ideal: 1920 }, height: { ideal: 1080 } }, audio: false // 只要视频,不要音频};
try { const stream = await navigator.mediaDevices.getUserMedia(constraints); // stream就是摄像头的数据流} catch(error) { console.log('用户拒绝了摄像头权限');}这里有个坑要注意:facingMode这个参数在PC上是无效的(因为电脑摄像头不分前后),但在手机上很重要:
environment= 后置摄像头(拍风景)user= 前置摄像头(自拍)
2. 把摄像头画面显示出来
拿到stream后,需要让用户看到摄像头画面。这里用到HTML5的<video>标签:
const video = document.getElementById('video');video.srcObject = stream; // 就这么简单!但是!这里有个细节很多人会忽略:video标签需要加上autoplay、muted和playsinline属性:
<video id="video" autoplay muted playsinline></video>为啥要这些属性?
autoplay:自动播放(废话)muted:静音,不然某些浏览器不让自动播放playsinline:在iOS Safari上防止全屏播放
3. 拍照的核心 - Canvas截图大法
这部分是整个功能最有技术含量的地方。原理是把video的当前画面”画”到canvas上:
function capturePhoto() { const video = document.getElementById('video'); const canvas = document.getElementById('canvas');
// 关键:canvas尺寸要设置为视频的真实尺寸 canvas.width = video.videoWidth; // 注意是videoWidth不是width canvas.height = video.videoHeight;
const ctx = canvas.getContext('2d'); ctx.drawImage(video, 0, 0);
// 获取图片数据 const imageData = canvas.toDataURL('image/jpeg', 0.9);}但等等!如果你用前置摄像头自拍,会发现照片是镜像的(左右反了)。这是因为前置摄像头默认就是镜像显示的,但拍照时我们需要翻转回来:
if (currentFacingMode === 'user') { // 水平翻转canvas ctx.scale(-1, 1); ctx.drawImage(video, -canvas.width, 0, canvas.width, canvas.height);} else { ctx.drawImage(video, 0, 0, canvas.width, canvas.height);}这个翻转逻辑困扰了我好久,Stack Overflow上各种说法都有,最后还是自己试出来的。
4. 摄像头切换的正确姿势
切换摄像头不是简单地改个参数就行,需要先停掉当前的流,再重新申请:
async function flipCamera() { // 步骤1:停掉当前摄像头 if (stream) { stream.getTracks().forEach(track => track.stop()); }
// 步骤2:切换摄像头类型 const newMode = currentFacingMode === 'environment' ? 'user' : 'environment';
// 步骤3:重新申请权限 const constraints = { video: { facingMode: { ideal: newMode } } };
const newStream = await navigator.mediaDevices.getUserMedia(constraints); video.srcObject = newStream;}踩过的坑和解决方案
坑1:iOS上摄像头启动失败
iOS Safari对摄像头权限特别严格,必须是用户主动触发(比如点击按钮)才能申请权限。所以我做了个”打开相机”按钮,而不是页面加载就自动启动。
坑2:不同手机摄像头名称不一样
有些Android手机的前置摄像头叫”front”,后置叫”back”。所以用ideal而不是exact:
facingMode: { ideal: 'environment' } // 优先使用,没有就降级// 而不是facingMode: { exact: 'environment' } // 必须使用,没有就报错坑3:切换页面后摄像头还在运行
用户切到微信回消息,摄像头还在偷偷运行,特别费电。解决方法:
document.addEventListener('visibilitychange', function() { if (document.hidden && stream) { // 页面隐藏时关闭摄像头 stream.getTracks().forEach(track => track.stop()); }});性能优化技巧
1. 合理的图片质量
canvas.toDataURL('image/jpeg', 0.9); // 0.9是个好的平衡点我测试了不同质量参数:
- 1.0:文件太大(2-3MB),上传慢
- 0.9:几乎看不出差别,文件小一半
- 0.7:开始有明显压缩痕迹
2. 及时释放资源
摄像头是很耗电的,用完记得关:
function closeCamera() { if (stream) { stream.getTracks().forEach(track => { track.stop(); // 关闭每个轨道 }); stream = null; video.srcObject = null; // 清空video }}3. 降级处理
不是所有浏览器都支持摄像头API,要做好降级:
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { // 降级到传统的file input alert('你的浏览器不支持摄像头,请选择图片上传'); showFileInput(); return;}UI设计
拍照按钮的设计
模仿了部分相机的设计,双层圆圈:
.capture-btn { width: 70px; height: 70px; border: 4px solid #fff; border-radius: 50%; background: rgba(255,255,255,0.2);}
.capture-btn::after { content: ''; width: 50px; height: 50px; background: #fff; border-radius: 50%;}底部控制栏的渐变遮罩
.camera-controls { background: linear-gradient(transparent, rgba(0,0,0,0.8));}这个渐变让按钮更突出,而且看起来更专业。
完整代码示例
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></head><body> <div class="camera-container"> <!-- 欢迎屏幕 --> <div class="welcome-screen" id="welcomeScreen"> <h1>📸 相机拍照</h1> <button class="open-camera-btn" onclick="openCamera()">打开相机</button> </div>
<!-- 相机视图 --> <div class="camera-view" id="cameraView"> <video id="video" autoplay muted playsinline></video> <button class="close-btn" onclick="closeCamera()">×</button> <button class="flip-btn" onclick="flipCamera()">🔄</button> <div class="camera-controls"> <div class="capture-btn" onclick="capturePhoto()"></div> </div> </div>
<!-- 预览屏幕 --> <div class="preview-screen" id="previewScreen"> <img id="preview" alt="拍照预览"> <div class="preview-controls"> <button class="control-btn retake" onclick="retakePhoto()">重新拍照</button> <button class="control-btn submit" onclick="submitPhoto()">提交照片</button> </div> </div>
<!-- 隐藏的canvas用于图片处理 --> <canvas id="canvas"></canvas> </div></body></html>核心JavaScript代码
let stream = null;let capturedImageData = null;let currentFacingMode = 'environment';
// 打开相机async function openCamera(facingMode = currentFacingMode) { try { // 如果已有流,先停止 if (stream) { stream.getTracks().forEach(track => track.stop()); }
// 请求相机权限 const constraints = { video: { facingMode: { ideal: facingMode }, width: { ideal: 1920 }, height: { ideal: 1080 } }, audio: false };
stream = await navigator.mediaDevices.getUserMedia(constraints); currentFacingMode = facingMode;
const video = document.getElementById('video'); video.srcObject = stream;
// 显示相机界面 document.getElementById('welcomeScreen').style.display = 'none'; document.getElementById('cameraView').style.display = 'block';
} catch (error) { console.error('相机启动失败:', error); handleCameraError(error); }}
// 拍照function capturePhoto() { const video = document.getElementById('video'); const canvas = document.getElementById('canvas'); const preview = document.getElementById('preview');
canvas.width = video.videoWidth; canvas.height = video.videoHeight;
const ctx = canvas.getContext('2d');
// 前置摄像头需要水平翻转 if (currentFacingMode === 'user') { ctx.scale(-1, 1); ctx.drawImage(video, -canvas.width, 0, canvas.width, canvas.height); } else { ctx.drawImage(video, 0, 0, canvas.width, canvas.height); }
// 获取图片数据 capturedImageData = canvas.toDataURL('image/jpeg', 0.9);
// 显示预览 preview.src = capturedImageData; document.getElementById('cameraView').style.display = 'none'; document.getElementById('previewScreen').style.display = 'block';}
// 错误处理function handleCameraError(error) { let errorMessage = '相机启动失败';
if (error.name === 'NotAllowedError') { errorMessage = '请允许访问相机权限'; } else if (error.name === 'NotFoundError') { errorMessage = '未找到相机设备'; } else if (error.name === 'NotSupportedError') { errorMessage = '浏览器不支持相机功能'; }
alert(errorMessage);}兼容性说明
| 浏览器 | 最低版本 | 支持情况 |
|---|---|---|
| iOS Safari | 11+ | ✅ 完全支持 |
| Android Chrome | 53+ | ✅ 完全支持 |
| 微信内置浏览器 | - | ⚠️ 部分机型可能有问题 |
| PC Chrome | 53+ | ✅ 支持(无法切换摄像头) |
| PC Firefox | 36+ | ✅ 支持(无法切换摄像头) |
注意事项
- HTTPS要求:出于安全考虑,摄像头API只能在HTTPS环境下使用。本地开发时可以用
localhost - 权限申请:首次使用会弹出权限申请,用户拒绝后需要手动在浏览器设置中开启
- 性能考虑:长时间开启摄像头会耗电,记得及时关闭
- 隐私保护:在显眼位置提示用户正在使用摄像头
部署
Serve工具
通过Node.js快速启动一个web服务器,为当前目录提供一个访问路径。 首先需要安装serve
npm i -g serve然后在当前目录下运行
serve index.html
但是目前只能在localhost本地访问,http格式的链接是无法获取对应的权限,
ngrok实现内网穿透
详细安装步骤可以参考 安装ngrok

最终我们可以通过这个链接访问。
完整代码
<!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> * { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #000; color: #fff; overflow: hidden; user-select: none; }
.camera-container { position: relative; width: 100vw; height: 100vh; display: flex; flex-direction: column; justify-content: center; align-items: center; }
.welcome-screen { text-align: center; padding: 20px; }
.welcome-screen h1 { font-size: 24px; margin-bottom: 30px; color: #fff; }
.open-camera-btn { background: linear-gradient(45deg, #007AFF, #5856D6); color: white; border: none; padding: 15px 30px; font-size: 18px; border-radius: 25px; cursor: pointer; transition: transform 0.2s; box-shadow: 0 4px 15px rgba(0, 122, 255, 0.3); }
.open-camera-btn:hover { transform: translateY(-2px); }
.camera-view { display: none; width: 100%; height: 100%; position: relative; }
#video { width: 100%; height: 100%; object-fit: cover; }
.camera-controls { position: absolute; bottom: 0; left: 0; right: 0; background: linear-gradient(transparent, rgba(0,0,0,0.8)); padding: 30px 20px; display: flex; justify-content: center; align-items: center; gap: 30px; }
.capture-btn { width: 70px; height: 70px; border: 4px solid #fff; border-radius: 50%; background: rgba(255,255,255,0.2); cursor: pointer; transition: all 0.2s; display: flex; align-items: center; justify-content: center; }
.capture-btn:hover { background: rgba(255,255,255,0.4); transform: scale(1.05); }
.capture-btn::after { content: ''; width: 50px; height: 50px; background: #fff; border-radius: 50%; }
.close-btn { position: absolute; top: 20px; right: 20px; background: rgba(0,0,0,0.5); color: white; border: none; width: 40px; height: 40px; border-radius: 50%; font-size: 20px; cursor: pointer; display: flex; align-items: center; justify-content: center; }
.flip-btn { position: absolute; top: 20px; left: 20px; background: rgba(0,0,0,0.5); color: white; border: none; width: 40px; height: 40px; border-radius: 50%; font-size: 18px; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: transform 0.3s; }
.flip-btn:hover { transform: rotateY(180deg); }
.preview-screen { display: none; width: 100%; height: 100%; position: relative; }
#preview { width: 100%; height: 100%; object-fit: cover; }
.preview-controls { position: absolute; bottom: 0; left: 0; right: 0; background: linear-gradient(transparent, rgba(0,0,0,0.8)); padding: 30px 20px; display: flex; justify-content: space-around; align-items: center; }
.control-btn { background: #007AFF; color: white; border: none; padding: 12px 24px; border-radius: 20px; font-size: 16px; cursor: pointer; transition: all 0.2s; }
.control-btn:hover { background: #0056b3; transform: translateY(-2px); }
.control-btn.retake { background: #666; }
.control-btn.retake:hover { background: #555; }
.control-btn.submit { background: #34C759; }
.control-btn.submit:hover { background: #28a745; }
#canvas { display: none; }
.status-message { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); background: rgba(0,0,0,0.8); color: white; padding: 20px; border-radius: 10px; text-align: center; display: none; }
@media (max-width: 480px) { .camera-controls { padding: 20px; gap: 20px; }
.capture-btn { width: 60px; height: 60px; }
.capture-btn::after { width: 40px; height: 40px; } } </style></head><body> <div class="camera-container"> <!-- 欢迎屏幕 --> <div class="welcome-screen" id="welcomeScreen"> <h1>📸 相机拍照</h1> <button class="open-camera-btn" onclick="openCamera()">打开相机</button> </div>
<!-- 相机视图 --> <div class="camera-view" id="cameraView"> <video id="video" autoplay muted playsinline></video> <button class="close-btn" onclick="closeCamera()">×</button> <button class="flip-btn" onclick="flipCamera()" title="切换摄像头">🔄</button> <div class="camera-controls"> <div class="capture-btn" onclick="capturePhoto()"></div> </div> </div>
<!-- 预览屏幕 --> <div class="preview-screen" id="previewScreen"> <img id="preview" alt="拍照预览"> <div class="preview-controls"> <button class="control-btn retake" onclick="retakePhoto()">重新拍照</button> <button class="control-btn submit" onclick="submitPhoto()">提交照片</button> </div> </div>
<!-- 隐藏的canvas用于图片处理 --> <canvas id="canvas"></canvas>
<!-- 状态消息 --> <div class="status-message" id="statusMessage"></div> </div>
<script> let stream = null; let capturedImageData = null; let currentFacingMode = 'environment'; // 'environment' 为后置,'user' 为前置
// 显示状态消息 function showStatus(message, duration = 2000) { const statusEl = document.getElementById('statusMessage'); statusEl.textContent = message; statusEl.style.display = 'block'; setTimeout(() => { statusEl.style.display = 'none'; }, duration); }
// 打开相机 async function openCamera(facingMode = currentFacingMode) { try { showStatus('正在启动相机...');
// 如果已有流,先停止 if (stream) { stream.getTracks().forEach(track => track.stop()); }
// 请求相机权限 const constraints = { video: { facingMode: { ideal: facingMode }, width: { ideal: 1920 }, height: { ideal: 1080 } }, audio: false };
stream = await navigator.mediaDevices.getUserMedia(constraints); currentFacingMode = facingMode;
const video = document.getElementById('video'); video.srcObject = stream;
// 等待视频加载 video.onloadedmetadata = () => { document.getElementById('welcomeScreen').style.display = 'none'; document.getElementById('cameraView').style.display = 'block';
const cameraType = facingMode === 'environment' ? '后置' : '前置'; showStatus(`${cameraType}相机已启动,点击拍照按钮进行拍照`); };
} catch (error) { console.error('相机启动失败:', error); let errorMessage = '相机启动失败';
if (error.name === 'NotAllowedError') { errorMessage = '请允许访问相机权限'; } else if (error.name === 'NotFoundError') { errorMessage = '未找到相机设备'; } else if (error.name === 'NotSupportedError') { errorMessage = '浏览器不支持相机功能'; } else if (error.name === 'OverconstrainedError') { // 如果指定的摄像头不可用,尝试使用另一个 const fallbackMode = facingMode === 'environment' ? 'user' : 'environment'; showStatus('切换到可用的摄像头...'); await openCamera(fallbackMode); return; }
showStatus(errorMessage, 3000); } }
// 切换摄像头 async function flipCamera() { if (!stream) return;
const newFacingMode = currentFacingMode === 'environment' ? 'user' : 'environment'; const cameraType = newFacingMode === 'environment' ? '后置' : '前置';
showStatus(`正在切换到${cameraType}摄像头...`);
try { await openCamera(newFacingMode); } catch (error) { showStatus('摄像头切换失败,请重试'); console.error('摄像头切换失败:', error); } }
// 关闭相机 function closeCamera() { if (stream) { stream.getTracks().forEach(track => track.stop()); stream = null; }
document.getElementById('cameraView').style.display = 'none'; document.getElementById('previewScreen').style.display = 'none'; document.getElementById('welcomeScreen').style.display = 'block'; }
// 拍照 function capturePhoto() { const video = document.getElementById('video'); const canvas = document.getElementById('canvas'); const preview = document.getElementById('preview');
// 设置canvas尺寸为视频尺寸 canvas.width = video.videoWidth; canvas.height = video.videoHeight;
// 将视频帧绘制到canvas const ctx = canvas.getContext('2d');
// 如果是前置摄像头,需要水平翻转图像 if (currentFacingMode === 'user') { ctx.scale(-1, 1); ctx.drawImage(video, -canvas.width, 0, canvas.width, canvas.height); } else { ctx.drawImage(video, 0, 0, canvas.width, canvas.height); }
// 获取图片数据 capturedImageData = canvas.toDataURL('image/jpeg', 0.9);
// 显示预览 preview.src = capturedImageData; document.getElementById('cameraView').style.display = 'none'; document.getElementById('previewScreen').style.display = 'block';
showStatus('照片拍摄成功!'); }
// 重新拍照 function retakePhoto() { document.getElementById('previewScreen').style.display = 'none'; document.getElementById('cameraView').style.display = 'block'; capturedImageData = null; }
// 提交照片(模拟下载到本地) function submitPhoto() { if (!capturedImageData) { showStatus('没有可提交的照片'); return; }
try { // 创建下载链接 const link = document.createElement('a'); const cameraType = currentFacingMode === 'environment' ? 'rear' : 'front'; link.download = `photo_${cameraType}_${new Date().getTime()}.jpg`; link.href = capturedImageData;
// 触发下载 document.body.appendChild(link); link.click(); document.body.removeChild(link);
showStatus('照片已下载到本地!');
// 返回欢迎界面 setTimeout(() => { closeCamera(); }, 1500);
} catch (error) { console.error('下载失败:', error); showStatus('下载失败,请重试'); } }
// 检查浏览器支持 window.onload = function() { if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { showStatus('您的浏览器不支持相机功能', 5000); document.querySelector('.open-camera-btn').disabled = true; } };
// 处理页面隐藏时关闭相机 document.addEventListener('visibilitychange', function() { if (document.hidden && stream) { closeCamera(); } }); </script></body></html>总结
这个功能看起来简单,实际上涉及的细节还挺多。特别是各种边界情况的处理,比如权限被拒绝、摄像头被占用、页面切换等等。
最让我意外的是,原生Web API已经这么强大了,完全可以做出不输原生APP的体验。当然,如果你的需求更复杂(比如美颜、滤镜),可能还是得用一些第三方库。
希望这篇文章对你有帮助。如果你在实现过程中遇到其他坑,欢迎留言交流!
PS: 测试的时候记得用HTTPS,HTTP下浏览器不给摄像头权限。本地开发可以用localhost,它被特殊对待了。
评论