1
我非常相信运气,我发现,我越努力,就越幸运。——托马斯·杰斐逊

大家好,我是柒八九。一个专注于前端开发技术/RustAI应用知识分享Coder

此篇文章所涉及到的技术有

  1. Blob
  2. ArrayBuffer
  3. FileReader
  4. FormData
  5. axios-onUploadProgress
  6. 断点续传

因为,行文字数所限,有些概念可能会一带而过亦或者提供对应的学习资料。请大家酌情观看。


前言

之前不是说过,最近公司有一个AI项目,要做一个文档问答的AI产品。

对于一款AI产品,我们肯定少不了前后端数据交互,这个我们在写一个类ChatGPT应用,前后端数据交互有哪几种中有过介绍。

然后呢,针对文档上传呢,我们也在文件上传 = 拖拽 + 多文件 + 文件夹讲解了,如何更优雅的进行文件上传

随后呢,我们又在Rust 赋能前端 -- 写一个 File 转 Img 的功能AI 赋能前端 -- 文本内容概要生成解释了,如何将文件内容抽离,并通过AI对其Summary处理,并利用Rust将其绘制成Svg作为文件的头图展示。

其实呢,通过上述的步骤,一个功能完备的AI+Summary+头图展示的需求算是大功告成了。

世界上没有永远不变的事物,唯一不变的是变化本身。

这不,可爱的产品又提出了一个大胆的想法。我们要支持大文件上传大文件下载。那这个大文件可以多大呢。

  • 她说:越大越好。
  • 我问:那该多大呢?
  • 她说:最好是50M开外,上不封顶,因为我们后期要支持音/视频
  • 我问:上不封顶?这谁受的了。给一个限制吧。最大多少!
  • 她说:那就暂时支持50M
  • 我闭嘴了,毕竟人家给让步了,起码不是上不封顶了。

既然,需求有变更(因为之前的需求只允许上传<5M的文件),那么我们就需要兵来将挡,水来土掩。

搞呗!雄起!

既然,我们需要对之前的需求做+法,那么我们就需要在之前的基础上做改造。有几个关键点 - 大文件+ 上传

我翻开技术的文档,每页都写着文件上传,但字缝里却都写着分片两个字 -- 摘抄自牛马的《如何成为一个合格的"我"》

所以,今天我们就来聊聊这个话题 - 大文件分片上传和分片下载(因为该技术是需要前后端同学共同努力,但是由于字数限制,我们这篇文章只讨论前端范围的逻辑)

文件分片上传和下载通过将大文件拆分成多个小片段并利用断点续传,使文件传输更加可靠和高效。

好了,天不早了,干点正事哇。

我们能所学到的知识点

  1. 文件流操作
  2. 文件分片
  3. 分片上传
  4. 分片下载
  5. 断点续传

1. 文件流操作

在软件开发中,我们会看到各种形形色色的文件/资源(pdf/word/音频/视频),其实它们归根到底就是不同数据格式的以满足自身规则的情况下展示。说的更浅显易懂点,它们都是数据,并且最终都会以二进制形式展示。也就是说,我们的各种操作都是在处理数据。那么处理文件也是如此。

在前端开发中,文件流操作允许我们通过数据流来处理文件,执行诸如读取写入删除文件的操作。

在前端开发中,文件可以作为数据流来处理。数据流是从一个源到另一个目的地传输的数据序列

Blob 对象和 ArrayBuffer:处理二进制数据

在前端处理二进制数据时,有两个对象是绕不开的。

  • Blob 对象Binary Large Object)对象是一种可以在 JavaScript 中存储大量二进制数据的对象。可以通过构造函数创建 Blob 对象,或者通过其他 API(如 FormData 对象)生成。
  • ArrayBufferJavaScript 中的另一种对象类型,它们可以存储二进制数据ArrayBuffers 通常用于较低级别的操作,如直接操作和处理二进制数据。

使用 FileReader 读取文件

FileReader 是一个前端浏览器 API,允许我们异步读取文件内容并将其转换为可用的数据格式,如文本或二进制数据。

它提供了如 readAsText()") 和 readAsArrayBuffer()") 等方法,可以根据我们的需要进行选择。

使用案例

下面,我们来用一个例子来简单说明一下FileReader的使用方式。

import { ChangeEvent, useState } from 'react';

