☰
Vue3+Node.js大文件断点续传:文件分片、hash计算与并发上传实践
2026/10/2 9:55:06 网站建设 项目流程

看到这个标题,我想你大概是遇到了一个现实问题:项目里要传几百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.slice,File继承自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,后端代码放server:

vue-big-file-upload-demo ├── src │ ├── App.vue │ └── main.js ├── server │ └── index.js ├── index.html ├── vite.config.js └── package.json

vite.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 id="app"></div> <script type="module" src="/src/main.js"></script> </body> </html>

4.2 前端页面与上传逻辑

App.vue是整个demo的前端核心。我用ref维护文件对象、hash、已上传分片集合和进度,再用一个上传状态字段标记当前处于“计算hash中、上传中、已暂停、已完成”哪个阶段。这三个状态字段看着简单,但断点续传最怕的就是前端状态没管好。

<template> <div> <input type="file" @change="handleFileChange" /> <button @click="startUpload">上传</button> <button @click="pauseUpload">暂停</button> <button @click="resumeUpload">续传</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 完整请求时序与体验对照

跑起来之后,一个典型的断点续传请求链路是这样的:

  1. 用户选择文件,前端切片并计算hash,状态显示“正在计算”。
  2. 前端POST /api/check,拿到服务端已存在的分片序号数组。
  3. 前端过滤出剩余分片,以3路并发逐片POST /api/upload。
  4. 每传完一片,前端将index加入已上传集合,进度条前进一步。
  5. 全部传完后,POST /api/merge,服务端按index排序合并,返回最终文件路径。

第一次全量传可能要几十秒。第二次选择同一个文件再上传,check接口直接返回所有分片序号已存在,前端不会发出任何upload请求,直接走merge,整个过程在1秒内完成,这就是秒传。

中断的体验也能复现:上传到一半,直接把后端进程杀掉,前端会抛出一堆请求失败,然后暂停再重启后端,点续传,前端重新计算hash后通过check发现已经上传了前10片,就从第11片开始继续传,剩余分片都是秒过。这个过程中真正网络传输的只有中断后剩余的部分,前面的分片一个都没有重传。

5. 断点续传DEMO里最容易踩的坑,我帮你整理好了

5.1 hash怎么算才不卡UI

demo里我为了可读性直接把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指纹、断点记录这三件事想清楚,代码自然就出来了。

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

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

立即咨询