ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Vue大文件与文件夹上传实践:分片、Web Worker哈希与断点续传

2026/9/9 21:01:46 拓冰建站 浏览量
Vue大文件与文件夹上传实践:分片、Web Worker哈希与断点续传 最近帮朋友做一个内部资源管理后台需求一句话就能说完用户要能把自己电脑上的整个文件夹传到网页里文件夹里可能有几百个小文件也可能有几个 GB 的单个大文件还要能看进度网络断了不能全部重新上传。这个需求听起来不复杂可真用 Vue 动手做才发现“大文件上传”和平时写的那种input FormData上传根本不是一回事。这篇就把我实现大文件、文件夹上传 DEMO 的完整思路和代码拆开讲一遍包括目录递归、分片并发、Web Worker 计算哈希以及后面实测踩到的 OOM 和 IE 兼容的坑。适合读这篇文章的人大概是这两类一是刚开始接触上传模块的前端同学需要知道大文件上传为什么不能“一把梭”二是在网上搜了一圈分片上传文章但一直没跑通一个完整 DEMO 的开发者。看完你可以直接参考第五部分的源码改到自己项目里。1. 为什么大文件上传不能走普通表单提交这条老路1.1 巨大请求体先被服务端拦下来大多数人第一次写文件上传都是这样input typefile选文件构造一个FormData然后用fetch或XHR把整个文件丢给后端。小文件这么干完全没问题可一旦文件上到几百 MB 甚至几个 GB第一个拦路的就是服务端配置。拿最常见的 Nginx 来说默认的client_max_body_size是 1MB意味着超过 1MB 的请求体直接给你返回413 Request Entity Too Large。Java 的 Tomcat 默认maxPostSize是 2MBSpring Boot 的spring.http.multipart.max-file-size默认也是 1MB。也就是说不调整后端配置你连大文件的边都摸不到。就算你把各种上限都调大了还有第二个问题请求超时。一个 2GB 的文件在一段不太稳定的网络上传耗时可能超过代理服务器的超时时间连接被掐断整个请求就废了。分片上传之所以能规避这个问题本质上是把一个大请求拆成了很多个小请求任何一个分片失败只需要重传那一片成本很低。1.2 没有断点失败一次等于白传普通上传还有一个很现实的问题没有断点。上传到 70% 的时候网络断了一下或者浏览器误点刷新这个文件的传输状态就完全丢失了。用户要是传一个 1GB 的视频已经等了十分钟这时候让他重新选文件再传一遍体验基本等于劝退。要支持断点续传光靠前端是做不到的。你得让前端能够识别“同一个文件”并且让服务端记录“这个文件已经收到了哪些分片”。所以分片上传通常还会配套算一个文件哈希作为文件的身份标识。前台上传前先算好哈希询问服务端这个文件之前传过多少片然后只传缺失的部分。这个逻辑在第三章会展开。1.3 文件夹上传和“选一堆文件”是两码事再来说文件夹上传。很多朋友第一次听到文件夹上传以为就像input加个multiple属性一样简单。其实不是文件夹上传的关键在于浏览器要能让你拿到整个目录结构而不只是文件列表。下面两种方式是有本质区别的用input typefile multiple用户只能在一个对话框里框选多个文件选不了“一个文件夹”。用input typefile webkitdirectory用户可以选中一个文件夹浏览器会把文件夹里的文件全部列出来并且每个文件对象带一个webkitRelativePath比如assets/images/logo.png有了这个相对路径前端才能还原目录层级。注意webkitdirectory这个属性名字以webkit开头但它并不是 Webkit 内核专属Chrome、Edge、Firefox、Safari 目前都支持。真正的坑在于 IE 不支持而不少内网项目现在还在用 IE这个我会在最后单独讲。2. Vue 里怎么把文件夹变成本地文件树2.1 用 webkitdirectory 拿文件列表在 Vue 组件里最直接的做法是绑定一个ref到input元素然后在change事件里处理文件。template div input reffolderInput typefile webkitdirectory multiple changehandleFolderChange / ul li v-forfile in fileList :keyfile.webkitRelativePath {{ file.webkitRelativePath }}{{ formatSize(file.size) }} /li /ul /div /template script setup import { ref } from vue const folderInput ref(null) const fileList ref([]) function handleFolderChange(e) { const files e.target.files if (!files || files.length 0) return // 用相对路径排序方便按目录结构展示 const list Array.from(files).sort((a, b) a.webkitRelativePath.localeCompare(b.webkitRelativePath) ) fileList.value list } function formatSize(size) { if (size 1024) return size B if (size 1024 * 1024) return (size / 1024).toFixed(2) KB return (size / 1024 / 1024).toFixed(2) MB } /script这里有个容易忽略的细节e.target.files返回的是一个FileList虽然它不是数组但可以直接用Array.from转成数组。文件对象的webkitRelativePath就是相对于所选文件夹根的路径包含顶层的文件夹名。如果只是展示文件列表这样已经够了。但如果要按目录结构做渲染比如“展开一个文件夹看到里面又有一层子文件夹”推荐把扁平的FileList转成树形结构。转为树的做法不复杂按/分隔webkitRelativePath然后逐层插入嵌套对象。这里给一个精简版function buildTree(files) { const root { name: , type: folder, children: [] } for (const file of files) { const parts file.webkitRelativePath.split(/) let node root for (let i 0; i parts.length; i) { const part parts[i] const isFile i parts.length - 1 let child node.children.find((item) item.name part item.type (isFile ? file : folder)) if (!child) { child isFile ? { name: part, type: file, size: file.size, file } : { name: part, type: folder, children: [] } node.children.push(child) } node child } } return root }这个树结构可以直接喂给el-tree之类的组件做展示也可以在你每次遍历时顺便把文件收集到一个数组里方便后续上传。2.2 用 DataTransferItem 递归还原目录树webkitRelativePath虽然方便但它有一个硬伤空目录会丢失。用户文件夹里有一个空的小子目录通过FileList是感知不到的因为文件列表本身只包含文件对象没有目录对象。如果业务上需要连空目录也上传就要用到DataTransferItem和FileSystemEntry这一层 API。你可以在input的change事件里拿到e.dataTransfer不行change事件的dataTransfer是拿不到的。正确的做法是拿e.target.files里对应的DataTransferItem也不能直接这么拿。我常用的解法是在change事件里遍历e.target.files但这拿不到目录树。要拿目录树得换一种监听方式// 在 dragover 阻止默认行为后通过 drop 事件可以拿到 items function handleDrop(e) { e.preventDefault() const items e.dataTransfer.items for (const item of items) { const entry item.webkitGetAsEntry() if (entry) { readEntry(entry) } } } function readEntry(entry) { if (entry.isFile) { entry.file((file) { // file 对象可以拿到但注意这里拿不到 webkitRelativePath // 需要自己在递归过程中拼接相对路径 console.log(file.name, file.size) }) } else if (entry.isDirectory) { const reader entry.createReader() // 注意readEntries 一次返回的条目数是有上限的 // 必须循环调用直到返回空数组 const readAllEntries () { reader.readEntries((entries) { if (entries.length 0) return for (const child of entries) { readEntry(child) } readAllEntries() }) } readAllEntries() } }readEntries这个 API 有个很经典的坑它一次不一定返回目录里的全部条目通常一次最多返回 100 条所以不能只调用一次就以为遍历完了一定要循环调用直到回调里返回空数组否则大目录下会莫名其妙丢文件。在 Vue 里我更推荐做一个组合式函数useFolderPicker把“选择文件夹”的 DOM 事件和后续的文件树生成封装在一起。实际项目中拖动文件夹到页面上、点击按钮弹窗选择文件夹这两件事都能复用同一套逻辑。我第五部分的 DEMO 里就是这种结构。3. 分片上传的核心设计切片、并发、进度3.1 分片大小和并发数怎么定分片大小不是拍脑袋定的需要稍微权衡一下。分片太小请求数量会爆炸。一个 1GB 文件如果用 1MB 分片就是 1024 个请求用 5MB 分片是 205 个请求。分片太大又失去了分片的意义——单次失败重传的成本会明显上升内存占用也高。我常用的分片大小是 5MB 到 10MB。内网环境下带宽充足可以选 10MB公网环境尤其可能要过一层代理的5MB 更稳。举个例子2GB 的文件5MB 一片总共 410 片取并发数 3理论上需要 137 轮。假设每片在网络上消耗 0.5 秒总共大概 68 秒这个速度在大多数业务场景里是可以接受的。并发数一般取 3 到 6。并发太高服务端同时打开的连接数会飙升磁盘 IO 也会进入排队状态并发太低带宽又喂不满。我在 demo 里默认用 3实测在普通企业宽带上表现比较稳定。3.2 上传流程检查、分片、合并完整的流程分四步计算文件哈希。这个哈希作为文件的唯一标识用于秒传与断点续传。请求后端检查接口/api/upload/check把哈希发给服务端服务端返回这个文件是否已上传过一部分以及已存在的分片索引列表。前端把文件按偏移量切成若干块逐块上传每一块通过multipart/form-data发送。全部上传成功后调用合并接口/api/upload/merge服务端把所有临时分片按顺序合并成完整文件。为什么第一步就要哈希因为断点续传的判断依据就是它。用户上次传了一半刷新页面重新选择同一个文件夹前端能算出一样的哈希服务端查到已经收到第 1 到第 200 片前端就只传 201 片之后的进度条直接从 200/410 继续。3.3 并发控制与进度计算并发控制是一个常见的面试题很多人喜欢用Promise.all把所有分片直接丢出去这在分片数量小的时候没问题分片一多一瞬间就有几百个请求同时发出去既可能触达浏览器连接数上限也可能把服务端打挂。一个稳妥的做法是用简单的“任务池”async function runPool(tasks, limit) { const results [] const executing [] for (const task of tasks) { const promise Promise.resolve().then(task) results.push(promise) if (limit tasks.length) { const whenFinished promise.then(() { executing.splice(executing.indexOf(whenFinished), 1) }) executing.push(whenFinished) if (executing.length limit) { await Promise.race(executing) } } } return Promise.all(results) }这个函数的核心逻辑是维护一个executing数组当并发数达到上限时用Promise.race等最快完成的一个任务腾出位置。进度计算我推荐用“已上传分片数 / 总分片数”而不是用xhr.upload.onprogress累加。原因我会在最后详细说。伪代码类似const total chunks.length let uploaded 0 await runPool(chunks.map((chunk, index) () uploadChunk(chunk, index).then(() { uploaded progress.value Math.floor((uploaded / total) * 100) })), 3)4. 用 Web Worker 把哈希计算从主线程搬出去4.1 选完文件界面卡住的原因我第一次做完分片上传后发现一个现象选完文件夹界面会卡一两秒如果文件很大或者文件数量很多卡的时间更久。原因主要有两个遍历文件夹、组装树结构本身有开销尤其是几百个文件的目录。计算哈希要读取整个文件内容。如果用纯 JS 在浏览器主线程里读取一个 1GB 的文件并计算 MD5主线程会被长时间占用浏览器无法响应点击、滚动等操作表现为“假死”。解决思路就是 Web Worker。Worker 可以开一个独立线程来跑这些重活主线程不会被阻塞。不过要注意Worker 里面拿到的File对象依然指向主线程的文件你用FileReader在 Worker 里读文件内容读取过程同样在浏览器底层进行但计算过程在独立线程不会卡 UI。4.2 在 Worker 里用 spark-md5 计算哈希我用的库是spark-md5它在浏览器和 Node 里都能跑。把 Worker 脚本单独放在一个文件里主线程通过new Worker(new URL(./hash.worker.js, import.meta.url), { type: module })创建。Worker 内部逻辑// hash.worker.js import SparkMD5 from spark-md5 self.onmessage async (e) { const { file, chunkSize } e.data const totalChunks Math.ceil(file.size / chunkSize) const spark new SparkMD5.ArrayBuffer() // 分批读取避免一次性把整个文件塞进内存 for (let i 0; i totalChunks; i) { const start i * chunkSize const end Math.min(start chunkSize, file.size) const slice file.slice(start, end) const buffer await slice.arrayBuffer() spark.append(buffer) } const hash spark.end() self.postMessage({ hash }) }主线程这边这样调用const worker new Worker(new URL(./hash.worker.js, import.meta.url), { type: module }) worker.onmessage (e) { console.log(文件哈希:, e.data.hash) worker.terminate() } worker.postMessage({ file, chunkSize: 5 * 1024 * 1024 })这里我用了slice.arrayBuffer()它会把一个分片读成ArrayBuffer。因为是按 5MB 分批读内存峰值大概维持在 5MB 水平不会把 1GB 文件一次性读进内存。4.3 传输数据的几个内存细节有朋友会在 Worker 里把大文件切分成多个ArrayBuffer后再postMessage回主线程然后主线程通过fetch上传。这种做法在数据量大的时候会出问题postMessage默认是复制数据把一个 5MB 的ArrayBuffer传回主线程内存里会同时存在两份 5MB 的数据。分片一多内存就蹭蹭往上涨。如果你的方案确实需要把二进制数据从 Worker 传回主线程记得用Transferable转交所有权语法是postMessage(buffer, [buffer])这样数据不会复制而是直接把底层内存转交给主线程Worker 那边同一块内存就不可用了。但更推荐的做法是Worker 只负责算哈希和返回切片规划真正的切片和上传都在主线程完成。因为主线程的File.slice()并不真正读取文件到内存它只是创建一个对原文件某一段的引用。这样内存占用是最小的。这一点也是我第六部分要展开讨论的 OOM 问题的关键。5. 一个能跑通的 Vue Node 上传 DEMO5.1 前端项目结构我特意把这个 DEMO 保持得很精简方便直接抄。前端用 Vite Vue 3后端用 Express没有引入任何复杂中间件。vue-folder-upload-demo/ ├── frontend/ │ ├── index.html │ ├── package.json │ ├── vite.config.js │ └── src/ │ ├── main.js │ ├── App.vue │ ├── components/ │ │ └── FolderUploader.vue │ └── utils/ │ ├── fileWalker.js │ ├── chunkUploader.js │ └── hash.worker.js └── backend/ ├── package.json └── server.js环境要求Node.js 18npm 或者 pnpm 都行。5.2 上传组件的关键代码FolderUploader.vue是整个 DEMO 的核心我把“选择文件夹”“展示文件树”“上传”“进度”集中在同一个组件里。为了篇幅控制这里给出剪掉样式后的主要逻辑。template div input reffolderInput typefile webkitdirectory multiple changeonPickFolder / button :disableduploading clickstartUpload {{ uploading ? 上传中... : 开始上传 }} /button div v-ifprogress 0总进度{{ progress }}%/div ul li v-foritem in fileList :keyitem.webkitRelativePath {{ item.webkitRelativePath }} /li /ul /div /template script setup import { ref } from vue import { buildFileTree, flattenTree } from ../utils/fileWalker import { uploadFileWithResume } from ../utils/chunkUploader const folderInput ref(null) const fileList ref([]) const fileTree ref(null) const uploading ref(false) const progress ref(0) function onPickFolder(e) { const files Array.from(e.target.files || []) fileList.value files fileTree.value buildFileTree(files) } async function startUpload() { if (!fileList.value.length) return uploading.value true progress.value 0 const files flattenTree(fileTree.value) for (let i 0; i files.length; i) { const file files[i] await uploadFileWithResume(file, (p) { // 这里做的是单文件进度你也可以改为整体进度 progress.value Math.round(((i p / 100) / files.length) * 100) }) } uploading.value false } /scriptuploadFileWithResume是我在chunkUploader.js里封装的分片上传函数它做了三件事算哈希、检查已传分片、上传缺失分片。// chunkUploader.js const CHUNK_SIZE 5 * 1024 * 1024 const API_BASE http://localhost:3000 async function uploadFileWithResume(file, onProgress) { const hash await calculateHash(file) const totalChunks Math.ceil(file.size / CHUNK_SIZE) const { uploaded } await fetch(${API_BASE}/api/upload/check, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ hash, fileName: file.name, totalChunks }) }).then((res) res.json()) const chunksToUpload [] for (let i 0; i totalChunks; i) { if (!uploaded.includes(i)) { chunksToUpload.push(i) } } await runPool( chunksToUpload.map((index) () uploadChunk(file, hash, index, totalChunks)), 3 ) await fetch(${API_BASE}/api/upload/merge, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ hash, fileName: file.name, totalChunks }) }) onProgress(100) } function uploadChunk(file, hash, index, totalChunks) { const start index * CHUNK_SIZE const end Math.min(start CHUNK_SIZE, file.size) const blob file.slice(start, end) const formData new FormData() formData.append(hash, hash) formData.append(index, index) formData.append(totalChunks, totalChunks) formData.append(file, blob, ${hash}-${index}.part) return fetch(${API_BASE}/api/upload/chunk, { method: POST, body: formData }) }calculateHash内部就是创建 Worker逻辑和上一章一致不重复贴了。runPool就是前面那个并发池函数。5.3 Node 后端的接收与合并后端接口我用 Express 写分片文件先存到临时目录合并时按索引顺序拼接。这个逻辑是所有分片上传后端的标配。// server.js const express require(express) const multer require(multer) const fs require(fs) const path require(path) const app express() app.use(express.json()) app.use(cors()) const TEMP_DIR path.join(__dirname, temp) const UPLOAD_DIR path.join(__dirname, uploads) fs.mkdirSync(TEMP_DIR, { recursive: true }) fs.mkdirSync(UPLOAD_DIR, { recursive: true }) const storage multer.diskStorage({ destination: (req, file, cb) { const hash req.body.hash const dir path.join(TEMP_DIR, hash) fs.mkdirSync(dir, { recursive: true }) cb(null, dir) }, filename: (req, file, cb) { cb(null, ${req.body.index}.part) } }) const upload multer({ storage }) // 检查文件上传状态 app.post(/api/upload/check, (req, res) { const { hash, totalChunks } req.body const dir path.join(TEMP_DIR, hash) const uploaded [] if (fs.existsSync(dir)) { const files fs.readdirSync(dir) for (const file of files) { const index Number(path.basename(file, .part)) uploaded.push(index) } } res.json({ uploaded }) }) // 上传单个分片 app.post(/api/upload/chunk, upload.single(file), (req, res) { res.json({ ok: true }) }) // 合并分片 app.post(/api/upload/merge, (req, res) { const { hash, fileName } req.body const chunkDir path.join(TEMP_DIR, hash) const outputPath path.join(UPLOAD_DIR, fileName) const chunkPaths fs .readdirSync(chunkDir) .sort((a, b) Number(path.basename(a, .part)) - Number(path.basename(b, .part))) const writeStream fs.createWriteStream(outputPath) for (const chunkPath of chunkPaths) { const data fs.readFileSync(path.join(chunkDir, chunkPath)) writeStream.write(data) } writeStream.end() res.json({ ok: true, path: outputPath }) }) app.listen(3000, () { console.log(server running at http://localhost:3000) })跑起来的方式很简单前端目录里npm install npm run dev后端目录里npm install express multer cors node server.js。6. 实测踩坑OOM、IE 兼容、进度显示6.1 大文件分片会 OOM 吗网上搜“大文件分片上传会 OOM 嘛”的人很多答案是会但大多数时候不是分片本身导致的而是你的代码在内存里复制了整个文件。最常见的错误写法是这样的const arrayBuffer await file.arrayBuffer() const chunks [] for (let i 0; i arrayBuffer.byteLength; i CHUNK_SIZE) { const chunk arrayBuffer.slice(i, i CHUNK_SIZE) chunks.push(new Blob([chunk])) }这段代码先把整个文件读入一个ArrayBuffer2GB 文件就直接占 2GB 内存浏览器直接崩溃。正确写法是用file.slice(start, end)它返回的是一个引用原文件数据段的Blob不会立刻把整段数据加载进内存浏览器只在你真正读取这个Blob内容时才会去磁盘/缓存里取数据。再强调一遍即使你把哈希计算放进 Worker也不代表内存就安全了。如果你在 Worker 里也用file.arrayBuffer()读整个文件2GB 文件依然会占掉 2GB 内存。正确做法是像我在 4.2 节写的循环分批读取用一片丢一片。还有一个容易忽略的点FormData里塞的Blob也要注意引用关系。如果你把 1000 个分片Blob都提前放到一个数组里并不会立刻吃满内存但浏览器引擎可能为了发送这些数据而准备缓冲区。更稳妥的做法是“边传边取”确保FormData构造完、请求发出后对应Blob能及时被垃圾回收。6.2 IE 浏览器能做到什么程度很多后台系统的真实运行环境还停留在 IE 11甚至有些公司内部文档里还在写“请确保使用 IE 浏览器”之类的说明。但现实是webkitdirectory在 IE 上完全不支持FileReader倒是支持但也存在兼容差异。如果项目必须兼容 IE就不能指望原生的目录选择能工作。我在 DEMO 里做了一个降级处理检测到浏览器不支持webkitdirectory时把 input 改成multiple让用户手动勾选文件夹内的所有文件。这个方案虽然用户体验差一些但至少功能是通的。const supportDirectory webkitdirectory in document.createElement(input)如果连File.slice都不支持那不管选多少个文件都没法分片这个时候我会给用户一个提示请升级浏览器到 Chrome 或 Edge。坦白讲我不建议为了 IE 去写一套 ActiveX 控件方案维护成本太高而且现在很多安全要求也已经不允许这么干了。6.3 进度条和断点续传的实测经验关于进度条我最初用的是xhr.upload.onprogress想算出每个分片的实时速率然后累加得到总进度。实测中发现两个问题分片比较小或本机网络极快时progress事件可能只触发一次而且触发时loaded已经等于total进度条直接从 0 跳到 100根本看不到中间过程。多个请求同时上传时各分片的progress事件交错触发如果直接加总进度有可能暂时超过 100%还得自己搞上限修正。后来我改成按“已成功上传分片数 / 总分片数”来计算虽然做不到字节级的精确但用户感知最舒服进度条始终平滑递增不会跳变。断点续传这块也有一个实战心得刷新页面后断点列表不要只存在内存里要持久化到localStorage。你可以在上传开始时往localStorage写入一条记录包含hash、fileName、totalChunks每成功一片就更新uploaded数组。刷新后重新选择同一文件夹前端先读localStorage发现同名同哈希的任务再调后端check接口就能彻底跳过已传分片。上传完成后把这条记录删掉。顺带一提合并接口的返回要在所有分片真正落盘之后再执行否则会出现文件还没合并完就提示成功的情况。如果你用 Node 的createWriteStream来合并记得等finish事件再响应否则并发高的场景容易出问题。最后说一个我在交付这个 DEMO 时特别深的体会上传模块的坑往往不在“写得出来”而在“演示的时候不翻车”。把错误处理、重试、恢复这些细节提前做进去比把分片大小调到最优更值得花时间。如果你只是想先跑通一个 DEMO建议按第五部分的最小链路来等业务需要再逐步加 Worker、断点续传这些增强功能。