function FileInput() {
  // 读取文件内容到 ArrayBuffer
  function readFileToArrayBuffer(file: File): Promise<ArrayBuffer> {
    return new Promise((resolve, reject) => {
      const reader = new FileReader();

      // 注册文件读取完成后的回调函数
      reader.onload = function (event) {
        const arrayBuffer = event.target?.result as ArrayBuffer;
        resolve(arrayBuffer);
      };

      // 读取文件内容到 ArrayBuffer
      reader.readAsArrayBuffer(file);

      // 处理文件读取错误
      reader.onerror = function (error) {
        reject(error);
      };
    });
  }

 
  // 处理文件选择事件
  function handleFileChange(event: ChangeEvent<HTMLInputElement>) {
    const file = event.target.files?.[0]; // 获取选择的文件

    if (file) {
      readFileToArrayBuffer(file)
        .then((arrayBuffer) => {
         // 此处已经能拿到文件的`arrayBuffer`信息,也就是Blob数据
        })
        .catch((error) => {
          console.error('文件读取失败:', error);
        });
    } 
  }

  return (
    <div>
      <input type="file" onChange={handleFileChange} />
    </div>
  );
}

export default FileInput;

在上面的代码中,我创建了一个名为 FileInput 的函数组件。该组件有一个文件选择框。当用户选择一个文件时,文件内容会使用 FileReader 读取到 ArrayBuffer。然后在对应的回调中就可以处理对应的Blob信息了。

当然,我们这里是利用FileReaderreadAsArrayBuffer将文件内容转换成(ArrayBuffer)。这样我们可以更好的进行分片处理(这个后面会讲)。其实,我们还可以使用例如readAsDataURL()将资源变成一个url,然后在页面中显示。

具体的显示方法取决于文件类型。例如,可以将文本文件直接显示在文本框或区域中,图片文件使用 img 标签显示,音频和视频文件使用 audiovideo 标签显示。通过在前端页面上显示文件流,可以在线预览和查看文件内容。

FileReader 工作流程和事件触发

  1. 初始化 FileReader 对象

    const reader = new FileReader();
  2. 设置 onload 事件处理程序

    reader.onload = function(event) {
      // 读取操作成功完成时执行的代码
      const result = event.target.result;
      console.log('文件内容:', result);
    };
  3. 调用读取方法

    const file = ...; // 获取的文件对象
    reader.readAsArrayBuffer(file); // 或者使用其他读取方法

当调用 readAsArrayBuffer, readAsDataURLreadAsText 方法时,FileReader 会开始读取文件。当读取操作成功完成后,onload 事件会被触发,并且 FileReader 对象的 result 属性包含了读取到的数据。

事件顺序

FileReader 触发的事件按以下顺序发生:

  1. onloadstart:读取操作开始时触发。
  2. onprogress:读取过程中持续触发,可以用于显示进度信息。
  3. onload:读取操作成功完成时触发。
  4. onloadend:读取操作完成(无论成功还是失败)时触发。
  5. onerror:读取操作失败时触发。
  6. onabort:读取操作被中止时触发。

下面的示例代码展示了如何在读取文件时显示读取进度:

document.getElementById('fileInput').addEventListener('change', function(event) {
      const file = event.target.files[0];
      const reader = new FileReader();

      // 进度事件
      reader.onprogress = function(e) {
        if (e.lengthComputable) {
          const percentLoaded = (e.loaded / e.total) * 100;
          document.getElementById('progressBar').value = percentLoaded;
        }
      };

      // 定义 onload 事件处理程序
      reader.onload = function(e) {
        const content = e.target.result;
        document.getElementById('fileContent').textContent = content;
      };

      // 读取文件为文本
      reader.readAsText(file);
    });

2. 文件分片

其实呢,无论是分片上传分片下载最核心的点就是需要对文件资源进行分片处理。

并且有很多现成的库或者框架都会为我们来实现该部分,但是呢本着探索知识的本质,我们还是对其内部比较核心的部分做一次讲解。

在前端范围内,我们使用JavaScript中的File API获取文件对象,并使用Blob.prototype.slice()")方法将文件切成多个分片,从而实现分片上传

让我们将第一节中的代码在稍加改造。

改造readFileToArrayBuffer

/**
 * 将文件读取为 ArrayBuffer 并分片
 * @param file 要读取的文件
 * @returns 返回包含分片 Blob 数组的 Promise
 */
