Vue大文件与文件夹上传实践:分片、Web Worker哈希与断点续传
2026/9/9 21:01:41 网站建设 项目流程

最近帮朋友做一个内部资源管理后台,需求一句话就能说完:用户要能把自己电脑上的整个文件夹传到网页里,文件夹里可能有几百个小文件,也可能有几个 GB 的单个大文件,还要能看进度,网络断了不能全部重新上传。这个需求听起来不复杂,可真用 Vue 动手做才发现,“大文件上传”和平时写的那种input + FormData上传根本不是一回事。这篇就把我实现大文件、文件夹上传 DEMO 的完整思路和代码拆开讲一遍,包括目录递归、分片并发、Web Worker 计算哈希,以及后面实测踩到的 OOM 和 IE 兼容的坑。

适合读这篇文章的人大概是这两类:一是刚开始接触上传模块的前端同学,需要知道大文件上传为什么不能“一把梭”;二是在网上搜了一圈分片上传文章,但一直没跑通一个完整 DEMO 的开发者。看完你可以直接参考第五部分的源码改到自己项目里。

1. 为什么大文件上传不能走普通表单提交这条老路

1.1 巨大请求体先被服务端拦下来

大多数人第一次写文件上传,都是这样:<input type="file">选文件,构造一个FormData,然后用fetchXHR把整个文件丢给后端。小文件这么干完全没问题,可一旦文件上到几百 MB 甚至几个 GB,第一个拦路的就是服务端配置。

拿最常见的 Nginx 来说,默认的client_max_body_size是 1MB,意味着超过 1MB 的请求体直接给你返回413 Request Entity Too Large。Java 的 Tomcat 默认maxPostSize是 2MB,Spring Boot 的spring.http.multipart.max-file-size默认也是 1MB。也就是说,不调整后端配置,你连大文件的边都摸不到。

就算你把各种上限都调大了,还有第二个问题:请求超时。一个 2GB 的文件在一段不太稳定的网络上传,耗时可能超过代理服务器的超时时间,连接被掐断,整个请求就废了。分片上传之所以能规避这个问题,本质上是把一个大请求拆成了很多个小请求,任何一个分片失败,只需要重传那一片,成本很低。

1.2 没有断点,失败一次等于白传

普通上传还有一个很现实的问题:没有断点。上传到 70% 的时候网络断了一下,或者浏览器误点刷新,这个文件的传输状态就完全丢失了。用户要是传一个 1GB 的视频,已经等了十分钟,这时候让他重新选文件再传一遍,体验基本等于劝退。

要支持断点续传,光靠前端是做不到的。你得让前端能够识别“同一个文件”,并且让服务端记录“这个文件已经收到了哪些分片”。所以分片上传通常还会配套算一个文件哈希,作为文件的身份标识。前台上传前先算好哈希,询问服务端这个文件之前传过多少片,然后只传缺失的部分。这个逻辑在第三章会展开。

1.3 文件夹上传和“选一堆文件”是两码事

再来说文件夹上传。很多朋友第一次听到文件夹上传,以为就像input加个multiple属性一样简单。其实不是,文件夹上传的关键在于:浏览器要能让你拿到整个目录结构,而不只是文件列表。

下面两种方式是有本质区别的:

  • <input type="file" multiple>:用户只能在一个对话框里框选多个文件,选不了“一个文件夹”。
  • <input type="file" webkitdirectory>:用户可以选中一个文件夹,浏览器会把文件夹里的文件全部列出来,并且每个文件对象带一个webkitRelativePath,比如assets/images/logo.png,有了这个相对路径,前端才能还原目录层级。

注意,webkitdirectory这个属性名字以webkit开头,但它并不是 Webkit 内核专属,Chrome、Edge、Firefox、Safari 目前都支持。真正的坑在于 IE 不支持,而不少内网项目现在还在用 IE,这个我会在最后单独讲。

2. Vue 里怎么把文件夹变成本地文件树

2.1 用 webkitdirectory 拿文件列表

在 Vue 组件里,最直接的做法是绑定一个refinput元素,然后在change事件里处理文件。

<template> <div> <input ref="folderInput" type="file" webkitdirectory multiple @change="handleFolderChange" /> <ul> <li v-for="file in fileList" :key="file.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是感知不到的,因为文件列表本身只包含文件对象,没有目录对象。

如果业务上需要连空目录也上传,就要用到DataTransferItemFileSystemEntry这一层 API。你可以在inputchange事件里拿到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 上传流程:检查、分片、合并

完整的流程分四步:

  1. 计算文件哈希。这个哈希作为文件的唯一标识,用于秒传与断点续传。
  2. 请求后端检查接口/api/upload/check,把哈希发给服务端,服务端返回这个文件是否已上传过一部分,以及已存在的分片索引列表。
  3. 前端把文件按偏移量切成若干块,逐块上传,每一块通过multipart/form-data发送。
  4. 全部上传成功后,调用合并接口/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 上传 DEMO

5.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 18+,npm 或者 pnpm 都行。

5.2 上传组件的关键代码

FolderUploader.vue是整个 DEMO 的核心,我把“选择文件夹”“展示文件树”“上传”“进度”集中在同一个组件里。为了篇幅控制,这里给出剪掉样式后的主要逻辑。

<template> <div> <input ref="folderInput" type="file" webkitdirectory multiple @change="onPickFolder" /> <button :disabled="uploading" @click="startUpload"> {{ uploading ? '上传中...' : '开始上传' }} </button> <div v-if="progress > 0">总进度:{{ progress }}%</div> <ul> <li v-for="item in fileList" :key="item.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 } </script>

uploadFileWithResume是我在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])) }

这段代码先把整个文件读入一个ArrayBuffer,2GB 文件就直接占 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写入一条记录,包含hashfileNametotalChunks,每成功一片就更新uploaded数组。刷新后重新选择同一文件夹,前端先读localStorage,发现同名同哈希的任务,再调后端check接口,就能彻底跳过已传分片。上传完成后,把这条记录删掉。

顺带一提,合并接口的返回要在所有分片真正落盘之后再执行,否则会出现文件还没合并完就提示成功的情况。如果你用 Node 的createWriteStream来合并,记得等finish事件再响应,否则并发高的场景容易出问题。

最后说一个我在交付这个 DEMO 时特别深的体会:上传模块的坑往往不在“写得出来”,而在“演示的时候不翻车”。把错误处理、重试、恢复这些细节提前做进去,比把分片大小调到最优更值得花时间。如果你只是想先跑通一个 DEMO,建议按第五部分的最小链路来,等业务需要再逐步加 Worker、断点续传这些增强功能。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询