简介HTML5多图片上传预览源码包面向前端初学者与进阶开发者基于File API和拖放API实现本地多图选择、即时预览无需服务器参与可应用于表单上传、后台管理等网页场景。压缩包共18个文件约145KB包含1个HTML入口、5个JavaScript脚本、1个CSS样式和9张界面图片其中jquery-1.7.2、zyUpload等文件分别承担DOM操作与多图上传核心逻辑目录层次清晰便于按需调用。已有1470人学习下载。源码附有可直接运行的示例页演示了FileReader读取图片并转换为dataURL、监听change事件动态生成预览图以及通过拖放事件扩展上传入口等实现细节帮助理解原生API的典型用法并展示如何与jQuery类库搭配组织代码。阅读后可快速改造出符合自己需求的上传组件适合日常练习与项目起步。1. 多图片上传预览HTML5 里最值得手写的一段源码在后台管理系统和 C 端表单里「多图片上传 前端预览」是出现频率最高的交互之一。以前大家习惯拖一个 upload.js 或者 Flash 组件进来但现在原生 HTML5 完全能扛住这件事不依赖任何框架一个页面加几段原生 JavaScript就能实现多选图片、即时预览、删除和上传。我这边已经把源码整理成一份已测试过的独立 HTML 文件你打开就能跑。这套方案适合两类人一是前端新手想搞懂 file input、FileReader 和 FormData 到底怎么协作二是被插件坑过的老手想用不到两百行代码把上传功能收回来自己控制。2. HTML5 多图片上传预览的底层原理三个 API 的分工与取舍文件上传预览这事拆开看其实是三个动作拿到图片文件、把图片转成可在页面显示的地址、把文件按原样发给后端。HTML5 为每个动作都提供了原生能力很多人写不好是因为把这三个动作混在一起用。2.1 FileReader.readAsDataURL 与 URL.createObjectURL两种预览方案的性能对比先看最常见的预览实现用 FileReader 把图片读成 Base64 字符串再塞给 img 的 src// 方案AFileReader 读成 DataURLBase64 字符串 function previewWithFileReader(file) { const reader new FileReader(); reader.onload function (event) { const img document.createElement(img); img.src event.target.result; // 形如 data:image/jpeg;base64,/9j/4AAQ... document.getElementById(previewBox).appendChild(img); }; reader.readAsDataURL(file); }这段代码在功能上没错但埋了一个性能坑Base64 字符串比原始文件体积大约膨胀 33%一张 5MB 的 JPG 读成 DataURL 后字符串接近 7MB。预览三张图页面内存占用立刻上去移动端尤其明显经常出现滑动卡顿甚至页面崩溃。另一个原生方案是用 URL.createObjectURL直接给文件生成一个临时的 blob 地址// 方案BURL.createObjectURL 生成 blob 地址加载后可回收 function previewWithObjectURL(file) { const objectURL URL.createObjectURL(file); const img document.createElement(img); img.src objectURL; // 形如 blob:http://localhost:8080/abcd-1234 img.onload function () { setTimeout(() URL.revokeObjectURL(objectURL), 1000); }; document.getElementById(previewBox).appendChild(img); }这两段代码的选择逻辑很简单预览用 objectURL提交用 FormData 传原始 File。objectURL 不拷贝文件数据浏览器按引用管理内存友好revokeObjectURL 是为了在图片加载完成后释放这个临时地址否则多次选择图片后会累积无效的 blob 资源。revoke 的时机我习惯放在 img.onload 之后再包一层 setTimeout避免某些浏览器在图片还没绘制完就回收地址导致裂图。参数说明FileReader 的 onload 是异步回调event.target.result 就是读取结果readAsDataURL(file) 是触发读取的方法。URL.createObjectURL(file) 的入参必须是一个 Blob 或 File 对象返回值只在当前页面会话内有效刷新页面后所有 blob 地址都会失效——所以它只适合做本地预览不能拿来直接提交给后端。那什么时候该用 FileReader如果你的业务要求前端先压缩图片、生成缩略图再把压缩后的 Blob 传出去那你就绕不开 canvas而 canvas.toBlob 和 FileReader 的组合是标准路径。我在第 4 章会专门讲压缩参数怎么配。还有一个细节FileReader 也可以用于把图片转成 base64 后直接存在后端数据库的 text 字段里这种场景不多但老项目里常有遇到时用方案A别用 objectURL因为 base64 字符串能持久化blob 地址不行。我一般会两种方法都封装成函数预览阶段的默认走 objectURL如果产品明确要求上传 base64再切换 FileReader。这样做的好处是图片数量超过十张的大表单页面内存曲线明显更平缓。2.2 用 FormData 把预览和上传串成一条线预览只是在页面上给用户一个反馈真正要让图片落到服务器还得通过 FormData 走 multipart/form-data 协议。很多人把这块写错是因为没弄明白 input.files 到底是个什么结构。// 从 input 元素里取文件列表 const input document.getElementById(fileInput); const fileList input.files; // FileList 对象不是数组也没有 forEach const files Array.from(fileList); // 转成真正的数组方便用 map/filter // 逐个塞进 FormData const formData new FormData(); files.forEach((file, index) { // 字段名带下标后端可以直接按顺序取 formData.append(images[${index}], file, file.name); }); // 附带业务字段 formData.append(bizId, A20240601); formData.append(remark, 来自测试表单); // 提交 fetch(/api/upload, { method: POST, body: formData, // 注意不要手动设置 Content-Type浏览器会自动加 boundary }).then(res res.json()).then(data { console.log(上传结果, data); });逻辑说明FileList 是类数组对象直接对它调用 forEach 会报错所以先用 Array.from 转成数组。FormData.append 接收三个参数字段名、文件对象、文件名。字段名我习惯写成 images[0]、images[1] 这种形式后端按数组解析PHP 端用 $_FILES[images][name][0] 就能取到第一张。关键坑在于 fetch 的 body 直接传 FormData同时不要手动设置 Content-Type。一旦你写了 Content-Type: application/json浏览器就不会自动追加 multipart 的 boundary后端解析器直接报错收不到任何文件。参数说明method 必须为 POST 或 PUTGET 请求带 FormData 没有意义。append 的第三个参数是上传后的文件名一般传 file.name 原样但要注意中文文件名在部分老后端会出现乱码稳妥做法是重命名成时间戳加随机串保留扩展名就好。到这里预览和上传的链路已经拼起来了。我遇到不少同事会额外加一个 FormData 的调试逻辑先 formData.get(images[0]) 看能不能取回文件对象再决定要不要调后端接口。这个动作虽然简单但在联调时能省下大量排查时间值得养成习惯。这里还要补一个认识为什么不用 jQuery 时代的 uploadify 或者老牌上传组件那些插件大多基于 Flash 或隐藏 iframe 模拟上传HTML5 原生方案出现后插件存在的意义只剩拖拽样式和上传队列的封装而已。你既然要的是「源码已测试」的落地件不如直接用原生 API少一层依赖也少一堆兼容性黑盒。这也是我写这份源码的出发点。还有一个和 multiple 相关的细节当 input 没有设置 multiple 时即使用户在系统对话框里按住 Ctrl 选了多张图files 也只会有第一张。这属于浏览器行为不是代码 bug。我见过有人在 multiple 漏写的情况下排查半天最后发现是 HTML 属性被复制时弄丢了。所以拿到代码第一件事查 input 标签上有没有 multiple没有它后面所有多图逻辑都是白写。3. 完整源码支持多选、预览、删除与上传的独立 HTML 页面这一章直接给完整源码。我建议不要把它拆成一堆片段让你自己拼整页复制后你会看到每个部分如何协作。源码里我用了一个独立数组 fileList 来管理文件而不是频繁操作 input.files这个设计从第 2.2 节的讨论衍生而来也是删除、重排、压缩等功能能叠加的基础。测试时你随便起一个本地静态服务用 VS Code 的 Live Server 或者其他方式都行直接 file:// 打开部分浏览器也能跑。3.1 页面骨架隐藏原始 input用一个按钮和预览容器撑起交互先说结构思路。原始的上传控件是一个 input[typefile]它的默认样式在不同浏览器里差异巨大尤其在 Windows 上丑得一言难尽。常见做法是把它隐藏用一个样式可控的按钮去触发它的 click。预览区域是一个普通的 div 容器里面动态插入缩略图节点。!DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 titleHTML5 多图片上传预览/title style .upload-wrapper { max-width: 680px; margin: 40px auto; padding: 24px; } .upload-btn { display: inline-block; padding: 10px 28px; background: #2c6fdb; color: #fff; border-radius: 6px; cursor: pointer; user-select: none; } #fileInput { display: none; } .preview-box { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 20px; } .preview-item { position: relative; width: 120px; height: 120px; } .preview-item img { width: 100%; height: 100%; object-fit: cover; border-radius: 4px; } .preview-item .del-btn { position: absolute; top: -6px; right: -6px; width: 22px; height: 22px; border-radius: 50%; background: rgba(0,0,0,.65); color: #fff; text-align: center; line-height: 22px; cursor: pointer; } #uploadBtn { margin-top: 20px; padding: 10px 32px; } /style /head body div classupload-wrapper div classupload-btn onclickdocument.getElementById(fileInput).click()选择图片/div input typefile idfileInput multiple acceptimage/* styledisplay:none div classpreview-box idpreviewBox/div button iduploadBtn开始上传/button /div script // 第 3.2 节的 JS 逻辑写在这里 /script /body /html参数说明input 的 multiple 属性是支持多选的关键去掉它就只能单选。acceptimage/* 可以让大多数系统的文件选择器默认只显示图片文件。这里的隐藏方式用的是 display: none有些教程会建议用 opacity: 0 定位覆盖那是为了在旧浏览器里保证 input 可聚焦现在 display: none 足够因为 click 是程序化触发的不受影响。如果你不想引入额外的 CSS 框架这段内联样式已经完全够用我特意没用任何预处理器就是为了让你复制下来就能跑。还有一个样式细节预览图容器里用 flex-wrap 加 gap 做间距gap 在现在的主流浏览器里都支持了不需要再给每个子项写 margin。缩略图的 120x120 尺寸搭配 object-fit: cover图片比例失调时会自动裁剪居中这个属性在 IE 里不支持但后台系统基本都迁到了 Chromium 内核可以直接用。3.2 核心交互change 事件、缩略图渲染与删除逻辑下面这一段是整份源码里改动最多的地方我直接把完整逻辑贴出来每段都带注释。const fileInput document.getElementById(fileInput); const previewBox document.getElementById(previewBox); const uploadBtn document.getElementById(uploadBtn); // 用自定义数组维护文件列表而不是每次都去读 input.files let fileList []; fileInput.addEventListener(change, function () { const selected Array.from(fileInput.files); if (selected.length 0) return; // 过滤掉非图片类型accept 在部分浏览器里可以绕过 const images selected.filter(file file.type.startsWith(image/)); if (images.length ! selected.length) { alert(已忽略非图片文件); } // 数量上限 9 张 const remain 9 - fileList.length; if (remain 0) { alert(最多上传 9 张图片); return; } // 把新选中的文件追加到数组最多保留 9 张 fileList fileList.concat(images.slice(0, remain)); renderPreview(); // 重置 input否则重复选择同一个文件不会触发 change fileInput.value ; }); // 渲染所有缩略图 function renderPreview() { previewBox.innerHTML ; fileList.forEach(function (file, index) { const item document.createElement(div); item.className preview-item; item.dataset.index index; const img document.createElement(img); img.src URL.createObjectURL(file); img.alt file.name; const delBtn document.createElement(div); delBtn.className del-btn; delBtn.textContent ×; delBtn.dataset.index index; item.appendChild(img); item.appendChild(delBtn); previewBox.appendChild(item); }); } // 用事件委托处理删除避免给每个删除按钮单独绑监听 previewBox.addEventListener(click, function (event) { const target event.target; if (!target.classList.contains(del-btn)) return; const index parseInt(target.dataset.index, 10); if (isNaN(index)) return; fileList.splice(index, 1); renderPreview(); });逻辑说明这里最关键的是用 fileList 数组接管文件管理。很多初版实现会直接操作 input.files但 FileList 是只读的你无法从中删除一个文件所以一旦要实现「删除某一张再上传」就卡住了。用数组就不存在这个问题添加、删除、重排都走数组操作最后提交时再从数组构建 FormData。change 事件末尾的 fileInput.value 是必须的否则你刚删了一张图再选同一张图input 的 value 没变化change 事件不会触发看起来就像按钮失灵。参数说明selected.filter(file file.type.startsWith(image/)) 这行代码是第二层防线。acceptimage/* 只是让文件选择器默认过滤但 Windows 的「所有文件」选项和部分国产浏览器的实现都会让你选到 .txt 或改名后的文件。type 为空的情况也要注意某些安卓 WebView 对未知扩展名返回空字符串如果你业务只收图片遇到空 type 直接丢弃是稳妥策略。渲染时用 URL.createObjectURL(file) 而不是 FileReader原因在第 2 章已经说过。删除按钮用事件委托挂到 previewBox 上新生成的节点不用单独绑监听省内存也省代码。删除按钮的 × 符号用 textContent 设置避免用户输入内容被当成 HTML 解析。3.3 提交上传用 XHR 拿上传进度用 fetch 做简单提交上传动作有两种实现方式。fetch 代码简洁但难以拿到上传进度XHR 的 upload.onprogress 才是进度条的正统做法。我这里两个都给你按需选。// 方式一fetch 上传适合不需要进度条的内部工具 async function uploadWithFetch() { if (fileList.length 0) { alert(请先选择图片); return; } const formData new FormData(); fileList.forEach(function (file, index) { formData.append(images[${index}], file, file.name); }); try { const res await fetch(/api/upload, { method: POST, body: formData }); const data await res.json(); console.log(上传成功, data); } catch (err) { console.error(上传失败, err); } } // 方式二XHR 进度条适合生产环境 function uploadWithXHR() { if (fileList.length 0) { alert(请先选择图片); return; } const formData new FormData(); fileList.forEach(function (file, index) { formData.append(images[${index}], file, file.name); }); const xhr new XMLHttpRequest(); xhr.open(POST, /api/upload, true); xhr.upload.onprogress function (event) { if (event.lengthComputable) { const percent Math.round((event.loaded / event.total) * 100); document.getElementById(progressText).textContent 上传进度 percent %; } }; xhr.onload function () { if (xhr.status 200) { console.log(上传完成, xhr.responseText); } else { console.error(服务端返回错误, xhr.status); } }; xhr.onerror function () { console.error(网络异常上传中断); }; xhr.send(formData); } uploadBtn.addEventListener(click, uploadWithXHR);逻辑说明XHR 的 upload.onprogress 里event.loaded 表示已上传的字节数event.total 是总字节数只有 lengthComputable 为 true 时 total 才有意义。多图上传时event.total 是所有图片的总大小所以进度条是整体进度不是单张进度。如果你是刚接触 XHR 的进度条注意 upload.onprogress 和 xhr.onprogress 是两回事upload 上的事件拿的是请求体的上传进度xhr 上的拿的是响应下载进度拿错对象你会看到进度条卡在 0% 半秒然后直接跳到 100%。另一个常见混淆是把 event.loaded 当成了文件数量实际上它是字节数数值很大不要直接往界面上怼。如果你准备把这套逻辑接到 Vue 或 React 项目里思路也是一样的fileList 换成响应式数组renderPreview 换成视图层自动更新事件委托换成组件里的事件。原生版本的意义在于先把文件处理、预览回收、上传进度这些逻辑跑通框架只是换壳核心 API 一个都不变。我见过很多直接照搬插件做法的项目最后卡在 FileList 的只读问题上就是因为没有理解第 2 章的数组管理思想。到这里一个完整的多图片上传预览页面已经可以在本地跑通。把前几段代码放到同一个 HTML 文件里后端你随便用一个能接收 multipart 的接口测试比如 Spring Boot 的 MultipartFile 或者 Node 的 multer接口返回 JSON 就够用了。4. 参数调优accept、multiple、maxCount、压缩阈值四个必调参数源码拿到手能跑只是第一步真正投入生产前这四个参数必须根据你的业务重新调一遍。下面的参数表是我的默认配置你可以直接抄但要清楚每一项是干嘛的。参数默认值调整依据MAX_COUNT9后端单请求体限制 / 产品需要的最大图数MAX_SIZE_MB5Nginx client_max_body_size / 上传带宽maxWidth1280详情页最大展示宽度quality0.7对体积更敏感时降到 0.5acceptimage/*业务允许的图片格式4.1 accept 过滤与 MIME 类型只设 accept 远远不够第一个参数是 acceptimage/*。它的作用是让系统文件选择器默认筛选图片但用户完全可以在 Windows 对话框里把类型切成「所有文件」或者从桌面拖一个 .psd 进来。所以真正的过滤必须在 change 事件里做。我常用的过滤逻辑分三层。第一层看 file.type 是否以 image/ 开头第二层看扩展名用 file.name 的结尾去匹配 jpg、jpeg、png、gif、webp第三层看 file.size 是否在上下限之间。三层都通过才允许预览。这里有一个容易忽略的场景用户在手机上从相册选图某些 Android 相册返回的 file.type 可能是空字符串但扩展名是 .jpg。如果你只判断 type图片会被误杀。所以我建议把扩展名判断当成保底type 存在时两者都过type 为空时只看扩展名。function isImage(file) { const extMatch /\.(jpe?g|png|gif|webp|bmp)$/i.test(file.name); if (file.type ) return extMatch; // 兼容某些安卓 WebView return file.type.startsWith(image/) extMatch; }参数说明正则里的 i 修饰符大小写不敏感.JPG 也能过。bmp 格式的压缩率很差如果产品面向移动端建议在过滤清单里去掉 bmp否则用户选一张 3MB 的 bmp压缩后可能还要 2MB。webp 目前兼容性已经很好可以保留。如果你业务确定只收 jpg 和 png就把 gif、webp 一并去掉过滤越严格后面的压缩逻辑越简单。4.2 数量上限与尺寸上限前端限制救的是用户体验不是安全数量上限就是第 3 章里的 maxCount。这个值一要参考后端单次请求体大小的限制二要参考你预览区域的布局。常见后台表单是 6 到 9 张商品图可能到 15 张。超过上限时的交互我建议用文案提示而不是 alertalert 在测试环境无所谓但正式环境太粗暴。尺寸上限要分两种情况讨论。一是单文件大小上限比如 5MB超过就直接拒绝提示「单张图片不能超过 5MB」。二是预览时的内存上限这个通常发生在用户选了 20MB 的图片时即使你没限制文件大小objectURL 生成的缩略图也要经过浏览器解码大图解码非常吃内存。所以如果业务允许最好对大图做前端压缩。const MAX_COUNT 9; const MAX_SIZE_MB 5; function validateFile(file) { if (!isImage(file)) return { ok: false, msg: 不支持的图片格式 }; if (file.size MAX_SIZE_MB * 1024 * 1024) { return { ok: false, msg: 单张图片不能超过 ${MAX_SIZE_MB}MB }; } return { ok: true }; }参数说明5MB 这个值不是固定的。如果你的服务器上传配置是 Nginx client_max_body_size 10m那 5MB 单张、9 张上限总体积最大 45MB会直接撞上请求体上限。所以 MAX_COUNT 和 MAX_SIZE_MB 要一起调保证 MAX_COUNT × MAX_SIZE_MB 明显小于后端请求体限制。前端限制不是为了防攻击是为了不让用户等到超时才发现传不上去。还有一个容易被忽略的场景用户选图后如果图片是 iOS 相册里旋转过的JPEG 的 EXIF 信息里会带 orientation 标记而 canvas.drawImage 绘制时不会自动应用这个旋转信息导致预览图显示正常但压缩后却横过来了。解决思路有两个一个是压缩前读取 EXIF 的 orientation 值对 canvas 做相应的旋转和翻转另一个是干脆不在前端压缩原图只在预览缩略图时用 canvas 降采样。如果你的用户画像里 iPhone 占比高压缩功能上线前一定要拿真机测一组竖拍图。4.3 图片压缩参数canvas.toBlob 的 quality 与 maxWidth 怎么配压缩是多图上传预览里最深的坑。不做压缩用户拍一张 12MB 的照片上传要卡做压缩压缩过头图片糊产品又来找你。我的配置原则是只压大图小图不压宽度超过 1280 的压否则原样传。function compressImage(file, maxWidth 1280, quality 0.7) { return new Promise((resolve) { const img new Image(); img.onload function () { // 等比缩放只处理宽或高超过 maxWidth 的图 let { width, height } img; if (width maxWidth height maxWidth) { // 小图直接用原文件不要走 canvas避免质量损失 resolve(file); return; } const ratio Math.min(maxWidth / width, maxWidth / height); width Math.round(width * ratio); height Math.round(height * ratio); const canvas document.createElement(canvas); canvas.width width; canvas.height height; const ctx canvas.getContext(2d); ctx.drawImage(img, 0, 0, width, height); // 转成 Blob质量参数只在有损格式下生效 canvas.toBlob(function (blob) { if (!blob) { // 个别浏览器在 toBlob 时会返回 null回退原文件 resolve(file); return; } // 用压缩后的 Blob 替换原文件 const compressed new File([blob], file.name, { type: file.type }); resolve(compressed); }, file.type, quality); }; img.onerror function () { resolve(file); // 图片解码失败回退 }; img.src URL.createObjectURL(file); }); }逻辑说明drawImage 之前必须等 img.onload 完成否则 canvas 画出来是空白的。ratio 用宽高方向上的最小比例保证图片不会变形。quality 参数只在 jpeg 和 webp 这种有损格式下生效png 传了 quality 也没用所以如果用户选的是 png要么保持原样要么在压缩前把 type 改成 image/jpeg 并接受透明背景变黑的副作用。toBlob 的回调里一定要处理 blob 为 null 的情况Safari 对某些 webp 编码会失败。参数说明maxWidth 我取了 1280适合大多数详情页布局如果缩略图只需要 320 宽那 1280 相对偏大但为了后续可能点开看大图1280 是平衡点。quality 0.7 是经验值压缩后肉眼几乎无损体积能降到原来的 20% 到 40%。想要更激进的体积控制可以改成 0.5但注意文字边缘和细线条会出现明显噪点。还有一组经验值quality 到 0.8 以上体积变化很小性价比低0.5 以下高频细节会明显失真。如果对体积有硬性指标比如压缩后不能超过 300KB那就不能用固定 quality而是先按 0.7 压一次看结果是否达标不达标再按 0.6、0.5 逐步降最多试三次这就是自适应压缩的土办法。这个压缩函数要用的话就在 fileList.push 之前 await 一下const compressedFiles await Promise.all(images.map(f compressImage(f))); fileList fileList.concat(compressedFiles);Promise.all 的结果顺序与输入数组一致不会出现压缩耗时不同导致的乱序。这一点在后面避坑章节还会再提。5. 避坑多图片上传预览最常见的 7 个翻车现场以下每条都是我在真实项目里踩过或者看同事踩过的坑按「现象 → 原因 → 解决」写清楚。这些坑都不难修但每一个都至少耗过半天时间尤其是第 5.3 条线上环境排查了很久才发现是请求头的问题。5.1 重复选择同一张图片界面没有任何反应现象用户删掉一张图后再选同一张图预览区没有新增也没有报错仿佛点击无效。原因input 的 value 没有变化。浏览器认为你选择了同一个文件change 事件不会触发这是 file input 的固有行为不是你的代码逻辑有问题。解决在每次 change 事件处理完所有文件后立刻把 fileInput.value 重置为空字符串。注意时机必须在读取完文件并渲染完成后再重置否则你还没拿到文件就把它清了。fileInput.addEventListener(change, function () { // ...处理文件... fileInput.value ; // 这一行不能省 });5.2 大图预览导致页面卡顿甚至 WebView 崩溃现象用户选了几张手机原图单张 8MB 以上预览区滚动开始掉帧连续选图后浏览器标签页变卡甚至崩溃。原因虽然 objectURL 不复制文件内容但 img 标签加载后浏览器要为原始尺寸的图片分配解码内存。一张 4000x3000 的 JPG解码后的位图内存约为 4000 × 3000 × 4 字节接近 48MB三张就是 144MB。解决缩略图渲染时不要把原图直接塞给 img而是先走一遍压缩或降采样。最简单的方式是用 canvas 先画一张 240px 宽的缩略图 Blob再把 Blob 地址给 img.src。如果你的源码里已经接了压缩函数把 maxWidth 设成预览框宽度的两倍即可。5.3 后端收到 FormData 后始终收不到文件现象前端 fetch 返回 200后端日志显示字段数量为 0或者直接报「Required part images[0] is not present」。原因八成是手动设置了 Content-Type。很多人习惯在 fetch 里加上 headers: { Content-Type: application/json } 或者 multipart/form-data一旦手动设置浏览器就不会自动生成 boundary后端解不了 multipart 报文。解决fetch 或 XHR 的 body 直接传 FormData 实例由浏览器自动设置 Content-Type不要手动覆盖。排查时先打开浏览器开发者工具的 Network 面板看请求头里的 Content-Type 是否带着 boundary没有 boundary 基本可以断定是手动设置导致的。5.4 删除一张图后提交后端还是收到旧文件现象页面上把第二张图删了但后端收到的文件数还是原来的数量删除没生效。原因提交时重建的 FormData 里字段名带着下标删除中间一张后数组下标没重排或者提交逻辑用的还是 input.files 而不是 fileList 数组。解决提交时统一遍历 fileList 重建 FormData不要碰 input.files。删除后调用 renderPreview() 重排确保下标连续。如果出现空洞后端按数组解析时会多出空位影响顺序对应关系。fileList.forEach(function (file, index) { formData.append(images[${index}], file, file.name); });5.5 移动端点击按钮不弹相册直接打开了相机现象在 iOS 的微信 WebView 里点「选择图片」没有弹出相册选择界面直接打开了相机。原因input 上有 acceptimage/* 但没有 multiple 时iOS 会默认认为你要拍照调起相机。有些项目还加了 capture 属性强行指定调用摄像头。解决确认 input 上加了 multiple。capture 属性只在确实需要直接调用摄像头时设置比如身份证拍照上传场景普通上传图片业务不要加 capture让系统弹相册选择器。我的习惯是把 accept 写成 acceptimage/jpeg,image/png,image/gif,image/webp比 image/* 触发相机的几率更低。5.6 上传后预览图还在但刷新页面就消失现象上传成功后回到列表页图片地址是 blob: 开头的刷新后全部裂图。原因把前端预览用的 objectURL 直接当成正式图片地址存进了数据库。blob 地址只在当前页面会话内有效服务端拿到的 multipart 文件已经转存到存储区了前端却在用本地临时地址显示。解决上传成功后用后端返回的永久 URL 替换预览区的 src。列表页的图片要么走后端静态资源地址要么经过 CDN总之不能依赖 objectURL。排查方法看网络请求里那个 img 的 src如果还是 blob: 开头一定没有替换。5.7 预览顺序和选择顺序不一致用户以为传错了图现象用户按顺序选择了 A、B、C 三张图预览区显示顺序变成了 C、A、B或者上传后后端的文件顺序和预览不一致。原因某些手机相册在选择时返回的文件列表顺序不稳定这是一个来源另一个来源是前端用了异步压缩压缩函数执行耗时不一致如果一边压缩一边 push 进数组后完成的文件会排在前面顺序就乱了。解决用 Promise.all 保持顺序。Promise.all 的结果数组顺序与输入数组严格一致等所有压缩都完成后再一次性 concat 到 fileList 里。不要用 forEach 里套 await 的方式往数组里 push那一定会乱序。const compressedFiles await Promise.all(images.map(f compressImage(f))); fileList fileList.concat(compressedFiles);6. 验证这套源码的边界从本地跑通到敢上生产环境源码给你了怎么确认它是真的「已测试」而不是只在本地打开没报错我建议按下面的清单过一遍每项都能过基本可以放心合入业务。第一浏览器矩阵。Chrome、Edge、Safari、Firefox 都打开页面各选三张不同格式的图片JPG、PNG、WebP确认预览、删除、上传三个动作都正常。Safari 尤其要测它对 canvas.toBlob 的格式支持一直不如 Chromium压缩函数里如果出现 null 回退会在 Safari 上暴露出来。第二交互边界。连续选 9 张再选第 10 张确认提示出现删到剩 0 张再选回去确认一切如初。第三性能观察。打开 Chrome 的 Performance 面板选三张 5MB 以上图片看内存曲线有没有异常上涨如果用了压缩确认每张图的压缩耗时不超过 500ms否则说明 maxWidth 或 quality 需要调。验证完边界下一步看你要不要再往上叠功能。按投入产出比排序第一优先是拖拽上传页面里加两个 dragover 和 drop 事件就能接住 files第二是粘贴上传从剪贴板里读 clipboardData.items适合后台编辑们习惯性 CtrlV第三是图片裁剪和旋转这块通常需要引入第三方库不是原生 API 能解决的。如果产品对图片尺寸有严格限制把压缩函数做成上传前强制执行的环节而不是让用户自己选择是否压缩。我的一个习惯是每份传下去的上传代码都在文件头部留一个「上传参数配置块」把 MAX_COUNT、MAX_SIZE_MB、maxWidth、quality、后端字段名前缀集中放在一起标注清楚哪些能改、哪些不能动。这样后面接手的人不会因为找不到参数而把过滤逻辑改坏。还有一件事值得做给上传接口加一个统一的返回体约定只需要形如 { code: 0, data: { url: ... } } 的 JSON前端按这个结构解析后端换接口时前端不用动。希望这个方案能帮你在下一个表单页里省掉两小时的查文档时间。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?