function readFileToArrayBuffer(file: File): Promise<{ chunkList: Blob[] }> {
  return new Promise((resolve, reject) => {
    let currentChunk = 0; // 当前分片的索引
    const chunkSize = 1024 * 1024; // 设置分片大小为 1MB
    const chunks = Math.ceil(file.size / chunkSize); // 计算总分片数
    const fileReader = new FileReader(); // 创建 FileReader 对象
    const chunkList: Blob[] = []; // 存储分片的数组

    // 文件读取完成后的回调函数
    fileReader.onload = function (e) {
      currentChunk++; // 增加当前分片索引

      // 如果还有分片需要读取,继续读取下一个分片
      if (currentChunk < chunks) {
        loadNextChunk();
      } else {
        // 所有分片读取完成,resolve Promise 并返回分片数组
        resolve({ chunkList });
      }
    };

    // 文件读取出错时的回调函数
    fileReader.onerror = function (e) {
      console.warn('读取文件出错', e);
      reject(e); // reject Promise 并传递错误信息
    };

    // 读取下一个分片的函数
    function loadNextChunk() {
      const start = currentChunk * chunkSize; // 当前分片的起始字节
      const end = start + chunkSize >= file.size ? file.size : start + chunkSize; // 当前分片的结束字节

      const chunk = file.slice(start, end); // 切割文件得到当前分片
      chunkList.push(chunk); // 将当前分片添加到分片数组中
      fileReader.readAsArrayBuffer(chunk); // 读取当前分片为 ArrayBuffer
    }

    // 开始读取第一个分片
    loadNextChunk();
  });
}
当然,在进行文件上传时,有时候需要用到md5加密等。计算文件的md5是为了检查上传到服务器的文件是否与用户所传的文件一致,由于行文限制,这里我们不做介绍。(其实在分片完成,就可以执行加密处理)

然后,我们就可以在readFileToArrayBuffer的调用处,获取到对应文件的分片信息。

function handleFileChange(event: ChangeEvent<HTMLInputElement>) {
    const file = event.target.files?.[0]; // 获取选择的文件
    if (file) {
      readFileToArrayBuffer(file)
        .then(({ chunkList }) => {
          for (let i = 0; i < chunkList.length; i++) {
            const chunk = chunkList[i];
            console.log('chunk', chunk);
          }
        })
        .catch((error) => {
          console.error('文件读取失败:', error);
        });
    }
  }

然后,我们就可以在for循环中执行后续的操作了。


3. 分片上传

大文件上传可能会很慢、效率低并且不可靠,但有一些解决方案可以改善上传过程的性能和稳定性。

传统上传 VS 分片上传

传统上传方法的问题分片上传的优点
大文件上传耗时长,容易导致超时。将大文件拆分成较小的分片,更快更可靠地上传。
占用服务器和网络带宽资源,可能影响其他用户的访问速度。监控并显示上传进度,提高用户体验。
如果上传中断,需要重新上传整个文件,效率低下。充分利用浏览器的并发上传能力,减轻服务器负载。
难以显示和控制上传进度。实现断点续传功能,避免重新上传已上传的分片。

代码实现

在前一节中,我们不是已经能够获取到chunklist信息了吗。此时,我们就可以在for循环中执行上传操作。

而实现前端分片上传的主要步骤如下

  1. 通过FormData对象和AJAXFetch API发送分片到服务器。
  2. 服务器接收分片并暂存,所有分片接收完成后合并为完整文件。
  3. 客户端可以监听上传进度事件并在进度条或提示中显示进度。

下面,我们主要讲讲前端范围的逻辑实现。

readFileToArrayBuffer(file)
    .then(async ({ chunkList }) => {
      for (let i = 0; i < chunkList.length; i++) {
        const chunk = chunkList[i];
        await upChunk(chunk, i);
      }
    })
    .catch((error) => {
      console.error('文件读取失败:', error);
    });

我们将chunk上传的逻辑,封装成一个函数upChunk,其主要的逻辑如下:

/**
 * 异步上传文件分片
 *
 * @param chunk - 当前需要上传的文件分片 (Blob 对象)
 * @param index - 当前文件分片的索引
 */
