一眼就会大文件分片上传,白送前后端全套功法
快看!前方好像有什么人在争吵。
我这个文件上传了这么多,怎么又要重新开始上传啊?
我这个网页怎么又卡住了。
切图仔快来看看!
「啊啊啊!」 前方突然传来一声尖叫,只见一个年轻人 “颤颤巍巍” 地从工位上站起,指着他后方的同事,随后大喊一声:“你不要再叫我切图仔啦!”
话音刚落,只见后方同事表现出一副嗤之以鼻的样子,随后带着一丝轻蔑,对着那个年轻人又撂下一句:“切图仔”。
随即,那个年轻人全身都在颤抖,仿佛就要......
引言
开始学习。
下文将带你打通从前端文件分片、文件哈希计算、Web Worker用法、文件断点续传和秒传,到后端分片接收、文件合并的完整技术链条。
后端基于 Node.js + Express 框架,使用 formidable 库高效处理分片数据,最终将分片合并为完整文件。
前端采用 Vue 3 + TS + Vite 构建,使用 Naive UI 写个好看点的上传进度监控界面,并利用 Web Worker 线程计算文件哈希。
关键词: 文件分片、文件秒传、断点续传、上传进度、文件 hash、Web Worker、Node.js
实现原理图解
下面两张图展示了从前端上传文件分片到后端解析分片数据并写入文件、合并分片文件的全套功法。
前端实现图解
后端实现图解
看完图解大概理解实现流程就可以上手写代码了
前端实现代码
首先我们的目的是封装一个提供大文件上传功能的函数,由于函数中又涉及到页面的一些「响应式状态」,比如上传进度,那么我们姑且就叫它为hook函数吧,然后把它命名为useBigFileUpload.ts
下面先把需要用到的「属性和类型」先写上,其他方法后面再逐步完善。
import axios, { type AxiosProgressEvent, type AxiosResponse } from "axios";import { createDiscreteApi } from "naive-ui";import { computed, markRaw, ref } from "vue";const baseUrl = "http://localhost:3000";// axios实例化const http = axios.create({baseURL: baseUrl,});const { message } = createDiscreteApi(["message"]);type Props = {chunkSize: number;};type ChunkRequestQueue = {promise: Promise<AxiosResponse>;abortController: AbortController;};export type Chunk = {// 切片数据chunk: Blob;// 当前切片索引index: number;// 切片文件名name: string;// 总大小size: number;// 已上传大小uploaded: number;// 请求总进度requestSize: number;// 是否完成上传completed: boolean;// 上传进度progress: number;};export const useBigFileUpload = (props: Props = {chunkSize: 1024 * 1024 * 1,}) => {// 当前计算hash进度const hashProgress = ref(0);// 是否暂停了上传const isPuase = ref(false);// 当前上传的文件let fileTarget: File | null = null;// 文件哈希值+文件后缀名let fileHash = "";// 当前上传切片数组const chunks = ref<Chunk[]>([]);// 当前上传请求队列let chunkReqQueueList: ChunkRequestQueue[] = [];// 当前上传完成的切片/总切片数const percentage = computed(() => {const chunkSize = chunks.value.length;if (chunkSize === 0) {return 0;}const conpletedChunks = chunks.value.filter((chunk) => chunk.completed);return (conpletedChunks.length / chunkSize) * 100;});return {percentage,chunks,isPuase,hashProgress,};};
文件切片
创建createChunks方法,对大文件进行切片
// 创建切片,切片大小可根据自身需求调整const createChunks = (file: File,chunkSize: number = props.chunkSize): Blob[] => {const chunks: Blob[] = [];for (let i = 0, j = 0; i < file.size; i += chunkSize, j++) {chunks.push(file.slice(i, i + chunkSize));}return chunks;};
通过分片计算文件hash值,其中有两种计算类型:
「抽样分片计算」。通过抽取文件的一部分分片进行一部分文件内容的 hash 值计算,这种方法可以节省计算耗时。
「全量分片计算」。通过所有分片计算完整的文件内容 hash 值,这个方法计算的值会比较准确,但是计算耗时相对多一些。
下面我们「采用」的是「第二种」方法进行计算。
文件 hash 计算
在你的项目的 utils 工具包中创建file-hash.work.js文件,计算文件内容 hash 值的Web Worker方法
// 在 utils/workers/file-hash.work.js 中// 引入 SparkMD5 计算文件内容hash// importScripts('/[email protected]/spark-md5.min.js');importScripts('https://cdn.jsdelivr.net/npm/[email protected]/spark-md5.min.js');self.onmessage = function (e) {// 从主线程接收分片数据const { chunks } = e.data;// 创建 SparkMD5 实例const spark = new SparkMD5.ArrayBuffer();// 读取切片functionreadChunk(index) {if(index >= chunks.length) {// 所有分片处理完毕,计算最终哈希const hash = spark.end();// 向主线程发送最终计算的哈希值self.postMessage({ index, hash });// 所有分片处理完毕,关闭workerself.close();return;}// 创建 FileReader 实例, 用于读取分片数据const fileReader = new FileReader();// 读取分片数据fileReader.readAsArrayBuffer(chunks[index]);// 分片数据加载完成事件监听fileReader.onload = (e) => {// 追加到 SparkMD5 实例spark.append(e.target.result);// 向主线程发送计算进度索引self.postMessage({ index });// 读取下一个分片readChunk(index + 1);};}// 从索引0开始读取分片readChunk(0);};
在utils文件夹下写好worker工具函数后,继续在useBigFileUpload函数当中添加getHashWorker方法异步获取文件hash值。
/*** 计算文件哈希值* @param tempChunks 切片数组* @returns 文件哈希值*/const getHashWorker = async (tempChunks: Blob[]): Promise<string> => {hashProgress.value = 0;return new Promise((resolve) => {// 导入worker线程,计算文件哈希值const worker = new Worker(new URL("../utils/workers/file-hash.work.js", import.meta.url));// 向worker线程发送消息,计算文件哈希值worker.postMessage({chunks: tempChunks,});// 监听worker线程消息,获取计算文件哈希值结果worker.onmessage = (e) => {const { index, hash } = e.data;// 计算当前计算hash进度if (tempChunks.length) {hashProgress.value = (index / tempChunks.length) * 100;}if (hash) {// 完成计算哈希值resolve(hash);}};});};
拿到 hash 值后就可以通过该值查询服务器是否存在该文件,来实现大文件秒传
文件秒传
通过hash查询服务器是否「存在该文件」,前端根据返回结果判断是否需要进行文件上传,已存在则直接显示上传完成,否则接着下面进行分片上传。
/*** 检查文件是否已经上传* @param fileHash 文件哈希值* @returns 检查结果*/const checkFileApi = async (fileHash: string) => {// 通过文件hash检查是否已经上传了该文件const res = await http.get(`${baseUrl}/check/file?fileHash=${fileHash}`);// 返回检查结果return res.data.hasExist}
文件断点续传
主要是通过检查服务器是否已经「存在该分片」,并返回查询结果,前端根据查询结果判断是否需要调用分片上传请求方法。
/*** 检查切片是否已经上传* @param chunkName 切片名* @returns 检查结果*/const checkChunkApi = async (chunkName: string) => {// 创建中断请求器const abortController = new AbortController();// 发生请求前先验证切片是否已经上传const res: AxiosResponse<{code: number;message: string;hasExist: boolean;}> = await http.get(`${baseUrl}/chunk/check?chunkName=${chunkName}`, {signal: abortController.signal,});return {res: res,abortController,}};
分片上传请求
创建开始分片上传请求方法uploadChunkApi
/*** 上传切片* @param data 切片数据* @returns ChunkRequestQueue 上传请求对象*/const uploadChunkApi = (data: Chunk): ChunkRequestQueue => {const { name, index, chunk } = data;const formData = new FormData();formData.append("chunk", chunk);// 创建中断请求器const abortController = new AbortController();const promise = http.post(`${baseUrl}/chunk?chunkName=${name}`, formData, {signal: abortController.signal,onUploadProgress: (progressEvent: AxiosProgressEvent) => {// 计算上传进度if (chunks.value[index] &&progressEvent.total &&progressEvent.progress) {// 记录当前上传切片请求进度。// 注意,由于网络请求具有额外开销,上传进度可能会和切片大小不一样chunks.value[index].uploaded = progressEvent.loaded;// 记录当前上传切片请求总大小// 注意,由于网络请求具有额外开销,上传进度可能会和切片大小不一样chunks.value[index].requestSize = progressEvent.total;// 计算上传进度chunks.value[index].progress = progressEvent.progress;}},});return {promise,abortController,};};
在发送分片上传http请求的方法中,监听了axios的onUploadProgress回调方法,在该方法里面可以拿到请求的完成进度,其中记录了AxiosProgressEvent的 3 个属性:uploaded、total、progress。主要是progress页面显示上传进度需要,其他如果页面不需要显示,可有可无吧。
最后把当前请求的promise对象和中断请求的abortController对象返回,放入请求队列当中。
当需要暂停上传时,可以使用abortController进行中断请求。
分片合并请求
创建合并切片请求方法mergeChunksApi,当请求队列中的分片都成功上传后,调用该方法并携带fileHash文件标识,用于服务器找到该文件分片所在位置,合并成功后,文件即上传完成。
/*** 合并切片* @param fileHash 文件哈希值* @returns 合并结果*/const mergeChunksApi = (fileHash: string) => {return http.post(`/merge-chunk`, {fileHash,});};
开始上传文件
上面的准备工作就绪后,就可以创建上传文件方法了。
/*** 开始上传* @param file* @param resume 是否恢复上传*/const startUpload = async (file: File, resume: boolean = false) => {if (!file) {message.error("请先选择文件!");return;}// 恢复上传不用重新创建切片,延续之前的切片进度if (!resume) {// 保存文件,用于后续恢复上传fileTarget = file;// 重置暂停状态,避免重复上传时暂停状态为true导致问题isPuase.value = false;// 创建切片const tempChunks = createChunks(file);// 计算文件哈希值,并拼接文件后缀名fileHash =(await getHashWorker(tempChunks)) + "." + file.name.split(".").pop();// 构建切片对象数组chunks.value = tempChunks.map((chunk, i) => {return {name: buildChunkName(fileHash, i),chunk: markRaw(chunk),index: i,size: props.chunkSize,uploaded: 0,requestSize: 0,progress: 0,get completed() {return this.progress === 1;},};});}// 通过文件hash检查是否已经上传了该文件const hasExist = await checkFileApi(fileHash);if (hasExist) {message.warning("文件已存在,无需重复上传");return;}// 如果是暂停后恢复上传时,过滤出未上传完成的切片const waitUploadChunks = chunks.value.filter((chunk) => {return !chunk.completed;});// 遍历未完成上传的切片for (let i = 0; i < waitUploadChunks.length; i++) {const chunk = waitUploadChunks[i] as Chunk;const name = chunk.name;// 创建中断请求器const abortController = new AbortController();// 发生请求前先验证切片是否已经上传const res: AxiosResponse<{code: number;message: string;hasExist: boolean;}> = await http.get(`${baseUrl}/chunk/check?chunkName=${name}`, {signal: abortController.signal,});// 暂停了直接退出for循环,且不再执行for循环后面的代码if (isPuase.value) return;if (res.data.hasExist) {chunkReqQueueList[i] = {// 返回校验结果promise: Promise.resolve(res),// 校验切片是否存在中断器abortController,};chunk.progress = 1;} else {// 切片不存在,继续上传chunkReqQueueList[i] = uploadChunkApi(chunk);}}if (chunkReqQueueList.length > 0) {await Promise.all(chunkReqQueueList.map((item) => item.promise));// 合并切片await mergeChunksApi(fileHash);}};
「buildChunkName 方法」
构建和服务器达成约定的分片名称,服务器可通过-分割获取分片所属文件;在合并分片时可根据index索引进行排序合并,保证文件的完整性。
/*** 构建切片名* @param hash 文件哈希值* @param index 切片索引* @returns 切片名*/const buildChunkName = (hash: string, index: number) => {return `${hash}-${index}`;};
在开始上传的startUpload方法中具有两种情况,分别为「开始上传」和「恢复上传」。
开始上传时,执行完整的步骤;在恢复上传时,可根据开始上传时的状态,过滤出未上传的分片waitUploadChunks数组,再发送分片上传请求,避免不必要的性能损耗。
暂停上传文件
// 暂停切片上传const pauseUpload = () => {if (!isPuase.value) {isPuase.value = true;chunkReqQueueList.forEach((req) => {if (req.abortController) {req.abortController.abort("用户取消上传!");}});// 清空上传队列chunkReqQueueList = [];} else {message.warning("文件已暂停上传!");}};
恢复上传文件
// 恢复切片上传const resumeUpload = () => {if (fileTarget && isPuase.value) {isPuase.value = false;startUpload(fileTarget, true);} else {message.warning("文件正在上传中!");}};
ui 页面构建 (用法)
BigFileUpload.vue
<template><divclass="upload-file"><n-upload @before-upload="onUpload"><n-button>上传文件</n-button></n-upload><divclass="section"><div><span>文件内容hash计算进度:</span><n-progresstype="line"color="green":percentage="hashProgress" /></div></div><divclass="percentage section"><div><span>上传进度:</span><n-progresstype="line":percentage="progress" /></div></div><divclass="actions section"><span>操作:</span><n-buttontype="default" @click="onPause">暂停</n-button><n-buttontype="primary" @click="onResume">继续</n-button><n-tagtype="error"v-if="isPuase">已暂停</n-tag></div><divclass="chunk-list"><h3>分片上传列表</h3><ChunkDetail:list="chunks"></ChunkDetail></div></div></template><scriptsetuplang="ts">import { NUpload, NButton, useMessage, NProgress, NTag } from "naive-ui";import type { UploadFileInfo } from "naive-ui";import { useBigFileUpload } from "@/hooks/useBigFileUpload";import ChunkDetail from "./ChunkDetail.vue";import { computed } from "vue";const {percentage,startUpload,pauseUpload,resumeUpload,chunks,isPuase,hashProgress} = useBigFileUpload();const message = useMessage();const progress = computed(() => {return parseFloat(percentage.value.toFixed(2))})const onUpload = (data: {file: UploadFileInfo;fileList: Array<UploadFileInfo>;event?: Event;}) => {console.log(data);const file = data.file.file;if (!file) {message.error("请选择文件!");return;}startUpload(file);};const onPause = () => {pauseUpload();};const onResume = () => {resumeUpload();};</script><stylescoped>.upload-file {width: 1000px;padding: 20px;}.actions {display: flex;align-items: center;gap: 10px;}.chunk-list {height: 600px;overflow-y: auto;}.section {margin-top: 20px;}</style>
ChunkDetail.vue
<template><divclass="chunk"><n-table:bordered="false":single-line="false"><thead><tralign="center"><th>索引</th><th>名称</th><th>分片大小(byte)</th><th>请求总大小(byte)</th><th>已上传(byte)</th><th>上传进度</th><th>是否完成</th></tr></thead><tbody><trv-for="data in list":key="data.index"align="center"><td>{{ data.index }}</td><td>{{ data.name }}</td><td>{{ data.size}}</td><td>{{ data.requestSize }}</td><td>{{ data.uploaded }}</td><td>{{ (data.progress * 100).toFixed(2) }}%</td><td><n-tagv-if="data.completed"type="success">是</n-tag><n-tagv-else>否</n-tag></td></tr></tbody></n-table></div></template><scriptsetuplang="ts">import { NTable, NTag } from "naive-ui";import type { Chunk } from "@/hooks/useBigFileUpload";defineProps<{list: Chunk[];}>();</script>
后端实现代码
文件上传目录示例
❝temp: 解析分片数据写入的临时目录,写入的分片可能不完整;
chunks: 写入完成的分片文件目录;
upload: 分片合并完成的文件目录;
❞
查询文件是否存在接口
在upload文件夹下,通过文件 hash 查询,是否存在该 hash 文件名的文件
// 查询mergeDir目录下是否存在上传的文件app.get('/check/file', (req, res) => {const fileHash = req.query.fileHash;const filePath = path.join(mergeDir, fileHash);const hasExistFile = fs.existsSync(filePath);if(hasExistFile) {res.json({code: 200,message: '文件已存在',hasExist: true})} else {res.json({code: 200,message: '文件不存在',hasExist: false})}})
查询分片是否存在接口
在chunks目录下的文件hash所属目录下,查询是否存在该 「hash 索引」的分片。
// 验证切片是否已存在接口app.get('/chunk/check', (req, res) => {// 切片文件名称const chunkName = req.query.chunkName// 从切片文件名称中提取文件hashconst fileHash = extractFileHash(chunkName);// 待合并的分片目录路径const chunkPath = path.join(chunkDir, fileHash, chunkName);// 检查当前切片文件是否已经存在const hasExistFile = fs.existsSync(chunkPath);console.log('hasExistFile', hasExistFile, chunkPath);if(hasExistFile) {// 直接响应。不再执行后续中间件,如后面的回调upload.single('chunk')、切片上传成功回调res.json({code: 200,message: '切片已存在,跳过该切片上传',hasExist: true})return} else {// 切片不存在res.json({ code: 200, message: '切片不存在,继续上传', hasExist: false })}return hasExistFile})
分片上传接口
// 从分片名称提取文件hash// 如:chunkName: 91a509c780df16d509e69d604292e870.mp4-0functionextractFileHash(chunkName) {return chunkName.split('-')[0];}// 分片存放路径const chunkDir = path.join(process.cwd(), 'chunks');// 处理分片文件上传。app.post('/chunk', async (req, res) => {const chunkName = req.query.chunkName;const fileHash = extractFileHash(chunkName);// 判断当前目录没有temp文件夹,则创建if(!fs.existsSync('./temp')) {fs.mkdirSync('./temp', { recursive: true });}// 使用formidable解析表单数据const form = formidable({uploadDir: './temp', // 临时存储路径keepExtensions: true,filename: () => {return chunkName}});try {// 解析表单数据,并把分片写入临时目录中const [fields, files] = await form.parse(req);const chunkFile = files.chunk[0];if(!fs.existsSync(chunkDir)) {fs.mkdirSync(chunkDir, { recursive: true });}// 将临时文件移动到 chunkDir 目录const fileHashDir = path.join(chunkDir, fileHash);const targetPath = path.join(fileHashDir, chunkName);if(!fs.existsSync(fileHashDir)) {fs.mkdirSync(fileHashDir, { recursive: true });}fs.renameSync(chunkFile.filepath, targetPath)} catch(error) {return res.status(500).json({ msg: '分片上传失败', error: error });}res.send({code: 200,message: '分片上传成功',file: req.file});});
合并分片接口
// 合并切片app.post('/merge-chunk', (req, res) => {const { fileHash } = req.body;if(!fileHash) {return res.status(400).send({ success: false, message: '缺少fileHash参数' });}// 合并后的文件路径,如果不存在则创建该目录if(!fs.existsSync(mergeDir)) {fs.mkdirSync(mergeDir, { recursive: true });}const mergeFilePath = path.join(mergeDir, fileHash);const chunkHashDir = path.join(chunkDir, fileHash);// 读取目录下所有切片文件,按 index 排序const chunkFilenames = fs.readdirSync(chunkHashDir)// 按 index 排序.sort((a, b) => {const indexA = parseInt(a.split('-').pop(), 10);const indexB = parseInt(b.split('-').pop(), 10);return indexA - indexB;});// 创建可写流,合并切片const writeStream = fs.createWriteStream(mergeFilePath);let mergedSize = 0;try {for(const chunkname of chunkFilenames) {// 创建可读流,读取切片const chunkPath = path.join(chunkHashDir, chunkname);// 读取切片const data = fs.readFileSync(chunkPath);// 写入合并后的文件writeStream.write(data);// 累加合并后的文件大小mergedSize += data.length;// 删除已合并的切片fs.unlinkSync(chunkPath);}// 删除空目录fs.rmdirSync(chunkHashDir);// 合并完成后关闭流writeStream.end();} catch(err) {return res.status(500).send({ success: false, message: '合并失败!', error: err.message });}res.send({success: true,message: '文件合并成功',file: {fileHash,size: mergedSize,path: mergeFilePath,chunkFilenames}});});
总结
本文使用文件对象的slice方法对大文件进行分片,使用SparkMD5对文件内容进行hash计算,并把计算过程执行在Web Worker线程当中,避免阻塞主线程;
实现分片上传的核心是使用文件hash和分片索引,对分片名称进行标识和排序,方便后端查找和排序分片,最后合并分片为一个完整的文件。
前后端完整源码
github 地址:大文件分片上传