ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

HTML5多图上传预览与压缩上传完整实现

HTML5多图上传预览与压缩上传完整实现

简介:这份HTML5多图片上传预览源码面向前端学习者及需要实现本地图片选择、拖放预览的开发者,重点演示File API、FileReader与拖放事件的配合使用,可在不依赖服务器端处理的情况下完成多图选取、即时缩略图展示和基础上传交互,适用于表单上传、后台管理、图库等页面参考。压缩包共18个文件、约145KB,以PNG界面素材和JavaScript脚本为主,并含CSS样式、HTML示例页及少量链接说明,分别承担图标资源、上传逻辑、布局样式与页面演示,目录按样式、脚本、核心模块和示例页组织,便于按功能查阅。已有1473人学习下载。源码经测试,涵盖多文件选择、DataURL预览、拖放区域事件处理及上传控件配套样式,读者可从中理解闭包保存文件对象、动态创建预览节点、阻止拖拽默认行为等细节,并据此扩展文件校验、删除和上传队列。

1. 从一次选十张图说起:HTML5 多图片上传预览到底解决了什么

后台管理系统里最常见的需求之一,是让运营一次挑十来张商品图,选完立刻看到缩略图,能删、能换顺序,确认无误再提交。如果按老办法先上传到服务器再返回 URL 渲染,用户选错一张就得重来一遍,流量和时间全浪费在路上。HTML5 多图片上传预览要干的事,就是把这套流程移到浏览器本地:input[type=file]加multiple拿到文件列表,FileReader或URL.createObjectURL在本地生成可显示的地址,用户看到的就是最终效果,服务端只在最后收一次数据。

这份源码的价值不在于炫技,而在于把「选图—预览—删除—压缩—上传」这条链路的每个环节都写全了,而且标了已测试。适合两类人:一是刚接触File/BlobAPI、想找个能直接跑的最小样例的前端新手;二是手上有个上传模块要重构、需要一份结构清晰的参考实现的老手。

2. 项目骨架与选型:三份核心文件如何撑起一个预览器

2.1 目录结构与文件职责:谁负责读图,谁负责上传

源码包给的是一份可直接双击打开的 HTML5 页面,没依赖打包工具。常见的组织方式是这样,把结构、样式、逻辑拆开,方便你二次开发时按需取用:

html5-upload-preview/ ├── index.html # 页面结构:input、拖拽区、预览网格、上传按钮 ├── css/ │ └── upload.css # 网格布局、拖拽高亮态、进度条、删除按钮定位 ├── js/ │ ├── main.js # 入口:初始化、事件绑定、状态汇总 │ ├── preview.js # 预览实现:FileReader 与 createObjectURL 两套 │ ├── compress.js # Canvas 降采样、格式转换、体积控制 │ └── upload.js # FormData 组装、XHR 进度、失败重试 └── README.md

职责划分的核心原则只有一条:读文件和传文件必须解耦。preview.js只关心「给我一个 File,我还你一个能塞进img.src的字符串」;upload.js只关心「给我一组 File 或 Blob,我把它们塞进 FormData 发出去」。中间靠main.js里维护的一个数组串起来。很多写得乱的上传组件,问题就出在这两层耦合在一起——预览里顺手调了上传接口,上传失败后想重试还得回头找 File 对象,结果发现只存了 base64 字符串,原文件早就丢了。

index.html里需要的关键节点也就这么几个:一个 hidden 的input[type=file]、一个拖拽接收区、一个.preview-grid容器、一个提交按钮、一个进度条元素。别把预览的img硬编码在 HTML 里,全部由 JS 动态生成,否则删除和排序时你会陷入和 DOM 索引搏斗的泥潭。

2.2 预览方案选型:FileReader 与 createObjectURL 的取舍

这是整个项目第一个要拍板的技术选择,两条路都能出图,差别不小。

FileReader.readAsDataURL()把文件读成 base64 字符串。优点是生成的字符串是纯数据,可以直接存进 localStorage、可以 POST 到只收 JSON 的接口、复制到别的窗口也不失效。缺点是体积膨胀约 33%,一张 5MB 的图读出来接近 6.7MB 的字符串,十张图就是六十多兆内存挂在页面里,低端机直接卡死。

URL.createObjectURL(file)返回的是blob:开头的短地址,指向浏览器内存里的 Blob 对象,几乎零拷贝、瞬时完成。缺点是这个地址只在当前文档生命周期有效,页面刷新即废,而且必须手动revokeObjectURL释放,否则内存只增不减。