const upChunk = async (chunk: Blob, index: number) => {
  const formData = new FormData();
  // 上传的唯一标识符,用于区分不同的文件上传,前后端约定的值
  formData.append('uploadId', 'front789');
  formData.append('partIndex', index.toString());
  formData.append('partFile', chunk);

  try {
    // 发送 POST 请求上传当前分片
    await axios.post('上传地址', formData, {
      headers: { 'Content-Type': 'multipart/form-data' },
      onUploadProgress: (progressEvent) => {
        // 检查进度事件的总大小是否存在
        if (progressEvent.total) {
          // 计算已上传的百分比
          const percentCompleted = Math.round((progressEvent.loaded / progressEvent.total) * 100);
          // 在这里添加更新进度条的逻辑
        }
      },
    });
  } catch (error) {
    // 如果上传失败,打印错误信息
    console.error(`Chunk ${index + 1} upload failed:`, error);
  }
  // 打印分片上传完成的信息
  console.log(`上传分片 ${index}完成`);
};

当我们把所有的chunklist都上传成功后,后端服务会将上传的分片组装成完整的文件。

我们使用了axios_onUploadProgress来处理文件上传进度问题,然后我们可以在特定的位置改变一下state的值,这样就可以实时显示文档上传进度了。


4. 分片下载

传统文件下载 VS 文件分片下载

文件分片下载是一种通过将大文件拆分成较小的片段(分片)并同时下载它们来提高文件下载效率的技术。
问题/技术传统文件下载文件分片下载
长时间等待用户可能需要等待很长时间才能开始使用大文件只需下载第一个分片,客户端就可以开始使用文件
网络拥堵如果网络带宽被大文件下载占用,其他用户可能会遇到下载速度慢的问题可以使用多个并行请求来下载分片,充分利用带宽并提高整体下载速度
难以恢复下载如果网络故障或用户中断,整个文件必须重新下载如果下载被中断,只需重新下载未完成的分片,而不是整个文件
下载效率下载速度较慢,特别是在网络不稳定或速度较慢的情况下通过将大文件拆分成较小的片段并同时下载,提高文件下载效率
并行下载不支持支持,可以使用多个并行请求来下载分片
下载管理整个文件作为一个整体进行下载每个分片可以单独管理和下载,提供更好的灵活性

分片下载的实现步骤

实现客户端分片下载的基本解决方案如下:

  1. 服务器端将大文件切割成多个分片,并为每个分片生成唯一标识符。
  2. 客户端发送请求以获取分片列表并开始下载第一个分片。
  3. 在下载过程中,客户端基于分片列表发起并发请求以下载其他分片,并逐渐拼接和合并下载的数据。
  4. 当所有分片下载完成后,客户端将下载的数据合并为一个完整的文件。

示例代码

async function downloadable() {
  try {
    // 发送文件下载请求,获取文件的总大小和总分片数
    const response = await fetch('/download', {
      method: 'GET',
      headers: {
        'Content-Type': 'application/json',
      },
    });

    // 解析响应数据
    const data = await response.json();
    const totalSize = data.totalSize;
    const totalChunks = data.totalChunks;

    // 初始化变量
    let downloadedChunks = 0;
    const chunks: Blob[] = [];

    // 下载每个分片
    for (let chunkNumber = 0; chunkNumber < totalChunks; chunkNumber++) {
      try {
        const chunkResponse = await fetch(`/download/${chunkNumber}`, {
          method: 'GET',
        });

        const chunk = await chunkResponse.blob();
        downloadedChunks++;
        chunks.push(chunk);

        // 当所有分片下载完成时
        if (downloadedChunks === totalChunks) {
          // 合并分片
          const mergedBlob = new Blob(chunks);

          // 创建对象 URL 以生成下载链接
          const downloadUrl = window.URL.createObjectURL(mergedBlob);

          // 创建一个 <a> 元素并设置属性
          const link = document.createElement('a');
          link.href = downloadUrl;
          link.setAttribute('download', 'file.txt');

          // 模拟点击下载
          link.click();

          // 释放资源
          window.URL.revokeObjectURL(downloadUrl);
        }
      } catch (chunkError) {
        console.error(`Chunk ${chunkNumber} download failed:`, chunkError);
      }
    }
  } catch (error) {
    console.error('文件下载失败:', error);
  }
}

