H5页面调用手机相机拍照并上传:getUserMedia与图片压缩实战 📅 发布时间:2026/9/12 22:20:00 👁 浏览次数: 简介面向需要在HTML5网页中调用手机相机拍照并上传照片的开发者这份zip资源包给出了完整可运行的前后端示例。核心代码涵盖从拍照到上传的完整链路input typefile的capture属性调起摄像头、FileReader实现本地预览、FormData与fetch将图片POST至PHP后台保存等关键环节并附有jQuery相关辅助脚本。压缩包内共22个文件以html、js、php源文件为主另含url参考链接、txt说明笔记及jpg/png截图整体约3.18MB方便按文档索引快速复用。资源已被2168人学习下载适合正在做移动端网页上传功能的初中级前端开发者参考。除了可直接部署的页面与上传处理脚本还额外提供了安卓、微信环境下的真机测试经验以及给www用户权限、PHP时区设置等常见排错记录能帮助减少实际开发中的踩坑成本。1. 在HTML5网页里调起手机相机先分清两种拍照路径手机网页表单要传照片传统做法是让用户先拍好再进相册选来回切换很割裂。而 HTML5 给前端提供了直接调起相机的能力两条主流路径input typefile acceptimage/* captureenvironment是文件选择框的加强版会拉起系统相机拍完回传一个 File 对象navigator.mediaDevices.getUserMedia()则能在网页内渲染实时取景画面通过 canvas 截取当前帧得到图片。两条路径最终都落到“上传照片给后台”这一个动作上。这篇文章会从实时预览拍照讲起覆盖前后端完整链路、图片压缩、后台存储和移动端兼容性排查适合需要做移动端 H5 表单、工单上报、证件照采集的工程师。2. 用getUserMedia实现实时取景与拍照摄像头权限、设备和画面捕获2.1 获取摄像头权限getUserMedia的参数与约束在 HTTPS 页面localhost 除外里navigator.mediaDevices.getUserMedia()是唯一标准入口。浏览器会弹权限询问框用户允许后摄像头数据才会流动到前端。这个接口是异步的返回一个 Promiseresolve 给一个 MediaStream 对象。先看最基础调用async function startCamera() { const stream await navigator.mediaDevices.getUserMedia({ video: { facingMode: environment, width: { ideal: 1280 }, height: { ideal: 720 } }, audio: false }); videoEl.srcObject stream; }参数说明facingMode: environment表示优先使用后置摄像头反之前置是user。width和height配ideal是“非强制理想值”浏览器会挑最近的分辨率如果你写exactAndroid 上经常抛OverconstrainedError所以不建议新手直接exact。audio: false表示不申请麦克风权限询问框会精简很多。拿到MediaStream赋给video的srcObject不赋的话画面上不会有任何内容。失败处理要覆盖用户拒绝、没摄像头、设备被占用三种情况。常见做法是做一个约束回退链依次尝试严格约束和不带约束async function startCamera(videoEl) { const prefers [ { video: { facingMode: environment, width: 1280 } }, { video: true } ]; for (const constraints of prefers) { try { const stream await navigator.mediaDevices.getUserMedia(constraints); videoEl.srcObject stream; return stream; } catch (err) { // 换一组约束继续尝试 } } throw new Error(No camera available); }被注释掉的那行意思是“上一个约束失败不 panic试下一个”。第二组{ video: true }是保底方案让浏览器决定用哪个摄像头。这样即使某些国产浏览器不认facingMode也能先把画面跑起来。权限状态可以用navigator.permissions.query({ name: camera })查但 iOS Safari 不支持所以别把它当成初始化流程的依赖。摄像头关键参数对效果的影响见下表参数写法作用风险facingModeenvironment / user指定前后摄部分 Android 浏览器忽略width / heightideal 或 exact控制分辨率exact 可能 OverconstrainedErroraspectRatio1.5 / 1.777画面宽高比不同摄像头实际比值不同frameRate{ ideal: 30 }取景帧率太高耗电低端机掉帧2.2 把取景画面渲染到video和canvas拍照瞬间的数据流video标签只是显示真正的图像数据在 MediaStream 里。拍照的标准动作拉一个隐藏canvas把video当前帧drawImage进去再用toBlob导出function takePhoto(videoEl, targetCanvas) { const ctx targetCanvas.getContext(2d); targetCanvas.width videoEl.videoWidth; targetCanvas.height videoEl.videoHeight; ctx.drawImage(videoEl, 0, 0, targetCanvas.width, targetCanvas.height); return new Promise((resolve, reject) { targetCanvas.toBlob((blob) { if (blob) resolve(blob); else reject(new Error(canvas.toBlob returned null)); }, image/jpeg, 0.8); }); }这里有几个关键细节。videoWidth在视频流未就绪时是 0所以按钮点击前至少要等loadedmetadata事件发生。canvas.width必须赋像素值不能只靠 CSS 尺寸否则画布默认 300x150导出就是小图。toBlob第三个参数0.8是 JPEG 质量质量每高 0.1体积大约涨 20%-40%建议先按 0.8 起步。另外drawImage时如果video处于暂停或还没有第一帧画出来就是一张黑图。稳妥的做法是在canplay事件后再启用拍照按钮videoEl.addEventListener(loadedmetadata, () { captureBtn.disabled false; });页面加载完成时先captureBtn.disabled true等事件触发再放开。手机上摄像头启动速度差异很大千元机可能花 2-3 秒别让用户提前点按钮。2.3 处理前置/后置摄像头切换与iOS/Android差异有些表单需要自拍或拍物要求能切换前后摄。最直接的办法是用新facingMode重新getUserMedia但重开摄像头前必须停掉旧流否则 Android 上会报NotReadableErrorasync function switchCamera(videoEl, toFacingMode) { const curStream videoEl.srcObject; if (curStream) { curStream.getTracks().forEach(track track.stop()); } const newStream await navigator.mediaDevices.getUserMedia({ video: { facingMode: toFacingMode }, audio: false }); videoEl.srcObject newStream; }toFacingMode传user时切前置传environment时切后置。切换过程会再次弹权限询问框内置浏览器常见体验上最好在按钮上加 loading禁止连点。iOS 上切换通常流畅Android WebView 可能会重新初始化摄像头导致画面卡顿半秒这属于正常现象。跨端另一个差异是输出比例。iOS 后置摄像头通常给 4:3Android 很多时候给 16:9。如果你的业务要求 4:3 图片直接画到 4:3 canvas 会拉伸。解决办法是保持宽度按目标比例居中裁掉上下高度const ASPECT 4 / 3; const sourceW videoEl.videoWidth; const sourceH videoEl.videoHeight; const targetH Math.round(sourceW / ASPECT); ctx.drawImage(videoEl, 0, (sourceH - targetH) / 2, sourceW, targetH, 0, 0, sourceW, targetH);注意canvas.height也要设成targetH并且videoEl.videoHeight可能比targetH小所以裁切前先做个Math.max(0, ...)保护。这部分裁切逻辑放在takePhoto外层更好维护。3. 把照片交给后台FormData上传、压缩与进度显示3.1 从canvas/Blob生成要上传的文件对象canvas.toBlob拿到的是Blob虽然也能上传但很多代理或 CDN 会按文件名后缀识别 Content-TypeBlob 的文件名是空的容易出问题。推荐转成File再塞进 FormDataconst photoBlob await takePhoto(videoEl, canvas); const photoFile new File([photoBlob], photo_${Date.now()}.jpg, { type: photoBlob.type || image/jpeg });Date.now()生成毫秒时间戳避免文件名重复。type字段要写清楚因为部分浏览器里blob.type可能为空字符串。注意new File()在 iOS 13 以下不支持如果要兼容古董 Safari可以直接把 Blob append 进 FormData后台用file.buffer处理。如果是用input typefile acceptimage/* capture这条路获取文件的过程更简单fileInput.addEventListener(change, async (e) { const file e.target.files[0]; if (!file) return; const formData new FormData(); formData.append(photo, file); // 调用上传函数 });acceptimage/*让文件选择框只显示图片captureenvironment告诉浏览器优先打开后置相机。这个属性在 iOS 上表现是“拍照”按钮在 Android Chrome 上会直接拉起相机在桌面浏览器退化成普通文件选择框。相比 getUserMedia它没有预览和压缩能力但胜在代码少、权限只弹一次。两种方式的 File 对象结构一致后续上传代码可以共用。3.2 用XMLHttpRequest上传到后台并跟踪进度上传照片用XMLHttpRequest是因为它原生暴露upload.onprogress而 Fetch 的请求进度至今没有标准实现额外引库不值当。下面是一个带进度、超时和错误处理的上传函数function uploadPhoto(url, file, onProgress) { return new Promise((resolve, reject) { const xhr new XMLHttpRequest(); xhr.open(POST, url, true); xhr.timeout 30000; const formData new FormData(); formData.append(photo, file); xhr.upload.onprogress (e) { if (e.lengthComputable onProgress) { onProgress(Math.round((e.loaded / e.total) * 100)); } }; xhr.onload () { if (xhr.status 200 xhr.status 300) { try { resolve(JSON.parse(xhr.responseText)); } catch { resolve({ ok: true, message: xhr.responseText }); } } else { reject(new Error(HTTP ${xhr.status}: ${xhr.responseText})); } }; xhr.onerror () reject(new Error(Network error)); xhr.ontimeout () reject(new Error(Upload timeout)); xhr.send(formData); }); }xhr.open里第三个参数true表示异步必须写。不要在open之后设置Content-TypeFormData 会自己生成multipart/form-data; boundary...头手动设置会把 boundary 搞丢后台解析直接报错。onProgress的回调值是 0-100 的整数可在页面上驱动进度条。超时时间 30 秒适合 2MB 左右的照片如果前端压到 300KB网络再差也够用了。3.3 上传前的图片压缩与方向修正手机原图动辄 3-6MB直接传浪费流量。建议上传前把最大边长压到 1280-1920再按 0.7-0.8 的质量导出 JPEG。createImageBitmap方案如下async function compressImage(file, maxSize, quality 0.7) { const bitmap await createImageBitmap(file); const scale Math.min(1, maxSize / Math.max(bitmap.width, bitmap.height)); const canvas document.createElement(canvas); canvas.width Math.round(bitmap.width * scale); canvas.height Math.round(bitmap.height * scale); canvas.getContext(2d).drawImage(bitmap, 0, 0, canvas.width, canvas.height); if (bitmap.close) bitmap.close(); return new Promise((resolve) { canvas.toBlob(resolve, image/jpeg, quality); }); }关键参数maxSize是最大边长超过就等比缩小不超过则保持原尺寸。createImageBitmap(file)会读取 JPEG 的 EXIF 方向在绘制时自动把方向转正这一点比直接用 Image 对象 drawImage可靠得多。bitmap.close()用于释放内存否则连续拍摄多张照片时内存占用会一直涨。canvas.toBlob返回 Promise 的 resolve 是 Blob还需要外层用之前提过的new File()包装。不同组合的体积参考最大边长质量典型体积适合场景12800.7150-300KB工单、证件照19200.8400-700KB社交分享、商品图25600.91-2MB大图预览原尺寸0.95减少约 30%需保留原画质4. 后台端接收与存储以Node.js和Java为例的Multipart处理4.1 Node.js接收multipart/form-data的接口写法前端用 FormData 发送后台就要按multipart/form-data解析。Node 生态里最常用的是multer它接管了 multipart 流式解析和临时文件写入。下面是 Express multer 的最小接收接口const express require(express); const multer require(multer); const upload multer({ dest: uploads/ }); const app express(); app.post(/api/upload, upload.single(photo), (req, res) { if (!req.file) { return res.status(400).json({ ok: false, message: No file received }); } res.json({ ok: true, url: /uploads/${req.file.originalname}, size: req.file.size }); }); app.listen(3000);初版能跑通但dest: uploads/只是把文件暂存到临时路径文件名为随机字符串且没有扩展名。正式环境要用diskStorage把文件落到业务目录并设置可读文件名const storage multer.diskStorage({ destination: uploads/, filename: (req, file, cb) { const ext path.extname(file.originalname) || .jpg; cb(null, photo_${Date.now()}${ext}); } }); const upload multer({ storage });path.extname(file.originalname)取原文件的扩展名但原文件名来自客户端允许中文和特殊字符。生产环境建议统一用服务端生成的随机名例如crypto.randomUUID()避免文件名冲突和路径注入。4.1.1 Java Spring Boot 的 MultipartFile 写法Java 系后台处理同样的请求Spring Boot 的写法则不同spring: servlet: multipart: max-file-size: 8MB max-request-size: 10MB必须先在application.yml里放开上传体积限制否则默认 1MB手机照片稍微大一点就 413。控制器示例PostMapping(/api/upload) public MapString, Object upload(RequestParam(photo) MultipartFile file) throws IOException { String filename photo_ System.currentTimeMillis() .jpg; file.transferTo(Paths.get(/data/uploads/, filename)); return Map.of(ok, true, url, /uploads/ filename); }RequestParam(photo)的参数名要和前端 FormData 的 key 严格一致。transferTo在 Windows 上如果目标文件存在会抛FileAlreadyExistsException所以用时间戳当文件名前缀最稳。max-request-size要略大于max-file-size因为 multipart 请求体里还有额外字段。4.2 限制大小、校验MIME和防止路径穿越上传接口一旦放开就要防三类问题超大文件拖垮磁盘、伪造 Content-Type 上传脚本、文件名里带上../../覆盖服务器文件。multer 的方案是加limits和fileFilterconst upload multer({ storage, fileFilter: (req, file, cb) { const allowed [image/jpeg, image/png, image/webp]; if (!allowed.includes(file.mimetype)) { return cb(new Error(Only images allowed)); } cb(null, true); }, limits: { fileSize: 8 * 1024 * 1024 } });fileFilter检查的是file.mimetype这个值来源于文件选择框的 type用户可以伪造。真正可靠的方法是读文件开头的魔数JPEG 的FF D8 FF、PNG 的89 50 4E 47。如果做实名认证之类的严肃场景别省这一步。再补一张校验对比表校验项手段可绕过性扩展名path.extname 白名单可绕过改名即可MIMEfileFilter可伪造文件头魔数读取前 4 字节可靠但需写代码文件内容二次编码解码重编码为 JPEG最可靠但耗时路径穿越防护的核心是“不信任原始文件名”。上面代码里用Date.now()重命名已经杜绝了../../。Java 侧同样不要直接使用file.getOriginalFilename()拼路径而是像示例那样自己生成文件名。这个习惯要刻在肌肉记忆里。4.3 返回JSON给前端并处理失败状态上传接口的响应要固定结构前端才能统一处理。推荐{ ok: true, url: ... }和{ ok: false, message: ... }两种HTTP 状态码也别乱用。multer 的错误是在 Express 错误中间件里抛出来的需要单独处理app.use((err, req, res, next) { if (err instanceof multer.MulterError) { return res.status(400).json({ ok: false, code: err.code }); } res.status(500).json({ ok: false, message: err.message }); });err.code会是LIMIT_FILE_SIZE或LIMIT_UNEXPECTED_FILE前端可以直接映射成中文提示。注意不能让err.message原样返回里面可能带服务端文件路径。前端拿到失败状态后要区分“请求没发出去”“后台拒绝了”“网络超时”三种情况。可以用一个uploadPhoto包装统一转 Human Readabletry { const res await uploadPhoto(/api/upload, photoFile, onProgress); if (!res.ok) throw new Error(res.message || Upload failed); } catch (err) { // 在这里区分 err.message 是 Network error / Upload timeout / HTTP 4xx }5. 上线前必须处理的兼容性与排查技巧HTTPS、iOS录像黑屏、微信浏览器5.1 为什么非HTTPS环境拿不到摄像头权限getUserMedia只在安全上下文中可用这是浏览器的硬性规定。navigator.mediaDevices在 HTTP 页面上通常是undefined你调getUserMedia会直接报TypeError。所以第一件要做的事是确保页面通过 HTTPS 域名访问或者本地开发时用localhostlocalhost算安全上下文但手机要访问开发机 IP 就不行了。内网测试的常见做法开发机跑一个自签 HTTPS 并在手机安装根证书或者用内网穿透转发一个带 HTTPS 的临时域名。注意自签证书的域名必须是手机浏览器信任的否则摄像头 API 同样不可用。这个环境问题解决不了后面所有拍照代码都是白写。5.2 iOS Safari调起相机的几个坑autoplay、playsinline、画布全白iOS Safari 对 video 播放策略比 Android 严格。给video加playsinline是必须的否则摄像头流会自动进入全屏播放页面上根本看不到画面。muted和autoplay也要加上因为 getUserMedia 的流没有音频但 Safari 仍然要走 autoplay 逻辑video idvideo autoplay muted playsinline/video第二个常见坑是 canvas 画出来整张黑图。原因通常是video.readyState还没到HAVE_ENOUGH_DATA就调用drawImage。可以在video的playing事件后再允许拍照videoEl.addEventListener(playing, () { captureBtn.disabled false; }, { once: true });once: true保证只启用一次切换摄像头后需要重新绑定。第三个坑是 iOS 上从相册选择图片时照片方向错位。iOS 照片会带 EXIF Orientation用Image元素直接画会导致顺时针旋转 90 度。解决方式是用createImageBitmap(file)它在 iOS 14.3 会自动处理方向如果团队还要兼容 iOS 12/13就用 exif-js 手动矫正。5.3 用chrome://inspect和vConsole定位拍照上传问题真机调试是移动 H5 的必修课。Android 上用 Chrome 打开页面连 USB在 PC 的chrome://inspect里找到页面就能开 DevTools 远程调试。这里能看 console 的[photo]前缀日志也能看 Network 的 FormData 部分。没有电脑或 iOS 场景推荐引入 vConsole。它是一个前端 debug 工具页面右下角会浮出按钮点击后能看到 console、network、storage 面板。对于“用户拍了照但后台没收到”这种问题直接在 network 面板里查看请求的 Form Data 部分确认photo字段有没有值、大小是否 0。如果请求根本没发出去那就看 console 里的抛错堆栈。最后给一个完整的拍照上传调用链async function handleTakePhoto() { const photoFile await takePhoto(videoEl, canvas); await uploadPhoto(/api/upload, photoFile, setProgress); }takePhoto返回 Blob外面套一层new File()后传给 uploadPhoto。如果这条链路上任何一步抛错在调用方用try/catch包住把err.message打在页面上比空转圈强得多。像请求重试、多文件上传upload.array(photos, 9)、断点续传等增强都可以在这个基础上按业务需要扩展。本文还有配套的精品资源点击获取