
1. 先搞明白一件事H5 在宿主 APP 里做附件上传起点和浏览器完全不同如果你只写过浏览器里的附件上传第一次把页面塞进爱山东 APP 这类宿主容器里跑大概率会在同一个地方卡住——点击选择文件没反应或者能选但选完回调不触发。这不是代码写错了而是容器里的文件选择这一环宿主 APP 有决定权。H5 本身只负责我要一个文件至于这个文件从哪里来、以什么形式交回来是原生层说了算。理解这条链路是后面所有方案选型的基础。我这几年接触过不少H5 集成到爱山东 APP这类需求附件上传几乎是每个业务页面都绕不开的一环涉及身份证照片、营业执照、合同扫描件、音视频佐证材料等等。场景的共性是用户手里是手机文件来源分散在相册、拍照、文件管理器文件体积从几十 KB 到上百 MB 不等网络环境还经常是弱网。这种组合下上传不是一个 API 调用而是一整套从选择、预处理、传输到容错的控制流。这篇文章面向的是已经上手写 H5、但对宿主容器行为不熟悉的同学也适合正在做移动端集成的老手拿来对照排查。我会把三种主流方案摆出来比一比讲清楚各自的适用边界然后把前端侧的压缩、分片、续传、进度这些环节拆开讲透最后把我和团队在实际项目里踩过的坑整理成一张速查表。所有涉及原生接口的写法都属于常见实践的合理补全具体字段名需要以你的宿主 APP 提供的实际桥接协议为准。1.1 容器内核决定了 input file 的命运Android 侧的宿主 APP绝大多数用的是系统 WebView也有部分会集成第三方内核。关键在于WebView 默认没有实现文件选择器的回调。也就是说你在 H5 里写一个input typefile点击之后 Android 层需要宿主 APP 重写WebChromeClient里的onShowFileChooser方法弹出系统文件选择器或自定义选择界面再把用户选中的 URI 通过FileChooserParams回传给 WebView。如果宿主没做这一步用户点击就是一个纯静态按钮毫无反应。iOS 侧用的是 WKWebView情况类似但有差异。早期版本对input typefile支持很差较新的系统版本才通过WKUIDelegate的runOpenPanelWith回调把控制权交给原生。iOS 返回给 JS 的一般是一个临时沙盒路径或者 blob 对象而 Android 通过 FileProvider 返回的通常是content://开头的 URI转成 blob 或 dataURL 的方式取决于宿主实现。这就引出一个很重要的判断你的页面能不能上传第一关不在你的代码而在容器有没有把这条路打通。所以接到需求后我做的第一件事往往不是写上传逻辑而是用一段十行的测试页面验证宿主到底支不支持 input file、支持到什么程度。这个验证成本极低但能省掉后面大量无效调试。1.2 权限和文件路径里的隐藏约束即便onShowFileChooser实现了还有几层约束等着你。Android 从较新的版本开始强制使用存储访问框架SAF和 FileProvider 共享文件宿主 APP 拿到的是临时授权 URI这个授权通常只在当前会话内有效。如果 H5 把 URI 存进 localStorage 打算下次再用多半会失效。正确做法是拿到文件后立即读取成 Blob 或 ArrayBuffer放进内存或 IndexedDB别指望路径能长期有效。iOS 的沙盒机制更严格。原生通过runOpenPanelWith给你的文件通常落在tmp目录下系统在合适的时机就可能清理。我遇到过用户选完照片、切出去接了个电话、回来再点上传文件已经读不出来的情况。所以我的习惯是change事件一触发立刻把文件内容读进内存同时记录文件的元信息名称、大小、类型后续的压缩和上传全部基于内存里的数据操作。还有一类特殊情况是相册权限。用户第一次点选择文件时弹权限申请如果他点了拒绝宿主可能直接静默返回空结果H5 侧看起来就是点了没反应。这时候要能区分用户没选和权限被拒前者可以重新引导后者需要提示去设置里开启。可惜很多宿主桥接不区分这两种状态所以我在前端会加一个计时器兜底点击后超过一定时间没有回调就给出未获取到文件请检查相册/存储权限的提示避免用户对着屏幕干瞪眼。1.3 和普通浏览器上传的差异对照把差异列成表格排查问题时对着看会快很多环节普通浏览器宿主 APP 内 H5文件选择器浏览器自带行为统一依赖宿主实现行为各异返回的数据形式File 对象File / blob / dataURL / 路径字符串accept 属性基本可靠常被忽略需前端二次校验multiple 多选支持部分宿主只允许单选路径有效期会话内有效更短随时可能被系统清理权限模型浏览器统一管理宿主权限 系统权限双层缓存行为可控宿主可能强缓存更新不生效这张表里最容易被低估的是最后一行。宿主 APP 为了提升加载速度通常会给 WebView 开强缓存甚至内置离线包。结果就是你改了 H5测试同事说还是老样子。我一般会在静态资源 URL 后面挂一个构建时生成的版本号或者干脆约定一个查询参数让每次发版都能强制刷新。2. 方案选型纯 H5、原生桥接、混合方案到底怎么挑选型这件事没有标准答案但有一条判断主线你的宿主 APP 对 input file 的支持到什么程度决定了你能走纯前端的路还是必须借原生能力。我见过三种典型情况宿主支持得非常完整纯 H5 就能跑通宿主只实现了部分能力比如能选相册但不能选文件管理器宿主压根没实现必须走原生桥接。下面把三种方案摆开讲。2.1 三种主流方案的对比纯input typefile方案的优势是接入成本最低前端独立完成不用和原生团队扯皮。缺点是能力天花板完全由宿主决定而且不同系统版本、不同宿主版本的表现可能不一致兼容性测试成本高。多选、拍照、文件管理器这些入口能不能用全看运气。原生桥接方案是指 H5 通过约定的 JSBridge 调用原生封装的选文件能力原生选完把文件内容或临时路径回传。优势是行为可控、体验统一、能拿到更多元信息比如文件真实路径、拍照后的原始分辨率、甚至能直接调起原生相机界面。代价是需要原生团队配合协议要协商联调周期长两边版本要同步跨端维护成本上升。混合方案是我个人最推荐的主路径走 input file把它当作快速通道同时监听桥接能力是否存在如果检测到宿主不支持或者用户点击后长时间无响应自动降级到原生桥接。这样在支持良好的宿主上零成本跑通在支持差的场景下也有兜底用户不会卡死。2.2 为什么我把 input file 作为主路径理由有三条。第一它的代码量最小一个标签加一个监听就能覆盖大部分场景维护成本低。第二它的行为在同类宿主间正在趋同随着容器版本迭代标准实现越来越普遍长期看是收敛的方向。第三走这条路时前端掌握完整控制权压缩、校验、分片这些逻辑全在自己手里出问题定位路径短。但主路径不等于唯一路径。我通常会写一个能力探测函数在页面初始化时判断宿主是否暴露了桥接对象以及这个对象的版本。如果检测到桥接可用就先注册回调等 input file 失败时再触发。判断逻辑大概是这样// 能力探测宿主是否注入了桥接对象以及版本是否满足要求 function detectBridge() { const bridge window.hostBridge || window.webkit?.messageHandlers?.hostBridge; if (!bridge) return { available: false, reason: no-bridge }; const minVersion [1, 3, 0]; const current (window.hostAppInfo?.bridgeVersion || 0.0.0).split(.).map(Number); const ok current[0] minVersion[0] || current[1] minVersion[1]; return { available: ok, reason: ok ? ok : version-too-low }; }这段代码的用意是把能不能用原生能力这件事变成可判断的状态而不是靠 try-catch 猜。版本号比对是因为桥接协议经常迭代老版本的回调格式可能不兼容硬用反而出问题。2.3 临时文件的生命周期必须提前约定清楚不管走哪条路都有一个绕不开的话题文件在原生侧存在多久。Android 通过 FileProvider 共享出来的 URI授权通常绑定在发起选择的那次 Activity 上iOS 的临时文件更是随时可能被清理。如果 H5 打算先记下路径用户填完表单再统一上传这条路径很可能已经失效了。我的处理方式是在change回调里立刻把文件读成 ArrayBuffer 或 Blob同时在内存里维护一个PendingFile列表里面存的是实际数据加上元信息。后续用户的任何操作包括预览、压缩、重试上传都基于这份内存数据。代价是大文件会占内存所以我会加一个上限比如单文件超过 200MB 就提示用户先裁剪或分段避免直接把页面拖崩。注意不要把原生返回的临时路径写进 localStorage 或传给后端这类路径只对当前设备当前会话有意义换台机器或换个时间点就是无效数据。3. 前端实操把选择、校验、压缩、分片、上传串成一条流水线方案定了之后剩下的是把流水线搭起来。我把这条线拆成五段选择、校验、压缩、分片、上传。每一段都有独立的关注点也都有各自容易翻车的地方。下面按顺序讲中间会给出经过实际验证的参数和写法。3.1 文件选择与格式校验的两个层次第一层是引导性校验用accept属性告诉容器我想要什么类型。写法上尽量用 MIME 类型加扩展名双保险input typefile acceptimage/jpeg,image/png,image/heic,application/pdf,.doc,.docx multiple /之所以两样都写是因为部分宿主只认扩展名部分只认 MIME双写能提高命中率。同时要注意accept只是引导用户在文件管理器里完全可以选别的类型所以第二层强制校验不能省。第二层在change回调里做校验三件事类型、大小、数量。类型校验别只信file.type因为有些平台返回空字符串这时候要回退到扩展名判断。大小校验要同时在前端和后端做前端做是为了快速反馈后端做是安全底线两边的阈值必须一致。数量校验容易被忽略我遇到过用户长按相册一次选了 50 张图前端直接卡死。const LIMITS { maxSize: 20 * 1024 * 1024, maxCount: 9, maxTotalSize: 80 * 1024 * 1024 }; const ALLOWED_EXT [jpg, jpeg, png, heic, pdf, doc, docx]; function validate(fileList, existing) { const errors []; const picked Array.from(fileList); if (existing.length picked.length LIMITS.maxCount) { errors.push(最多上传 ${LIMITS.maxCount} 个文件); } let total existing.reduce((s, f) s f.size, 0); picked.forEach((f) { const ext f.name.split(.).pop()?.toLowerCase() || ; if (!ALLOWED_EXT.includes(ext)) errors.push(${f.name} 格式不支持); if (f.size LIMITS.maxSize) errors.push(${f.name} 超过 20MB); total f.size; }); if (total LIMITS.maxTotalSize) errors.push(文件总大小超出限制); return { ok: errors.length 0, errors }; }3.2 压缩之前先解决图片方向这个老问题图片压缩的常规做法是用 canvas 重绘但有一个坑几乎每个项目都会撞上手机拍照的 JPEG 带 EXIF 方向标记canvas 画出来之后方向标记丢了结果竖拍的照片上传后变成横的。解决办法有两种一是解析 EXIF 拿到 Orientation 再手动旋转二是用createImageBitmap时指定方向参数async function compressImage(file, { maxEdge 1920, quality 0.75 } {}) { const bitmap await createImageBitmap(file, { imageOrientation: from-image }); const scale Math.min(1, maxEdge / Math.max(bitmap.width, bitmap.height)); const w Math.round(bitmap.width * scale); const h Math.round(bitmap.height * scale); const canvas document.createElement(canvas); canvas.width w; canvas.height h; const ctx canvas.getContext(2d); ctx.drawImage(bitmap, 0, 0, w, h); bitmap.close(); return new Promise((resolve, reject) { canvas.toBlob( (blob) (blob ? resolve({ blob, width: w, height: h }) : reject(new Error(压缩失败))), image/jpeg, quality ); }); }imageOrientation: from-image这个参数能自动按 EXIF 方向摆正省掉手写解析。但它有兼容性差异老内核可能不支持所以我会在初始化时做一次探测不支持就退回手动解析 EXIF 的方案。参数选择上maxEdge我一般取 1920 或 2048。算笔账一张 4000×3000 的手机照片按长边 1920 缩放后是 1920×1440像素数从 1200 万降到 276 万再以 0.75 质量编码体积通常从 4~6MB 压到 300~600KB压缩比接近 10:1而肉眼观感在手机屏幕上完全够用。如果是证件照类需要识字的材料我会把 maxEdge 提到 2560、质量提到 0.85保证文字边缘不糊。3.3 分片上传切多大、并发几个、怎么续大文件直传的问题很明显一次 POST 几十 MB弱网下超时概率极高断了还得从头来。分片上传把文件切成固定大小的块分别发送配合服务端的合并接口能把断点续传做起来。分片大小怎么定太小会导致请求数暴涨头部开销占比高太大会让单片失败重传的代价变大。我常用5MB这个值理由是它在多数移动网络下单次请求耗时可控同时一个 100MB 的文件正好切成 20 片请求数在合理区间。并发数我一般给3 到 4再多会挤占带宽导致单片更容易超时反而拖慢整体。计算方式很简单总片数 Math.ceil(fileSize / chunkSize)。假设文件 103MBchunkSize 取 5MB那就是 21 片最后一片 3MB。上传完成后服务端按序号拼接前端需要提交一个清单包含文件标识、总片数和每片的校验值。秒传和续传靠的是文件指纹。完整计算整个文件的 hash 在移动端太慢我采用采样哈希取文件头部、中部、尾部各若干 KB加上文件大小拼在一起做哈希。这样计算耗时可忽略同时碰撞概率在日常使用中足够低。async function sampleHash(file, sampleSize 64 * 1024) { const chunks []; const points [0, Math.floor(file.size / 2), Math.max(0, file.size - sampleSize)]; for (const start of points) { const blob file.slice(start, Math.min(start sampleSize, file.size)); chunks.push(new Uint8Array(await blob.arrayBuffer())); } const header new TextEncoder().encode(${file.size}:); const merged new Uint8Array(header.length chunks.reduce((s, c) s c.length, 0)); merged.set(header, 0); let offset header.length; chunks.forEach((c) { merged.set(c, offset); offset c.length; }); const digest await crypto.subtle.digest(SHA-256, merged); return Array.from(new Uint8Array(digest)).map((b) b.toString(16).padStart(2, 0)).join(); }拿到指纹后先问服务端这个文件是否已存在存在就直接完成这就是秒传。断点续传则是服务端返回已成功接收的分片序号数组前端跳过这些序号继续传。上传过程中把进度写进 localStorage即使页面被宿主回收、用户重新进来也能接着传。3.4 进度反馈和失败重试的粒度控制进度条不能只在整体层面更新否则用户看到卡在 60% 不动会以为死机。我的做法是分片级进度 整体进度双层展示每个分片用 XHR 的upload.onprogress拿到实时字节数汇总后算出整体百分比同时显示已完成 12/21 片这样的文字让用户知道系统在工作。失败重试要区分错误类型。网络类错误超时、连接中断适合自动重试我一般给每个分片最多 3 次机会采用指数退避间隔 1s、2s、4s再加一点随机抖动避免所有分片同时重试把服务端打爆。业务类错误格式校验失败、权限不足不应该重试直接报错并保留其他分片的进度。还有一个细节当宿主 APP 切到后台WebView 的 JS 执行会被挂起正在进行的上传可能被中断。较新的宿主会提供前后台事件桥接可以监听后手动暂停回到前台再恢复。如果没这个能力就靠分片上传本身兜底——重新回到前台时检查进度把未完成的分片补上。4. 和原生层对接协议、差异和那些不写文档的约定只要涉及桥接就会涉及协议设计。这部分最容易出问题因为两端对同一条消息的理解可能不一致而且出问题时很难定位到底是哪一端的锅。我总结的经验是协议要显式声明版本消息格式要统一回调要带唯一的请求标识。4.1 桥接消息的结构设计我用的格式是一个统一的信封结构所有消息都套这个壳function callNative(action, payload, timeout 30000) { return new Promise((resolve, reject) { const callbackId cb_${Date.now()}_${Math.random().toString(36).slice(2, 8)}; const timer setTimeout(() { delete window.__bridgeCallbacks[callbackId]; reject(new Error(bridge timeout: ${action})); }, timeout); window.__bridgeCallbacks[callbackId] (result) { clearTimeout(timer); delete window.__bridgeCallbacks[callbackId]; result?.error ? reject(new Error(result.error)) : resolve(result); }; const message JSON.stringify({ version: 1.0, action, callbackId, payload: payload || {}, timestamp: Date.now(), }); if (window.hostBridge?.postMessage) { window.hostBridge.postMessage(message); } else if (window.webkit?.messageHandlers?.hostBridge) { window.webkit.messageHandlers.hostBridge.postMessage(message); } else { clearTimeout(timer); delete window.__bridgeCallbacks[callbackId]; reject(new Error(bridge unavailable)); } }); }这里几个设计点值得说明。callbackId是为了解决并发调用的响应错位问题如果不带标识同时发起两次选文件请求回调就分不清谁是谁。timeout是防呆原生侧如果因为异常没回调前端不能无限等。version字段让原生能做兼容分发遇到老 H5 就走老逻辑。timestamp主要用于排查日志里带上时间戳回放问题时能对齐两端的时间线。4.2 Android 和 iOS 的差异要在封装层抹平两端原生实现的差异很多但我不建议在业务代码里到处写if (isAndroid)而是集中在一层适配器里抹平。常见的差异包括差异点Android 侧iOS 侧抹平方式调用方式注入全局对象messageHandlers 通道适配器内统一封装文件回传content URI / base64沙盒路径 / base64统一转 base64 或 blob多选部分宿主限制通常支持检测能力后分别处理拍照返回原始分辨率可能被压缩统一再走一遍压缩权限提示系统弹窗系统弹窗 设置跳转分别给文案base64 和 blob 怎么选base64 的好处是跨桥接通道传输稳定不受路径有效期影响坏处是体积膨胀约 33%大文件传输时内存压力大。我的策略是小文件比如 2MB 以下直接 base64 传回大文件让原生写到应用沙盒目录再返回路径前端立即读取后在本地上报完成时通知原生清理。这样兼顾了稳定性和内存。4.3 缓存、白屏和版本不同步宿主 APP 的 WebView 缓存是另一个高频问题。表现包括改了代码不生效、桥接对象在页面早期拿不到、静态资源加载了旧版本。我遇到过最诡异的一次是新版本 H5 调用了新桥接方法但宿主还是老版本方法不存在页面直接白屏。处理这类问题的思路有三条。一是入口处加环境探测页面初始化时读取宿主注入的版本信息不满足最低要求就展示升级提示而不是让页面崩掉。二是桥接调用全部包在 try-catch 或 Promise 里任何一个方法不存在都不能影响页面主体渲染。三是静态资源加版本戳并在发布流程里约定必须先发 H5 再发宿主版本或者保证向后兼容至少两个版本。还有个小技巧在页面里放一个隐藏的调试面板长按标题若干秒呼出显示宿主版本、桥接版本、内核信息、最近几条桥接调用日志。测试同学反馈问题时让他们截图这个面板能省掉大量来回确认。5. 常见问题与排查实录真正耗时间的从来不是写代码而是排查那些看起来应该能跑的问题。下面这张表是我和团队在实际项目里积累的按问题现象排序配合排查思路和解决方案。5.1 问题速查表现象可能原因排查方法解决方向点击选择文件无反应宿主未实现文件选择回调用最小测试页验证走桥接兜底或推动宿主支持选完文件 change 不触发回调未正确回传 URI打日志看是否有 onShowFileChooser检查宿主实现上传后图片方向不对EXIF 方向丢失对比原图与压缩图用 imageOrientation 或手动旋转大文件上传必失败单请求体过大 / 超时看服务端日志的请求体大小改分片上传上传中切后台就断JS 被挂起复现前后台切换分片续传 前后台事件中文文件名乱码编码不一致看服务端收到的文件名统一 UTF-8或改用 ID 命名页面更新不生效WebView 强缓存清除缓存后对比加版本戳走离线包更新内存占用飙升后崩溃大文件全量读进内存看内存监控曲线限制单文件大小及时释放 blobiOS 选 HEIC 上传失败服务端不识别 HEIC看服务端 MIME 判断前端转 JPEG 或服务端支持多选时只传回一个宿主不支持多选测试单选与多选前端限制数量或串行调用5.2 几个印象最深的坑第一个是中文文件名。用户上传营业执照扫描件.pdf服务端收到的变成乱码。根因是桥接传输过程中编码没统一Android 侧有的实现用 GBK前端用 UTF-8来回转换就烂了。后来我们统一约定文件名只作为展示用实际存储用服务端生成的文件 ID前端提交时把原始文件名放进 JSON 体的一个字段由 HTTP 层保证 UTF-8 编码。这样彻底绕开了桥接层的编码问题。第二个是HEIC 格式。iOS 拍照默认输出 HEIC虽然扩展名可能被容器伪装成 jpg但实际编码不是 JPEG服务端按 JPEG 解析直接失败或者解析出全黑图片。解决办法是在前端压缩环节统一走 canvas 重绘输出恒定为 JPEG等于顺手做了格式转换。这个思路比让服务端支持 HEIC 更省事也不用担心服务端图像库版本问题。第三个是上传过程中的页面卸载。用户在上传时点了返回页面被销毁未完成的上传就断了。如果业务要求离开也要继续传就必须把上传任务交给原生侧接管H5 只负责发起和查询状态。这是个架构级的决定需要在需求阶段就确认别等做完了才发现实现不了。提示所有涉及文件的功能上线前一定要在真实设备上跑一遍完整链路模拟器上的文件选择行为往往和真机不一致尤其是相册和拍照入口。6. 性能、安全与体验上还能再抠的细节功能跑通只是及格线真正拉开差距的是细节。这部分讲三个容易被忽略但影响很大的点内存管理、安全校验、以及上传体验的细节打磨。6.1 内存管理及时释放比什么都重要移动端的 WebView 内存本来就紧张如果再叠加几个大文件的 Blob很容易触发系统回收。我的原则是用完即释放能不算就不算。具体做法包括读取文件后如果不需要保留原始数据就把引用置空压缩完成后调用bitmap.close()主动释放图形资源上传完成的分片数据不再持有引用待上传队列里如果某个文件被用户删除同步从内存里移除。还有一个容易忽略的点是 URL 对象。如果用URL.createObjectURL做预览一定要在预览关闭时调用URL.revokeObjectURL否则每预览一次就泄漏一份内存。我在预览组件里把这两个调用严格配对写成一个 hook 统一管理。6.2 安全校验前端做体验后端做底线前端的所有校验都可以被绕过这一点必须清醒。所以我的原则是前端校验负责快速反馈和减少无效请求后端校验负责真正的安全。两边的规则要一致包括允许的扩展名白名单、单文件大小上限、总大小上限、文件数量上限。后端还应该做内容探测也就是读取文件头几个字节判断真实类型不能只信 MIME。关于文件名的处理我建议服务端生成存储名原始名只存数据库。这样既避免了文件名注入、路径穿越这类问题也不用担心不同文件系统的编码差异。上传接口要做鉴权分片接口要校验该分片是否属于当前用户的这个上传任务防止越权覆盖。还有一点上传目录绝不能可以被直接执行或者被 Web 服务器解析。存储路径和访问路径要分离对外只暴露经过鉴权的下载接口。6.3 体验细节用户愿不愿意等你全看这几秒上传体验的核心是让用户知道发生了什么并且可以随时中断。进度条要真实不能是假的动画剩余时间估算可以给但要基于滑动平均值别用瞬时速度导致数字乱跳。每个文件要有独立状态等待中、上传中、成功、失败、已取消失败的要能单独重试成功的可以在提交前删除。弱网下的策略也值得优化。我会在检测到网络类型是移动数据且文件较大时给一个提示让用户确认因为有些用户的流量套餐有限。上传失败后不要弹一堆错误而是把失败的文件标记出来配一个重试全部失败项的按钮减少重复操作。最后是提交逻辑。我习惯把附件上传和表单提交解耦附件先传到临时区拿到文件 ID 后随表单一起提交表单提交成功后再通知服务端把附件转为正式状态。这样做的好处是表单填到一半用户退出附件不会变成脏数据定时任务可以清理那些长时间未关联到表单的临时文件避免存储无限增长。整个链路的稳定性最后还是回到最开始那句话先把宿主环境摸清楚再决定技术路线然后把每个环节的边界条件都测到。附件上传看起来只是个小功能但它串联了容器能力、前端性能、网络传输、后端存储四条链路任何一环的疏忽都会在用户那里放大成这个 APP 不好用。我在实际项目里的体会是把测试页和调试面板提前做好后面所有问题都能快一倍定位这个投入非常值。