• 大文件切片上传
  • 秒传
  • 断点续传

大文件的切片上传

1.大致流程

    1. 将大文件切片
    1. 分别上传切片至后端
    1. 后端接收切片并存储在文件夹
    1. 合并切片成大文件,并删除切片和文件夹
      切片上传流程

1. 实现切片

文件File的原型上提供了一个 slice 方法,通过这个方法可以对文件进行切割。以每5m分一个切片。

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
<input type="file"  @change="handleFileChange"/>

data() {
return {
chunkList: [],
file: null
};
},

function handleFileChange (e) {
this.file = e. target.files[0]
this.chunkList = createChunk(this.file)
console.log(this.chunkList)
}

function createChunk (file, size = 5 * 1024 * 1024) {
let chunks = []
let cur = 0
while(cur < file.size) {
chunks.push({file:file.slice(cur,cur+size)})
cur += size
}
return chunks
}

我上传了个9.6m左右大小的文件,每片以5m分割,看log输出可以看到文件被切分成了两片,这样就实现了切片的效果
切片

2. 上传切片

设置接口接收文件类型和进度条
onUploadProgress是axios的进度条事件

1
2
3
4
5
6
7
8
9
10
11
export function uploadFile(param, cb) {
return axios({
url: `/upload/file`,
method: 'POST',
data: param,
headers: {
'Content-Type': 'multipart/form-data'
},
onUploadProgress:cb
})
}

首先我们需要组装一下chunkList,将所需要的参数填充进去
切片名称拼接索引, 是为了后续续传的告诉前端已经上传的切片。

1
2
3
4
5
6
7
8
9
10
11
12
13
function fillParams (files, chunkList) {
const suffix = files.name.lastIndexOf('.')
const fileName = files.name.slice(0,suffix) // 获取文件名称
let chunkFile = chunkList.map( ({ file }, index) => ({
file,
size: file.size,
percent: 0, // 上传进度
chunkName: `${fileName}-${index}`,
fileName: fileName,
index
}))
return chunkFile
}

将所需的参数通过 FormData 传递给后端
其中 e.loaded 表示当前上传的数据大小,e.total 表示整个要上传的数据大小

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

function async handleFileChange () {
const _self = this
this.file = e.target.files[0]
this.chunkList = fillParams(createChunk(this.file))

let reqList = this.chunkList.map((item,index) => {
const formData = new FormData()
formData.append("file", item.file)
formData.append("fileName", item.fileName)
formData.append("chunkName", item.chunkName)
return {
formData,
index
}
}).map(({formData,index}) => {
return uploadFile(formData,(e) => {
// 设置每个切片的进度比
_self.chunkList[index].percent = parseInt(String((e.loaded / e.total) * 100))
})
})
await Promise.all(reqList)
}

3. 后端处理切片

将切片存储到文件夹中

后端的话我是用node的koa来模拟实现一下
上传的话通过koa-multer中间件来实现。具体用法可以参考链接,这里就不贴出来啦。
koa-multer中间件不能通过动态参数存储在动态路径,所以需要我们手动的去移动切片到文件夹中。
fs.rename修改文件名称,更改文件的存放路径。

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

router.post('/file', upload.single('file'), async(ctx, next) => {
const folderName = ctx.req.body.fileName // 临时存储的文件夹名称(对应切片的fileName)
const fileName = ctx.req.file.filename // 中间件上传后切片的名称
const chunkName = `${ctx.req.body.chunkName}.${ctx.req.file.originalname}`
const folderPath = path.join(__dirname,`../../public/file/${folderName}`)
// 判断文件夹是否存在
if (!fs.existsSync(folderPath)) {
fs.mkdir(folderPath, err => {
if (err) throw err;
});
}
await moveFile(fileName,folderName,chunkName)
ctx.success(fileName,'上传切片成功')
})


// 移动文件
const moveFile = async function (fileName,movePath,newFileName) {
var chunkPath = path.join(__dirname, `../public/file/${fileName}`); // 切片的路径
var destPath = path.join(__dirname, `../public/file/${movePath}`, newFileName);
fs.rename(chunkPath, destPath, function (err) {
if (err) throw err;
});
}

每个切片上传的size = 当前切片进度 * 当前切片size
总进度 totalPercent = 每个切片上传的size ➗ 文件总size

1
2
3
4
5
6
7
computed: {
totalPercent () {
let percent = this.chunkList.map(({size,percent}) => size * percent)
.reduce((prev, curr) => prev + curr)
return parseInt((percent/this.file.size).toFixed(2))
}
}

4. 合并切片

切片上传完成之后,发送一个merge请求,让后端合并切片

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
const pipeStream = (path, writeStream) => {
return new Promise(resolve => {
const readStream = fs.createReadStream(path);
readStream.on("end", () => {
fs.unlinkSync(path); // 删除切片
resolve();
});
readStream.pipe(writeStream);
});
}

