news 2026/10/14 6:03:03

手写生产级文件上传内核:并发控制、类型识别与断点续传

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
手写生产级文件上传内核:并发控制、类型识别与断点续传

1. 项目概述:为什么一个“上传功能”值得花三天重写三次?

你有没有遇到过这样的场景:用户在网页上点“上传文件”,结果弹出个灰扑扑的原生<input type="file">,连预览都没有,选完直接卡住;或者上传个20MB的PDF,进度条走到一半就断掉,控制台报错net::ERR_CONNECTION_ABORTED,但页面连个提示都不给;更别提那些要求“一次选多个Word和Excel混传”“图片要自动压缩再上传”“PDF得校验页数不能超50”的需求——开发时觉得是小功能,上线后80%的客诉都围着它转。

这个标题里写的“多文件上传、单文件上传、图片上传(支持word、pdf、excel、.jpg、txt等)”,表面看是三个并列功能,实则是一套分层上传体系:底层是统一的HTTP文件传输协议栈,中间是类型感知与策略路由引擎,上层才是面向用户的交互形态。我带过的几个项目里,凡是把这三者当成“三个按钮+三段if语句”来写的,无一例外都在第二周开始疯狂修上传失败、文件名乱码、大文件超时、移动端拍照旋转异常这些坑。

核心关键词其实就四个:并发控制、类型白名单、元数据预检、失败可续传。不是所有上传都叫“上传”——用户点一下“上传图片”,背后可能触发图像解码→EXIF清理→尺寸裁剪→WebP转码→分片上传→MD5校验→CDN预热七步操作;而上传一个300页的PDF,重点反而是首帧预览生成+文本可检索性校验+服务端沙箱解析防恶意嵌入。所以这篇不讲“怎么用Element UI的Upload组件”,而是从零手写一套可插拔、可监控、可降级的上传内核,覆盖从5KB的TXT到2GB的工程图纸全场景。适合前端工程师、全栈开发者,以及需要对接第三方文档系统的后端同学——尤其当你发现现有方案在iOS Safari下上传失败率高达47%,或企业微信内嵌H5无法触发文件选择器时,这里每一步都是踩出来的。

2. 整体架构设计:为什么不用现成SDK?三层解耦才是生产级底线

2.1 拒绝“all-in-one”黑盒:我们拆的是责任边界

市面上90%的上传SDK(包括某些大厂开源库)本质是“胶水代码”:把axios封装一层,加个loading,扔个onSuccess回调。这种写法在Demo里跑得飞起,一旦进生产环境立刻暴露三类硬伤:

  • 错误不可追溯:用户说“上传失败”,你查日志发现只有request failed,根本不知道是DNS解析失败、TLS握手超时、还是服务端返回了503;
  • 策略无法隔离:图片要压缩,PDF要解析,Excel要校验表头,全塞在一个upload()函数里,改一个逻辑就得测全部;
  • 降级无从下手:当CDN挂了,你想自动切回直传OSS,但SDK里URL写死在构造函数里,只能发版。

所以我坚持用三层洋葱模型重构:

┌───────────────────────┐ │ UI 层 │ ← 暴露 uploadImage() / uploadDoc() / uploadMulti() 三个语义化API ├───────────────────────┤ │ 策略路由层 │ ← 根据文件类型、大小、网络环境(navigator.onLine + effectiveType)动态选择上传通道 ├───────────────────────┤ │ 传输内核层 │ ← 纯HTTP协议实现:分片控制、断点续传、并发管理、重试退避、进度上报 └───────────────────────┘

提示:UI层绝不处理任何业务逻辑。比如“上传图片后自动生成缩略图”,这个动作必须由后端通过回调URL或消息队列触发,前端只管把原始文件二进制流安全送达。

2.2 类型识别不是靠后缀:真实世界文件没那么听话

标题里列的.jpg、.pdf、.docx只是常见后缀,但生产环境里你会遇到:

  • 用户把PNG改名为.jpg上传(后缀欺骗);
  • 微信转发的图片实际是image/webp但后缀为.jpg;
  • 某些国产WPS导出的PDF,Magic Number(文件头)是%PDF-1.4但实际是加密PDF;
  • Excel文件可能是.xlsx(ZIP包)或.xls(OLE复合文档),解析方式天差地别。

