不就完了? 结果产品补充道:"最好能切换前后摄像头,拍完能预览重拍,体验要和原生APP...">前端相机拍照 | 飞云的编程宝典不就完了? 结果产品补充道:"最好能切换前后摄像头,拍完能预览重拍,体验要和原生APP...">
Skip to content

前端相机拍照

约 3532 字大约 12 分钟

前端

2025-08-12

最近项目里碰到个有意思的需求。产品经理找到我:"咱们APP的身份验证页面,用户反馈说每次上传照片都要从相册里翻半天,能不能直接拍照上传?"

我心想这不简单吗,改个<input type="file" accept="image/*" capture="camera">不就完了?

结果产品补充道:"最好能切换前后摄像头,拍完能预览重拍,体验要和原生APP一样流畅。"

实现效果

先看看最终效果,功能包括:

  • 直接调用手机摄像头(不是选择相册图片)
  • 前后摄像头切换
  • 拍照后预览
  • 重拍或保存
  • 完全适配移动端
image-20250812103414848
image-20250812103414848

技术原理详解

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标签需要加上autoplaymutedplaysinline属性:

<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 Safari11+✅ 完全支持
Android Chrome53+✅ 完全支持
微信内置浏览器-⚠️ 部分机型可能有问题
PC Chrome53+✅ 支持(无法切换摄像头)
PC Firefox36+✅ 支持(无法切换摄像头)

注意事项

  1. HTTPS要求:出于安全考虑,摄像头API只能在HTTPS环境下使用。本地开发时可以用localhost
  2. 权限申请:首次使用会弹出权限申请,用户拒绝后需要手动在浏览器设置中开启
  3. 性能考虑:长时间开启摄像头会耗电,记得及时关闭
  4. 隐私保护:在显眼位置提示用户正在使用摄像头

部署

Serve工具

通过Node.js快速启动一个web服务器,为当前目录提供一个访问路径。 首先需要安装serve

npm i -g serve

然后在当前目录下运行

serve index.html
image-20250812102921193
image-20250812102921193

但是目前只能在localhost本地访问,http格式的链接是无法获取对应的权限,

ngrok实现内网穿透

详细安装步骤可以参考 安装ngrok

image-20250812103212633
image-20250812103212633

最终我们可以通过这个链接访问。

完整代码

<!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,它被特殊对待了。

贡献者

  • flycodeuflycodeu

公告板

2025-03-04正式迁移知识库到此项目