我们先使用 Blob 对象创建一个总对象 URL,用于生成下载连接。然后创建一个标签,并将 href 属性设置为刚创建的对象 URL。继续设置标签的属性以下载文件名,这样在点击时可以自动下载文件。


5. 断点续传

在前端,可以使用localStoragesessionStorage存储已上传分片的信息,包括已上传的分片索引和分片大小。

每次上传前,检查本地存储中是否存在已上传分片信息。如果存在,则从断点处继续上传。

在后端,可以使用临时文件夹或数据库记录已接收的分片信息,包括已上传的分片索引和分片大小。

上传完成前,保存上传状态,以便在上传中断时能够恢复上传进度。

import axios from 'axios';
import React, { useState, useEffect, ChangeEvent } from 'react';

function FileUp() {
  const [file, setFile] = useState(null); // 本地上传的文件
  const [uploadedChunks, setUploadedChunks] = useState([]); // 已上传的分片列表
  const [uploading, setUploading] = useState(false); // 上传是否进行中

  function handleFileChange(event: ChangeEvent<HTMLInputElement>) {
    setFile(event.target.files?.[0]);
  }

  // 处理文件选择事件
  async function upload() {
    if (!file) {
      alert('请选择要上传的文件!');
      return;
    }
    const chunkSize = 1024 * 1024; // 1MB
    const totalChunks = Math.ceil(file.size / chunkSize);

    let start = 0;
    let end = Math.min(chunkSize, file.size);
    setUploading(true);

    for (let i = 0; i < totalChunks; i++) {
      const chunk = file.slice(start, end);
      const uploadedChunkIndex = uploadedChunks.indexOf(i);

      if (uploadedChunkIndex === -1) {
        try {
          const response = await upChunk(chunk, i);
          setUploadedChunks((prevChunks) => [...prevChunks, i]);

          // 将已上传的分片列表保存到本地存储
          localStorage.setItem('uploadedChunks', JSON.stringify(uploadedChunks));
        } catch (error) {
          console.error(error); // 处理错误
        }
      }

      start = end;
      end = Math.min(start + chunkSize, file.size);
    }

    setUploading(false);

    // 上传完成,清除本地存储中的分片信息
    localStorage.removeItem('uploadedChunks');
  }

  const upChunk = async (chunk: Blob, index: number) => {
    const formData = new FormData();
    // 这应该是一个随机值,用于标识当前上传的文件,这是和后端做约定的值
    formData.append('uploadId', 'front789');
    formData.append('partIndex', index.toString());
    formData.append('partFile', chunk);

    try {
      return await axios.post(`https://Front789/api/uploadChunk`, formData, {
        headers: { 'Content-Type': 'multipart/form-data' },
      });
    } catch (error) {
      console.error(`Chunk ${index + 1} upload failed:`, error);
    }

    console.log(`上传分片 ${index}完成`);
  };

  useEffect(() => {
    const storedUploadedChunks = localStorage.getItem('uploadedChunks');

    if (storedUploadedChunks) {
      setUploadedChunks(JSON.parse(storedUploadedChunks));
    }
  }, []);

  return (
    <div>
      <input type="file" onChange={handleFileChange} />
      <button onClick={upload} disabled={uploading}>
        {uploading ? `上传中..` : '上传'}
      </button>
    </div>
  );
}

export default FileUp;

FileUp函数组件使用ReactuseState钩子创建uploadedChunks状态来保存已上传的分片索引数组。

当用户选择要上传的文件时,handleFileChange()函数会更file状态。

upChunk()函数将分片发送到服务器并返回一个Promise对象来处理响应。

upload()函数通过获取总分片数并将uploading状态设置为true来禁用上传按钮,从断点处继续上传。它遍历所有分片并检查分片索引是否已包含在uploadedChunks数组中。如果没有,该函数会上传分片并将已上传的分片索引添加到uploadedChunks数组中。然后使用localStorage保存已上传的分片信息。最后,上传完成后,函数会将uploading状态设置为false并清除本地存储中的分片信息。

在上传大文件时,需要考虑服务器的处理能力和存储空间,以及安全问题。同时,避免并发上传相同文件以确保续传的准确性。可以使用唯一的文件标识符或用户会话标识符来区分。


后记

分享是一种态度

全文完,既然看到这里了,如果觉得不错,随手点个赞和“在看”吧。

本文由mdnice多平台发布


前端柒八九
18 声望3 粉丝