在了解切片上传之前,我们需要先了解一些api.
读取文件 fileReader
这个对象上的api可以以不同的方式读取文件内容到result属性中。
readAsText(file, encoding): 以纯文本的形式读取文件readAsDataURL(file): 将文件以base64的方式存储在result中。readAsBinaryString(file): 以字符串的形式读取文件,字符串中的每个字符表示一个字节。readAsArrayBuffer(file): 读取文件,将文件以ArrayBuffer格式存储在result中。
具有三个事件来为我们在读取文件时做一些操作
- error: 读取文件发生错误时触发
- progress: 继续读取文件时触发,每次默认读取
109117440字节文件。 - load: 全部读取文件时触发
在读取对应的文件时,一定要选择正确的api进行读取,不然result可能会出现乱码或者为空。
如果监听的是progress事件,那么他每次读取109117440字节的文件。并且这里面是不能获取到result的值的。还是需要监听load事件来获取result值。
FileReader 对象允许 Web 应用程序异步读取存储在用户计算机上的文件(或原始数据缓冲区)的内容,使用 File 或 Blob 对象指定要读取的文件或数据。
slice 文件截取
通过slice方法,file是blob的子类型。但是每个浏览器对于file文件对象的截取方法不同。所以一般都是使用blob.slice来截取的。
- 第一个参数有表示开始截取的字节数。
- 第二个参数表示截取的长度。
第二个参数 - 第一个·参数才是读取的长度。
如果我们想要分段读取文件。那么我们就需要在onload事件中去再次切割文件,来达到递归调用的效果,直到文件读取完毕。
let reader = new FileReader();
let blob = null;
function readerBlob(start) {
blob = files[0].slice(start, start + 100000000);
// 将文件读取到ArrayBuffer中然后存入reader.result
reader.readAsArrayBuffer(blob)
}
readerBlob(start)
reader.onload = function (e) {
console.log("blob.size", blob.size) // 但是·就我感觉slice的第二个参数他并不是读取的长度,而是每次截止的字节数。2 - 1才是读取的总大小
// 每次切片的数据
console.log("this.result", this.result)
if(blob.size < total) {
start = start + 100000000;
}
readerBlob(start)
};
对象URL URL.createObjectURL
传递一个blob类型的数据,然后会生成一个字符串。指向一块内存地址。
可以实现图片预览。而不需要后端返回上传图片的url。
<input type="file" name="a" id="a">
<img id="img" src="" alt="">
<script>
const a = document.getElementById("a")
const img = document.getElementById("img")
a.onchange = function(e) {
// 获取文件对象
const [file] = e.target.files;
// 创建一个blob格式的url对象
const url = URL.createObjectURL(file)
img.setAttribute("src", url)
}
</script>
如果不在使用这个字符串,那么我们需要手动释放内存。
URL.revokeObjectURL(url)
预览图片第二种方式
通过FileReader对象将file对象转换为base64格式,然后放在result中返回,这时就可以监听onload事件拿到该值。
//第二种:使用FileReader
const reader = new FileReader();
reader.onload = (function (aImg) {
return function (e) {
aImg.src = e.target.result;
};
})(img);
reader.readAsDataURL(file);
表单键值对对象 FormData
FormData 接口提供了一种表示表单数据的键值对 key/value 的构造方式,并且可以轻松的将数据通过XMLHttpRequest.send() 方法发送出去,本接口和此方法都相当简单直接。如果送出时的编码类型被设为 "multipart/form-data",它会使用和表单一样的格式。
创建一个formData对象
一个可选参数form, 他是表单的form对象, 如果传入form dom创建的FormData对象会自动将 form 中的表单值也包含进去,包括文件内容也会被编码之后包含进去。
const formData = new FormData(?form)
该对象上有很多操作键值对的方法
-
append。向formData对象中添加新的属性,该属性不存在覆盖操作,只会新增。
-
delete。删除一个键值对。
-
entries。返回所有键值对的iterator对象。
-
keys。返回一个包含所有键的
iterator对象。 -
has。
返回一个布尔值表明 FormData对象是否包含某些键。 -
getAll。返回一个包含
FormData对象中与给定键关联的所有值的数组。 -
get。
返回在 FormData对象中与给定键关联的第一个值。 -
set。给
FormData设置属性值,如果FormData对应的属性值存在则覆盖原值,否则新增一项属性值。他只会覆盖第一个查到的属性,并且删除后面重复的属性的键值对。
-
values。返回包含所有值的iterator对象。
<form action="#" id="form">
<input type="text" name="name" value="zh">
<input type="password" name="pwd" value="a">
</form>
<script>
const form = document.getElementById("form")
const data = new FormData(form);
data.append("name", "zh")
// data.set("name", "llm")
console.log(data.getAll("name")) // ["zh", "zh"]
console.log([...data.entries()])
</script>
xml对象中的upload.onprogress事件
如果你使用原生 XMLHttpRequest 发送请求的话,那么xml中有一个upload属性上面有个onprogress事件,可以实时获取我们上传的文件大小。
onprogress事件并不是只执行分块大小的次数,而是根据读取文件大小来确定执行多少次,直到文件全部上传完毕。
事件对象中记录了两个重要的值
- loaded: 表示当前分片已加载的大小。
- total:表示当前分片总大小。
测试大文件上传
前端上传的具体逻辑
- 点击input监听change事件,获取file对象。
// 点击input上传文件
handleFileChange(e) {
const [file] = e.target.files;
if (!file) return;
Object.assign(this.$data, this.$options.data());
this.container.file = file;
}
- 将获取的file对象分片。调用
slice方法。再加入数组之前,需要添加一些额外的属性,来为以后操作分片对象提供方便。
// 生成文件切片
createFileChunk(file, size = SIZE) {
const fileChunkList = [];
let cur = 0;
while (cur < file.size) {
fileChunkList.push({ file: file.slice(cur, cur + size) });
cur += size;
}
return fileChunkList;
},
// 点击上传
async handleUpload() {
if (!this.container.file) return;
// 分割文件
const fileChunkList = this.createFileChunk(this.container.file);
// 将切片赋值给data保存。并加入一些其他属性
this.data = fileChunkList.map(({ file }, index) => ({
chunk: file,
index,
// 文件名 + 数组下标
hash: this.container.file.name + "-" + index,
percentage: 0,
}));
// 上传切片
await this.uploadChunks();
},
}
- 封装原生的xml对象发送请求
request({
url,
method = "post",
data,
headers = {},
onProgress = (e) => e,
}) {
return new Promise((resolve) => {
const xhr = new XMLHttpRequest();
// 获取文件的上传进度
// onprogress事件并不是只执行分块大小的次数,而是根据读取文件大小来确定执行多少次,直到文件全部上传完毕
xhr.upload.onprogress = onProgress;
xhr.open(method, url);
Object.keys(headers).forEach((key) =>
xhr.setRequestHeader(key, headers[key])
);
xhr.send(data);
xhr.onload = (e) => {
// 在这里进行总的进度计算。
let cur = 0;
this.data.forEach((item) => {
// 这里也可以测试时并行的,因为cur不是每次增加100
cur += item.percentage;
this.totalPercentage = (
(cur / (this.data.length * 100)) *
100
).toFixed(0);
});
resolve({
data: e.target.response,
});
};
});
},
// 上传切片
async uploadChunks() {
// 设置上传列表值,增加一些属性
const requestList = this.data
.map(({ chunk, hash, index }) => {
// 创建表单键值对上传对象。
const formData = new FormData();
formData.append("chunk", chunk);
formData.append("hash", hash);
formData.append("filename", this.container.file.name);
return { formData, index };
})
.map(({ formData, index }) =>
this.request({
url: "http://localhost:3000",
data: formData,
// 获取单个切片的值(内部有hash ,chunk)
onProgress: this.createProgressHandler(this.data[index]),
})
);
// 并发请求
await Promise.all(requestList);
// 合并切片
await this.mergeRequest();
},
// 合并请求只需要传递一个文件名即可。
async mergeRequest() {
await this.request({
url: "http://localhost:3000/merge",
headers: {
"content-type": "application/json",
},
data: JSON.stringify({
filename: this.container.file.name,
}),
});
},
- 如果需要统计每个分片上传的进度,我们可以使用
xml.upload.onprogress事件来监听每次上传的文件大小,来计算。也就是上文提到的。
// 单个chunk上传的进度。如果是整个文件,我们只需要在xml中的load事件中计算进度即可。
// 这个事件会被调用很多次,而不是只调用分片多少的次数。
createProgressHandler(item) {
return (e) => {
// e是onprogress事件对象。 loaded表示当前分片已加载的大小,total表示当前分片大小
item.percentage = parseInt(String((e.loaded / e.total) * 100));
};
}
- 文件上传的总进度计算方式
第一种,直接在onload事件中计算,因为每个分片上传完毕,都会触发onload事件。
计算方法就是,每个分片的上传进度都是100,全部分片 * 100,然后遍历累计每个分片的percentage相除即可。
xhr.onload = (e) => {
// 在这里进行总的进度计算。
let cur = 0;
this.data.forEach((item) => {
// 这里也可以测试时并行的,因为cur不是每次增加100
cur += item.percentage;
this.totalPercentage = (
(cur / (this.data.length * 100)) *
100
).toFixed(0);
});
};
第二种,由于我们通过onprogress事件,实时计算每个分片的precentage上传进度,所以可以直接计算。
// 通过上传的进度可知,我们上传文件的时候,他是并行的。
uploadPercentage() {
if (!this.container.file || !this.data.length) return 0;
// 如果没有发送的他们的percentage还是0
const loaded = this.data
.map((item) => item.chunk.size * (item.percentage / 100))
.reduce((acc, cur) => acc + cur);
console.log(
"====================已加载的, 文件总大小, 比例",
loaded,
this.container.file.size
);
return parseInt(((loaded / this.container.file.size) * 100).toFixed(2));
},
前端完整代码
<template>
<div>
<input type="file" @change="handleFileChange" />
<el-button @click="handleUpload">上传</el-button>
<h1>总进度条</h1>
<el-progress :percentage="uploadPercentage"></el-progress>
<!-- <el-progress :percentage="totalPercentage"></el-progress> -->
<h1>每个chunk的进度条</h1>
<el-progress
v-for="item in data"
:key="item.hash"
:percentage="item.percentage"
>
</el-progress>
</div>
</template>
<script>
// 切片大小
// the chunk size
const SIZE = 10 * 1024 * 1024;
let c = 0;
export default {
data: () => ({
container: {
file: null,
},
// 放置若干个切片
data: [],
totalPercentage: 0,
}),
computed: {
// 通过上传的进度可知,我们上传文件的时候,他是并行的。
uploadPercentage() {
if (!this.container.file || !this.data.length) return 0;
// 如果没有发送的他们的percentage还是0
const loaded = this.data
.map((item) => item.chunk.size * (item.percentage / 100))
.reduce((acc, cur) => acc + cur);
console.log(
"====================已加载的, 文件总大小, 比例",
loaded,
this.container.file.size
);
return parseInt(((loaded / this.container.file.size) * 100).toFixed(2));
},
},
methods: {
request({
url,
method = "post",
data,
headers = {},
onProgress = (e) => e,
}) {
return new Promise((resolve) => {
const xhr = new XMLHttpRequest();
// 获取文件的上传进度
// onprogress事件并不是只执行分块大小的次数,而是根据读取文件大小来确定执行多少次,直到文件全部上传完毕
xhr.upload.onprogress = onProgress;
xhr.open(method, url);
Object.keys(headers).forEach((key) =>
xhr.setRequestHeader(key, headers[key])
);
xhr.send(data);
xhr.onload = (e) => {
// console.log("eee", e);
// 在这里进行总的进度计算。
let cur = 0;
this.data.forEach((item) => {
// 这里也可以测试时并行的,因为cur不是每次增加100
cur += item.percentage;
this.totalPercentage = (
(cur / (this.data.length * 100)) *
100
).toFixed(0);
});
resolve({
data: e.target.response,
});
};
});
},
// 点击input上传文件
handleFileChange(e) {
const [file] = e.target.files;
if (!file) return;
Object.assign(this.$data, this.$options.data());
this.container.file = file;
},
// 生成文件切片
createFileChunk(file, size = SIZE) {
const fileChunkList = [];
let cur = 0;
while (cur < file.size) {
fileChunkList.push({ file: file.slice(cur, cur + size) });
cur += size;
}
return fileChunkList;
},
// 上传切片
async uploadChunks() {
const requestList = this.data
.map(({ chunk, hash, index }) => {
const formData = new FormData();
formData.append("chunk", chunk);
formData.append("hash", hash);
formData.append("filename", this.container.file.name);
return { formData, index };
})
.map(({ formData, index }) =>
this.request({
url: "http://localhost:3000",
data: formData,
// 获取单个切片的值(内部有hash ,chunk)
onProgress: this.createProgressHandler(this.data[index]),
})
);
// 并发请求
await Promise.all(requestList);
// 合并切片
await this.mergeRequest();
},
// 单个chunk上传的进度。如果是整个文件,我们只需要在xml中的load事件中计算进度即可。
// 这个事件会被调用很多次,而不是只调用分片多少的次数。
createProgressHandler(item) {
return (e) => {
c++;
console.log("eeeeeeee", e, this.container.file.size, c);
item.percentage = parseInt(String((e.loaded / e.total) * 100));
};
},
// 合并请求只需要传递一个文件名即可。
async mergeRequest() {
await this.request({
url: "http://localhost:3000/merge",
headers: {
"content-type": "application/json",
},
data: JSON.stringify({
filename: this.container.file.name,
}),
});
},
// 点击上传
async handleUpload() {
if (!this.container.file) return;
// 分割文件
const fileChunkList = this.createFileChunk(this.container.file);
// 将切片赋值给data保存。并加入一些其他属性
this.data = fileChunkList.map(({ file }, index) => ({
chunk: file,
index,
// 文件名 + 数组下标
hash: this.container.file.name + "-" + index,
percentage: 0,
}));
// 上传切片
await this.uploadChunks();
},
},
};
</script>
后端完整代码
const http = require("http");
const path = require("path");
const fse = require("fs-extra");
const multiparty = require("multiparty");
const server = http.createServer();
// 大文件存储目录
const UPLOAD_DIR = path.resolve(__dirname, "./target");
const resolvePost = req =>
new Promise(resolve => {
let chunk = "";
req.on("data", data => {
chunk += data;
});
req.on("end", () => {
resolve(JSON.parse(chunk));
});
});
// 写入文件流
const pipeStream = (path, writeStream) =>
new Promise(resolve => {
const readStream = fse.createReadStream(path);
readStream.on("end", () => {
fse.unlinkSync(path);
resolve();
});
readStream.pipe(writeStream);
});
// 合并切片
const mergeFileChunk = async (filePath, filename, size = 10 * 1024 * 1024) => {
const chunkDir = path.resolve(UPLOAD_DIR, 'chunkDir' + filename);
const chunkPaths = await fse.readdir(chunkDir);
// 根据切片下标进行排序
// 否则直接读取目录的获得的顺序会错乱
chunkPaths.sort((a, b) => a.split("-")[1] - b.split("-")[1]);
// 并发写入文件
await Promise.all(
chunkPaths.map((chunkPath, index) =>
pipeStream(
path.resolve(chunkDir, chunkPath),
// 根据 size 在指定位置创建可写流
fse.createWriteStream(filePath, {
start: index * size,
})
)
)
).catch(err => {
console.log("=======err", err)
});
// 合并后删除保存切片的目录
fse.rmdirSync(chunkDir);
};
server.on("request", async (req, res) => {
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Headers", "*");
if (req.method === "OPTIONS") {
res.status = 200;
res.end();
return;
}
const multipart = new multiparty.Form();
multipart.parse(req, async (err, fields, files) => {
if (err) {
return;
}
const [chunk] = files.chunk;
const [hash] = fields.hash;
const [filename] = fields.filename;
// 创建临时文件夹用于临时存储 chunk
// 添加 chunkDir 前缀与文件名做区分
const chunkDir = path.resolve(UPLOAD_DIR, 'chunkDir' + filename);
if (!fse.existsSync(chunkDir)) {
await fse.mkdirs(chunkDir);
}
// fs-extra 的 rename 方法 windows 平台会有权限问题
// @see https://github.com/meteor/meteor/issues/7852#issuecomment-255767835
await fse.move(chunk.path, `${chunkDir}/${hash}`);
res.end("received file chunk");
});
if (req.url === "/merge") {
const data = await resolvePost(req);
const { filename,size } = data;
const filePath = path.resolve(UPLOAD_DIR, `${filename}`);
await mergeFileChunk(filePath, filename);
res.end(
JSON.stringify({
code: 0,
message: "file merged success"
})
);
}
});
server.listen(3000, () => console.log("listening port 3000"));
上面部分都是参考这篇文章的内容,具体请看这里
通过vue定义一个切片上传的组件
下面这个组件是公司自己封装的一个组件,但是需要后端配合。他的思路就是一边切片一边上传。
<template>
<div class="upload-continue-box">
<!-- 上传按钮 -->
<el-col v-bind:span="8">
<input
id="bag"
type="file"
@change="handleFileChange"
class="upload-continue-btn file-btn"
/>
<input type="button" :value="btnText" class="upload-continue-btn" />
</el-col>
<!-- 文件和进度条 -->
<el-col v-bind:span="8">
<div class="filename-progress-box" v-if="fileProgressVisible">
<div id="bagStop">{{ fileName }}</div>
<el-progress size="small" :percentage="percent" />
</div>
</el-col>
</div>
</template>
<script>
var bagFile = document.getElementById('bag')
var bagReader = null //读取操作对象
var bagStep = 1024 * 1024 * 3.5 //每次读取文件大小
var bagCuLoaded = 0 //当前已经读取总数
var bagSession = null //当前读取的文件对象
var bagEnableRead = true //标识是否可以读取文件
var bagNum = 0
var bagTnum = 0
var bagFileresult = ''
export default {
data() {
return {
percent: 0,
fileName: '',
}
},
computed: {
action() {
return ""
},
},
methods: {
handleClose() {
this.messageVisible = false
},
handleFileChange(e) {
let _this = this
const [file] = e.target.files
if (!file) return
if (
file.name.toLowerCase().split('.').splice(-1)[0] != 'apk' &&
file.name.toLowerCase().split('.').splice(-1)[0] != 'zip' &&
file.name.toLowerCase().split('.').splice(-1)[0] != 'aab'
) {
_this.$message.error('只能上传apk,zip,aab格式')
bagFile.value = null
return
}
bagCuLoaded = 0
//获取文件对象
bagSession = file
var total = bagSession.size
if (total > 0) {
bagTnum = total
var startTime = new Date()
bagReader = new FileReader()
//读取一段成功
bagReader.onload = function (e) {
//处理读取的结果
var result = bagReader.result
var loaded = e.loaded
bagNum = loaded
if (bagEnableRead == false) return false
//将分段数据上传到服务器
_this.uploadFile(result, bagCuLoaded, function () {
//如果没有读完,继续
bagCuLoaded += loaded
if (bagCuLoaded < total) {
_this.bagReadBlob(bagCuLoaded)
} else {
if (JSON.parse(bagFileresult).resultInfo != total) {
// _this.$message.error('上传文件长度不一致,请重新上传!')
}
bagCuLoaded = total
}
let _percent = (bagCuLoaded / total) * 100
_this.percent = Math.trunc(_percent)
_this.fileName = bagSession.name
_this.fileProgressVisible = true
if (_this.percent == 100) {
if (!_this.manual) {
_this.$emit('getGameVersionListById', true)
} else {
_this.$emit(
'uploadFileToNetDisc',
_this.toNetDiscFileName,
_this.toNetDiscFileUrl
)
}
document.getElementById('bag').value = ''
}
})
}
//开始读取
_this.bagReadBlob(0)
}
},
uploadFile(result, startIndex, onSuccess) {
var _this = this
var isend = ''
var blob = new Blob([result])
//提交到服务器
var fd = new FormData()
fd.append('appId', _this.currentGame.appId)
fd.append('file', blob)
fd.append('filename', bagSession.name)
fd.append('manual', _this.manual)
fd.append('loaded', startIndex > 0 ? 1 : startIndex)
if (bagCuLoaded + bagNum >= bagTnum) {
fd.append('isend', 'true')
} else {
fd.append('isend', 'false')
}
var xhr = new XMLHttpRequest()
xhr.open('post', _this.action, true)
xhr.setRequestHeader(
Object.keys(_this.headers)[0],
_this.headers[Object.keys(_this.headers)[0]]
)
xhr.withCredentials = true
xhr.onreadystatechange = function () {
if (xhr.readyState == 4 && xhr.status == 200) {
var response = JSON.parse(xhr.response)
if (response.resultCode == 1) {
bagFileresult = xhr.responseText
console.log(bagFileresult)
onSuccess()
}
}
}
//开始发送
xhr.send(fd)
},
//指定开始位置,分块读取文件
bagReadBlob(start) {
var blob = bagSession.slice(start, start + bagStep)
bagReader.readAsArrayBuffer(blob)
},
//中止
bagStop() {
if (bagSession != null) {
bagEnableRead = false
bagReader.abort()
}
},
//继续
bagContainue() {
if (bagSession != null) {
bagEnableRead = true
_this.bagReadBlob(bagCuLoaded)
}
},
},
}
</script>
<style lang="scss">
.upload-continue-box {
.upload-continue-btn {
display: block;
width: 120px;
background: #539fff;
height: 40px;
text-align: center;
line-height: 40px;
border-radius: 2px;
color: #ffffff;
font-size: 14px;
cursor: pointer;
border: none;
outline: none;
}
.file-btn {
position: absolute;
z-index: 200;
opacity: 0;
display: block;
// width: 58px;
margin-left: 0px;
}
.filename-progress-box #bagStop {
width: 300px;
height: 13px;
font-size: 12px;
line-height: 13px;
color: #606266;
background: url('../../../assets/images/packCenter/text.png') no-repeat;
padding-left: 20px;
overflow: hidden;
text-overflow: ellipsis;
}
}
</style>
但是需要后端配合才能完成。
具体测试代码,请访问github
如果对你有帮助,请给一个star。