移动端 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" 配合 acceptcapture 是最常见也最稳的起点,但它不能保证每个 WebView 都按你想的方式打开相机,也不能替你处理大图压缩、方向、取消、重试和权限提示。

用户真正会在意的,是拍完以后能不能预览清楚、压缩不卡、方向不歪、上传失败能重试、取消时不乱报错——相机调没调起来,反倒是最不容易出问题的一环。 只要目标环境里有微信、企业微信、钉钉或自家 App 的 WebView,真机矩阵就不能省——我这一遍里几乎每个坑都是真机上点出来的,桌面浏览器一个都拦不住。