读取文件 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>
但是需要后端配合才能完成。