需求分析
当要实现大文件分片上传,不如先理清流程,先设计出主要步骤出来,再在步骤中不断细化和处理边缘条件。
- 首先要实现文件分片,就要先有文件,第一步就是获取文件。
- 在分片之前,还得提前得到分片信息(分片数量、分片大小等)。
- 在分片信息的条件约束下,将文件进行分片。
- 将分片上传服务端。
步骤设计
1. 获取文件
利用
<input type="file">
标签的选择事件就可以获取到获取到选择的文件
2. 分片信息的获取
其中分片大小可以定义一个全局变量进行存储,以便后续更改大小时便利。
1024 * 1024 * 5
表示5MB
分片数量,用文件大小除以分片大小便时分片数量,
线程数量和每个线程多少分片,这里可以考虑到分片时会有MD5编码,是同步完成编码,是个耗时操作,会阻塞主线程,为了避免阻塞主线程,这里用
webworker
创建辅助线程,线程数量可以定死,但是更好的做法是,获取当前机器可用于运行线程的逻辑处理器数量:navigator.hardwareConcurrency
这个变量也应该存在全局下,而每个线程多少分片取决于有多少辅助线程。
分片顺序索引,当把分片交给不同辅助线程时,处理结果并不清楚,为了使回传分片顺序正确,这里还需要计算每个线程开始索引和结束索引,结束索引这时候由于之前向上取整可能会大于分片数量,这时候需要将结束索引进行矫正。
线程完成数,为了验证分片完成,还需要统计线程完成分片的数量,当这个值与线程数量相同时表示完成了分片,当一个线程完成时可以将该线程进行清除。
注:这里为了使文件完整分片,分片数量和每个线程多少分片都应该向上取整来保证文件分片完整
3. 分片
利用分片大小和顺序索引计算出分片开始字节和结束字节,利用
FileReader
对象读取对应文件内容,这里需要注意的时第一步获取到的file是文件信息,并不是文件内容,用readAsArrayBuffer()
方法将分片文件读成blob对象。
这里需要考虑文件上传是需要一个唯一信息,防止篡改以及秒传的功能,文件名显然不合适,这里采用文件
hash
的方式进行,使用第三方库SparkMD5
加密的方式生成文件hash
,在这过程中如果直接对整个文件每个分片进行hash
的话,这就需要读取整个文件,整个文件放入内存是很占内存的。这里优化,将文件hash
放在每个线程处理某段分片的时候进行,当分片完成时,也就完成文件hash
,SparkMD5
也提供了一个append()
方法用于不断的添加加密的文件,加密完就把当前文件从内存剔除,最后用end()
方法结束加密。
4. 上传服务器
最终分片数据得到的是一个blob数组,对这个数组进行上传,当中途遇到网络问题等导致上传失败时,再次上传文件,可以在失败的地方接着上传,这也就是断点续传。
这里后端应该有三个接口
- 检查文件的接口(文件
hash
)- 文件上传过,并上传完成,可以直接完成请求===>文件秒传
- 文件上传过,但是不完全,可以返回需要续传,从第几个分片开始 ===>断点续传
- 文件未上传
- 上传接口 :
- 合并分片接口 提供hash :告知成功
具体实现
这里分成了 4个功能函数:main.js、cutFile.js、worker.js、createChunk.js
- main.js
main 主函数
用来获取用户选择的文件
并且调用cutFile函数进行文件分片
import {cutFile} from "./cutFile.js";
const inpFile = document.querySelector('input[type="File"]')
inpFile.onchange = async (e)=>{
const file = e.target.files[0]
const chunks = await cutFile(file)
console.log(chunks)
}
- cutFile.js
cutFile 计算、创建线程函数
- 计算分片数量
- 计算每个线程分片数量
- 创建线程
- 传输消息
- 消息回传
- 清除线程
- 保证回传分片顺序正确
- 结束
const CHUNK_SIZE = 1024 * 1024 * 5 //分片大小 5MB
const THREAD_COUNT = navigator.hardwareConcurrency || 4 // 获取机器CPU数量线程
export async function cutFile(file) {
return new Promise((resolve)=>{
const result = [] // 结果
let finishCount = 0 // 线程完成数量
const chunkCount = Math.ceil(file.size /CHUNK_SIZE)// 计算多少个分片
const workerChunkCount = Math.ceil(chunkCount /THREAD_COUNT)// 每个线程多少个分片
for (let i = 0;i<THREAD_COUNT;i++){
// 创建新的Worker线程
const worker = new Worker('./worker.js',{
type:"module"
})
// 计算每个线程开始索引和结束索引,用来使回传分片顺序正确
const startIndex = i * workerChunkCount // 开始索引
let endIndex = startIndex + workerChunkCount// 结束索引
if (endIndex > chunkCount){// 当大于分片数量时,最后一个线程的结束索引可能大于总分片数量
endIndex = chunkCount
}
worker.postMessage({// 传输消息
file,
CHUNK_SIZE,
startIndex,
endIndex
})
worker.onmessage = (e) =>{//消息回传
for (let i = startIndex;i<endIndex;i++){// 保证回传分片顺序正确
result[i] = e.data[i-startIndex]
}
worker.terminate() // 清除线程
finishCount++
if (finishCount === THREAD_COUNT){
resolve(result.flat())
}
}
}
}
})
}
- worker.js
worker - 线程函数
import {createChunk} from "./createChunk.js";
onmessage = async (e)=>{
const proms = []
const{file, CHUNK_SIZE, startIndex, endIndex} = e.data
for (let i =startIndex;i<endIndex;i++){
proms.push(createChunk(file,i,CHUNK_SIZE))
}
const chunks = await Promise.all(proms)// 并行执行Promise
postMessage(chunks)
}
- createChunk.js
createChunk - 文件切片函数
import SparkMD5 from './spark-md5.js'
export function createChunk(file, index, chunkSize) {
return new Promise((resolve)=>{
const start = index * chunkSize// 开始字节
const end = start + chunkSize// 结束字节
const spark = new SparkMD5.ArrayBuffer()// MD5
const fileReader = new FileReader() // 文件读取器
fileReader.onload = (e) =>{
spark.append(e.target.result)// MD5编码
resolve({
start,
end,
index,
hash:spark.end()
})
}
// 转换成二进制数据
fileReader.readAsArrayBuffer(file.slice(start,end))
})
}