// 合并切片 (filePath:将切片合并的路径)
const mergeFileChunk = async (filePath, chunkName, size = 5 * 1024 * 1024) => {
const chunkDir = path.join(__dirname,`../public/file/${chunkName}`) // 切片文件路径
let chunkPaths = fs.readdirSync(`public/file/${chunkName}`) // 获取切片文件夹里所有切片
// 根据切片索引进行排序,因为是异步请求所以文件顺序与文件读取顺序不一定一致
const eq = (str) => parseInt(str.slice(str.lastIndexOf('-')+1,str.lastIndexOf('.')))
chunkPaths.sort((a, b) => eq(a) - eq(b))
const arr = chunkPaths.map((chunk, index) => {
return pipeStream(
path.resolve(chunkDir, chunk),
// 指定位置创建可写流
fs.createWriteStream(filePath, {
start: index * size,
end: (index + 1) * size
})
)
})
await Promise.all(arr)
};

router.post('/mergeChunkFile', async(ctx, next) => {
const {fileName, chunkName} = ctx.request.body
// 存放切块的路径
const mergePath = path.join(__dirname,`../../public/file/mergeFile/${fileName}`)
await mergeFileChunk(mergePath,chunkName)
fs.rmdirSync(path.join(__dirname,`../../public/file/${chunkName}`)) // 删除存储切片的文件夹

ctx.success(fileName,'合并文件成功')
})

上传成功后,可以看到合并后的音乐已经存到了mergeFile文件夹了,并且切片文件夹已经删除。

merge


大文件秒传

秒传的实现原理其实就是后端去校验文件是否已经上传,是的返回上传成功给前端。
秒传关键在于怎么校验文件是否已经上传?用文件名来校验肯定是不靠谱的,真实场景的话是通过文件的hash值来校验
那么前端怎么生成文件的hash值呢?
本篇是通过一个spark-md5插件来生成的,具体用法可参考链接。
后端的话通过node的crypto模块生成文件的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
router.post('/verifyFile', async(ctx, next) => {
const {hash} = ctx.request.body
const shouldUpload = hasFile(hash)
ctx.success({shouldUpload},'校验成功')
})

// 文件hash校验
function hasFile (hash) {
filePaths = fs.readdirSync(`public/file/mergeFile`)
const len = filePaths.length
let flag = true
for(let i = 0;i<len;i++) {
const hash = createHash(filePaths[i])
if (fileHash === hash){
flag = false
break
}
}
return flag
}

// 获取文件hash
function createHash (fileName) {
const buffer = fs.readFileSync(path.join(__dirname,`../public/file/mergeFile/${fileName}`));
const fsHash = crypto.createHash('md5');
fsHash.update(buffer);
const md5 = fsHash.digest('hex');
return md5
}

大文件续传

续传的原理就是当一个大文件切分成了10个切片,上传了5个切片的时候被暂停了,点击续传的时候后端需要告诉前端已经上传的切片索引,前端再根据后端返回的索引并过滤掉他们从新请求未上传的切片
思考:前端要怎么暂停请求?
CancelToken是axios提供取消ajax请求的,我们可以通过这个来实现

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

<button @click="pauseUpload">暂停</button>

export function uploadFile(param, cb,cancelToken) {
return axios({
url: `/upload/file`,
method: 'POST',
data: param,
headers: {
'Content-Type': 'multipart/form-data'
},
onUploadProgress:cb,
cancelToken
})
}


const CancelToken = axios.CancelToken;
let source = CancelToken.source();
// 截取部分前面的请求代码
map(({formData,index}) => {
return uploadFile(formData,(e) => {
_self.chunkList[index].percent = parseInt(String((e.loaded / e.total) * 100))
},source.token) // 主要代码在这里
})

// 暂停
const pauseUpload = () => {
source.cancel("中断上传!");
source = CancelToken.source();
}

后端实现校验需要 文件夹的名称,切片总数字段

1
2
3
4
5
6
7
8
9
// uploadChunk已上传的切片索引
const filePath = path.join(__dirname,`../../public/file/${chunkName}`)
if (fs.existsSync(filePath)) {
chunkFiles = fs.readdirSync(`public/file/${chunkName}`)
if (chunkFiles.length < chunkLen) {
const eq = (str) => parseInt(str.slice(str.lastIndexOf('-')+1,str.lastIndexOf('.')))
const uploadChunk = chunkFiles.map(item => eq(item))
}
}

前端根据后端返回的索引,进行过滤

1
chunkList.filter(item => !uploadChunk.includes(item.index))

来看看具体效果

大部分参考掘金作者三心的文章实现。