Elment UI 多文件上传功能实现,超大文件上传与数据接收

316 阅读12分钟

一、多文件上传功能的实现

1.准备工作

image.png

1.找到我们需要的上传模板,这里的drag为可拖拽,multiple为可多文件
2.根据需求做一些修改:

  • action="" :auto-upload="false"这里关闭自动上传,后续我们通过点击按钮来上传
  • :file-list="fileList" :show-file-list="false"这里关闭了文件列表的默认显示,后续我们用table来完整显示文件的相关信息以及添加一些控制按钮
  • :on-change="handleFileChange"这里我们使用了一个文件状态改变时的钩子,用于读取fileList

image.png

2.Task(任务①):显示文件大小,文件数量统计

Action(行动):
  • 1.:on-change="handleFileChange"
  • 2.handleFileChange(file, fileList) {console.log(file, fileList)}
  • 3.看一眼
handleFileChange(file, fileList) {
      console.log(file, fileList);
      
      this.cancelTokens={}
      this.progress={}
      this.fileList=[]
      fileList.forEach((file) => {
        file.uploading = false;
        file.progress = 0;
        this.$set(this.progress, file.uid, 0);
      });
      this.fileList = fileList;
      console.log(this.fileList);
    },

image.png

通过观察,这里的on-change钩子函数,当文件状态发生改变时会触发它,我们可以通过这个钩子函数获取两个返回的参数

Result(结果):
  • 1.file是fileList最后一个文件的信息
  • 2.fileList是一个数组,包含所有文件的file信息
  • 3.file:{name,percentage,raw[uid,lastModified,lastModifiedDate],size,type,webkitRelativePath}

image.png raw里面包含了文件的详细信息,包括了它的创建时间,名称以及文件类型,我们要上传的就是这个玩意

这里可以注意到一些我们后续需要使用的一些属性
1.progress为上传的进度
2.size为文件大小,单位:字节
3.uid为唯一id
4.uploading上传

那么我们现在可以渲染table表格和一些相关的信息了

<el-table :data="fileList" border style="margin-top: 20px">
      <el-table-column prop="name" label="文件名" />
      <el-table-column prop="size" label="大小 (KB)" width="100">
        <template #default="scope">
          {{ (scope.row.size / 1024).toFixed(2) }}
        </template>
      </el-table-column>
      <el-table-column label="状态" width="120">
        <template #default="scope">
          <span v-if="progress[scope.row.uid] === 100">上传成功</span>
          <span v-else-if="progress[scope.row.uid] === 0">待上传</span>
          <span v-else>上传中</span>
          
        </template>
      </el-table-column>
      <el-table-column label="进度" width="150">
        <template #default="scope">
          <el-progress :text-inside="true" :stroke-width="24" :percentage="progress[scope.row.uid]" status="success">
          </el-progress>
          <!-- <el-progress
            :percentage="scope.row.progress"
            v-if="scope.row.progress <= 100 && scope.row.progress >= 0"
          /> -->
          <!-- v-if="scope.row.uploading" -->
        </template>
      </el-table-column>
      <el-table-column label="操作" width="150">
        <template #default="scope">
          <el-button
            size="small"
            type="info"
            @click="cancelSingleUpload(scope.row)"
            
          >
            取消
          </el-button>
          <el-button
            size="small"
            type="danger"
            @click="removeFile(scope.row)"
           
          >
            删除
          </el-button>
        </template>
      </el-table-column>
    </el-table>

3.Task(任务②):使用 axios 实现并发文件上传,并对上传进度进行监控

Action(行动):
  • 1.:auto-upload="false"
  • 2.使用 axios 配合 Promise.allSettled 实现并发上传文件。
  • 3.:on-progress="onUploadProgress"

注意:我们在这次行动中做了两个变化,属于行动中时产生的一些优化

  • 1.当我们要使用Promise.allSettled并发上传时,我们可以提前封装一个单文件上传的函数并且把请求return出去,这样我们便可以轻松的得到一个promise对象数组来配合使用
  • 2.这里我选择使用AJAX在进行文件上传时的一个回调函数onUploadProgress用于监听上传进度,e.loaded为上传字节数,e.total为文件总字节数
  • 3.因为要独立每一个文件上传的进度,这里我们在data里面配置了一个数据progress,以文件的id为属性名,进度为属性值,进行传入。其实这里不难发现我们后续的取消令牌也是用的此方式