对比一下更直观:

维度FileReader.readAsDataURLURL.createObjectURL
生成速度需完整读取 + base64 编码,大图明显慢几乎瞬时,只是建引用
内存占用约原文件 1.33 倍,且常驻仅 Blob 本体,可主动释放
生命周期字符串存哪都在绑定文档,需手动 revoke
能否持久化能存 localStorage不能
能否用于 Canvas 绘图能能

我的习惯是:预览一律用createObjectURL,只有需要把图片数据落地到本地存储或塞进 JSON 时才用 DataURL。源码里两套都给了,正好方便你对照——如果你只是做后台管理系统,直接删掉 FileReader 那套,能省掉一半内存问题。真要保留,也让它在压缩完成后再启用,别拿原始大图去读 base64。

2.3 数据模型:用数组当唯一状态源,别把 DOM 当数据库

这条经验值得单独拎出来讲。预览网格渲染完之后,每一项的 File 对象存哪儿?很多人图省事,把 File 挂到 DOM 上:img.dataset.file = file,或者用全局的document.querySelectorAll('.preview-item')反查索引。写的时候很爽,等到要支持拖拽排序,就发现 DOM 顺序变了,数据和视图对不上号。

正确的做法是维护一个数组当唯一状态源:

// 唯一状态源:每项是一个描述对象,file 始终保留原始引用 const state = { items: [ // { id: 'f_1', file: File, previewUrl: 'blob:...', status: 'ready' } ], nextId: 1 }; // 渲染函数只做一件事:把 state.items 映射成 DOM function render() { grid.innerHTML = ''; state.items.forEach((item, index) => { const cell = document.createElement('div'); cell.className = 'preview-item'; cell.dataset.id = item.id; // 用稳定 id 而不是数组下标定位 cell.innerHTML = ` <img src="${item.previewUrl}" alt="${item.file.name}"> <button type="button" class="remove"><!-- 隐藏原生控件,用自定义按钮触发,视觉可控 --> <input type="file" id="fileInput" multiple accept="image/png,image/jpeg,image/webp,image/gif" hidden > <!-- 拖拽接收区,同时承担点击触发的职责 --> <div id="dropZone" class="drop-zone"> 点击选择或把图片拖到这里 </div>