所以我们的类型识别必须双校验:

  1. 客户端嗅探:读取文件前4个字节(Magic Number)

    • JPG:FF D8 FF→image/jpeg
    • PNG:89 50 4E 47→image/png
    • PDF:25 50 44 46→application/pdf
    • DOCX:50 4B 03 04(ZIP头)→ 再检查[Content_Types].xml是否存在 →application/vnd.openxmlformats-officedocument.wordprocessingml.document
  2. 服务端二次确认:前端上传时附带X-File-Mime: image/jpeg请求头,后端用file命令或libmagic库校验,不一致则拒绝。

实操心得:不要用file.type属性!浏览器对非标准文件类型识别极不可靠。某次我们发现Safari对.heic格式返回空字符串,导致上传被拦截,最后强制用FileReader读取前512字节做Magic Number比对才解决。

2.3 并发控制:为什么限制3个上传任务是黄金数字?

很多人以为“并发越多越快”,但在真实网络中这是个危险误区。我们做过压测:在4G弱网(1Mbps,100ms RTT)下,并发数从1调到10,平均上传耗时反而增加37%,失败率飙升至62%。

根本原因有三:

  • TCP连接竞争:每个并发请求独占一个TCP连接,弱网下连接建立耗时远超传输耗时;
  • 内存爆炸:同时加载10个20MB文件到内存(File API的ArrayBuffer),iOS Safari直接崩溃;
  • 服务端限流:Nginx默认limit_conn按IP限制连接数,超限请求直接503。

所以我们采用动态并发策略:

网络类型(navigator.connection.effectiveType)最大并发数触发条件
4g/3g3默认值,平衡速度与稳定性
2g/slow-2g1弱网模式,禁用分片,启用压缩
wifi5仅限WiFi,且文件总大小<100MB

注意:navigator.connection兼容性需polyfill(如network-information-polyfill),iOS Safari 14.5+才支持,降级方案用window.addEventListener('online/offline')配合心跳检测。

3. 核心细节解析:从文件选择到服务端落盘的17个关键节点

3.1 文件选择阶段:绕不开的移动端兼容性雷区

桌面端用<input type="file" accept="image/*,.pdf,.docx">很稳,但移动端问题集中爆发:

  • iOS Safari:accept=".pdf"完全失效,用户能选任何文件;
  • Android微信内置浏览器:capture="camera"会直接调用扫码,而非拍照;
  • 华为/小米浏览器:multiple属性被忽略,一次只能选1个文件。

解决方案是渐进式增强:

// 1. 先用原生input兜底 const input = document.createElement('input'); input.type = 'file'; input.accept = 'image/*,application/pdf,application/vnd.openxmlformats-officedocument.wordprocessingml.document,text/plain'; // 2. 移动端特殊处理 if (isMobile()) { // iOS:移除accept,改用JS过滤 input.accept = ''; input.addEventListener('change', filterFilesByType); // Android微信:监听click后手动触发相机(需用户授权) if (isWeChat()) { input.capture = 'user'; // 强制前置摄像头 } } // 3. 过滤函数(示例:只允许图片和PDF) function filterFilesByType(e) { const files = Array.from(e.target.files); const validFiles = files.filter(file => { // 优先用Magic Number,fallback到type和name return isImage(file) || isPdf(file) || isDocx(file); }); // 替换原始files列表(需用DataTransfer模拟) const dt = new DataTransfer(); validFiles.forEach(f => dt.items.add(f)); input.files = dt.files; }

踩坑记录:曾有个项目在华为手机上input.click()无效,最后发现是DOM未挂载完成,必须用setTimeout(() => input.click(), 0)确保事件循环下一帧执行。

3.2 分片上传:不是所有文件都要分片,分片大小有讲究

分片(Chunking)是大文件上传的生命线,但滥用会适得其反:

  • 小文件(<5MB)分片:HTTP头开销>传输收益,纯属增加复杂度;
  • 分片过大(>10MB):单片失败重传成本高,且易触发服务端超时;
  • 分片过小(<100KB):HTTP请求频次爆炸,TCP握手耗时占比过高。

我们采用自适应分片算法:

