移动端 H5 拍照功能怎么做
趁着手头一个"工单现场拍照上传"的需求排期没那么紧,我决定不查一堆资料,直接从一个空白 HTML 开始,一行行把这个功能自己搭一遍,看看到底哪些地方会绊住自己。结论很快就出来了:调起相机那一步几乎不费劲,真正花时间的全在拍完之后——预览、校验、压缩、方向、上传、重试、取消。从一个 <input> 到能在真机上稳定上传,中间每一步都得自己动手撞过才算数。
第一步:用 input 把相机调起来
H5 里实现拍照,最常见的方式是文件选择框,而不是直接控制摄像头。我先写了最短的一行:
1<input type="file" accept="image/*" capture="environment">
几个属性的含义:type="file" 表示选择文件,accept="image/*" 表示只接收图片,capture="environment" 表示优先用后置摄像头。想优先用前置就写 capture="user":
1<input type="file" accept="image/*" capture="user">
我拿几台手机试了一圈,发现不同手机和浏览器对 capture 的支持并不一致:有的直接打开相机,有的弹出相册和拍照两个选项,有的干脆忽略前后摄像头偏好。所以这一步不能想当然,一定要在目标机型和 WebView 里各点一遍。
如果业务跑在微信、企业微信、钉钉或自家 App 的 WebView 里,还要确认容器有没有自己的相机能力。有些容器的 JS SDK 能拿到更稳定的拍照结果,也能处理权限提示,但会带来平台绑定。我的做法是先用标准 input 做基础方案,再按目标容器决定是否接 SDK 增强。
第二步:把拍到的图片显示出来
调起来之后,得先能看到拍了什么。用户拍照或选图后,通过 change 事件拿文件。
1<input id="camera" type="file" accept="image/*" capture="environment"> 2<img id="preview" alt="图片预览">
1const input = document.querySelector("#camera"); 2const preview = document.querySelector("#preview"); 3 4input.addEventListener("change", (event) => { 5 const file = event.target.files[0]; 6 7 if (!file) { 8 return; 9 } 10 11 const url = URL.createObjectURL(file); 12 preview.src = url; 13 14 preview.onload = () => { 15 URL.revokeObjectURL(url); 16 }; 17});
这里我特意选了 URL.createObjectURL 而不是转 base64,做本地预览它更快,也不用把图片变成一大串字符串。预览完成后调 URL.revokeObjectURL 释放临时地址。写完随手把一张损坏的图片拖进相册再选它,果然预览框空白——所以我又补了个 img.onerror,给用户明确提示,而不是留个空白框。移动端相册里损坏图片、云端没下载完的图片都真实存在,这个失败态不能省。
什么时候才需要 FileReader
后续逻辑如果确实需要 base64,才用 FileReader:
1const reader = new FileReader(); 2 3reader.onload = function (event) { 4 const base64 = event.target.result; 5 preview.src = base64; 6}; 7 8reader.readAsDataURL(file);
我动手比过一次:同一张几 MB 的照片,转成 base64 体积涨了大约三分之一,还占更多内存,在一台低端安卓机上转的时候页面明显卡了一下。所以现在我基本不主动转 base64,除非接口明确要求,或者要把图片嵌进某段文本数据。能传二进制就传二进制。
第三步:上传前先把文件校验一遍
移动端拍出来的图片可能很大,一张几 MB 很常见,不做限制上传会慢也容易失败。上传前至少校验类型和大小:
1function validateImage(file) { 2 const allowTypes = ["image/jpeg", "image/png", "image/webp"]; 3 const maxSize = 5 * 1024 * 1024; 4 5 if (!allowTypes.includes(file.type)) { 6 return "只支持 JPG、PNG 或 WebP 图片"; 7 } 8 9 if (file.size > maxSize) { 10 return "图片不能超过 5MB"; 11 } 12 13 return ""; 14}
我一度以为 accept 就能挡住非图片文件,动手试了才确认它只是提示系统选择器过滤,绕过它太容易了。所以它不能当安全校验,服务端仍要重新校验文件类型、大小和内容。
第四步:用 FormData 上传
最常见的上传方式是 FormData:
1async function uploadImage(file) { 2 const formData = new FormData(); 3 formData.append("file", file); 4 5 const response = await fetch("/api/upload", { 6 method: "POST", 7 body: formData, 8 }); 9 10 if (!response.ok) { 11 throw new Error("上传失败"); 12 } 13 14 return response.json(); 15}
我第一版手贱给这个请求加了 Content-Type: multipart/form-data,结果服务端解析不了。查了才明白:浏览器会自动带上带 boundary 的 Content-Type,手动设反而把 boundary 冲掉了。这行绝对不能自己写。
上传错误也要动手制造几种情况看看。我把网络断开、文件过大、服务端校验失败、登录过期分别触发了一遍,发现如果都只弹"上传失败",用户根本不知道该干嘛。于是把错误归一成稳定结构,再在页面给出明确行动:重试、重新选择、重新登录,或者联系管理员。
1function normalizeUploadError(error) { 2 return { 3 code: error?.code || "UPLOAD_ERROR", 4 message: error?.message || "图片上传失败,请稍后重试", 5 }; 6}
我还想要个上传进度条,试了才发现 fetch 目前没有稳定的上传进度事件,最直接的方式还是 XMLHttpRequest:
1function uploadWithProgress(file, onProgress) { 2 return new Promise((resolve, reject) => { 3 const xhr = new XMLHttpRequest(); 4 const formData = new FormData(); 5 formData.append("file", file); 6 7 xhr.upload.onprogress = (event) => { 8 if (event.lengthComputable) { 9 onProgress(Math.round((event.loaded / event.total) * 100)); 10 } 11 }; 12 13 xhr.onload = () => { 14 if (xhr.status >= 200 && xhr.status < 300) { 15 resolve(JSON.parse(xhr.responseText)); 16 } else { 17 reject({ 18 code: "UPLOAD_HTTP_ERROR", 19 message: "图片上传失败", 20 status: xhr.status, 21 }); 22 } 23 }; 24 25 xhr.onerror = () => reject({ code: "UPLOAD_NETWORK_ERROR", message: "网络异常" }); 26 xhr.open("POST", "/api/upload"); 27 xhr.send(formData); 28 }); 29}
多图上传我一开始图省事全并发开出去,拿五六张现场照一测,低端手机直接卡死。改成排队后就稳了:压缩和上传都排队,最多同时处理 2 个,并把每张图的状态拆开——待压缩、压缩中、待上传、上传中、失败、成功。这样用户知道哪张失败,也能只重试失败那张。
第五步:压缩和方向,这才是重头戏
亲手拍几张再看,移动端照片经常两个毛病:体积太大、方向不对。
体积太大在前端用 canvas 压:
1function compressImage(file, maxWidth = 1280, quality = 0.8) { 2 return new Promise((resolve, reject) => { 3 const img = new Image(); 4 const url = URL.createObjectURL(file); 5 6 img.onload = () => { 7 const scale = Math.min(1, maxWidth / img.width); 8 const canvas = document.createElement("canvas"); 9 canvas.width = Math.round(img.width * scale); 10 canvas.height = Math.round(img.height * scale); 11 12 const context = canvas.getContext("2d"); 13 context.drawImage(img, 0, 0, canvas.width, canvas.height); 14 15 canvas.toBlob( 16 (blob) => { 17 URL.revokeObjectURL(url); 18 blob ? resolve(blob) : reject(new Error("图片压缩失败")); 19 }, 20 "image/jpeg", 21 quality 22 ); 23 }; 24 25 img.onerror = reject; 26 img.src = url; 27 }); 28}
方向问题我是拍了张竖着的照片才撞上的:iPhone 拍出来预览里是横的。它和 EXIF 信息有关,部分浏览器会自动处理,部分场景不会。业务对证件照、现场照方向要求严格时,得在目标设备上测,必要时读 EXIF 并修正方向。我当时试着把 canvas 的绘制按 EXIF 的 orientation 值旋转回去才对上。
压缩也要控制节奏。我一次性把多张大图都丢进 canvas,主线程立刻卡住。多图时改成排队处理,或者限制一次最多选几张。对现场工单,用户宁愿看到"正在压缩第 2/5 张",也不愿页面假死。
还有一点是动手才发现的:不能只固定一个 quality。不同手机拍出来差异很大,有的 0.8 就能压进 1MB,有的还超限。我写了个简单循环,在最大宽度和质量之间试几次:
1async function compressToLimit(file, maxSize = 1024 * 1024) { 2 let quality = 0.85; 3 let width = 1600; 4 let blob = file; 5 6 for (let i = 0; i < 4; i += 1) { 7 blob = await compressImage(file, width, quality); 8 if (blob.size <= maxSize) { 9 return blob; 10 } 11 quality -= 0.15; 12 width = Math.round(width * 0.8); 13 } 14 15 return blob; 16}
这不是追求压得越小越好,而是把上传成功率控制住。证件照、工单现场照还要保留足够细节,压缩后最好让用户看预览,而不是后台偷偷把图糊掉。
如果目标机型较新,还有个更省事的路子值得试:createImageBitmap 可以直接把 File 解码成位图,配合 OffscreenCanvas 在 Worker 里离屏绘制,就不会阻塞主线程了。我在一台较新的安卓机上试了下,多图压缩时页面明显不卡了,但旧机型和部分 WebView 不一定支持,所以我只把它当能力检测后的增强路径,主线程那套仍留着兜底。
权限被拒了怎么办
用 input 调相机有个好处是权限交给系统弹窗管,不用自己申请。但用户拒了之后是什么表现,我也动手试了一遍:在 iOS 上把相机权限关掉再点,input 直接没反应,change 也不触发,页面这边收不到任何回调,纯粹静默失败。这就意味着不能靠事件去感知"权限被拒",只能在交互上兜——比如点了按钮却迟迟没进入预览,给一句引导文案告诉用户去系统设置里打开相机权限。
如果哪天需要更强的能力(比如实时取景、连续拍摄),就得走 getUserMedia,它能拿到相机权限的明确状态,也能自己画取景框:
1async function openCamera(videoEl) { 2 try { 3 const stream = await navigator.mediaDevices.getUserMedia({ 4 video: { facingMode: "environment" }, 5 }); 6 videoEl.srcObject = stream; 7 } catch (error) { 8 if (error.name === "NotAllowedError") { 9 // 用户拒绝了权限,引导去设置 10 } else if (error.name === "NotFoundError") { 11 // 设备没有可用摄像头 12 } 13 throw error; 14 } 15}
不过 getUserMedia 有硬性前提:必须在 HTTPS 下才可用(localhost 除外),而且部分 WebView 里被禁用或表现不一致。我实测下来,普通的"拍一张传上去"根本没必要上它,input 那套更省心也更兼容;只有确实要做取景、扫码这类交互才值得。这也是我动手比过之后才敢下的判断,不是照着文档抄的。
第六步:取消不是错误,同一文件要能再选
用户打开相机后可能取消拍照,这不是错误。我故意点了取消,代码里 files[0] 是 undefined,得允许它不存在,并且不要弹"上传失败"。
还有个反直觉的:同一张照片连续选两次,部分浏览器不触发第二次 change。我复现出来后,在处理完成后清空 input 就解决了:
1input.value = "";
这样用户再次选同一个文件,也能触发事件。
自己搭一遍之后的几点确认
把这个功能从空白页搭到能真机上传,最直接的收获是:input type="file" 配合 accept 和 capture 是最常见也最稳的起点,但它不能保证每个 WebView 都按你想的方式打开相机,也不能替你处理大图压缩、方向、取消、重试和权限提示。
用户真正会在意的,是拍完以后能不能预览清楚、压缩不卡、方向不歪、上传失败能重试、取消时不乱报错——相机调没调起来,反倒是最不容易出问题的一环。 只要目标环境里有微信、企业微信、钉钉或自家 App 的 WebView,真机矩阵就不能省——我这一遍里几乎每个坑都是真机上点出来的,桌面浏览器一个都拦不住。