multiple不用解释,不加就只能单选。accept里的写法有讲究:写image/*兼容性最好,但部分安卓机型会因此把「文件管理」里的非图片也列出来;写具体 MIME 更严格,却要求你把要支持的格式穷举全,漏了image/webp用户就选不了 WebP 图。我一般是accept="image/*"兜底,服务端再按魔数校验一遍,前端校验只能算体验优化,不能当安全边界。

移动端还有个capture属性:capture="environment"会直接唤起后置摄像头,capture="user"是前置。不加则是让用户选「拍照还是从相册挑」。做扫码、拍证件照这类场景加它,做普通图片上传千万别加,加了等于剥夺用户从相册选的权利。

触发逻辑本身很短,但有个坑必须处理:

const input = document.getElementById('fileInput'); const dropZone = document.getElementById('dropZone'); // 点击自定义区域,转交给隐藏的 input dropZone.addEventListener('click', () => input.click()); input.addEventListener('change', (e) => { // FileList 是类数组且是「活」的,先拷贝成真数组再处理 const files = Array.from(e.target.files || []); if (files.length) addFiles(files); // 关键:清空 value,否则再次选同一个文件不会触发 change e.target.value = ''; });

最后那行e.target.value = ''是血泪经验。不清空的话,用户第一次选了a.jpg,删掉预览后想重新选同一个文件,change事件根本不会触发,因为 input 认为值没变。用户会以为功能坏了,你在控制台也看不到任何报错,纯玄学现场。

3.2 FileReader 并发读图与顺序保证

如果你选了 FileReader 这条路,要注意它是异步的,十张图同时readAsDataURL,回来的顺序不保证和选择顺序一致。直接往数组里 push,缩略图就会乱序,用户明明按 A、B、C 选的,显示出来是 C、A、B。

解决办法是让每个读取任务带着自己的索引,结果按索引落位:

// 单个文件的读取 Promise 化,出错时也 resolve,避免一张坏图拖垮整批 function readFileAsDataURL(file, index) { return new Promise((resolve) => { const reader = new FileReader(); reader.onload = () => resolve({ index, url: reader.result, ok: true }); reader.onerror = () => resolve({ index, url: '', ok: false, error: reader.error }); reader.readAsDataURL(file); }); } // 限制并发,避免一次性把内存打满 async function readBatch(files, concurrency = 3) { const results = new Array(files.length); let cursor = 0; async function worker() { while (cursor < files.length) { const i = cursor++; // 抢占式取任务,天然去重 results[i] = await readFileAsDataURL(files[i], i); } } const workers = Array.from( { length: Math.min(concurrency, files.length) }, worker ); await Promise.all(workers); return results; // 下标即原始顺序,无需再排序 }

concurrency取 3 到 4 比较稳,理由是大图 base64 编码本身吃 CPU 和内存,开十个并发在低端安卓上会直接触发页面卡顿甚至崩溃。results[i] = ...的写法利用了数组下标预分配,天然保证顺序,比读完之后再sort((a,b) => a.index - b.index)少一次遍历。onerror里选择resolve而不是reject,是为了让单张损坏图片不中断整批——你可以给失败项打上ok: false,在 UI 上显示一个「读取失败,请移除」的占位块。

读完之后再统一塞进state.items并渲染,不要每读完一张就render()一次。前者是一次 DOM 操作,后者是十次,差距在几十张图时非常明显。

3.3 createObjectURL 版本与内存回收时机

换成createObjectURL后代码反而更短,因为不需要 Promise 包装异步读取:

function addFiles(files) { const added = []; files.forEach((file) => { // 前置校验:类型、单文件大小、总量 if (!file.type.startsWith('image/')) return; if (file.size > 20 * 1024 * 1024) return; // 单张上限 20MB added.push({ id: `f_${state.nextId++}`, file, previewUrl: URL.createObjectURL(file), // 同步返回,立刻可用 status: 'ready' }); }); state.items = state.items.concat(added); // concat 不改原数组 render(); }

previewUrl是同步拿到的,所以这里没有顺序问题,forEach顺序即选择顺序。

真正需要小心的是回收。createObjectURL创建的引用不会自动释放,页面开着就一直占内存。回收时机有三个选择,各有代价:

一是img.onload里立刻 revoke。此时图片已经解码完成,<img>能继续显示,看似完美。但如果后面要做「点击查看大图」重新给它赋一次src,地址已经废了,图裂。而且拖拽排序时如果重新创建img元素,同样会裂。

二是删除项时 revoke。这是最稳妥的,因为该项的File和预览地址都不再需要了:

function removeItem(id) { const idx = state.items.findIndex((it) => it.id === id); if (idx === -1) return; const [removed] = state.items.splice(idx, 1); URL.revokeObjectURL(removed.previewUrl); // 释放内存 render(); }

三是组件卸载或页面beforeunload时统一遍历 revoke。作为兜底一定要加,尤其单页应用里路由切走后组件实例被丢弃,不 revoke 就是纯泄漏。

提示:revokeObjectURL传入已释放过的地址不报错,所以重复调用是安全的;但传入非blob:开头的字符串(比如 DataURL)没有意义,所以在项目里最好统一约定previewUrl只存 blob 地址。

3.4 删除与重排:在数组模型上做增删改

删除在上一节已经写了,核心是splice+revoke+ 重渲染。重排稍微复杂一点,拖拽方案有两种:HTML5 原生 Drag and Drop API,或者用现成的排序库。

原生方案监听dragstart/dragover/drop三个事件,在dragover里preventDefault()才能让drop生效,这是新手最常漏的一步:

let dragId = null; grid.addEventListener('dragstart', (e) => { const cell = e.target.closest('.preview-item'); if (!cell) return; dragId = cell.dataset.id; // 记录被拖项的稳定 id e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', dragId); // 部分浏览器要求必须 setData }); grid.addEventListener('dragover', (e) => { e.preventDefault(); // 不写这句,drop 永远不触发 e.dataTransfer.dropEffect = 'move'; }); grid.addEventListener('drop', (e) => { e.preventDefault(); const target = e.target.closest('.preview-item'); if (!target || !dragId) return; const targetId = target.dataset.id; if (targetId === dragId) return; const from = state.items.findIndex((it) => it.id === dragId); const to = state.items.findIndex((it) => it.id === targetId); const [moved] = state.items.splice(from, 1); state.items.splice(to, 0, moved); // 先删后插,位置即目标位置 dragId = null; render(); });

参数说明:effectAllowed和dropEffect只是视觉提示(光标变移动图标),不影响逻辑;setData('text/plain', ...)在 Firefox 里是拖拽启动的必要条件,不设置可能压根拖不动。用的是id而不是下标做匹配,这样即使拖拽过程中有别的操作改了数组顺序,也不会插错位置。

排序在业务上通常对应「第一张是主图」,所以重排后要把顺序信息一并提交。常见做法是 FormData 里额外带一个orders字段,值是id组成的 JSON 数组,或者干脆按顺序追加文件,让后端按接收顺序落位——后者更省事,但要和后端对齐好约定。

4. 上传与压缩:FormData 组装、进度回调与 Canvas 降采样

4.1 FormData 字段约定与 XHR 上传

到了提交环节,FormData是唯一正确选择,因为它能承载二进制、自动生成multipart/form-data边界符,前端不用手拼请求体。关键是字段名约定:

function buildFormData(items, extra = {}) { const fd = new FormData(); items.forEach((item) => { // 第三个参数是 filename,不传会用 Blob 的默认名 'blob' const blob = item.compressed || item.file; fd.append('files', blob, item.file.name); }); // 业务字段跟着一起走,不用另发一个请求 fd.append('bizType', extra.bizType || 'product'); fd.append('order', JSON.stringify(items.map((it) => it.id))); return fd; }

append('files', blob, filename)里的files是后端接收时的字段名,如果后端是 Java 的@RequestParam("files") MultipartFile[] files,名字必须一模一样,错一个字母就是 400。同名 key 重复 append 会被解析成数组,这是标准行为,不用担心覆盖。第三个参数filename别偷懒不传——canvas.toBlob()产出的 Blob 没有名字,不传的话后端收到一堆叫blob的文件,落盘时全冲突。

发送用XMLHttpRequest而不是fetch,理由很实际:fetch至今没有稳定可用的上传进度事件,而 XHR 的upload.onprogress是所有浏览器都支持的:

function upload(formData, { onProgress, onDone, onFail }) { const xhr = new XMLHttpRequest(); xhr.open('POST', '/api/upload', true); // 需要带 cookie 时开启;跨域场景下后端还要配 credentials xhr.withCredentials = true; xhr.upload.onprogress = (e) => { if (!e.lengthComputable) return; // 拿不到总大小时跳过 onProgress(Math.round((e.loaded / e.total) * 100)); }; xhr.onload = () => { if (xhr.status >= 200 && xhr.status < 300) { onDone(JSON.parse(xhr.responseText || '{}')); } else { onFail(new Error(`HTTP ${xhr.status}`), xhr.status); } }; xhr.onerror = () => onFail(new Error('网络错误'), 0); xhr.ontimeout = () => onFail(new Error('请求超时'), 0); xhr.timeout = 60 * 1000; // 大图上传留足时间 xhr.send(formData); }

要点说明:onprogress里lengthComputable为false的情况确实存在(某些代理或分块传输),不加判断会算出NaN,进度条直接跳到 100% 或者显示成乱码。xhr.status的判断必须包含2xx全段,别只写=== 200,后端返回 201 就被你当成失败了。timeout默认是 0,也就是永不超时,移动网络差的时候请求能挂几分钟,用户以为卡死,所以显式设置一个值是必要的。

4.2 上传进度、并发队列与失败重试

一次性把所有文件塞进一个请求,问题在于:一个 50MB 的请求,断在第 49MB 就得从头再来;而且服务端通常有单请求体积上限,十张图一起发很容易撞上 413。更稳的做法是每个文件一个请求 + 并发控制,进度按「已完成数 / 总数」算,失败项单独重试。

async function uploadAll(items, { concurrency = 3, onItem, onOverall }) { const total = items.length; let finished = 0; async function runOne(item) { const fd = buildFormData([item]); const result = await withRetry(() => sendOne(fd, item), 2); finished += 1; onItem(item, result); onOverall(Math.round((finished / total) * 100)); } // 同样的抢占式并发模板,单批最多 3 个请求在飞 let cursor = 0; const workers = Array.from({ length: Math.min(concurrency, total) }, async () => { while (cursor < total) { const item = items[cursor++]; await runOne(item); } }); await Promise.all(workers); } // 失败重试,退避时间逐次翻倍,避免瞬时重试把服务端压垮 async function withRetry(fn, times) { let lastErr; for (let i = 0; i <= times; i++) { try { return await fn(); } catch (err) { lastErr = err; if (err.status === 413 || err.status === 401) throw err; // 这些重试没意义 await new Promise((r) => setTimeout(r, 500 * Math.pow(2, i))); } } throw lastErr; }

concurrency取 2 到 3 是上传场景的甜点值,移动网络下开太多会导致每个请求都被分到极窄的带宽,总耗时反而更长。withRetry里对 413 和 401 直接抛出,因为文件太大重传一百次还是太大,未授权重传也不会自动变成授权——把不可恢复的错误和网络抖动区分开,是重试逻辑不变成死循环的关键。onItem回调用来把单项状态写回state.items,比如上传成功就把status改成done并把后端返回的 URL 存下来,视图上对应那一格显示对勾。

4.3 Canvas 压缩与 iOS 方向纠偏

手机拍的照片动辄 4MB 到 8MB,十张就是几十兆,用户流量和服务器存储都受不了。前端压缩的标准做法是解码到canvas,缩放到指定边长,再toBlob输出。

async function compressImage(file, { maxSide = 1600, quality = 0.82, mime = 'image/jpeg' } = {}) { // imageOrientation: 'from-image' 让浏览器按 EXIF 方向自动旋正 let bitmap; try { bitmap = await createImageBitmap(file, { imageOrientation: 'from-image' }); } catch (e) { bitmap = await createImageBitmap(file); // 老版本浏览器降级 } const scale = Math.min(1, maxSide / 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) => { canvas.toBlob( (blob) => resolve(blob), // 可能为 null,调用方要兜底 mime, quality ); }); }

参数逐个说清:maxSide控制长边像素,1600 对商品图够用,做头像 800 就行,超过 2000 一般没必要。quality只对image/jpeg和image/webp生效,PNG 是无损格式会忽略它,所以想压体积就得转成 JPEG —— 代价是透明背景会变成黑底或白底,需要ctx.fillStyle = '#fff'; ctx.fillRect(...)先铺一层底色。mime选image/webp体积能再小 25% 左右,但要确认后端和 CDN 都支持。

imageOrientation: 'from-image'这一行是专门给 iOS 和部分安卓机型准备的。手机拍的照片,像素数据本身是横的,靠 EXIF 里的 Orientation 标记告诉查看器要旋转 90 度。如果你用new Image()+drawImage的老写法,canvas 拿到的是未旋转的原始像素,于是上传完在电脑上看人像都是躺着的——这就是那个经典的「iOS 上传图片旋转 90 度」问题。createImageBitmap的这个选项让浏览器在解码阶段就把方向应用上,输出即所见。

最后一步,压缩产出要写回状态,但保留原文件:

async function compressAll(items) { for (const item of items) { const blob = await compressImage(item.file); if (blob && blob.size < item.file.size) { // 压完反而更大就用原图 item.compressed = new File([blob], item.file.name, { type: blob.type, lastModified: Date.now() }); } } }

blob.size < file.size这个判断不能省。一张已经压过的小图,再走一遍 JPEG 编码体积可能不降反升,还白白损失一次画质。把它包成File而不是直接用Blob,是为了保留name和lastModified,两者在上传时分别对应 filename 和部分后端的时间字段。

5. 常见问题排查:预览空白、内存暴涨、上传 413 的三类现场

5.1 预览与渲染类问题

现象一:选完图,网格里一片空白,控制台没有报错。

原因通常是三个之一:img.src拿到的地址是blob:但已经被revokeObjectURL提前释放了;渲染时用的模板字符串里previewUrl拼成了undefined;或者 CSS 里.preview-item img被设了width: 100%而父容器宽度为 0,图其实在里面只是被压没了。

排查顺序建议从数据开始:先在render()里console.log(state.items),确认每项都有非空的previewUrl。有值就看 DOM,document.querySelector('.preview-item img').src打出来是不是blob:开头。都正常就检查 CSS,给.preview-grid加上grid-template-columns: repeat(auto-fill, minmax(120px, 1fr))和明确的width: 100%,容器塌陷导致「图存在但不可见」是最隐蔽的一种。

现象二:缩略图顺序和用户选择的顺序不一致。

原因基本可以锁定在 FileReader 的异步回调上——十张图并发读,谁先读完谁先 push,顺序自然乱。解决办法在 3.2 节已经给过:用索引落位results[i] = ...,别用 push。如果已经写了 push 版本,最小改动是把每个结果带上item.id,渲染前按state.items的 id 顺序重排一次,但这是打补丁,不如一开始就把索引设计对。

现象三:连续选图十几次后页面卡顿,标签页内存占用飙升到几百兆。

原因是createObjectURL生成的引用没释放。每选一次生成一批新地址,旧的删除后地址还挂在内存里。打开 Chrome DevTools 的 Memory 面板拍两次堆快照对比,会看到大量Blob和URL相关对象只增不减。修复方法就是 3.3 节的removeItem里补URL.revokeObjectURL,同时在组件卸载、路由切走时统一遍历state.items再 revoke 一遍。加了之后,反复选删二十次,内存曲线应该是一条平稳的锯齿线,而不是持续爬升。

5.2 上传与兼容类问题

现象四:本地预览一切正常,点上传后端返回 413,或者后端说只收到一个文件。

413 是请求体超限,先看两端限制:前端每张图压缩到 1600 长边后通常是 200KB 到 500KB,如果没做压缩直接传原图,一张 8MB 十张 80MB,服务端默认的 1MB 或 10MB 上限当然拦住。要么前端压缩,要么和后端一起调client_max_body_size(Nginx)和框架的max-file-size。

「只收到一个文件」通常是FormData.append的字段名和后端不一致,或者后端配置成了单文件绑定。用 DevTools 的 Network 面板看请求体,multipart/form-data里应该有多个name="files"的段,段数对不上就说明前端append次数不对;段数对了但后端只解析出一个,问题在后端注解,不是前端的事。另外注意别在循环里new FormData(),那样每轮都是新的空对象,只有一个文件能发出去——这个错误我见过不止一次。

现象五:iOS 上传的照片在电脑上方向不对,或者 HEIC 格式后端不认。

方向问题按 4.3 节用createImageBitmap(file, { imageOrientation: 'from-image' })处理。如果环境不支持这个 API,退路是显式解析 EXIF 的Orientation标签再手动ctx.rotate,代价是要引入一个 EXIF 解析函数。

格式问题更常见:iPhone 默认拍的是 HEIC,file.type是image/heic,很多后端库不认识,accept="image/*"又刚好放行了。处理方式是在addFiles里做白名单,只允许image/jpeg、image/png、image/webp、image/gif,其余的直接提示不支持,让用户在系统相册里转成 JPEG 再传。指望前端解码 HEIC 转码,成本高且兼容性差,不如在入口处挡住。

6. 二次开发:把 Demo 收成可复用组件的三步改造

源码能跑,但直接复制到第二个项目里就要改一堆id。收成组件其实只需要三步,我按顺序说。

第一步,把状态和 DOM 查询解耦。现在的main.js大概率用document.getElementById到处捞元素,改成在初始化时把根节点传进去,所有查询都相对根节点做:

class ImageUploader { constructor(root, options = {}) { this.root = typeof root === 'string' ? document.querySelector(root) : root; this.options = { maxCount: 9, maxSize: 20 * 1024 * 1024, maxSide: 1600, concurrency: 3, uploadUrl: '/api/upload', ...options }; this.items = []; this.mount(); } }

options用展开运算符覆盖默认值,调用方只需要传想改的那几个。这样同一页面放两个上传组件(比如商品主图和详情图)也不冲突。

第二步,把回调暴露出来。组件内部不要直接调业务接口,而是抛出事件让外部决定:

回调触发时机参数
onChange增删、排序后当前 items 快照
onUploaded单个文件上传成功{ item, response }
onError校验失败或上传失败{ item?, code, message }
onComplete全部结束{ success, failed }

外部拿到onComplete再决定要不要跳转页面或者刷新列表,组件本身保持无业务状态。

第三步,做验证。别只在 Chrome 里点两下就交付。我固定会走这几条:连选十张 8MB 的图看内存曲线是否平稳;选同一个文件两次看change是否还能触发;传一个改了扩展名的.txt看类型校验是否拦住;用 DevTools 的 Network 限速到 Slow 3G 看进度条和超时提示是否正常;最后在真机 iOS 上拍一张竖图,确认上传后方向正确。

这套流程走下来,组件基本能扛住真实用户的折腾。从那以后我每次接上传需求,都强制先把「验证清单」过一遍再提测——预览空白、进度卡死、方向错乱这三类问题,只要漏测其中一个,上线后一定有人截图来找你。希望帮到你。

本文还有配套的精品资源,点击获取

返回列表