uploadFile(file, uid) {
      const formData = new FormData();
      formData.append("file", file);

      const source = axios.CancelToken.source();
      this.$set(this.cancelTokens,uid,source)
      console.log('取消令牌',this.cancelTokens);
      
        // this.cancelTokens[uid] = source;

      // formData.append("type", "contract");
      return axios.post(
        "https://jsonplaceholder.typicode.com/posts/",
        formData,
        {
          cancelToken: source.token,
          onUploadProgress: (progressEvent) => {
            const percentCompleted = Math.round(
              (progressEvent.loaded * 100) / progressEvent.total
            );
            file.progress = percentCompleted;
            this.$set(this.progress, uid, percentCompleted)
            console.log(this.progress);
            console.log(`Upload progress: ${file.progress}%`);
          },
        }
      );
      // .then((response) => {
      //   file.uploading = false;
      //   // 处理上传成功的响应
      // })
    },
Result(结果):
  • 1.开始上传按钮应写在el-upload标签外部
  • 2.封装了一个单文件上传函数,遍历fileList调用上传函数获得promise对象数组,使用Promise.allSettled并发上传
  • 3.单文件上传函数中上传进度监控onUploadProgress
  • 4.解决进度条视图不响应问题,使用了this.set()方法
submitUpload() {
      const uploadList = this.fileList.map((item) => {
        return this.uploadFile(item.raw, item.uid);
      });
      Promise.allSettled(uploadList)
        .then((res) => {
          console.log("res:", res);
        })
        .catch((error) => {
          console.error("上传失败:", error);
        });
        }

4.Task(任务③):实现取消单个文件上传和清除所有任务的功能

Action(行动):
  • 1.创建数据cancelTokens: {},每个id绑一个自己的取消令牌源对象
  • 2.将请求与取消令牌关联起来
  • 3.通过id来取消请求,或者遍历Object.values()全部取消

重点理解

  • axios.CancelToken.source()创建一个取消令牌(Cancel Token)的源对象,返回token 和 cancel 两个属性的对象
  • ①将取消令牌token传递给 Axios 请求的 cancelToken 配置选项//token是用来绑定的
  • ②source.cancel('请求被手动取消')//cancel是用来取消请求的
Result(结果):
  • 1.const source = axios.CancelToken.source()
  • 2.cancelToken: source.token
  • 3.source.cancel()
  • 总结:我们在单文件上传中已经完成了前两步,我们的source以键值对的方式(key为文件id)存在了cancelTokens中,那我们只需要拿到文件的id就可以执行第三步取消请求了!
cancelAllUploads() {
      Object.values(this.cancelTokens).forEach((source) => {
        source.cancel("全部上传已取消");
      });
      Message.success("全部上传已取消");
      this.cancelTokens = {};
    },
    cancelSingleUpload(file) {
      const source = this.cancelTokens[file.uid];
      if (source) {
        source.cancel("单个文件上传已取消");
        delete this.cancelTokens[file.uid];
        Message.success("单个文件上传已取消");
      }
    },

image.png

二、超大文件上传优化方式

1.切片上传

原理

将大文件分割成多个较小的文件块(切片),然后分别上传这些切片,最后在服务器端将这些切片按顺序合并成完整的文件。

优势
  • 降低上传失败风险:如果在上传过程中某个切片失败,只需重新上传该切片,而不需要重新上传整个大文件。
  • 提高上传效率:可以并行上传多个切片,充分利用网络带宽,加快上传速度。
实现步骤
  • 客户端

    1. 文件切片:使用 JavaScript 的 File.prototype.slice 方法将文件分割成多个切片。
    2. 并行上传:使用多线程或异步请求同时上传多个切片。可以使用 XMLHttpRequest 或 fetch API 发送切片数据。
    3. 记录切片信息:为每个切片分配唯一的标识,记录切片的序号、文件总切片数等信息,以便服务器端合并。
// 获取文件输入元素 const fileInput = document.getElementById('fileInput'); 
fileInput.addEventListener('change', async (event) => { 
const file = event.target.files[0]; 
const chunkSize = 1024 * 1024; // 每个切片大小为 1MB 
const totalChunks = Math.ceil(file.size / chunkSize); 

for (let i = 0; i < totalChunks; i++) { 
const start = i * chunkSize; 
const end = Math.min(start + chunkSize, file.size); 
const chunk = file.slice(start, end); 
const formData = new FormData(); 
formData.append('chunk', chunk); 
formData.append('chunkIndex', i); 
formData.append('totalChunks', totalChunks); 
try { await fetch('your_upload_url', { method: 'POST', body: formData }); } 
catch (error) { console.error('切片上传失败:', error); } } });

2. 断点上传

原理

