前端相机拍照
最近项目里碰到个有意思的需求。产品经理找到我:"咱们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,它被特殊对待了。
贡献者
flycodeu
版权所有
版权归属:flycodeu