大文件上传?其实真的没有那么难!(一)
作为一名前端练习生,经常看到有关大文件上传的文章或视频,但从未动手去实现过,今天终于鼓起勇气,认真的分析了一下,发现其实并没有那么复杂。
一、问题分析
试想,如果在项目中遇到大文件上传,应该怎么办?一次性将它上传到服务器吗,不太合适,整个上传过程耗时漫长,万一上传失败,只能进行重传。那么如果去解决呢,一个简单的思想就是分而治之,将大文件切割成小块,分别上传给服务端,最后通知服务端合并。这么一说,是不是觉得并没有那么难了?
二、我们要做什么?
根据前面的分析,我们这里可以列举一些需要做的事情
前端:
- 大文件分片
- 分片文件上传
- 通知后端合并
后端:
- 接收并存储分片文件
- 合并分片文件
接下来本文也将通过分而治之的思想,逐个击破各个模块,实现大文件上传。
三、实现
1. 前端
(1)基本框架搭建
前端这里我是用react进行搭建的,不过无所谓,大家可以自行选择自己喜欢的框架进行搭建。
首先是页面结构:
<div>
<input ref={inputRef} type="file" onChange={handleChange} />
<button onClick={handleUpload}>上传</button>
<button onClick={handleStop}>暂停</button>
<button onClick={clear}>清除</button>
<div>
hash计算进度:
<Progress
style={{
backgroundColor: "blue",
}}
progress={hashProgress}
/>
</div>
<div>
上传进度:
<ProgressGroup progresses={progresses}></ProgressGroup>
</div>
</div>
这里包括input
来进行文件上传,另外添加了几个按钮做上传、暂停、清除的操作。
除此之外,由于hash计算和文件上传都需要时间,这里做了一个progress
组件用来展示进度,具体实现可以到git仓库查看,这里就不多说了。效果如下:
上传前:
上传完成:
接着是监听input
的onChange
事件:
const bigFileRef = useRef<BigFile>();
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
const [file] = e.target.files ?? [];
if (file) {
bigFileRef.current = new BigFile(file, {
hooks: {
onHashProgress: (progress: number) => {
setHashProgress(progress);
},
},
});
}
};
可以看到这里我们获取到了file文件,然后实例化了一个BigFile
对象,并将这个对象挂载到了bigFileRef
上,没错,大文件的分片逻辑就藏在这个BigFile
中。
(2)大文件分片-BigFile实现
想想对于BigFile, 我们应该做什么?
首先是接收参数,必不可少的是file
参数,因为我们的核心是要把它分片,其次是两个可选参数size
和hooks
,用来控制分片的大小以及钩子函数,这里的钩子函数用来实现进度条功能。
那么BigFile
基本的雏形就是这样:
// 类型定义
interface Hooks {
onHashProgress?: ((progress: number) => void)[];
onHashCompleted?: ((hash: string) => void)[];
}
interface FileChunkSize {
chunkSize: number;
hashSize: number;
}
interface BigFileOptions {
size: FileChunkSize;
hooks?: {
onHashProgress?: (progress: number) => void;
onHashCompleted?: (hash: string) => void;
};
}
// 默认参数定义
const defalutOptions = {
size: {
chunkSize: 1024 * 1024 * 20,
hashSize: 1024 * 1024 * 20,
},
};
export class BigFile {
file: File;
// 分片大小
size: FileChunkSize;
hooks: Hooks;
// 分片结果缓存
chunks?: IFileChunk[];
// 用于判断hash计算是否完成
hashPromise?: Promise<string>;
constructor(file: File, userOptions: Partial<BigFileOptions>) {
// 将默认参数与用户参数合并
const options: BigFileOptions = { ...defalutOptions, ...userOptions };
const { size, hooks } = options;
// 初始化参数
this.file = file;
this.size = size;
this.hooks = {
onHashProgress: hooks?.onHashProgress
? (<any>[]).concat(hooks.onHashProgress)
: [],
onHashCompleted: hooks?.onHashCompleted
? (<any[]>[]).concat(hooks.onHashCompleted)
: [],
};
}
}
计算hash值:
既然要涉及到文件上传,而且是切片上传那么我们最好是创建一个hash
来作为文件的唯一标识,这样即使文件名改变,只要内容不变,hash就不会变化,这也对后续的断点续传、秒传功能有很大的帮助:
我们新建一个utils.ts
文件,并构造了一个getHash
函数,接收file
、size
以及一个cb
回调,使用spark-md5
进行hash计算,由于计算hash是很耗时的操作,所以我们这里使用切片,读取每个切片的 ArrayBuffer 并不断传入 spark-md5
中,每计算完一个切片就发送一个回调,用于前端的进度监控,最后返回一个promise控制异步:
// 计算文件hash值
export const getHash = (
file: File,
size: number,
cb?: (progress: number) => void
): Promise<{
code: number;
hash?: string;
message?: string;
}> => {
const blobSlice = File.prototype.slice;
const chunkSize = size;
const chunks = Math.ceil(file.size / chunkSize);
let currentChunk = 0;
const spark = new SparkMD5.ArrayBuffer();
const fileReader = new FileReader();
// 切片
function loadNext() {
var start = currentChunk * chunkSize,
end = start + chunkSize >= file.size ? file.size : start + chunkSize;
fileReader.readAsArrayBuffer(blobSlice.call(file, start, end));
}
const promise = new Promise<{
code: number;
hash?: string;
message?: string;
}>((resolve) => {
// 读取完成
fileReader.onload = function (e) {
const progress = ((currentChunk + 1) / chunks) * 100;
console.log(progress);
//
cb?.(progress);
console.log("read chunk nr", currentChunk + 1, "of", chunks);
spark.append(e.target!.result as ArrayBuffer); // Append array buffer
currentChunk++;
if (currentChunk < chunks) {
loadNext();
} else {
console.log("finished loading");
const hash = spark.end();
console.info("computed hash", hash); // Compute hash
resolve({ code: 1, hash });
}
};
fileReader.onerror = function () {
console.warn("oops, something went wrong.");
resolve({ code: 0, message: "oops, something went wrong." });
};
});
loadNext();
return promise;
};
接下来给BigFile
调用即可,我们首先获取到所需的一些参数:file
、hashSize
,然后传给getHash
,除此之外,我们还会定义一个回调函数,回调内部再调用当前hooks
中的onHashProgress
,即可完成hash进度的监听操作。
export class BigFile {
//...省略
constructor(file: File, userOptions: Partial<BigFileOptions>) {
//...
this.calcHash();
}
async calcHash() {
return (this.hashPromise = new Promise<string>(async (resolve, reject) => {
try {
// 获取文件及hash分片
const {
file,
size: { hashSize },
} = this;
const hashRes = await getHash(file, hashSize, (progress) => {
// 触发onHashProgress钩子函数
this.hooks.onHashProgress?.forEach((cb) => cb(progress));
});
// 得到hash计算结果
this.hash = hashRes.hash || "";
// 触发钩子函数
this.hooks.onHashCompleted?.forEach((cb) => cb(this.hash));
resolve(this.hash || "");
} catch (error) {
reject(error);
}
}));
}
}
除此之外,我们注意到最后将返回值还赋给了this.hashPromise
,这里原因是我们的calcHash
操作必须在切片前完成,而我们计算hash函数的操作是在constructor
中被调用的,所以这里用hashPromise
保存calcHash
的异步信息,在后续切片操作时会等待hash计算完成,即(这个下文就会提到):
this.hashPromise.then((hash)=>{
//xxx
})
创建切片: 切片的原理其实很简单,在File对象的原型上有个slice方法专门用来切片,我们可以把它想象成数组切割,假设有个长度为10的数组,我们要将其切割为长度为3的数组,那么我们可以这么做:
const arr = Array(10).fill(0)
// 设置初始偏移量指针
let cur = 0;
// 设置切片大小
const size = 3;
const len = arr.length;
// 创建一个chunks数组,存放切片后的结果
const chunks = []
while(cur < len){
// 切割数组,从cur -> cur + size
chunks.push(arr.slice(cur, cur + size))
// 移动指针
cur += size
}
console.log(chunks) //[Array(3), Array(3), Array(3), Array(1)]
这样看是不是很简单,其实文件切割也是一样的思路,只不过为了后续使用方便,这里会把chunk文件做一层封装:cb(chunk)
,即每一个切片文件不仅包含chunk,还包含源文件的hash
以及当前chunk的下标,这些信息会在后续的工作中用到。
而且这个cb
并不是固定的,他有一个默认值,当然开发者也可以自行决定chunk的返回结果。
我们在utils.ts
文件中继续编写createFileChunk
函数:
// utils.ts-切割文件
export interface IFileChunk {
chunk: Blob;
hash: string; // 文件hash值
index: number; // 文件切片索引
complete?: boolean;
}
export const createFileChunk = <T = IFileChunk>(
file: File & {
hash?: string;
},
hash: string,
size: number,
cb: (file: Blob, index?: number) => T = (blob: Blob, index) => {
return {
chunk: blob,
hash: hash + "_" + index,
index: index,
} as T;
}
): T[] => {
const chunks = [];
const totalSize = file.size;
let cur = 0;
while (cur < totalSize) {
chunks.push(cb(file.slice(cur, cur + size), cur / size));
cur += size;
}
return chunks;
};
编写完成后,就可以在BigFile
中使用了:
export class BigFile{
// ……
async createChunks() {
return (
// 先等待hash计算完成
this.hashPromise?.then((hash) => {
// 获取file及chunkSize参数
const {
file,
size: { chunkSize },
} = this;
// 创建切片
this.chunks = createFileChunk(file, hash, chunkSize);
return this.chunks;
}) || Promise.reject("chunks not created")
);
}
}
到此,BigFile
的编写基本就结束了,不过我们还要定义一个getChunks
函数,避免每次都要重新进行切片:
async getChunks() {
// 如果this.chunks有值说明已经切片,直接返回,否则创建切片
return this.chunks || (await this.createChunks());
}
接下来我们回到前端页面,构造一个handleUpload
函数,测试一下切片功能:
const handleUpload = async () => {
const bigFile = bigFileRef.current!;
bigFile.getChunks().then(async (chunks) => {
console.log(chunks);
});
};
符合预期!
时间不短了,今天我们解决了大文件上传中的分片问题,我们可以先思考下接下来要做什么:
- 分片文件的上传(需要做并行限制)
- 服务端的分片文件接收与合并
期待下篇文章吧~
转载自:https://juejin.cn/post/7185015074024030245