【一步一个脚印】前端文件上传二:大文件快速上传
大文件快速上传
要实现大文件快速上传,就先要了解文件在JavaScript中是以什么形式保存的,文件处理有哪些可以用的api
【扩展】File和Blob对象
对于不知道的东西,查mdn是一个很好的方法,除了mdn外,有时候利用console.log你会有不一样的发现,以下为File和Blob打印的结果
File | Blob |
---|---|
分析:
- File:保存的文件名,大小,类型,最后修改时间,其是继承Blob的,了解关于File的更多信息请看官方文档,传送门
- Blob:保存文件的大小和类型,有slice/stream/arrayBuffer/text方法,了解关于Blob的更多信息请看官方文档,传送门
对File/Blob/arrayBuffer感兴趣的,可以看参考这篇文章,传送门
Blob的slice方法和本文要讲的大文件快速上传有关,接下来我们举例重点说明
`Blob.slice(start,end)`//返回一个新的 `Blob` 对象,包含了源 `Blob` 对象中指定范围内的数据
start //文件开始裁剪的字节
end //文件截止的字节
Blob.slice(10*1024,20*1024)//截取文件10kb-20kb这段
最简单的文件切割
<template>
<div id="app">
<input type="file" name="" id="file" ref="input" @change="handleChange" />
<button class="btn" @click="handleClick">点击上传</button>
<div v-if='!!file'>
<span>上传的文件大小:</span>
{{file.size}}
</div>
<ul>
<div>切割成文件块</div>
<li v-for="(item, index) in fileChuck" :key="index">
文件块{{ index + 1 }}:{{ item.size }}
</li>
</ul>
</div>
</template>
export default {
name: "App",
data() {
return {
file: null,
fileChuck: [],//存放被切割后的文件块
};
},
methods: {
handleClick() {
this.$refs.input.click();
},
handleChange(e) {
const files = e.target.files;//获得input需要上传的文件
this.file = files[0];
const SIZE = 10 * 1024;//截取的文件大小
this.fileChuck = [];
files.forEach((file) => {
let curSize = 0;
const fileSize = file.size;
while (curSize <= fileSize) {//文件切割
let end = (curSize + SIZE <= fileSize) ? (curSize + SIZE) : fileSize;
this.fileChuck.push(file.slice(curSize, end));
curSize += SIZE;
}
});
},
},
};
大文件快速上传
先抛开文件上传,思考下面的问题
在A市有100t的货物,要送往B市,怎么缩短运输时间?
- 交通工具的选择
- 同一交通工具的载重量
- 同一运输时间内的使用某一交通工具的数量
文件上传也可以用上述问题类比,第一可以理解为网络传输环境,比如光纤,第二点可以理解为带宽(这个可能不太恰当),第三点就是文件切片,一次性传递完成
前两个在数据传递过程中都是固定的,剩下的就只有第三点文件切片了,但是传递文件不是运输货物,到达目的地就行,文件到了后端还需要组装成原来的部分,所以针对文件切片,另外的难点就是1.后端怎怎么知道文件已经传递完成了;2.后端怎么进行文件还原
思路总结
-
问题点一:前端将文件做成切片进行传递,那么后端怎么知道已经全部接收到所有的文件切片
方法:前端主动通知,当所有的切片传递完成后(Promise.all),再发送一个请求通知后端已经完成切片传递,后端进行切片合并
-
问题点二:文件切片传递到后端,后端怎么将文件进行还原
方法:文件编号,前后端思路如下
前端: 通过Blob.slice()进行文件切片,给每一个切片按顺序进行编号(index),将编号信息一并传递给后端(通过异步Promise发送请求)
后端:nodejs搭建服务,接收切片,在本地或者静态资源服务器新建切片文件夹,将切片保存到文件夹中(fs.createReadStream/fs.createWriteStream/pipe),得到合并通知读取文件按前端传递的文件顺序进行切片合并
下面用一种图说明下整个过程
两次请求分别对应不同的后端接口
代码实现
效果如下图,重点观察左边目录的变化,前端核心代码在文件切片 后端代码用的是nodejs,核心是对文件的操作,要求对fs和stream模块有了解
前端代码
前端文件上传的两个核心api是form.append()和blob.slice()方法,不熟悉的请看上文
前端需要做的事情按照触发顺序分为以下几个方面:
- click 事件触发上传框打开
- change事件触发文件切片和文件上传
- 通过blob.slice()方法将文件按照一定大小进行切片
- 通过form.append()方法将切片放入formData对象内存储
- 切片全部完成后通知后端进行切片合并
以下代码是按照上述思路完成的,供参考,不合理之处也可留言讨论
<!--App.vue -->
<div id="app">
<upload>
<button class="btn" >点击上传</button>
</upload>
</div>
<!--Upload.vue -->
<template>
<div class="upload">
<input
type="file"
name=""
id="file"
ref="input"
@change="handleChange"
/>
<div @click="handleClick">
<slot></slot>
</div>
<div v-if="!!file">
<span>上传的文件大小:</span>
{{ file.size }}
</div>
<ul>
<div>切割成文件块</div>
<li v-for="(item, index) in fileChunks" :key="index">
文件块{{ index + 1 }}:{{ item.size }}
</li>
</ul>
</div>
</template>
export default {
name: "Upload",
data() {
return {
file: null, //保存需要上传的文件
fileChunks: [], //保存所有的切片
chunksNameList: [], //保存切片名称,用于切片合并
};
},
methods: {
handleClick() {
//用户点击
this.$refs.input.click();
},
handleChange(e) {
//input:file change事件,
const files = e.target.files;
this.file = files[0];
this.fileChunks = this.createFileChunk(files); //文件转变为切片
this.uploadFile(this.fileChunks); //切片上传
},
/**
* @description: 切片函数
* @param {file} files
* @return {Array} 切片数组
*/
createFileChunk(files) {
if (!files.length) return;
const SIZE = 1 * 1024 * 1024; //1M,切片大小
const fileChunks = [];
files.forEach((file) => {
let curSize = 0;
let index = 0;
const fileSize = file.size;
while (curSize <= fileSize) {
let end = curSize + SIZE <= fileSize ? curSize + SIZE : fileSize;
index++;
fileChunks.push(file.slice(curSize, end));
curSize += SIZE;
}
});
return fileChunks;
},
/**
* @description: 切片上传
* @param {Blob} fileChunks
* @return {*}
*/
uploadFile(fileChunks) {
if (!fileChunks.length) return;
const uploadFileQuene = [];
this.chunksNameList = [];
fileChunks.forEach((chunks, index) => {
const chunksName = this.file.name + "_" + index;
const form = new FormData();
form.append(`chunks`, chunks);
form.append("fileName", this.file.name);
form.append("chunksNameList", chunksName);
this.chunksNameList.push(chunksName);
uploadFileQuene.push(
this.uploadApi({
url: "/api/upload", //上传切片
data: form,
})
);
});
Promise.all(uploadFileQuene).then((res) => {
console.log(res);
this.uploadApi({
url: "/api/merge", //合并切片
data: JSON.stringify({
fileName: this.file.name,
chunksNameList: this.chunksNameList,
}),
});
});
},
/**
* @description:切片及合并切片请求接口
* @param {String} url
* @param {Object} data
* @return {*}
*/
uploadApi({ url, data }) {
return new Promise((resolve) => {
const xhr = new XMLHttpRequest();
xhr.open("POST", url);
xhr.onload = function () {
resolve(JSON.parse(xhr.response));
};
xhr.send(data);
});
},
},
};
后端代码
后端代码核心就是切片保存和切片还原为文件,代码仅供参考
后端完成的功能按照执行顺序,分为以下步骤
- 接收到前端发送的切片(包含文件信息),创建文件夹(已文件或者hash命名),将切片保存到已经创建的文件夹下
- 接收到合并文件请求,读取切片,创建stream,切片为可读流,要合并的文件为可写流,通过pipe()方法,最终转变成文件
- 切片完成后删除切片所在的文件夹(可选)
ps:nodejs用的不是很熟悉,代码在文件流操作完成后,准备删除文件的时候偶尔会有bug
const Koa = require('koa');
const Router = require('koa-router');
const koabody = require('koa-body');
const fs = require('fs');
const path = require('path');
const app = new Koa();
const router = new Router();
app.use(koabody({
multipart: true
}))
router.post('/upload', ctx => { //切片保存接口
const chunks = {
...ctx.request.files,
...ctx.request.body
}
if (!Object.keys(chunks).length) {
ctx.body = JSON.stringify({
data: {
message: '未传递数据'
}
})
return;
}
uploadCtr(chunks).then(res => {
ctx.body = JSON.stringify({
data: { ...chunks, message: '上传成功' }
});
ctx.set("Access-Control-Allow-Origin", " * ");
})
})
router.post('/merge', ctx => { //切片合并接口
const mergeInfo = JSON.parse(ctx.request.body);
const fileName = mergeInfo.fileName;
const chunksNameList = mergeInfo.chunksNameList;
mergeChunks(fileName, chunksNameList);
ctx.body = mergeInfo;
})
app.use(router.routes())
app.listen(3000);
/**
* @description: 创建切片文件夹,并开启切片保存
* @param {Blob} chunks
* @param {String} fileName
* @param {Array} chunksNameList
* @return {*}
*/
function uploadCtr({ chunks, fileName, chunksNameList }) {
//创建保存切片的文件夹
!fs.existsSync(fileName) && fs.mkdirSync(fileName);
return new Promise(async resolve => {
const result = await saveChunks({ chunks, fileName, chunksNameList });
resolve(result);
});
}
/**
* @description: 保存切片
* @param {Blob} chunks
* @param {String} fileName
* @param {Array} chunksNameList
* @return {*}
*/
function saveChunks({ chunks, fileName, chunksNameList }) {
return new Promise(resolve => {
const chunksSavePath = path.resolve(__dirname, fileName, chunksNameList)
const readStream = fs.createReadStream(chunks.path);
const writeStream = fs.createWriteStream(chunksSavePath);
readStream.pipe(writeStream);
resolve({
chunksSaveDir: path.resolve(__dirname, fileName),
})
})
}
/**
* @description: 合并切片
* @param {*} fileName
* @param {*} chunksNameList
* @return {*}
*/
function mergeChunks(fileName, chunksNameList) {
const saveFilePath = path.resolve(__dirname, "img", fileName)
const chunksStream = chunksNameList.map(chunks => {
const chunksPath = path.resolve(__dirname, fileName, chunks);
const readStream = fs.createReadStream(chunksPath);
return readStream
})
const chunksLength = chunksStream.length;
const writeStream = fs.createWriteStream(saveFilePath);
let isEnd = false
for (let index = 0, i = 0; index < chunksLength; index++) {
//这段代码bug出没
chunksStream[index].pipe(writeStream, {
end: isEnd
})
i++;
chunksStream[index].on('end', () => {
if (i == chunksLength) {
writeStream.end();
delDir(fileName);
}
});
}
}
/**
* @description: 删除文件夹
* @param {String} path
* @return {*}
*/
function delDir(path) {
const dirs = fs.readdirSync(path);//读取当前路径下的文件及文件夹
console.log(dirs)
dirs.forEach(dir => {
let curPath = path + '/' + dir//获得当前路径
console.log(curPath)
if (fs.statSync(curPath).isDirectory()) {//是否为文件夹
delDir(curPath);//遍历
} else if (fs.statSync(curPath).isFile()) {//是否为文件
fs.unlinkSync(curPath)
}
})
fs.rmdirSync(path)//删除空文件夹
}
同一文件切片与非切片上传时间对比
切片上传耗时
非切片上传耗时
有点凌乱了,猜测原因,这个时间其实是硬盘保存文件/切片的耗时,多个切片需要保存多次,造成切片耗时比非切片还要长,上述代码的优势体现在网络传输上,因为是本地起的服务,网络传输忽略不计,以上是我的猜想,欢迎大神留言指导
参考文档:
-
mdn Blob和File说明