前言
大文件上传是一个比较通用的功能,基本上只要涉及到一些文件上传的功能,都需要使用大文件上传功能
大文件上传步骤
- 获取用户上传的文件
- 对文件进行格式校验(可选)
- 对文件进行切片,并计算每个切片的 hash 值
- 上传每个切片
- 合并上传的切片
具体实现
前端界面部分
我们先创建一个 html 文件,然后粘贴下面的代码进行界面的初始化
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38
| <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>大文件上传</title> </head>
<body> <input id="fileInput" type="file" /> <button id="uploadButton">上传</button> <script src="https://cdn.bootcdn.net/ajax/libs/spark-md5/3.0.2/spark-md5.min.js"></script> <script src="https://cdn.bootcdn.net/ajax/libs/axios/1.7.2/axios.min.js"></script> <script src="./index.js"></script> <script> window.onload = () => { const fileInput = document.getElementById("fileInput"); const uploadButton = document.getElementById("uploadButton"); let file = null; fileInput.onchange = (event) => { const fileList = event.target.files; if (fileList.length) { file = fileList[0]; } }; uploadButton.addEventListener("click", () => { if (file) { uploadFile(file); } }); }; </script> </body> </html>
|
界面很简单,就是一个文件选择框和一个上传按钮,里面的 js 代码就是获取界面这两个元素然后绑定事件,当用户选择文件后,会将文件对象保存在全局变量 file 中,然后调用 uploadFile 函数进行上传
html 界面导入的两个库一个是 spark-md5,一个是 axios,spark-md5 用于计算文件的 hash 值,axios 用于发送请求
index.js 文件是我们待会要编写的脚本文件
js 代码部分
js 代码部分我们一步一步按照顺序编写
请求使用 axios 库进行发送,这里不对 axios 语法进行讲解
创建 uploadFile 函数
uploadFile 函数接受三个参数,分别是文件对象、切片大小、上传后的文件名称,因为上传文件是比较耗时的操作,所以我们要异步处理,返回一个 Promise 对象,并使用 try…catch 捕获异常
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
|
const uploadFile = (file, chunkSize = 1024 * 1024 * 2, fileName) => { return new Promise(async (resolve, reject) => { try { resolve(true); } catch (error) { reject(error); } }); };
|
接着我们继续完善
创建 cutFile 函数
上传文件之前我们肯定要对文件进行切片
那么如何切片呢,其实我们可以将一个文件理解为由许多个数据块组成,那么比如一个 11.5 兆的文件,我们可以将其分为 6 个数据块,其中 5 块是 2M,1 块是 1.5M
那么我们先计算文件有多少个数据块,用文件大小除以切片大小,然后取整,得到切片的数量,接着使用 for 循环,调用 file.slice 方法,将文件切片,并将切片对象添加到 chunks 数组中
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
|
const cutFile = async (file, chunkSize = 1024 * 1024 * 2) => { let chunkCount = Math.ceil(file.size / chunkSize); const chunks = []; for (let i = 0; i < chunkCount; i++) { const blob = file.slice(i * chunkSize, (i + 1) * chunkSize); chunks.push({ blob, index: i, start: i * chunkSize, end: (i + 1) * chunkSize, }); } };
|
创建 calculateHash 函数
将文件切片之后,我们就要计算 hash 值了,hash 值是什么?为什么要计算 hash 值
hash 值就是一种摘要算法,它将任意长度的数据转换为固定长度的字符串,这个字符串就是 hash 值,相同的数据必然会得到相同的 hash 值,不同的数据必然会得到不同的 hash 值
有了它服务器就会检查这个 hash 值来确定这个文件是否已经上传过了,如果相同的文件上传过了,就不用再上传了
我们这里使用 spark-md5 库来计算 hash 值,spark-md5 库是一个快速的 md5 算法的 javascript 实现,速度非常快
同样计算 hash 是一个费时过程,我们使用 Promise 进行封装处理
这里计算的方式是,将文件的第一个数据块、最后一个数据块、中间所有数据块的前 2 字节、中间 2 字节后 2 字节拼接起来,然后形成一个 Blob 对象,使用这个 Blob 对象进行 hash 值的计算
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38
|
const calculateHash = (chunks, chunkSize) => { return new Promise((resolve, reject) => { try { const target = []; const fileReader = new FileReader(); const spark = new SparkMD5.ArrayBuffer(); fileReader.onload = (e) => { const data = e.target.result; spark.append(data); resolve(spark.end()); }; chunks.forEach((chunk, index) => { if (index === 0 || index === chunks.length - 1) { target.push(chunk.blob); } else { target.push(chunk.blob.slice(0, 2)); target.push(chunk.blob.slice(chunkSize / 2, chunkSize / 2 + 2)); target.push(chunk.blob.slice(chunkSize - 2, chunkSize)); } }); fileReader.readAsArrayBuffer(new Blob(target)); } catch (error) { reject(error); } }); };
|
修改 cutFile 函数
接着我们修改 cutFile 函数,在 cutFile 函数中调用 calculateHash 函数,并将计算后的 hash 值添加到每个切片对象中
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32
|
const cutFile = async (file, chunkSize = 1024 * 1024 * 2) => { let chunkCount = Math.ceil(file.size / chunkSize); const chunks = []; for (let i = 0; i < chunkCount; i++) { const blob = file.slice(i * chunkSize, (i + 1) * chunkSize); chunks.push({ blob, index: i, start: i * chunkSize, end: (i + 1) * chunkSize, }); } const fileHash = await calculateHash(chunks, chunkSize); chunks.forEach((chunk, index) => { chunk.hash = `${fileHash}-${index}`; }); return { chunks, fileHash, }; };
|
创建 uploadChunk 函数
切完片,计算完了 hash 值,那我们是不是应该要上传了呀
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
|
const uploadChunk = async (chunk, fileHash) => { const formData = new FormData(); formData.append("chunkHash", chunk.hash); formData.append("fileHash", fileHash); formData.append("chunk", chunk.blob); return axios.post("http://localhost:3000/chunk", formData); };
|
写完了这个那我们是不是要马上遍历所有切片然后将切片上传呀?
哎,不是的,心急吃不了热豆腐
创建 uploadChunks 函数
上传前我们先思考
假如我们有一个 GB 的文件,每个数据块是 2MB,那么我们是不是要分为 512 个数据块?那么 512 个数据块一起发送到服务器吗?一次性发送 512 个请求,这样服务器会很忙的,这显然是不合适的,所以我们需要分批发送,每一批最多发送固定数量的数据块,比如 3 个,这个有点类似于生产者和消费者的关系
所以我们需要建立一个发送列表,先发送 3 个数据块,这样会生成 3 个异步任务,将这 3 个异步任务放入发送列表,这时候停止发送数据块,等待异步任务结束后,将列表中结束的异步任务删除
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37
|
const uploadChunks = (chunks, fileHash) => { return new Promise(async (resolve, reject) => { const maxCount = 3; let index = 0; const taskList = []; while (index < chunks.length) { const task = uploadChunk(chunks[index], fileHash); taskList.push(task); index++; task.then(() => { taskList.splice(taskList.indexOf(task), 1); }); if (taskList.length >= maxCount) { await Promise.race(taskList); } } await Promise.all(taskList); resolve(true); }); };
|
创建 mergeChunks 函数
切片全部上传完了之后我们是不是就要合并切片了呀
哎,确实是这样
切片合并函数比较简单,只需要告诉后端合并哪个文件夹的切片和合并后的文件名就好了
接口地址需要改成自己实际使用的地址
1 2 3 4 5 6 7 8 9 10 11 12 13 14
|
const mergeChunks = (fileHash, chunkSize, fileName) => { return axios.post("http://localhost:3000/merge", { fileHash, chunkSize, fileName, }); };
|
修改 uploadFile 函数
这样我们的函数就编写完毕了
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
|
const uploadFile = (file, chunkSize = 1024 * 1024 * 2, fileName) => { return new Promise(async (resolve, reject) => { try { const { chunks, fileHash } = await cutFile(file); await uploadChunks(chunks, fileHash); await mergeChunks(fileHash, chunkSize, fileName); resolve(true); } catch (error) { reject(error); } }); };
|
到这里我们整个大文件上传前端部分的代码就结束了
最终代码
javascript 代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165
|
const calculateHash = (chunks, chunkSize) => { return new Promise((resolve, reject) => { try { const target = []; const fileReader = new FileReader(); const spark = new SparkMD5.ArrayBuffer(); fileReader.onload = (e) => { const data = e.target.result; spark.append(data); resolve(spark.end()); }; chunks.forEach((chunk, index) => { if (index === 0 || index === chunks.length - 1) { target.push(chunk.blob); } else { target.push(chunk.blob.slice(0, 2)); target.push(chunk.blob.slice(chunkSize / 2, chunkSize / 2 + 2)); target.push(chunk.blob.slice(chunkSize - 2, chunkSize)); } }); fileReader.readAsArrayBuffer(new Blob(target)); } catch (error) { reject(error); } }); };
const cutFile = async (file, chunkSize = 1024 * 1024 * 2) => { let chunkCount = Math.ceil(file.size / chunkSize); const chunks = []; for (let i = 0; i < chunkCount; i++) { const blob = file.slice(i * chunkSize, (i + 1) * chunkSize); chunks.push({ blob, index: i, start: i * chunkSize, end: (i + 1) * chunkSize, }); } const fileHash = await calculateHash(chunks, chunkSize); chunks.forEach((chunk, index) => { chunk.hash = `${fileHash}-${index}`; }); return { chunks, fileHash, }; };
const uploadChunk = async (chunk, fileHash) => { const formData = new FormData(); formData.append("chunkHash", chunk.hash); formData.append("fileHash", fileHash); formData.append("chunk", chunk.blob); return axios.post("http://localhost:3000/chunk", formData); };
const uploadChunks = (chunks, fileHash) => { return new Promise(async (resolve, reject) => { const maxCount = 3; let index = 0; const taskList = []; while (index < chunks.length) { const task = uploadChunk(chunks[index], fileHash); taskList.push(task); index++; task.then(() => { taskList.splice(taskList.indexOf(task), 1); }); if (taskList.length >= maxCount) { await Promise.race(taskList); } } await Promise.all(taskList); resolve(true); }); };
const mergeChunks = (fileHash, chunkSize, fileName) => { return axios.post("http://localhost:3000/merge", { fileHash, chunkSize, fileName, }); };
const uploadFile = (file, chunkSize = 1024 * 1024 * 2, fileName) => { return new Promise(async (resolve, reject) => { try { const { chunks, fileHash } = await cutFile(file); await uploadChunks(chunks, fileHash); await mergeChunks(fileHash, chunkSize, fileName); resolve(true); } catch (error) { reject(error); } }); };
|
html 代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38
| <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Document</title> </head>
<body> <input id="fileInput" type="file" /> <button id="uploadButton">上传</button> <script src="https://cdn.bootcdn.net/ajax/libs/spark-md5/3.0.2/spark-md5.min.js"></script> <script src="https://cdn.bootcdn.net/ajax/libs/axios/1.7.2/axios.min.js"></script> <script src="./index.js"></script> <script> window.onload = () => { const fileInput = document.getElementById("fileInput"); const uploadButton = document.getElementById("uploadButton"); let file = null; fileInput.onchange = (event) => { const fileList = event.target.files; if (fileList.length) { file = fileList[0]; } }; uploadButton.addEventListener("click", () => { if (file) { uploadFile(file, 1024 * 1024 * 2, file.name); } }); }; </script> </body> </html>
|