Vue3+Node.js大文件断点续传:文件分片、hash计算与并发上传实践
发布时间:2026/10/2 9:55:07来源:尧图网络
看到这个标题我想你大概是遇到了一个现实问题项目里要传几百MB甚至几个G的文件结果每次传到一半就断要不就是页面卡死、用户关掉又重新从0%开始。这套基于JavaScript、Vue、Node.js写出来的大文件断点续传DEMO就是为了解决这个痛点。我会用Vue 3配合File API里的slice方法做文件分片用spark-md5计算文件hash再用axios并发上传分片最后写一个不算复杂的Node.js服务端做分片存储和合并。整个过程下来有Vue基础的人照着敲一遍就能跑通也能理解断点续传背后的原理。1. 先把断点续传这件事想明白拆片、记录、重组1.1 一次上传为什么靠不住在开始写DEMO之前先倒回去想一个问题为什么不能直接拿一个文件往后端丢不是不能而是现实世界里大文件直接传输的体验非常差。浏览器里的File对象本质上是一个Blob你把一个几个GB的文件塞进FormData一个HTTP请求发出后整个请求体一直占着网络连接服务器那边如果用的是Express这类框架默认也会对整个请求做缓冲内存一下就绷紧。更关键的是传输中间只要有一点抖动TCP连接断了整个请求就没了浏览器不会因为文件太大就对你手下留情用户体验就是“卡在99%然后重来”。还有一个容易被忽略的问题很多网关和Web服务器对单次请求体都有硬性限制Nginx默认的client_max_body_size很小就算你配置了十几GB也架不住中间机器超时回收。所以“把一个文件变成很多个小文件分别上传”是绕开这些限制的最实用手段这也是断点续传方案的核心一个文件被切成N片每片都是一个独立请求失败了对单一片重试而不是对整个文件重试。1.2 断点续传和普通上传到底差在哪普通上传里文件的二进制流从头传到尾中间任何一个地方断掉已经传出去的字节就全部作废。断点续传则围绕“状态”来做文章先记住这个文件的唯一身份再记住哪些分片已经上传成功下次重新打开页面或者点击续传时只需要从没传过的分片继续已经传过的分片直接跳过。这个“记住”的过程就是后端只认hash和分片序号hash是文件指纹分片序号是位置。把断点续传和完整上传放在一起对比差异就非常明显传输粒度完整上传是单一数据流断点续传是一个个独立分片。失败成本完整上传一旦失败全部重来断点续传只需要重传失败的那几片理想状态下接近零成本。可暂停性完整上传不能中途暂停恢复断点续传随时可以暂停下次继续。秒传能力如果服务端已经保存过同样hash的文件可以直接提示上传完成根本不用再上传。这些差异是断点续传被选来对付大文件的核心原因。1.3 整体方案选型为什么选Vue 3 Node.js严格说起来断点续传是前端工程和前后端契约共同完成的不涉及什么黑魔法。demo里我用的组合是Vue 3 axios spark-md5后端用Node.js Express multer主要是因为这套组合和你标题里的“JavaScript怎么编写”完全在一个语言体系里前端JavaScript负责切片、算hash、并发控制后端JavaScript负责收分片、存状态、合并文件前后端不需要切换技术栈看起来更顺。Vue 3的Composition API在管理上传任务时比Vue 2的Options API直观得多ref、computed这些响应式原语能把文件、hash、已上传分片、进度这些状态统一管理起来。demo里我不会引入太重量的UI库直接用原生button和简单的div滚动条因为断点续传的核心不在UI组件而在于分片、hash和状态同步你以后接Element Plus还是Ant Design Vue都很容易。提示如果你在真实项目里用的是React、Angular或原生JS这套思路同样成立因为真正核心的是File.slice、FormData和XHR/fetch这些浏览器标准API框架只是外层壳。2. 前端分片与指纹计算从代码层面吃透2.1 用 Blob.slice 把大文件切成小块文件分片最根本的API就是Blob.prototype.sliceFile继承自Blob所以可以直接file.slice(start, end)拿到原文件的一部分。这个操作不是把文件物理切开而是生成一个新的Blob底层可能仍然引用原文件的内存区域所以性能很高不会因为切一下就把大文件整个复制一遍。以4MB为一片举例分片逻辑如下const CHUNK_SIZE 4 * 1024 * 1024 function createFileChunks(file) { const chunks [] let start 0 while (start file.size) { const end Math.min(start CHUNK_SIZE, file.size) const chunk file.slice(start, end) chunks.push(chunk) start end } return chunks }这里有几个细节要特别注意。第一最后一片的大小往往小于CHUNK_SIZE切片时end要用Math.min兜住文件大小否则会拿到一个空的Blob。第二每个分片必须带上它在整个文件中的序号否则服务端合并时不知道先后顺序这也就是后面接口里一直出现的index字段。第三切片时尽量让每片大小保持一致这样服务端合并、前端重试时逻辑都简单。实际上分片大小不是一个固定的最佳值我见过很多人直接套一个“5MB”到处用。片越小请求数量越多服务端文件句柄和前端Promise对象都会压满片越大单请求传输时间越长断点续传的“续”就变得不精细。通常我建议从2MB到10MB之间取值公网环境取4MB左右比较平衡内网环境可以放大到10MB请求数量和恢复精度都兼顾了。2.2 用 spark-md5 算出文件唯一指纹分片之后遇到第一个问题怎么让服务端知道“这两个文件其实是同一个”最可靠的方案不是文件名因为同名文件内容可以完全不一样也不是文件大小因为大小相同内容也可能不同而是给文件内容本身算一个摘要也就是hash。前端把整个文件的hash发给后端后端拿hash作为分片目录名这样同一个文件即使你改了文件名再传也能直接续上。spark-md5是前端算文件hash最常用的库它支持增量计算可以边读分片边更新hash不用一口气把整个文件读进内存。核心代码如下import SparkMD5 from spark-md5 async function calcFileHash(file) { const spark new SparkMD5.ArrayBuffer() let offset 0 while (offset file.size) { const chunk file.slice(offset, offset CHUNK_SIZE) const buffer await chunk.arrayBuffer() spark.append(buffer) offset CHUNK_SIZE } return spark.end() }这里值得说一下ArrayBuffer模式spark.append可以接收ArrayBuffer、binary string等对大文件来说用ArrayBuffer模式的性能更好因为浏览器底层可以直接给你二进制内存块。每次循环只把当前这一片读成buffer算完就释放内存占用量被控制在一个分片大小内。如果文件达到了几个GB这个计算过程可能要花几十秒为了不阻塞界面真实项目最好放到Web Worker里跑demo阶段为了少绕一圈直接放主线程后面我会专门讲怎么处理卡UI的问题。有一点必须提前说清楚hash算出来的值只代表你本地看到的内容不代表传输过程中没有损坏。真正要求严谨的场景服务端在合并完成后还要再算一次hash和前端提交的hash比对不一致就说明传坏了。demo里我会在合并接口里做一次最简单的数量校验。2.3 分片上传并发控制的实现思路分片准备好了hash也有了是不是直接把所有分片一次性全部发出去不行。几百个分片同时发起请求浏览器连接数有限服务器也会被瞬间打挂进度条回升得又慢又不稳定。正确做法是控制并发比如最多同时传3到5片。并发控制写起来一点都不难核心就是一个消费者队列维护一个游标分给固定数量的worker去消费待上传列表每个worker取到一片传一片传完继续取下一下片直到全部消费完。我习惯写成这样async function uploadWithConcurrency(pendingList, poolLimit) { let cursor 0 const workers [] const runWorker async () { while (cursor pendingList.length) { const current cursor const item pendingList[current] await uploadChunk(item.chunk, item.index) } } const workerCount Math.min(poolLimit, pendingList.length) for (let i 0; i workerCount; i) { workers.push(runWorker()) } await Promise.all(workers) }这样写的好处是让每个worker异步循环拉任务而不是先分配固定任务这样慢的分片不会拖累其他worker。实际跑的时候你可以在uploadChunk里给每个分片附加index和hash并在成功后把index记录进已上传集合中。暂停功能的核心也是这个队列暂停时用一个标志位让worker的while循环直接退出并且取消掉正在进行的axios请求恢复时用当前已上传集合过滤出还没传的分片重新建一个队列跑起来。3. 服务端接口怎么设计才能支撑前端这套玩法3.1 约定三个核心接口check、upload、merge前端和后端的分工必须通过接口契约固定下来demo里我把它收成三个接口这也是大多数断点续传服务的最小集合接口方法核心参数职责/api/checkPOSThash查询服务端已存在哪些分片序号/api/uploadPOSTfile、hash、index上传单个分片/api/mergePOSThash、name、total通知服务端合并所有分片check接口的作用是“断点查询”。用户打开页面重新选择一个文件前端算完hash后先问服务端这个文件传过吗传到了第几片服务端把已有分片的索引数组返回给前端前端过滤掉这些序号只传剩下的。这个接口让“续传”真正成立不然每次重开页面都不知道从哪里继续。upload接口接收的就是某一个分片的二进制这里有个重要约定分片必须带hash和index。hash决定它落到哪个目录index决定它在合并时的位置。为了简化设计后端按hash建目录目录里的文件名直接就是“index.part”查找已上传分片就是读取目录下有哪些part文件。merge接口是最后一击前端把所有分片都传完后通知后端合并。这个接口不能少因为如果每传一片就直接往最终文件尾部追加网络乱序会让你根本无法还原文件正确做法是先落盘成独立分片最后统一排序合并。3.2 分片落盘与断点记录后端我用Express multer来接分片。multer是表单文件处理的标准库配置磁盘存储后文件会自动保存到指定目录我们只需要在存储配置里把req.body里的hash和index取出来拼路径即可。const path require(path) const fs require(fs) const multer require(multer) const upload multer({ storage: multer.diskStorage({ destination(req, file, cb) { const dir path.join(UPLOAD_DIR, req.body.hash) fs.mkdirSync(dir, { recursive: true }) cb(null, dir) }, filename(req, file, cb) { cb(null, ${req.body.index}.part) } }) }) app.post(/api/upload, upload.single(file), (req, res) { res.json({ code: 0, message: ok }) })很多初次写断点续传的人会在这里犯一个错误在destination回调里通过req.body拿到字段。如果你去打印发现req.body是空的原因通常是multipart字段顺序不对。multer解析表单时文件字段之前的文本字段会先进入req.body所以前端构造FormData时最好把hash和index放在file字段之前append这个问题就自然消失了const formData new FormData() formData.append(hash, fileHash.value) // 先放文本字段 formData.append(index, index) formData.append(file, chunk) // 再放文件断点记录不需要引入数据库因为文件系统本身就是记录某个hash目录下有多少个part文件已经自然记录了上传进度。check接口做的事情就是读目录app.post(/api/check, (req, res) { const { hash } req.body const dir path.join(UPLOAD_DIR, hash) const uploaded fs.existsSync(dir) ? fs.readdirSync(dir).map((name) Number(name.split(.)[0])) : [] res.json({ code: 0, uploaded }) })在真实场景里如果你的服务端是多机部署或者有几十万人同时传文件文件系统就不是可靠的记录方式了那时候得换Redis之类的记住分片状态。demo阶段用文件系统逻辑最透明也最容易排查问题。3.3 合并分片时的顺序与校验问题合并接口收到请求后要读取hash目录下所有part文件按序号从小到大依次写入最终文件。顺序错了整个文件就是乱码。写合并逻辑时有两点值得注意。第一sort不能直接按文件名字符串来因为10.part会排在2.part前面必须把文件名里的序号解析成数字再比较。第二大文件合并不要用readFileSync把所有分片同时读进内存几个GB的文件可能会把Node进程内存撑爆。正确姿势是用流式管道或者用ReadableStream的异步迭代边读边写const { createReadStream, createWriteStream, existsSync, mkdirSync, readdirSync } require(fs) async function mergeChunks(hash, name) { const dir path.join(UPLOAD_DIR, hash) const chunkFiles readdirSync(dir) .filter((item) item.endsWith(.part)) .sort((a, b) Number(a.split(.)[0]) - Number(b.split(.)[0])) const outputPath path.join(MERGED_DIR, ${Date.now()}-${name}) if (!existsSync(MERGED_DIR)) mkdirSync(MERGED_DIR, { recursive: true }) const ws createWriteStream(outputPath) for (const file of chunkFiles) { const rs createReadStream(path.join(dir, file)) for await (const data of rs) { if (!ws.write(data)) { await new Promise((resolve) ws.once(drain, resolve)) } } } ws.end() await new Promise((resolve, reject) { ws.on(finish, resolve) ws.on(error, reject) }) }合并完成后强烈建议用绝对路径读写文件避免文件名里带上../或者/之类的路径穿越问题。真实项目中文件名不能直接拿来拼路径应该用hash作为存储名或者做一层白名单过滤demo里用name主要是方便看到合并结果。注意不要在真实项目里让用户传来的文件名直接落盘一定要对文件名做清洗或重命名。这是很多文件上传服务被上传恶意文件的入口。4. 完整DEMO实操从前端Vue到后端Node的落地过程4.1 初始化工程与依赖先建一个最简单的工程。我特意不引入复杂的脚手架只保留能跑通断点续传的核心依赖方便你放进自己的项目里改造。mkdir vue-big-file-upload-demo cd vue-big-file-upload-demo npm init -y npm install express multer axios spark-md5 npm install -D vite vitejs/plugin-vue后端部分需要express和multer前端部分需要axios和spark-md5。vite只是demo的启动工具你可以理解成帮我们把Vue单文件组件编译到浏览器能跑的程度。目录结构我习惯把前后端放在同一个工程里前端代码放src后端代码放servervue-big-file-upload-demo ├── src │ ├── App.vue │ └── main.js ├── server │ └── index.js ├── index.html ├── vite.config.js └── package.jsonvite.config.js里配置devServer代理把/api开头的请求转发到后端的3000端口import { defineConfig } from vite import vue from vitejs/plugin-vue export default defineConfig({ plugins: [vue()], server: { port: 5173, proxy: { /api: http://localhost:3000 } } })main.js和index.html最简单能挂载App.vue就行// src/main.js import { createApp } from vue import App from ./App.vue createApp(App).mount(#app)!-- index.html -- !DOCTYPE html html head title大文件断点续传DEMO/title /head body div idapp/div script typemodule src/src/main.js/script /body /html4.2 前端页面与上传逻辑App.vue是整个demo的前端核心。我用ref维护文件对象、hash、已上传分片集合和进度再用一个上传状态字段标记当前处于“计算hash中、上传中、已暂停、已完成”哪个阶段。这三个状态字段看着简单但断点续传最怕的就是前端状态没管好。template div input typefile changehandleFileChange / button clickstartUpload上传/button button clickpauseUpload暂停/button button clickresumeUpload续传/button div文件指纹{{ fileHash || 等待计算 }}/div div总体进度{{ percent }}%/div div状态{{ status }}/div /div /template script setup import { ref } from vue import axios from axios import SparkMD5 from spark-md5 const CHUNK_SIZE 4 * 1024 * 1024 const file ref(null) const fileHash ref() const uploadedIndexes ref([]) const totalChunkCount ref(0) const percent ref(0) const status ref(idle) const pauseFlag ref(false) const cancelControllers ref([]) function handleFileChange(e) { file.value e.target.files[0] } function createChunks(sourceFile) { const chunks [] let start 0 while (start sourceFile.size) { const end Math.min(start CHUNK_SIZE, sourceFile.size) chunks.push(sourceFile.slice(start, end)) start end } return chunks } async function calcHash(sourceFile) { const spark new SparkMD5.ArrayBuffer() let offset 0 while (offset sourceFile.size) { const chunk sourceFile.slice(offset, offset CHUNK_SIZE) spark.append(await chunk.arrayBuffer()) offset CHUNK_SIZE } return spark.end() } async function uploadChunk(chunk, index) { const formData new FormData() formData.append(hash, fileHash.value) formData.append(index, index) formData.append(file, chunk) const controller new AbortController() cancelControllers.value.push(controller) try { await axios.post(/api/upload, formData, { signal: controller.signal }) uploadedIndexes.value.push(index) percent.value Math.round((uploadedIndexes.value.length / totalChunkCount.value) * 100) } finally { const i cancelControllers.value.indexOf(controller) if (i -1) cancelControllers.value.splice(i, 1) } } async function runTaskWithLimit(pendingList, limit) { let cursor 0 const runWorker async () { while (cursor pendingList.length) { if (pauseFlag.value) return const current cursor const item pendingList[current] try { await uploadChunk(item.chunk, item.index) } catch (e) { if (pauseFlag.value) return // 网络失败时重试一次 await uploadChunk(item.chunk, item.index) } } } const workers [] for (let i 0; i Math.min(limit, pendingList.length); i) { workers.push(runWorker()) } await Promise.all(workers) } async function startUpload() { const sourceFile file.value if (!sourceFile) return status.value computing-hash pauseFlag.value false fileHash.value await calcHash(sourceFile) const chunks createChunks(sourceFile) totalChunkCount.value chunks.length status.value checking const { data } await axios.post(/api/check, { hash: fileHash.value }) const uploaded data.uploaded || [] uploadedIndexes.value uploaded if (uploaded.length 0) { percent.value Math.round((uploaded.length / totalChunkCount.value) * 100) } const pending chunks .map((chunk, index) ({ chunk, index })) .filter((item) !uploaded.includes(item.index)) if (pending.length 0) { await mergeRequest(sourceFile) status.value done percent.value 100 return } status.value uploading try { await runTaskWithLimit(pending, 3) if (pauseFlag.value) return await mergeRequest(sourceFile) status.value done percent.value 100 } catch (e) { status.value failed } } async function mergeRequest(sourceFile) { await axios.post(/api/merge, { hash: fileHash.value, name: sourceFile.name, total: totalChunkCount.value }) } function pauseUpload() { pauseFlag.value true status.value paused cancelControllers.value.forEach((controller) controller.abort()) } function resumeUpload() { if (!file.value) return startUpload() } /script这段代码里有一个很重要的细节上传成功后立即把index推进uploadedIndexes数组并刷新进度这样即使页面中途刷新下一次续传也能通过check接口拿回这些记录。另一个细节是暂停时调用controller.abort让正在传输的axios请求真的中断而不是只把标志位改成true等着它自己跑完。之所以用AbortController而不是axios旧版的cancelToken是因为这是现在更推荐的标准方式axios新版也支持signal配置。4.3 后端服务完整实现server/index.js里放一个完整的Express服务包含前面说的三个接口。为了演示直接我合并文件时用时间戳加原始文件名作为输出文件名但真实项目里建议换成hash加后缀可读性差一点安全性高很多。const express require(express) const multer require(multer) const fs require(fs) const path require(path) const app express() const PORT 3000 const UPLOAD_DIR path.join(__dirname, uploads) const MERGED_DIR path.join(__dirname, merged) fs.mkdirSync(UPLOAD_DIR, { recursive: true }) fs.mkdirSync(MERGED_DIR, { recursive: true }) app.use(express.json()) const storage multer.diskStorage({ destination(req, file, cb) { const dir path.join(UPLOAD_DIR, req.body.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, upload.single(file), (req, res) { res.json({ code: 0, message: chunk uploaded, index: Number(req.body.index) }) }) app.post(/api/check, (req, res) { const { hash } req.body const dir path.join(UPLOAD_DIR, hash) if (!fs.existsSync(dir)) { return res.json({ code: 0, uploaded: [] }) } const uploaded fs.readdirSync(dir) .filter((name) name.endsWith(.part)) .map((name) Number(name.split(.)[0])) res.json({ code: 0, uploaded }) }) app.post(/api/merge, async (req, res) { const { hash, name, total } req.body const dir path.join(UPLOAD_DIR, hash) if (!fs.existsSync(dir)) { return res.status(400).json({ code: 1, message: 分片目录不存在 }) } const partFiles fs.readdirSync(dir) .filter((item) item.endsWith(.part)) .sort((a, b) Number(a.split(.)[0]) - Number(b.split(.)[0])) if (partFiles.length ! Number(total)) { return res.status(400).json({ code: 1, message: 分片数量不完整 }) } const outputPath path.join(MERGED_DIR, ${Date.now()}-${name}) const ws fs.createWriteStream(outputPath) for (const partFile of partFiles) { const rs fs.createReadStream(path.join(dir, partFile)) try { for await (const data of rs) { if (!ws.write(data)) { await new Promise((resolve) ws.once(drain, resolve)) } } } catch (e) { ws.destroy(e) return res.status(500).json({ code: 1, message: 合并失败 }) } } ws.end() ws.on(finish, () res.json({ code: 0, message: merge ok, filePath: outputPath })) ws.on(error, (e) res.status(500).json({ code: 1, message: e.message })) }) app.listen(PORT, () { console.log(server running at http://localhost:${PORT}) })这里merge接口里对分片总数做了一个校验前端传过来的total必须等于实际part文件数量否则直接拒绝合并。这是防止“漏传了几个分片就去合并”的第一道防线但只靠数量校验还不够更好的做法是校验所有分片大小之和是否等于原始文件大小或者合并后重新计算整文件hash。4.4 完整请求时序与体验对照跑起来之后一个典型的断点续传请求链路是这样的用户选择文件前端切片并计算hash状态显示“正在计算”。前端POST /api/check拿到服务端已存在的分片序号数组。前端过滤出剩余分片以3路并发逐片POST /api/upload。每传完一片前端将index加入已上传集合进度条前进一步。全部传完后POST /api/merge服务端按index排序合并返回最终文件路径。第一次全量传可能要几十秒。第二次选择同一个文件再上传check接口直接返回所有分片序号已存在前端不会发出任何upload请求直接走merge整个过程在1秒内完成这就是秒传。中断的体验也能复现上传到一半直接把后端进程杀掉前端会抛出一堆请求失败然后暂停再重启后端点续传前端重新计算hash后通过check发现已经上传了前10片就从第11片开始继续传剩余分片都是秒过。这个过程中真正网络传输的只有中断后剩余的部分前面的分片一个都没有重传。5. 断点续传DEMO里最容易踩的坑我帮你整理好了5.1 hash怎么算才不卡UIdemo里我为了可读性直接把hash计算放在主线程小文件没问题一旦文件超过1GB你就能明显看到页面卡在“计算中”转圈按钮点不动。原因很简单spark-md5的ArrayBuffer.append虽然每次只处理一个分片但整个计算循环占着主线程的事件循环Vue的响应式更新根本没机会插进来。解决办法是放进Web Worker。把分片读取和spark-md5计算全部丢到worker里主线程只负责接收最后的hash字符串和进度。还有一个折中方案在主线程计算时每隔几个分片await一次setTimeout或requestAnimationFrame让出事件循环界面能保持响应但是对超大文件仍然不够稳。真实项目里我建议直接开Worker并且把“计算hash”也做成可中断的否则用户等了几十秒想取消算到一半也退不掉。另一个性能问题是读文件的方式。chunk.arrayBuffer()会把这一片完整拷进内存4MB一片没问题但如果你大面积用整文件arrayBuffer几个GB的文件直接内存溢出。demo里我始终基于切片来读这一点不要改成file.arrayBuffer()一次性读取。5.2 分片大小和并发数到底怎么配分片大小和并发数是断点续传最影响吞吐的两个参数它们的合理值取决于你的用户网络环境。我整理了一份经验值可以直接当初始基准场景推荐分片大小推荐并发数公网普通宽带、用户量大2MB-4MB3内网高速、服务端带宽充足5MB-10MB5-8手机弱网环境1MB-2MB2-3公网环境并发数别开太大看起来快实际上容易触发服务端连接数上限、代理超时一个分片失败还会带动整条连接排队。弱网环境分片小一点这样中断发生时损失的字节更少重试速度也更快。这些值可以在后台做成配置项按文件大小动态调整比如小于100MB走普通单请求大于100MB再启用分片。还有一个容易忽略的参数是axios的超时时间。给每个分片请求设置一个合理的timeout比如30秒不要让一个分片卡在服务端永远等下去否则所有worker都等在同一片上后续分片全部排队表现就是进度条卡住不动。5.3 断点失效、秒传失败、进度不准逐个排查先看最常见的“断点续传不生效”重新打开页面选择同一个文件check接口返回的uploaded却是空数组。这种情况十有八九是hash对不上检查点有这几个前端hash计算是否基于原始文件的完整内容有没有因为切片边界算错。后端保存分片目录时是否真的用了hash而不是用索引值拼错路径。同一个文件在前后两次选择时File对象的size和lastModified是否一致。如果文件内容没变但hash不同多半是读取逻辑里混入了无关字节。再看“秒传失败”所有分片明明都在前端还是把几百个分片重新传了一遍。先把check接口返回的数组打印出来如果返回的index是字符串而前端用includes判断时类型不匹配就会全部判断成不存在。这是JavaScript弱类型最容易踩的坑之一要么后端返回数字要么前端parseInt之后再去includes。进度不准的问题多数出在进度计算公式上。整个上传过程其实包含hash计算和上传两个阶段很多人只算了上传阶段的片数百分比用户从0%开始等hash算了半天进度一直是0体验很差。我的习惯是给hash计算分配一个固定小百分比比如5%上传阶段占95%然后总体进度等于hash阶段完成后5%再加上uploadedIndexes.length / totalChunkCount * 95。最后一个坑是关于端口和代理的。Vite开发服务器默认5173后端3000如果跨域或不走代理前端发出的/api请求会404。我建议demo里统一通过vite的proxy转发生产环境则用nginx把/api反代到后端这是最不容易出问题的部署姿势。6. 演示之外我把这套方案搬进真实项目的经验demo写完之后我在自己维护的一个网盘类小工具里把这套逻辑改了改上线遇到了一些demo里没有体现的问题分享几个方向你可以作为下一步扩展的参考。第一是取消和续传的状态管理。demo里暂停用一个布尔标志位搞定但真实场景用户可能在上传中刷新页面、切走再回来前端需要把上传任务持久化到localStorage甚至IndexedDB记录文件hash、分片序号和进度。重新加载后先恢复任务列表再逐个调check接口确定真实进度。这一步不做用户中途刷新一次前面传的片就全“丢了”心智虽然服务端物理上还在但前端不知道从哪里继续。第二是Web Worker里跑hash。我在worker里维护了一个“取消计算”的信号主线程传给worker或者直接调用worker.terminate()用户取消上传时能把hash计算立刻停下来。这一改动对体验的提升非常明显尤其面对10GB以上的文件hash计算可能要好几分钟。第三是服务端的整洁度。文件系统做断点记录在单机demo里很舒服但上线后最好把“分片状态”抽到Redis缓存文件本体放对象存储或者分布式文件系统合并操作交给后端异步任务队列去跑而不是在HTTP请求里同步读写大文件。同步读写会导致请求一直挂着网关很快超时断开合并却还在后台写文件返回包用户根本收不到。如果只是要一个能跑的DEMO照着上面的代码敲一遍就够了如果要把断点续传做成生产功能分片存储、失败重试、秒传校验、权限控制这几个模块都值得单独花时间打磨。我现在回看这个项目最大的收获不是某个API有多难而是“把大问题拆成小问题、再对小问题分别处理”这个思路在断点续传里体现得尤其明显。真遇到大文件传输需求时先别急着找现成的上传组件把文件分片、hash指纹、断点记录这三件事想清楚代码自然就出来了。
网站建设高端定制企业官网