function getChunkSize(fileSize) { if (fileSize < 5 * 1024 * 1024) return 0; // 不分片 if (fileSize < 100 * 1024 * 1024) return 2 * 1024 * 1024; // 2MB if (fileSize < 1 * 1024 * 1024 * 1024) return 5 * 1024 * 1024; // 5MB return 10 * 1024 * 1024; // >1GB用10MB分片 } // 计算逻辑依据: // - 2MB分片:在10Mbps带宽下,单片传输约1.6秒,低于Nginx默认client_body_timeout(60s) // - 5MB分片:平衡HTTP头开销(约200B)与重传成本(5MB重传 vs 2MB重传)

分片上传的核心是唯一标识与顺序保证:

  • 文件ID:用file.name + file.lastModified + file.size生成MD5,避免同名文件覆盖;
  • 分片ID:fileId_${index},服务端按此排序合并;
  • 校验机制:每个分片上传后,服务端返回该分片的MD5,前端比对确保传输无损。

实操心得:某次发现Chrome对大文件slice()操作内存占用激增,改用Blob.prototype.slice()(返回新Blob而非ArrayBuffer)后内存下降73%。

3.3 图片专项处理:为什么上传前压缩比服务端压缩更省流量?

用户手机拍的照片动辄3MB+,但网页展示通常只需1080p。如果让3MB原图直传,浪费的是用户流量、服务器带宽、CDN缓存空间。我们坚持前端压缩三原则:

  1. 分辨率裁剪:目标宽度设为1200px(覆盖99%屏幕),高度等比缩放;
  2. 质量可控:JPEG用canvas.toBlob(callback, 'image/jpeg', 0.8),WebP用0.75;
  3. 格式智能降级:iOS 14.5+支持WebP,否则用JPEG;Android 12+支持AVIF,但兼容性差暂不启用。

关键代码:

function compressImage(file, maxWidth = 1200) { return new Promise((resolve, reject) => { const img = new Image(); img.onload = () => { const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d'); // 计算缩放比例 const scale = Math.min(maxWidth / img.width, 1); canvas.width = img.width * scale; canvas.height = img.height * scale; ctx.drawImage(img, 0, 0, canvas.width, canvas.height); // WebP优先,失败降级JPEG canvas.toBlob( blob => resolve(blob), 'image/webp', 0.75 ); }; img.onerror = reject; img.src = URL.createObjectURL(file); }); }

注意:toBlob在Safari 14.1以下不支持WebP,需捕获错误后重试JPEG。我们用document.createElement('canvas').toDataURL('image/webp')做特性检测。

3.4 元数据预检:上传前就知道文件是否会被拒

很多团队把校验全丢给服务端,导致用户等2分钟上传完,才看到“PDF页数超限”。我们把校验提到最前端:

文件类型前端可校验项工具/方法
PDF页数、是否加密、线性化(fast web view)pdfjs-dist(轻量版,仅解析metadata)
ExcelSheet数量、行数、是否含宏SheetJS(xlsx.min.js,<100KB)
Word字数、是否含修订痕迹mammoth(解析.docx XML结构)
图片EXIF方向、GPS信息、是否含ICC配置文件exifr(轻量EXIF读取)

以PDF页数校验为例:

import { getDocument } from 'pdfjs-dist/build/pdf.mjs'; async function checkPdfPages(file) { const data = await file.arrayBuffer(); const pdf = await getDocument({ data }).promise; return pdf.numPages; // 同步获取,无需渲染 } // 调用 const pages = await checkPdfPages(file); if (pages > 50) { throw new Error(`PDF页数超限(${pages}/50)`); }

优势:pdfjs-dist的getDocument只解析PDF结构,不渲染页面,100页PDF解析耗时<200ms,内存占用<5MB。

4. 实操过程:从零搭建可商用上传内核的完整步骤

4.1 初始化上传内核:15行代码定义协议基础

我们不依赖任何框架,用原生ES6 Class构建内核:

class UploadCore { constructor(options = {}) { this.options = { endpoint: '/api/upload', // 上传地址 maxConcurrent: 3, // 并发数 chunkSize: 2 * 1024 * 1024, // 分片大小 retry: 3, // 失败重试次数 timeout: 30000, // 单请求超时 ...options }; // 任务队列(FIFO) this.queue = []; // 正在运行的任务Map this.activeTasks = new Map(); // 绑定this防止箭头函数丢失上下文 this.upload = this.upload.bind(this); } // 主上传入口 async upload(file, options = {}) { const task = new UploadTask(file, { ...this.options, ...options }); this.queue.push(task); await this.processQueue(); return task.result; } // 队列处理器 async processQueue() { while (this.queue.length && this.activeTasks.size < this.options.maxConcurrent) { const task = this.queue.shift(); this.activeTasks.set(task.id, task); try { await task.start(); } finally { this.activeTasks.delete(task.id); } } } }

关键设计点:processQueue是异步递归,但用while循环+await确保并发数严格受控。activeTasks用Map存储,方便后续查询状态、取消任务。

4.2 分片上传实现:如何让1GB文件断点续传不丢一片

分片上传的核心是状态持久化与幂等性设计:

class UploadTask { constructor(file, options) { this.file = file; this.options = options; this.id = generateId(); // 随机ID this.chunks = this.splitIntoChunks(); // 分片数组 this.uploadedChunks = new Set(); // 已上传分片索引 this.progress = 0; // 从localStorage恢复断点(key: upload_${fileId}) this.restoreFromStorage(); } splitIntoChunks() { const chunkSize = this.options.chunkSize; const chunks = []; for (let start = 0; start < this.file.size; start += chunkSize) { const end = Math.min(start + chunkSize, this.file.size); chunks.push({ index: chunks.length, blob: this.file.slice(start, end), size: end - start }); } return chunks; } async start() { // 1. 预检:发送文件元数据(名称、大小、MD5、分片数) await this.precheck(); // 2. 逐片上传(带重试) for (const chunk of this.chunks) { if (this.uploadedChunks.has(chunk.index)) continue; // 跳过已传分片 let retry = 0; while (retry < this.options.retry) { try { await this.uploadChunk(chunk); this.uploadedChunks.add(chunk.index); this.saveToStorage(); // 持久化状态 break; } catch (err) { retry++; if (retry >= this.options.retry) throw err; await this.delay(1000 * Math.pow(2, retry)); // 指数退避 } } } // 3. 合并:通知服务端合并所有分片 await this.mergeChunks(); } async uploadChunk(chunk) { const formData = new FormData(); formData.append('file', chunk.blob, `${this.file.name}.part${chunk.index}`); formData.append('chunkIndex', chunk.index); formData.append('totalChunks', this.chunks.length); formData.append('fileId', this.fileId); // 发送请求(使用fetch,支持AbortController) const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), this.options.timeout); const res = await fetch(this.options.endpoint, { method: 'POST', body: formData, signal: controller.signal }); clearTimeout(timeoutId); if (!res.ok) throw new Error(`HTTP ${res.status}`); } }

关键细节:saveToStorage()将uploadedChunks序列化存入localStorage,restoreFromStorage()在实例化时读取,实现真正的断点续传。某次线上故障后,用户重启浏览器,上传自动从断点继续,NPS评分因此提升22%。

4.3 类型策略路由:让不同文件走不同的上传流水线

策略路由层是整个架构的“交通指挥中心”:

class UploadStrategyRouter { static getStrategy(file) { const mime = detectMimeType(file); // Magic Number检测 // 图片:走压缩+WebP流水线 if (mime.startsWith('image/')) { return new ImageUploadStrategy(); } // PDF/DOCX/Excel:走元数据预检+分片流水线 if (['application/pdf', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'].includes(mime)) { return new DocumentUploadStrategy(); } // TXT/JSON等纯文本:直传,但校验编码(UTF-8) if (mime.startsWith('text/') || mime === 'application/json') { return new TextUploadStrategy(); } // 默认:基础上传(不分片,不压缩) return new BasicUploadStrategy(); } } // 使用示例 const strategy = UploadStrategyRouter.getStrategy(file); const result = await strategy.upload(file, options);

每个策略类实现upload()方法,内部调用UploadCore但传入不同参数:

  • ImageUploadStrategy:启用压缩,chunkSize: 0(不分片),timeout: 60000(图片上传慢);
  • DocumentUploadStrategy:启用分片,chunkSize: 5 * 1024 * 1024,precheck: true(PDF页数校验);
  • TextUploadStrategy:添加Content-Encoding: utf-8请求头,服务端强制校验编码。

实操心得:某次发现用户上传GBK编码的TXT文件,服务端解析乱码。我们在TextUploadStrategy里加入new TextDecoder('utf-8').decode(new Uint8Array(await file.arrayBuffer()))做预检,失败则提示“请保存为UTF-8编码”。

4.4 错误监控与用户反馈:让失败变得可理解

上传失败的痛点不是技术问题,而是用户不知所措。我们设计三级反馈机制:

  1. 实时进度条:显示“上传中(32%)|剩余时间:1分23秒|网络:4G”
  2. 结构化错误码:服务端返回{ code: 'FILE_ENCRYPTED', message: 'PDF文件已加密,无法解析' }
  3. 智能建议:前端根据code给出操作指引

错误码映射表(部分):

Code用户可见提示建议操作
FILE_SIZE_EXCEED“文件大小超出限制(当前50MB,您上传了120MB)”使用压缩工具减小文件
FILE_TYPE_DISALLOWED“不支持的文件类型(检测到application/x-executable)”检查文件是否被篡改或感染病毒
CHUNK_MISMATCH“文件传输异常,请重试(错误ID:a1b2c3)”截图错误ID联系客服
NETWORK_TIMEOUT“网络不稳定,正在重试第2次…”切换WiFi或稍后重试

前端错误处理核心逻辑:

async function handleUploadError(error, task) { if (error.name === 'AbortError') { // 用户主动取消 showNotification('已取消上传'); return; } if (error.response?.data?.code) { // 服务端结构化错误 const tip = ERROR_TIPS[error.response.data.code] || '上传失败,请重试'; showNotification(tip, 'error'); logError(error.response.data.code, task.file.name); return; } // 网络错误 if (error.message.includes('timeout') || error.message.includes('network')) { showNetworkError(); return; } // 未知错误 showNotification('未知错误,请截图错误ID联系客服', 'error'); }

关键经验:把错误ID(如a1b2c3)和task.file.name、navigator.userAgent一起上报,运维同学10分钟内就能定位到是哪个机型+哪个浏览器版本的问题。

5. 常见问题与排查技巧实录:那些文档里不会写的血泪教训

5.1 iOS Safari上传失败率高的根因与解法

现象:iOS Safari上传成功率仅53%,Android Chrome达98%,差异集中在>10MB文件。

根因分析(经Wireshark抓包证实):

  • iOS Safari对multipart/form-data的boundary长度有限制(>72字符截断);
  • FormData.append('file', blob)在iOS 15.4以下存在内存泄漏,连续上传3次后页面卡死;
  • fetch在iOS 15.0-15.3有AbortController兼容性Bug,controller.abort()不触发。

解决方案:

  1. Boundary手动控制:不用FormData,改用Blob拼接(兼容性更好):
    function createMultipartBlob(file, fields) { const boundary = '----WebKitFormBoundary' + Math.random().toString(36).substr(2, 10); const parts = []; // 拼接字段... parts.push(`--${boundary}--`); return new Blob(parts, { type: `multipart/form-data; boundary=${boundary}` }); }
  2. 内存泄漏防护:每次上传后URL.revokeObjectURL()清理临时URL;
  3. AbortController降级:iOS 15.3以下用setTimeout模拟超时,不依赖signal。

数据验证:上线后iOS上传成功率从53%提升至96.2%,用户投诉下降89%。

5.2 企业微信/钉钉内嵌H5无法触发文件选择器

现象:在企业微信工作台打开H5,点击上传按钮无反应,input.click()静默失败。

根因:企业微信Android版(v4.0.12)禁用<input type="file">的JavaScript触发,必须用户真实点击。

解法:用透明遮罩层诱导用户点击:

<!-- 真实input隐藏 --> <input id="realInput" type="file" style="display:none"> <!-- 可见按钮 --> <button id="uploadBtn">上传文件</button> <!-- 透明遮罩(覆盖按钮,捕获点击) --> <div id="mask" style=" position: absolute; top: 0; left: 0; width: 100%; height: 100%; opacity: 0; z-index: 100; cursor: pointer; "></div>
// 点击遮罩时,触发真实input mask.addEventListener('click', () => { realInput.click(); }); // 企业微信检测 if (isWeCom()) { // 延迟0.1秒显示遮罩,确保DOM渲染完成 setTimeout(() => mask.style.display = 'block', 100); }

注意:遮罩必须用position: absolute,且z-index高于所有元素。某次因CSS优先级问题,遮罩被其他组件覆盖,导致问题复发。

5.3 大文件上传时进度条“假死”问题

现象:上传200MB文件,进度条卡在99%长达2分钟,用户反复点击“重试”。

根因:XMLHttpRequest.upload.onprogress事件在Chrome 95+对大文件触发频率降低,且event.loaded值在分片末尾有精度丢失。

解法:双进度计算——服务端返回分片上传进度 + 客户端计算整体进度:

// 服务端返回:{ chunkIndex: 12, totalChunks: 20, chunkProgress: 100 } // 客户端计算:overallProgress = ((12-1)*100 + 100) / (20*100) * 100 = 60% // 但更准的是用已上传字节数 let uploadedBytes = 0; for (const idx of uploadedChunks) { uploadedBytes += chunks[idx].size; } const overallProgress = Math.round((uploadedBytes / file.size) * 100);

实测效果:进度条跳变从“0→50→100”变为平滑的“0→1→2…→100”,用户等待焦虑感下降明显。

5.4 文件名中文乱码:服务端收到的文件名是“%E4%BD%A0%E5%A5%BD.txt”

现象:用户上传“你好.pdf”,服务端req.file.originalname是URL编码字符串。

根因:RFC 7578规定filename*参数支持UTF-8编码,但旧版Node.js(<16.14)的busboy解析器不支持。

解法:前端上传时手动编码,服务端显式解码:

// 前端:用encodeURIComponent编码文件名 formData.append('file', file, encodeURIComponent(file.name)); // 服务端(Express + busboy) const busboy = new Busboy({ headers: req.headers }); busboy.on('file', (fieldname, file, info) => { const filename = decodeURIComponent(info.filename); // 显式解码 // 保存文件... });

补充:若服务端是Java Spring Boot,需在application.properties中添加spring.servlet.encoding.force=true。

5.5 上传后文件内容损坏:PDF打不开,Excel表格错乱

现象:前端上传的PDF,服务端保存后用Adobe Reader打开报“文件已损坏”。

根因:File.slice()返回的Blob在某些浏览器(如Firefox 91)中,blob.text()读取时会改变二进制内容。

终极解法:全程使用ArrayBuffer,杜绝Blob转换:

// 错误示范(可能损坏二进制) const blob = file.slice(0, 1000000); const reader = new FileReader(); reader.readAsText(blob); // text()会破坏二进制! // 正确示范(保持二进制纯净) const arrayBuffer = await file.slice(0, 1000000).arrayBuffer(); const uint8Array = new Uint8Array(arrayBuffer); // 直接上传uint8Array,或转为Blob:new Blob([uint8Array])

验证方式:上传前后计算MD5,必须完全一致。我们用spark-md5库在前端计算SparkMD5.ArrayBuffer.hash(uint8Array),服务端用md5sum校验。

6. 性能优化与扩展建议:让上传能力成为产品护城河

6.1 预签名URL直传:绕过服务端,节省90%带宽成本

当文件最终存储到对象存储(如AWS S3、阿里云OSS)时,最佳实践是前端直传:

  1. 前端请求服务端获取预签名URL(含权限、过期时间、限制条件);
  2. 前端用PUT直接上传到OSS,服务端不经过任何带宽;
  3. 上传成功后,前端回调服务端“文件已就绪”。

优势:

  • 服务端带宽成本下降90%(某项目月省¥23,000);
  • 上传速度提升(绕过服务端代理);
  • 天然支持断点续传(OSS分片上传API完善)。

关键代码(服务端生成预签名URL):

// Node.js + AWS SDK v3 import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3'; import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; const client = new S3Client({ region: 'us-east-1' }); export async function getPresignedUrl(filename) { const command = new PutObjectCommand({ Bucket: 'my-bucket', Key: `uploads/${Date.now()}_${filename}`, // 防重名 ContentType: 'application/octet-stream', // 条件:文件大小<500MB ContentLengthRange: { min: 0, max: 500 * 1024 * 1024 } }); return await getSignedUrl(client, command, { expiresIn: 3600 }); // 1小时有效 }

前端直传:

async function uploadWithPresignedUrl(file, presignedUrl) { const res = await fetch(presignedUrl, { method: 'PUT', body: file, // 直传原始File对象 headers: { 'Content-Type': file.type || 'application/octet-stream' } }); if (!res.ok) throw new Error('直传失败'); }

注意:预签名URL需严格校验Content-Type和Content-Length,防止恶意用户上传任意类型文件。

6.2 智能降级策略:当所有方案都失效时,最后一道防线

再完善的上传方案,也架不住用户在电梯里信号断续。我们设计三级降级:

降级级别触发条件行为
L1(前端)连续3次上传失败,且navigator.onLine === false提示“网络已断开”,启用离线缓存,待联网后自动重传
L2(协议)分片上传失败,且文件<5MB自动切换为不分片直传(减少HTTP头开销)
L3(体验)所有上传失败,且用户点击“重试”>3次启用“精简模式”:关闭压缩、关闭预检、并发=1,保底上传成功

离线缓存实现(使用IndexedDB):

import { openDB } from 'idb'; const dbPromise = openDB('uploadDB', 1, { upgrade(db) { db.createObjectStore('pendingUploads', { keyPath: 'id' }); } }); async function saveForOffline(file, options) { const db = await dbPromise; await db.put('pendingUploads', { id: generateId(), file: file, // 存File对象(IndexedDB支持) options, timestamp: Date.now() }); } // 监听online事件,自动重传 window.addEventListener('online', async () => { const db = await dbPromise; const tx = db.transaction('pendingUploads', 'readwrite'); const store = tx.objectStore('pendingUploads'); const uploads = await store.getAll(); for (const upload of uploads) { try { await uploadCore.upload(upload.file, upload.options); await store.delete(upload.id); } catch (e) { // 重试失败,保留待
版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/14 6:02:56

OpenClaw技能排行榜安装指南:优先级与依赖解析

最近好几个新手朋友都在问同一个问题&#xff1a;OpenClaw技能排行榜上那么多技能&#xff0c;到底该先装哪些&#xff1f;我看了下&#xff0c;多数人都是看到什么火就装什么&#xff0c;结果助手越来越慢&#xff0c;还各种报错。今天这篇就专门解决这个问题&#xff0c;我会…

作者头像 李华
网站建设 2026/10/14 6:02:48

SpringBoot+Vue电影票务系统设计与高并发实践

1. 项目背景与核心价值去年帮本地一家影院做系统升级时&#xff0c;发现他们还在用纸质票Excel登记的原始方式。高峰期排队购票的顾客经常抱怨&#xff0c;而影院经理也为排片统计和财务对账头疼不已。这个基于SpringBootVue的电影票务系统就是为解决这类痛点而设计的现代化解决…

作者头像 李华
网站建设 2026/10/14 6:02:31

MapReduce任务流程与Shuffle机制深度解析

1. 这不是“背概念”&#xff0c;而是搞懂MapReduce怎么真正跑起来的你点开这篇&#xff0c;大概率正被“MapReduce”四个字卡在入门门口——教材里说它是“分布式计算模型”&#xff0c;老师讲它分Map和Reduce两阶段&#xff0c;PPT上画着带箭头的方框图&#xff0c;可一合上书…

作者头像 李华
网站建设 2026/10/14 6:02:24

Ansible自动化运维入门:从零到批量管理服务器

1. 为什么偏偏选 Ansible 作为自动化入门工具刚接触自动化的人&#xff0c;往往第一反应是“写点 Shell 脚本不就行了吗”。这想法不算错&#xff0c;但几年下来你会发现问题不在“脚本写不出来”&#xff0c;而在“脚本管不过来”。我自己是从手动登录服务器敲命令&#xff0c…

作者头像 李华
网站建设 2026/10/14 6:02:06

2ASK与2FSK的Simulink仿真:从链路搭建到误码率分析

简介&#xff1a;这是一份基于Simulink的2ASK与2FSK数字调制解调仿真系统文档&#xff0c;面向通信工程专业学生、课程设计人员及对数字调制技术感兴趣的工程师。内容先系统阐述2ASK幅度键控与2FSK频移键控的基本原理、功率谱特性及带宽特征&#xff0c;再基于Simulink逐步搭建…

作者头像 李华
网站建设 2026/10/14 6:01:26

AI智能体评测与合成数据:从黑箱行为到可工程化迭代

1. 为什么“评测合成数据”成了AI智能体创业最硬的支点&#xff1f;最近和几位做AI应用层的朋友吃饭&#xff0c;聊到一个现象&#xff1a;去年还在疯狂堆模型、卷参数、比谁家大模型更“聪明”的团队&#xff0c;今年全在悄悄拆解自家智能体的“行为逻辑”。不是不想炫技&…

作者头像 李华