流程图解析
大文件传输的'高速公路':不堵车、不限速、不抛锚
传统文件传输的四大困扰
- 传输卡顿/中断
网络波动、服务器超时导致反复重传,耗时耗力。 - 格式与大小限制
老旧系统仅支持小文件(如<100MB),无法满足高清视频、设计源文件等需求。 - 安全性缺失
明文传输易被截获,敏感数据泄露风险高。 - 进度不可控
无法实时查看进度,失败后需从头开始,用户体验差。
大文件上传解决方案
- 智能分片上传
将文件自动拆分为多块并行传输,网络波动自动续传,速度提升300%。 - 格式/容量无感化
支持100GB+超大文件、100+格式(视频/3D模型/RAW图等),无需压缩。 - 企业级安全保障
传输加密(AES-256+TLS)+ 权限管控 + 日志溯源,满足GDPR/等保合规。 - 全流程可视化
实时进度条、预估剩余时间、失败自动定位断点,用户掌控感更强
大文件上传核心功能与实现原理
-
分片上传
- 原理:将大文件切割为多个小块(如每片5MB),并行上传至服务器。
- 示例:上传10GB视频时,自动拆分为2000个5MB分片,分批上传。
-
合并切片
- 原理:服务端按顺序将所有分片拼接成完整文件,并校验完整性(如MD5比对)。
- 示例:上传完成后,服务端在1秒内合并2000个分片生成原始视频文件。
-
秒传(极速上传)
- 原理:计算文件的唯一哈希值(如SHA-256),若服务器已存在相同文件,则直接复用。
- 示例:用户重复上传1GB文件时,系统0秒完成"上传"(实际跳过传输)。
-
断点续传
- 原理:记录已上传分片信息,网络中断后自动从断点继续上传。
- 示例:上传8GB文件到50%时断网,恢复后仅需传剩余50%,而非重新开始。
-
上传进度实时反馈
- 原理:前端动态计算已传分片占比,实时显示进度条、速度、剩余时间。
- 示例:用户上传20GB文件时,界面显示"65%完成,预计剩余10分钟"。
典型应用场景
-
企业云盘
- 场景:跨国团队传输4K视频工程文件(80GB),分片上传确保跨国网络波动下1小时完成,断点续传避免因VPN中断重传。
-
在线医疗影像系统
- 场景:上传患者CT扫描DICOM文件(30GB/人),秒传技术避免重复存储相同病例,加密分片满足HIPAA合规要求。
-
云备份服务
- 场景:企业每日备份500GB数据库,实时进度条让管理员预估完成时间,合并切片确保备份完整性。
-
视频平台创作工具
- 场景:博主上传未压缩8K素材(200GB),分片并行跑满带宽,进度反馈辅助规划发布时间。
达成 让数据流动,像呼吸一样自然! 传大文件?我们连'卡顿'这个词都传丢了!
开始展示
分片上传
这个功能点分为前端的文件分片、计算hash值、上传分片文件和服务端的创建分片目录并存储分片
- html标签元素
<button id="upload" onClick="handleUpload()">上传</button>
<p id="hash-progress"></p>
<p id="total-slice"></p>
- 获取上传的文件信息
ini
let fileName = "";
let fileSize = 0;
let fileHash = ""; // file hash
let fileChunkListData = [];
const handleUpload = async () => {
const file = document.getElementById("file").files[0];
if (!file) return alert("请选择文件!");
fileName = file.name; // 文件名
fileSize = file.size; // 文件大小
// 文件分片
const fileChunkList = createFileChunk(file);
// 计算文件hash
fileHash = await calculateHash(fileChunkList);
};
- 对文件进行分片:使用slice() 方法对大文件进行分片 ,并把分片的内容、大小等信息都放入到分片列表中,最后在页面上显示一下分片数量
ini
// 文件切片
const createFileChunk = (file) => {
const chunkList = [];
// 计算文件切片总数
const sliceSize = 5 * 1024 * 1024; // 5MB
const totalSlice = Math.ceil(file.size / sliceSize);
for (let i = 1; i <= totalSlice; i++) {
let chunk;
if (i === totalSlice) {
chunk = file.slice((i - 1) * sliceSize, fileSize);
} else {
chunk = file.slice((i - 1) * sliceSize, i * sliceSize);
}
chunkList.push({
file: chunk,
fileSize,
});
}
const sliceText = `一共分片:${totalSlice}`;
document.getElementById("total-slice").innerHTML = sliceText;
return chunkList;
};
- 计算文件hash: 使用spark-md5 分别计算每个分片的hash值,最后得到整个文件hash值。计算hash值需要比较长的时间,可以在页面上输出计算hash值的进度。可以在web-work中计算hash
spark-md5:它通过增量计算和利用Web Workers在后台处理,避免阻塞主线程。这对于大文件来说尤其重要,因为直接计算整个文件的MD5可能会非常慢,甚至导致浏览器无响应。
//
const calculateHash = (fileChunkList) => {
return new Promise((resolve) => {
const spark = new SparkMD5.ArrayBuffer(); // 创建一个 SparkMD5 实例,用于计算 MD5 哈希值
let count = 0; // 计数器,用于跟踪处理的块数
const loadNext = (index) => {
const render = new FileReader(); // 创建一个 FileReader 实例,用于读取文件内容
render.readAsArrayBuffer(fileChunkList[index].file); // 读取当前块的内容为ArrayBuffer
render.onload = (e) => { // 文件块读取完成时的回调函数
count++; // 增加计数器
spark.append(e.target.result); // 将读取到的块内容传递给 SparkMD5 实例
if (count === fileChunkList.length) { // 如果所有块都处理完毕
resolve(spark.end()); // 计算并返回最终的 MD5 哈希值
} else {
const percentage = parseInt(((count + 1) / fileChunkList.length) * 100);
const progressText = `计算hash值:${percentage}%`;
document.getElementById("hash-progress").innerHTML = progressText;
loadNext(count); // 处理下一个块
}
};
};
loadNext(0); // 开始处理第一个块
});
};
- 需要将分片数据全部上传到服务器,这里需要注意是的分片的hash值是 ${fileHash}-{index}, 服务端会根据这个hash值创建分片文件。
ini
const handleUpload = async () => {
const file = document.getElementById("file").files[0];
if (!file) return alert("请选择文件!");
fileName = file.name; // 文件名
fileSize = file.size; // 文件大小
const fileChunkList = createFileChunk(file);
fileHash = await calculateHash(fileChunkList); // 文件hash
fileChunkListData = fileChunkList.map(({ file, size }, index) => {
const hash = `${fileHash}-${index}`;
return {
file,
size,
fileName,
fileHash,
hash,
};
});
await uploadChunks();
};
javascript
// 上传
const uploadChunks = async () => {
const requestList = fileChunkListData
.map(({ file, fileHash, fileName, hash }, index) => {
const formData = new FormData();
formData.append("file", file);
formData.append("fileHash", fileHash);
formData.append("name", fileName);
formData.append("hash", hash);
return { formData };
})
.map(async ({ formData }) => {
return requestApi({
url: `${HOST}`,
method: "POST",
body: formData,
});
});
await Promise.all(requestList);
};
- 请求方法
javascript
/**
* @description: 封装fetch
* @param {Object} FetchConfig fetch config
* @return {Promise} fetch result
*/
const requestApi = ({
url,
method = "GET",
...fetchProps
}) => {
return new Promise(async (resolve, reject) => {
const res = await fetch(url, {
method,
...fetchProps,
});
resolve(res.json());
});
};
服务端
javascript
import * as http from "http"; //ES 6
import path from "path";
const server = http.createServer();
server.on("request", async (req, res) => {
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Headers", "*");
if (req.method === "OPTIONS") {
res.status = 200;
res.end();
return;
}
});
server.listen(3000, () => console.log("正在监听 3000 端口"));
- 接下来,我们就可以在里面添加上传分片的接口。使用multiparty读取到客户端提交的表单数据后,判断切片目录是否存在,不存在就使用 fileHash 值创建一个临时的分片目录,并使用fs-extra 的move 方法存储文件分片到对应的分片目录下
multiparty 是 Node.js 中处理 multipart/form-data 的基础工具,适合需要精细控制文件上传流程的场景
javascript
import * as http from "http"; //ES 6
import path from "path";
import { fileURLToPath } from "url";
import fse from "fs-extra";
import multiparty from "multiparty";
const extractExt = (filename) =>
filename.slice(filename.lastIndexOf("."), filename.length); // 提取后缀名
const server = http.createServer();
// 获取当前模块文件的路径
const __filename = fileURLToPath(import.meta.url);
// 获取当前模块文件所在目录的路径
const __dirname = path.dirname(__filename);
// 设置大文件存储目录
const UPLOAD_DIR = path.resolve(__dirname, "dist");
server.on("request", async (req, res) => {
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Headers", "*");
if (req.method === "OPTIONS") {
res.status = 200;
res.end();
return;
}
if (req.url === "/") {
const multipart = new multiparty.Form();
multipart.parse(req, async (err, fields, files) => {
if (err) {
console.error(err);
res.status = 500;
res.end("process file chunk failed");
return;
}
const [chunk] = files.file;
const [hash] = fields.hash;
const [filename] = fields.name;
const [fileHash] = fields.fileHash;
const chunkDir = `${UPLOAD_DIR}/${fileHash}`; // dist/xxxx
console.log("chunkDir", chunkDir);
// 最后的文件路径
const filePath = path.resolve(
UPLOAD_DIR,
`${fileHash}${extractExt(filename)}`
);
console.log("filePath", filePath);
// 文件存在直接返回
if (fse.existsSync(filePath)) {
res.end(
JSON.stringify({
messaage: "file exist",
})
);
return;
}
// 切片目录不存在,创建切片目录
if (!fse.existsSync(chunkDir)) {
await fse.mkdirs(chunkDir);
}
// chunk.path 是服务器临时路径,
await fse.move(chunk.path, `${chunkDir}/${hash}`);
res.status = 200;
res.end(
JSON.stringify({
messaage: "received file chunk",
})
);
});
}
});
server.listen(3000, () => console.log("正在监听 3000 端口"));
合并分片
- 在上传完文件分片之后,我们就可以对所有文件分片进行合并,这里需要请求一个合并分片的接口,需要传递文件的fileHash 和 filename
javascript
//上传分片
const uploadChunks = async () => {
//...
await mergeRequest(fileName, fileHash);
};
// 合并分片
const mergeRequest = async (fileName, fileHash) => {
await requestApi({
url: `${HOST}/merge`,
method: "POST",
headers: {
"Content-Type": "application/json;charset=utf-8",
},
body: JSON.stringify({
filename: fileName,
fileHash,
}),
});
};
服务端
- 实现一下合并分片的接口,首先需要读取请求中的数据,然后拼接出合并后的文件名称 <math xmlns="http://www.w3.org/1998/Math/MathML"> u p l o a d D i r / {uploadDir}/ </math>uploadDir/{fileHash}${ext},最后调用合并分片方法。
ini
const extractExt = (filename) =>
filename.slice(filename.lastIndexOf("."), filename.length); // 提取后缀名
const resolvePost = (req) =>
new Promise((resolve) => {
let chunk = "";
req.on("data", (data) => {
chunk += data;
});
req.on("end", () => {
resolve(JSON.parse(chunk));
});
});
if (req.url === "/merge") {
const data = await resolvePost(req);
const { filename, fileHash } = data;
const ext = extractExt(filename);
const filePath = `${UPLOAD_DIR}/${fileHash}${ext}`;
await mergeFileChunk(filePath, fileHash);
res.status = 200;
res.end(JSON.stringify("file merged success"));
}
- 合并切片功能最核心的功能就是根据fileHash读取对应分片目录下的分片文件列表,并按照分片下标进行排序,避免后面合并时顺序错乱。然后,使用 writeFile 方法创建一个空文件,再使用appendFileSync 依次向文件中添加分片数据,最后删除临时的分片目录
javascript
// 合并切片
const mergeFileChunk = async (filePath, fileHash) => {
const chunkDir = `${UPLOAD_DIR}/${fileHash}`;
try {
// 检查目录是否存在
if (!fse.existsSync(chunkDir)) {
throw new Error(`Chunk directory does not exist: ${chunkDir}`);
}
const chunkPaths = await fse.readdir(chunkDir);
// 根据切片下标进行排序,否则直接读取目录的获得的顺序可能会错乱
chunkPaths.sort((a, b) => a.split("-")[1] - b.split("-")[1]);
await fse.writeFile(filePath, "");
chunkPaths.forEach((chunkPath) => {
fse.appendFileSync(
filePath,
fse.readFileSync(`${chunkDir}/${chunkPath}`)
);
fse.unlinkSync(`${chunkDir}/${chunkPath}`);
});
fse.rmdirSync(chunkDir); // 合并后删除保存切片的目录
} catch (err) {
console.error(err);
}
};
秒传
前端
实现秒传只需要在文件上传之前请求接口验证一下文件是否存在。
javascript
const handleUpload = async () => {
//...
const { shouldUpload } = await verifyUpload(
fileName,
fileHash
);
if (!shouldUpload) {
alert("秒传:上传成功");
return;
}
};
//文件秒传
const verifyUpload = async (filename, fileHash) => {
const data = await requestApi({
url: `${HOST}/verify`,
method: "POST",
headers: {
"Content-Type": "application/json;charset=utf-8",
},
body: JSON.stringify({
filename,
fileHash,
}),
});
return data;
};
服务端
如果文件存在shouldUpload 就返回 false,否则就返回 true 。
ini
if (req.url === "/verify") {
const data = await resolvePost(req);
const { fileHash, filename } = data;
const ext = extractExt(filename);
const filePath = `${UPLOAD_DIR}/${fileHash}${ext}`;
if (fse.existsSync(filePath)) {
res.end(
JSON.stringify({
shouldUpload: false,
})
);
} else {
res.end(
JSON.stringify({
shouldUpload: true,
})
);
}
}
断点续传
- 新增两个按钮,来控制文件上传进度。
- 改造请求方法,添加 abortControllerList 用于存储需要被取消的请求,如果接口请求成功,则将fetch从 abortControllerList 中移除。
ini
/* ... */
<button id="pause" onClick="handlePause()" style="display: none">
暂停
</button>
<button id="resume" onClick="handleResume()" style="display: none">
恢复
</button>
/* ... */
/**
* @description: 封装fetch
* @param {Object} FetchConfig fetch config
* @return {Promise} fetch result
*/
let abortControllerList = [];
const requestApi = ({
url,
method = "GET",
onProgress,
...fetchProps
}) => {
const controller = new AbortController();
abortControllerList.push(controller);
return new Promise(async (resolve, reject) => {
const res = await fetch(url, {
method,
...fetchProps,
signal: controller.signal,
});
// 将请求成功的 fetch 从列表中删除
const aCIndex = abortControllerList.findIndex(
(c) => c.signal === controller.signal
);
abortControllerList.splice(aCIndex, 1);
//...
});
};
- 在分片上传也需要做一些改造,将接口中获取到的uploadedList ,从所有分片列表中过滤出去,当已上传的uploadedList 数量加 requestList 的数量等于分片列表fileChunkListData 的数量时才进行分片合并。
javascript
let fileName = "",
fileHash = "",
fileSize = 0,
fileChunkListData = [],
abortControllerList = [];
const HOST = "http://localhost:3000";
//...
const handleUpload = async () => {
//...
const { shouldUpload, uploadedList } = await verifyUpload(
fileName,
fileHash
);
if (!shouldUpload) {
alert("秒传:上传成功");
return;
}
//...
await uploadChunks(uploadedList);
};
//上传分片
const uploadChunks = async (uploadedList) => {
const requestList = fileChunkListData
.filter(({ hash }) => !uploadedList.includes(hash))
.map(({ file, fileHash, fileName, hash }, index) => {
//...
})
.map(async ({ formData, hash }) => {
. //...
});
//...
// 之前上传的切片数量 + 本次上传的切片数量 = 所有切片数量时
//合并分片
if (
uploadedList.length + requestList.length ===
fileChunkListData.length
) {
await mergeRequest(fileName, fileHash);
}
};
然后,实现一下暂停和恢复的事件处理,暂停是通过调用 AbortController 的 abort() 方法实现。恢复则是重新获取uploadedList 后再进行分片上传实现。
ini
//暂停
const handlePause = () => {
abortControllerList.forEach((controller) => controller?.abort());
abortControllerList = [];
};
// 恢复
const handleResume = async () => {
const { uploadedList } = await verifyUpload(fileName, fileHash);
await uploadChunks(uploadedList);
};
服务端
断点续传是在秒传接口的基础上实现的,只是需要新增已上传分片列表uploadedList 。
javascript
import * as http from "http"; //ES 6
import path from "path";
import fse from "fs-extra";
import multiparty from "multiparty";
const server = http.createServer();
//...
// 返回已经上传切片名列表
const createUploadedList = async (fileHash) =>
fse.existsSync(`${UPLOAD_DIR}/${fileHash}`)
? await fse.readdir(`${UPLOAD_DIR}/${fileHash}`)
: [];
server.on("request", async (req, res) => {
//...
if (req.url === "/verify") {
const data = await resolvePost(req);
const { fileHash, filename } = data;
const ext = extractExt(filename);
const filePath = `${UPLOAD_DIR}/${fileHash}${ext}`;
if (fse.existsSync(filePath)) {
res.end(
JSON.stringify({
shouldUpload: false,
})
);
} else {
res.end(
JSON.stringify({
shouldUpload: true,
uploadedList: await createUploadedList(fileHash),
})
);
}
}
});
server.listen(3000, () => console.log("正在监听 3000 端口"));
上传进度
- 新增显示进度的标签。
- 对fetch请求再做一点改造,这里需要使用getReader() 手动读取数据流,获取到当前上传进度,并添加onProgress 回调。
ini
<p id="progress"></p>
/**
* @description: 封装fetch
* @param {Object} FetchConfig fetch config
* @return {Promise} fetch result
*/
const requestApi = ({
url,
method = "GET",
onProgress,
...fetchProps
}) => {
//...
return new Promise(async (resolve, reject) => {
const res = await fetch(url, {
method,
...fetchProps,
signal: controller.signal,
});
const total = res.headers.get("content-length");
const reader = res.body.getReader(); //创建可读流
const decoder = new TextDecoder();
let loaded = 0;
let data = "";
while (true) {
const { done, value } = await reader.read();
loaded += value?.length || 0;
data += decoder.decode(value);
onProgress && onProgress({ loaded, total });
if (done) {
break;
}
}
//...
resolve(JSON.parse(data));
});
};
然后,在上传的时候将已上传进度设置成100,并添加onProgress回调处理,累计每个分片的进度,得到整体的上传进度。
ini
let fileName = "",
fileHash = "",
fileSize = 0,
fileChunkListData = [],
abortControllerList = [];
const HOST = "http://localhost:3000";
//...
const handleUpload = async () => {
//...
fileChunkListData = fileChunkList.map(({ file, size }, index) => {
//...
return {
percentage: uploadedList.includes(hash) ? 100 : 0,
};
});
//...
};
//上传分片
const uploadChunks = async (uploadedList) => {
const requestList = fileChunkListData
.filter(({ hash }) => !uploadedList.includes(hash))
.map(({ file, fileHash, fileName, hash }, index) => {
//...
})
.map(async ({ formData, hash }) => {
return requestApi({
url: `${HOST}`,
method: "POST",
body: formData,
onProgress: ({ loaded, total }) => {
const percentage = parseInt((loaded / total) * 100);
const curIndex = fileChunkListData.findIndex(
({ hash: h }) => h === hash
);
fileChunkListData[curIndex].percentage = percentage;
const totalLoaded = fileChunkListData
.map((item) => item.size * (item.percentage / 100))
.reduce((acc, cur) => acc + cur, 0);
const totalPercentage = Math.min(
parseInt((totalLoaded / fileSize) * 100),
100
);
const progressText = `上传进度:${totalPercentage}%`;
document.getElementById("progress").innerHTML = progressText;
},
});
});
//...
};