前言
文件切片上传是个比较容易遇到的场景, 但是很多人都是去CV代码, 搞不懂其中代码到底是干嘛, 今天阶段性的给大家说明白切片的实现原理
使用场景
切片上传常用于如下场景:
- 用户上传超大文件,比如上传1GB电影文件。
- 网络条件较差的情况,上传大文件容易失败,可以通过切片降低失败率。
- 需要显示实时上传进度的场景。
- 服务端需要支持分布式进行大文件上传处理。
所以,对于大文件上传,使用切片上传可以有效优化传输速度和体验,适合大文件、弱网络环境以及需要显示上传进度的场景。前端实现也不复杂,值得在项目中使用
切片的实现思路
其实切片上传是需要前后端配合的大文件切片上传的一个典型的实现思路
前端:
- 使用File/Blob的slice方法切割文件为定长的数据块。
- 使用md5加密给文件做一个唯一标识,切片文件根据切片顺序依次标识为(hash_1, hash_2...)
- 上传切片时把切片标识符和切片内容一起上传。
- 检查已上传的切片标识符,找到断点位置。
- 从断点位置开始按序上传剩余切片。
后端:
- 接收切片标识符和切片内容,存储到云存储中。
- 检查切片标识符是否重复,实现切片去重。
- 所有切片上传完成后,请求合并切片。
- 根据切片标识符顺序合并切片内容,组装成完整文件。
- 删除云存储中的切片,只保留合并后的文件。
- 返回上传结果。
这种思路主要通过切片标识符来对应切片内容,并保证切片不重复上传,即使中间断点也可以无损续传。 前后端配合实现切片上传的控制流程,就可以实现大文件可靠断点续传了。 此外,上传进度也可以根据已上传切片数计算得出。
实现切片上传
那么理清思路我们就可以开始实现了, 我们按照思路依次去实现切片 -> 加密 -> 标识切片后的chunk -> 上传
切片
做过上传的各位都知道, 不论是原生还是插件,点击上传之后我们会拿到一个 file
对象,该对象有一个slice
方法,该方法会根据传入的开始和结束位置截取文件并返回这段文件,我们通过slice
去做文件的切割,把下标和文件处理给到每个切好的块chunk
, 我们写这么个函数,接收一个文件和每块的大小(例如切成10M, 那么就传一个10*1024*1024
),这个函数返回切好的 blob
和 flag
。
ini
function chunkSlice(file, chunkSize) {
const result = []
let index = 0
for (let i = 0; i < file.size; i += chunkSize) {
result.push({
flag: index,
blob: file.slice(i, i + chunkSize),
})
index++
}
return result
}
文件指纹
接着我们针对该文件去生成一个加密指纹,一般就使用spark-md5
,点击查看npm文档,引入实现的SparkMD5
这个类,它通过实例方法append()
去拼凑整个文件,调用end()
即完成本次文件指纹生成,会返回一个唯一标识。
因为给文件加密是需要读取文件的, 所以我们使用增量算法去把文件给处理一下,一次读一块,减少内存的占用, IO操作是异步的,我们返回一个Promise
,递归调用内部的读取文件函数,每次读取前判断一下,当i = chunks.length
即表示数组中的整个文件都读完了,那么每次我们通过FileReader
读取,在onload之后拿到读取出的文件给到 SparkMD5
追加,读一段加一段,最后读取结束之后 resolve
把文件指纹返回
scss
function getFileHash(chunks) {
return new Promise((resolve, reject) => {
const spark = new SparkMD5()
function _readFile(i) {
if (i >= chunks.length) {
resolve(spark.end())
return
}
const { blob } = chunks[i]
const reader = new FileReader()
reader.onload = (e) => {
const bytes = e.target.result
spark.append(bytes)
_readFile(i + 1)
}
reader.readAsArrayBuffer(blob)
}
_readFile(0)
})
}
标识切片后的每个chunk
这个就比较简单了, 拿到了文件指纹hash
,循环我们的chunks
数组, 把每项的flag
修改为${hash}_${flag}
即可完成标识
上传 & 断点续传
调用后端给的接口依次上传就可以了, 通常的交互流程是:
- 上传前通过一个接口把文件指纹发送给后端,后端在服务器创建一个该指纹名的目录去接收整个
chunks
- 调用上传接口,这里可以做一个上传进度条, 比如
chunks.length
为20, 那么每上传成功一个chunk
, 做个动画把进度条+5%即可 - 所有的
chunk
都上传完毕了,调用一个参数为指纹名的结束接口告诉后端已经传完了,后端就开始根据flag
的下标顺序去合成一个文件,合成之后给到服务器删除该文件目录再返回结果给前端,整个交互结束
在以上的交互中,我们该怎么实现断点续传呢,我们知道文件的hash
是唯一的,假如说用户在上传中途刷新了或者做了别的处理, 那么后端会把这个未完成的文件目录保留一段时间(比如一天或者三天), 当我们进行第一步时,后端判断出该文件指纹目录存在,即可告诉前端,里面已完成的chunk
有哪些,前端去处理不发送这些chunk
。这样就完成断点续传了。
前端实现指纹文件切片
首先项目下执行安装 spark-md5
,
css
npm i spark-md5 -S
接着实现添加指纹以及切片函数fileSlice
javascript
import SparkMD5 from 'spark-md5'
/**
*
* @param {File} file
* @param {Object} [options]
* @param {string} [options.hash='hash']
* @param {string} [options.blob='blob']
* @param {number} [options.chunkSize= 10 * 1024 * 1024]
* @returns {Promise<[hash: string, blob: Blob]>}
* @description 传入一个file对象,会将file处理为一个切片数组
*/
function fileSlice(file, options) {
let option = Object.assign(
{ hash: 'hash', blob: 'blob', chunkSize: 10 * 1024 * 1024 },
options
)
/**
* @returns {Array<index: number, blob: Blob>}
* @description 文件分块函数
*/
function _chunkSlice() {
const result = []
let index = 0
for (let i = 0; i < file.size; i += option.chunkSize) {
result.push({
index: index,
blob: file.slice(i, i + option.chunkSize),
})
index++
}
return result
}
/**
*
* @param {Array<index: number, blob: Blob>} chunks
* @returns {Promise}
*/
function _getFileHash(chunks) {
return new Promise((resolve, reject) => {
const spark = new SparkMD5()
function _readFile(i) {
if (i >= chunks.length) {
resolve(spark.end())
return
}
const { blob } = chunks[i]
const reader = new FileReader()
reader.onload = (e) => {
const bytes = e.target.result
spark.append(bytes)
_readFile(i + 1)
}
reader.readAsArrayBuffer(blob)
}
_readFile(0)
})
}
return new Promise((resolve, reject) => {
const fileResult = _chunkSlice()
_getFileHash(fileResult).then((res) => {
const chunks = fileResult.map((item) => {
return {
[option.hash]: `${res}_${item.index}`,
[option.blob]: item.blob,
}
})
resolve(chunks)
}, reject)
})
}
export default fileSlice
调用示例如下:
javascript
fileSlice(file, { hash: 'flag', chunkSize: 1 * 1024 * 1024, blob: 'file' }).then((res) => {
console.log(res)
})