在文件上传过程中,如果出现网络中断、页面关闭等异常情况,下次上传时可以从上次中断的位置继续上传,而不需要重新开始。

优势
  • 提高用户体验:避免用户因意外情况而重新上传整个文件,节省时间和流量。
  • 增强上传稳定性:即使在不稳定的网络环境下,也能保证文件最终可以完整上传。
实现步骤
  • 客户端

    1. 记录上传进度:在每次上传切片成功后,记录已上传的切片序号或字节数。
    2. 检查断点:在重新上传文件时,检查是否存在上次上传的断点信息。如果存在,则从断点位置开始继续上传。
// 获取文件输入元素 const fileInput = document.getElementById('fileInput'); 
fileInput.addEventListener('change', async (event) => { 
const file = event.target.files[0]; 
const chunkSize = 1024 * 1024; // 每个切片大小为 1MB 
const totalChunks = Math.ceil(file.size / chunkSize); 
// 获取上次上传的断点信息 
let startChunk = localStorage.getItem('uploadChunkIndex') || 0; 
for (let i = startChunk; i < totalChunks; i++) { 
const start = i * chunkSize; 
const end = Math.min(start + chunkSize, file.size); 
const chunk = file.slice(start, end); 
const formData = new FormData(); 
formData.append('chunk', chunk); 
formData.append('chunkIndex', i); 
formData.append('totalChunks', totalChunks); 
try { await fetch('your_upload_url', { method: 'POST', body: formData }); 
// 更新断点信息 
localStorage.setItem('uploadChunkIndex', i + 1); } 
catch (error) { console.error('切片上传失败:', error); break; } } });

3. 并行上传

原理

同时上传多个文件切片,充分利用网络带宽,提高上传速度。

优势
  • 加快上传速度:在网络条件允许的情况下,并行上传可以显著缩短文件上传的时间。
实现步骤
  • 客户端

    1. 切片划分:将大文件分割成多个切片。
    2. 并行请求:使用多线程或异步请求同时上传多个切片。可以使用 Promise.all 或 Promise.allSettled 来管理并行请求。
// 获取文件输入元素 
const fileInput = document.getElementById('fileInput'); 
fileInput.addEventListener('change', async (event) => { 
const file = event.target.files[0]; const chunkSize = 1024 * 1024; 
// 每个切片大小为 1MB 
const totalChunks = Math.ceil(file.size / chunkSize); 
const uploadPromises = []; 
for (let i = 0; i < totalChunks; i++) { 
const start = i * chunkSize; 
const end = Math.min(start + chunkSize, file.size); 
const chunk = file.slice(start, end); 
const formData = new FormData(); formData.append('chunk', chunk); 
formData.append('chunkIndex', i); 
formData.append('totalChunks', totalChunks); 
const uploadPromise = fetch('your_upload_url', { method: 'POST', body: formData }); 
uploadPromises.push(uploadPromise); } 
try { await Promise.all(uploadPromises); 
console.log('文件上传完成'); } 
catch (error) { console.error('文件上传失败:', error); } });

4. 压缩文件

原理

在上传文件之前,对文件进行压缩处理,减小文件的大小,从而减少上传所需的时间和带宽。

优势
  • 节省带宽和时间:压缩后的文件体积更小,上传速度更快。
实现步骤
  • 客户端

    1. 选择压缩算法:常见的压缩算法有 ZIP、GZIP 等。可以使用 JavaScript 库(如 jszip)进行文件压缩。
    2. 压缩文件:将大文件压缩成压缩包,然后上传压缩包。
import JSZip from 'jszip'; // 获取文件输入元素 
const fileInput = document.getElementById('fileInput'); 
fileInput.addEventListener('change', async (event) => { 
const file = event.target.files[0]; 
const zip = new JSZip(); 
zip.file(file.name, file); 
const compressedBlob = await zip.generateAsync({ type: 'blob' }); 
const formData = new FormData(); 
formData.append('compressedFile', compressedBlob); 
try { await fetch('your_upload_url', { method: 'POST', body: formData }); 
console.log('压缩文件上传完成'); } 
catch (error) { console.error('压缩文件上传失败:', error); } });

5. 预上传检查

原理

在开始上传文件之前,先对文件进行一些检查,如文件大小、文件类型、文件完整性等,避免上传不符合要求的文件。

优势
  • 节省资源:避免上传无效或不符合要求的文件,减少服务器和网络资源的浪费。
实现步骤
  • 客户端

    1. 文件大小检查:检查文件的大小是否超过服务器允许的最大限制。
    2. 文件类型检查:检查文件的类型是否符合要求,如只允许上传图片、文档等特定类型的文件。
    3. 文件完整性检查:可以使用哈希算法(如 MD5、SHA-1 等)计算文件的哈希值,然后将哈希值发送到服务器进行验证。
// 获取文件输入元素 
const fileInput = document.getElementById('fileInput'); 
fileInput.addEventListener('change', (event) => { 
const file = event.target.files[0]; const maxSize = 1024 * 1024 * 10; // 最大文件大小为 10MB
const allowedTypes = ['image/jpeg', 'image/png']; 
if (file.size > maxSize) { console.error('文件大小超过限制'); return; } 
if (!allowedTypes.includes(file.type)) { console.error('不支持的文件类型'); return; } 
// 计算文件哈希值(示例中使用伪代码) 
const hash = calculateFileHash(file); // 发送检查请求 
fetch('your_check_url', { 
method: 'POST', headers: { 'Content-Type': 'application/json' }, 
body: JSON.stringify({ size: file.size, type: file.type, hash: hash }) }) 
.then(response => response.json()) 
.then(data => { if (data.valid) { // 文件检查通过,开始上传 uploadFile(file); } 
else { console.error('文件检查未通过:', data.message); } }) 
.catch(error => { console.error('文件检查请求失败:', error); }); }); 

function calculateFileHash(file) { // 实际实现中需要使用具体的哈希算法库 return 'dummy_hash'; }

function uploadFile(file) { // 实现文件上传逻辑 }

HTTP/2 特性及其对超大文件上传的优化作用

1. 二进制分帧
  • 原理:HTTP/2 将所有传输的信息分割为更小的消息和帧,并采用二进制格式编码。每个帧都有一个帧头,包含了该帧的元信息,如所属的流编号等。消息由一个或多个帧组成,流则是多个消息的双向传输序列。
  • 对超大文件上传的优化:在上传超大文件时,文件被分割成多个二进制帧进行传输。这些帧可以独立发送和接收,服务器可以根据帧的优先级和可用资源灵活处理,避免了 HTTP/1.x 中请求排队和阻塞的问题,提高了上传效率。
2. 多路复用
  • 原理:允许在一个 TCP 连接上同时进行多个流的传输,各个流之间相互独立、互不干扰。客户端和服务器可以同时发送和接收多个请求和响应,无需等待前一个请求处理完成。
  • 对超大文件上传的优化:在超大文件上传过程中,可将文件切片上传的多个请求复用在同一个 TCP 连接上。例如,客户端可以同时发送多个文件切片的上传请求,服务器也能同时处理并返回响应,大大提高了并发性能,减少了连接建立和关闭的开销,加快了上传速度。
3. 头部压缩
  • 原理:HTTP/2 使用 HPACK 算法对请求和响应的头部进行压缩。该算法通过建立静态和动态表来存储已经出现过的头部字段及其值,当再次出现相同的头部字段时,只需引用表中的索引,从而减少了头部数据的传输量。
  • 对超大文件上传的优化:在上传超大文件时,多次请求的头部信息往往有很多重复部分。通过头部压缩,可显著减少每个请求的头部数据大小,降低了网络带宽的占用,提高了数据传输效率。
4. 优先级与流量控制
  • 原理

    • 优先级:客户端可以为每个流指定优先级,服务器根据优先级来分配资源,优先处理高优先级的流。
    • 流量控制:HTTP/2 提供了基于流和连接的流量控制机制,发送方需要等待接收方的窗口更新通知才能继续发送数据,防止接收方缓冲区溢出。
  • 对超大文件上传的优化:在超大文件上传时,可以为关键的控制信息或重要的文件切片设置较高的优先级,确保这些数据能优先处理。同时,流量控制机制可以根据网络状况和接收方的处理能力,合理调整上传速度,避免网络拥塞和数据丢失,保证上传的稳定性。

const fileInput = document.getElementById('fileInput'); 
fileInput.addEventListener('change', async (event) => { 
const file = event.target.files[0]; 
const chunkSize = 1024 * 1024; // 每个切片大小为 1MB 
const totalChunks = Math.ceil(file.size / chunkSize); 
for (let i = 0; i < totalChunks; i++) { 
const start = i * chunkSize; 
const end = Math.min(start + chunkSize, file.size); 
const chunk = file.slice(start, end); 
const formData = new FormData(); 
formData.append('chunk', chunk); 
formData.append('chunkIndex', i); 
formData.append('totalChunks', totalChunks); 
try { const response = await fetch('your_upload_url', { method: 'POST', body: formData }); 
if (response.ok) { console.log(`切片 ${i} 上传成功`); } 
else { console.error(`切片 ${i} 上传失败`); } } 
catch (error) { console.error('切片上传出错:', error); } } });
const express = require('express'); 
const app = express(); 
const fs = require('fs'); 
const path = require('path'); 
app.use(express.raw({ type: '*/*' })); 
app.post('/your_upload_url', (req, res) => { 
const chunkIndex = parseInt(req.query.chunkIndex); 
const totalChunks = parseInt(req.query.totalChunks); 
const chunk = req.body; 
const tempDir = path.join(__dirname, 'temp'); 
if (!fs.existsSync(tempDir)) { 
fs.mkdirSync(tempDir); } 
const chunkPath = path.join(tempDir, `chunk_${chunkIndex}`); 
fs.writeFileSync(chunkPath, chunk); 
if (chunkIndex === totalChunks - 1) { 
// 所有切片上传完成,合并切片 
const mergedFilePath = path.join(__dirname, 'merged_file'); 
const writeStream = fs.createWriteStream(mergedFilePath); 
for (let i = 0; i < totalChunks; i++) { 
const chunkPath = path.join(tempDir, `chunk_${i}`); 
const readStream = fs.createReadStream(chunkPath); 
readStream.pipe(writeStream, { end: false }); 
readStream.on('end', () => { fs.unlinkSync(chunkPath); }); } 
writeStream.on('finish', () => { console.log('文件合并完成'); 
res.status(200).send('文件上传并合并成功'); }); } 
else { res.status(200).send('切片上传成功'); } }); 
const port = 3000; 
app.listen(port, () => { console.log(`服务器运行在端口 ${port}`); });

三、超大文件数据接收优化方式

场景:你向后端发了个数据请求,返回了1000个G的内容,并且此刻后端人员在厨房手持菜刀(明显打不过),怎么办?很急!

1. 流式处理数据

流式处理允许前端在数据传输过程中就开始处理数据,而不是等待整个响应下载完成,这样可以减少内存占用。

async function streamData() { 
const response = await fetch('your_api_endpoint'); 
const reader = response.body.getReader(); 
while (true) { 
const { done, value } = await reader.read(); 
if (done) break; // 这里可以对每一块数据进行处理,例如解析、存储等 
console.log('Received chunk:', value); } } 
streamData();
2. 利用 IndexedDB 存储数据

由于前端内存有限,无法一次性容纳 1000GB 数据,而 IndexedDB 是一种基于浏览器的数据库,可以存储大量数据。可以将接收到的数据分块存储到 IndexedDB 中。

async function saveToIndexedDB(dataChunk, chunkIndex) { 
return new Promise((resolve, reject) => { 
const request = indexedDB.open('LargeDataDB', 1); 

request.onupgradeneeded = function(event) { 
const db = event.target.result; 
const objectStore = db.createObjectStore('chunks', { keyPath: 'id' }); }; 

request.onsuccess = function(event) { 
const db = event.target.result; 
const transaction = db.transaction(['chunks'], 'readwrite'); 
const objectStore = transaction.objectStore('chunks'); 
const addRequest = objectStore.add({ id: chunkIndex, data: dataChunk }); 

addRequest.onsuccess = function() { resolve(); }; 
addRequest.onerror = function() { reject(new Error('Failed to save chunk to IndexedDB')); }; };

request.onerror = function() { reject(new Error('Failed to open IndexedDB')); }; }); } 

async function receiveAndStoreData() { 
const response = await fetch('your_api_endpoint'); 
const reader = response.body.getReader(); 
let chunkIndex = 0; 

while (true) { 
const { done, value } = await reader.read(); 
if (done) break; 
await saveToIndexedDB(value, chunkIndex); chunkIndex++; } } 

receiveAndStoreData();
3. 数据压缩处理(使用 pako 库进行 Gzip 解压缩)

如果服务器返回的数据可以压缩,前端可以在接收后进行解压缩。这样在传输过程中可以减少数据量,提高传输效率。不过前端解压缩也会消耗一定的 CPU 资源。

import pako from 'pako'; 
async function receiveAndDecompressData() { 
const response = await fetch('your_api_endpoint'); 
const reader = response.body.getReader(); 
let buffer = []; 
while (true) { 
const { done, value } = await reader.read(); 
if (done) break; buffer.push(value); } 
const concatenatedBuffer = new Uint8Array([].concat(...buffer)); 
const decompressedData = pako.inflate(concatenatedBuffer); 
console.log('Decompressed data:', decompressedData); } 
receiveAndDecompressData();