
文件传输是一个常见的需求。对于大文件的下载和上传,直接使用传统的方式可能会遇到性能和用户体验方面的问题。 幸运的是,前端技术提供了一些高效的解决方案:文件流操作和切片下载与上传。本文将深入探讨这些技术,帮助你理解它们的原理和实现方法,以优化文件传输效率和提升用户体验。
在前端开发中,文件流操作是指通过数据流的方式处理文件,对文件进行读取、写入和展示等操作。下面详细介绍了前端文件流操作的几个基本概念和技术。
数据流是指连续的数据序列,可以从一个源传输到另一个目的地。在前端开发中,文件可以被看作数据流的一种形式,可以通过数据流的方式进行处理。文件处理涉及读取和写入文件的操作,包括读取文件的内容、写入数据到文件,以及对文件进行删除、重命名等操作。
在前端处理文件时,经常需要处理二进制数据。Blob(Binary Large Object)对象是用来表示二进制数据的一个接口,可以存储大量的二进制数据。Blob 对象可以通过构造函数进行创建,也可以通过其他 API 生成,例如通过 FormData 对象获取上传的文件。而 ArrayBuffer 是 JavaScript 中的一个对象类型,用于表示一个通用的、固定长度的二进制数据缓冲区。我们可以通过 ArrayBuffer 来操作和处理文件的二进制数据。 
代码如下:
import React, { useState } from 'react';
function FileInput() {
  const [fileContent, setFileContent] = useState('');
  // 读取文件内容到ArrayBuffer
  function readFileToArrayBuffer(file) {
    return new Promise((resolve, reject) => {
      const reader = new FileReader();
      // 注册文件读取完成后的回调函数
      reader.onload = function(event) {
        const arrayBuffer = event.target.result;
        resolve(arrayBuffer);
      };
      // 读取文件内容到ArrayBuffer
      reader.readAsArrayBuffer(file);
    });
  }
  // 将ArrayBuffer转为十六进制字符串
  function arrayBufferToHexString(arrayBuffer) {
    const uint8Array = new Uint8Array(arrayBuffer);
    let hexString = '';
    for (let i = 0; i < uint8Array.length; i++) {
      const hex = uint8Array[i].toString(16).padStart(2, '0');
      hexString += hex;
    }
    return hexString;
  }
  // 处理文件选择事件
  function handleFileChange(event) {
    const file = event.target.files[0];  // 获取选中的文件
    if (file) {
      readFileToArrayBuffer(file)
        .then(arrayBuffer => {
          const hexString = arrayBufferToHexString(arrayBuffer);
          setFileContent(hexString);
        })
        .catch(error => {
          console.error('文件读取失败:', error);
        });
    } else {
      setFileContent('请选择一个文件');
    }
  }
  return (
    <div>
      <input type="file" onChange={handleFileChange} />
      <div>
        <h4>文件内容:</h4>
        <pre>{fileContent}</pre>
      </div>
    </div>
  );
}
export default FileInput;
上面代码里,我们创建了一个名为 FileInput 的函数式组件。该组件包含一个文件选择框和一个用于显示文件内容的 <pre> 元素。当用户选择文件时,通过 FileReader 将文件内容读取为 ArrayBuffer,然后将 ArrayBuffer 转换为十六进制字符串,并将结果显示在页面上。
FileReader 是前端浏览器提供的一个 API,用于读取文件内容。通过 FileReader,我们可以通过异步方式读取文件,并将文件内容转换为可用的数据形式,比如文本数据或二进制数据。FileReader 提供了一些读取文件的方法,例如 readAsText()、readAsArrayBuffer() 等,可以根据需要选择合适的方法来读取文件内容。
一旦我们成功地读取了文件的内容,就可以将文件流展示在前端页面上。具体的展示方式取决于文件的类型。例如,对于文本文件,可以直接将其内容显示在页面的文本框或区域中;对于图片文件,可以使用 <img> 标签展示图片;对于音视频文件,可以使用 <video> 或 <audio> 标签来播放。通过将文件流展示在前端页面上,我们可以实现在线预览和查看文件内容的功能。
好的,这一部分就基本介绍完毕,总结一下。前端文件操作流是处理大型文件的一种常见方式,他可以通过数据流的方式对文件进行操作。Blob对象 和 ArrayBuffer是处理二进制数据的重要工具。而FileReader则是读取文件内容的的关键组件。通过这些技术,我们可以方便的在前端页面上进行操作或者文件展示。
这一步就进入到我们今天文章主题了,先来主要的看下流程
graph LR
A(开始) --> B{选择文件}
B -- 用户选择文件 --> C[切割文件为多个切片]
C --> D{上传切片}
D -- 上传完成 --> E[合并切片为完整文件]
E -- 文件合并完成 --> F(上传成功)
D -- 上传中断 --> G{保存上传进度}
G -- 上传恢复 --> D
G -- 取消上传 --> H(上传取消)
文件切片下载是一种提升文件下载效率的技术,通过将大文件分割成多个小片段(切片),并使用多个并发请求同时下载这些切片,从而加快整体下载速度。
传统的文件下载方式对于大文件来说存在性能问题。当用户请求下载一个大文件时,服务器需要将整个文件发送给客户端。这会导致以下几个问题:

文件切片下载通过将文件分割成多个小片段,每个片段大小通常在几百KB到几MB之间。然后客户端通过多个并发请求同时下载这些片段。这样做的好处是:
切片上传代码示例:
const [selectedFile, setSelectedFile] = useState(null); 
const [progress, setProgress] = useState(0);
 // 处理文件选择事件
 function handleFileChange(event) {
   setSelectedFile(event.target.files[0]);
 }
 // 处理文件上传事件
 function handleFileUpload() {
   if (selectedFile) {
     // 计算切片数量和每个切片的大小
     const fileSize = selectedFile.size;
     const chunkSize = 1024 * 1024; // 设置切片大小为1MB
     const totalChunks = Math.ceil(fileSize / chunkSize);
     // 创建FormData对象,并添加文件信息
     const formData = new FormData();
     formData.append('file', selectedFile);
     formData.append('totalChunks', totalChunks);
     // 循环上传切片
     for (let chunkNumber = 0; chunkNumber < totalChunks; chunkNumber++) {
       const start = chunkNumber * chunkSize;
       const end = Math.min(start + chunkSize, fileSize);
       const chunk = selectedFile.slice(start, end);
       formData.append(`chunk-${chunkNumber}`, chunk, selectedFile.name);
     }
     // 发起文件上传请求
     axios.post('/upload', formData, {
       onUploadProgress: progressEvent => {
         const progress = Math.round((progressEvent.loaded / progressEvent.total) * 100);
         setProgress(progress);
       }
     })
       .then(response => {
         console.log('文件上传成功:', response.data);
       })
       .catch(error => {
         console.error('文件上传失败:', error);
       });
   }
 }
当涉及到切片上传和下载时,前端使用的技术通常是基于前端库或框架提供的文件处理功能,结合后端服务实现。
上面代码里我们提到了文件如何切片上传。
handleFileChange 函数处理文件选择事件,将选择的文件保存在 selectedFile 状态中。handleFileUpload 函数处理文件上传事件。handleFileUpload 函数中,计算切片数量和每个切片的大小,并创建一个 FormData 对象用于存储文件信息和切片数据。实现客户端切片下载的基本方案如下:
代码示例:
function downloadFile() {
  // 发起文件下载请求
  fetch('/download', {
    method: 'GET',
    headers: {
      'Content-Type': 'application/json',
    },
  })
    .then(response => response.json())
    .then(data => {
      const totalSize = data.totalSize;
      const totalChunks = data.totalChunks;
      let downloadedChunks = 0;
      let chunks = [];
      // 下载每个切片
      for (let chunkNumber = 0; chunkNumber < totalChunks; chunkNumber++) {
        fetch(`/download/${chunkNumber}`, {
          method: 'GET',
        })
          .then(response => response.blob())
          .then(chunk => {
            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(error => {
      console.error('文件下载失败:', error);
    });
}
我们看下代码,首先使用BLOB对象创建一共对象URL,用于生成下载连接,然后创建a标签并且设置href的属性为刚刚创建的对象URL,继续设置a标签的download属性是文件名,方便点击的时候自动下载文件。
为了显示下载进度和完成状态,可以在客户端实现以下功能:
这里我们可以继续接着切片上传代码示例里的继续写。
代码示例:
  // 处理文件下载事件
  function handleFileDownload() {
    axios.get('/download', {
      responseType: 'blob',
      onDownloadProgress: progressEvent => {
        const progress = Math.round((progressEvent.loaded / progressEvent.total) * 100);
        setProgress(progress);
      }
    })
      .then(response => {
        // 创建一个临时的URL对象用于下载
        const url = window.URL.createObjectURL(new Blob([response.data]));
        const link = document.createElement('a');
        link.href = url;
        link.setAttribute('download', 'file.txt');
        document.body.appendChild(link);
        link.click();
        document.body.removeChild(link);
      })
      .catch(error => {
        console.error('文件下载失败:', error);
      });
  }
  
  
  <button onClick={handleFileDownload}>下载文件</button>
  <div>进度:{progress}%</div>
handleFileDownload 函数处理文件下载事件。handleFileDownload 函数中,使用 axios 库发起文件下载请求,并设置 responseType: 'blob' 表示返回二进制数据。onDownloadProgress 属性获取下载进度,并更新进度条的显示。<a> 元素模拟点击下载。- 使用 JavaScript 的 `File API` 获取文件对象,并使用 `Blob.prototype.slice()` 方法将文件切割为多个切片。
FormData 对象将切片数据通过 AJAX 或 Fetch API 发送到服务器。const [file, setFile] = useState(null);  //用来存放我本地上传的文件
const chunkSize = 1024 * 1024; // 1MB 切片大小
  const upload = () => {
    if (!file) {
      alert("请选择要上传的文件!");
      return;
    }
    const chunkSize = 1024 * 1024; // 1MB
    let start = 0;
    let end = Math.min(chunkSize, file.size);
    while (start < file.size) {
      const chunk = file.slice(start, end);
      
      // 创建FormData对象
      const formData = new FormData();
      formData.append('file', chunk);
      // 发送切片到服务器
      fetch('上传接口xxxx', {
        method: 'POST',
        body: formData
      })
      .then(response => response.json())
      .then(data => {
        console.log(data);
        // 处理响应结果
      })
      .catch(error => {
        console.error(error);
        // 处理错误
      });
      start = end;
      end = Math.min(start + chunkSize, file.size);
    }
  };
  
 return (
    <div>
      <input type="file" onChange={handleFileChange} />
      <button onClick={upload}>上传</button>
    </div>
  );
}
在上面的代码中,创建了一个名为Upload的函数组件。它使用了 React 的useState钩子来管理选中的文件。
通过onChange事件监听文件输入框的变化,并在handleFileChange函数中获取选择的文件,并更新file状态。
点击“上传”按钮时,调用upload函数。它与之前的示例代码类似,将文件切割为多个大小相等的切片,并使用FormData对象和fetch函数发送切片数据到服务器。
localStorage 或 sessionStorage 来存储已上传的切片信息,包括已上传的切片索引、切片大小等。import React, { useState, useRef, useEffect } from 'react';
function Upload() {
  const [file, setFile] = useState(null);
  const [uploadedChunks, setUploadedChunks] = useState([]);
  const [uploading, setUploading] = useState(false);
  const uploadRequestRef = useRef();
  const handleFileChange = (event) => {
    const selectedFile = event.target.files[0];
    setFile(selectedFile);
  };
  const uploadChunk = (chunk) => {
    // 创建FormData对象
    const formData = new FormData();
    formData.append('file', chunk);
    // 发送切片到服务器
    return fetch('your-upload-url', {
      method: 'POST',
      body: formData
    })
    .then(response => response.json())
    .then(data => {
      console.log(data);
      // 处理响应结果
      return data;
    });
  };
  const upload = async () => {
    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 uploadChunk(chunk);
          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');
  };
  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>
  );
}
首先,使用useState钩子创建了一个uploadedChunks状态来保存已上传的切片索引数组。初始值为空数组。
然后,我们使用useRef钩子创建了一个uploadRequestRef引用,用于存储当前的上传请求。
在handleFileChange函数中,我们更新了file状态以选择要上传的文件。
在uploadChunk函数中,我们发送切片到服务器,并返回一个Promise对象来处理响应结果。
在upload函数中,我们添加了断点续传的逻辑。首先,我们获取切片的总数,并设置uploading状态为true来禁用上传按钮。
然后,我们使用for循环遍历所有切片。对于每个切片,我们检查uploadedChunks数组中是否已经包含该索引,如果不包含,则进行上传操作。
在上传切片之后,我们将已上传的切片索引添加到uploadedChunks数组,并使用localStorage保存已上传的切片信息。
最后,在上传完毕后,我们将uploading状态设为false,并清除本地存储的切片信息。
在实现大文件上传时要考虑服务器端的处理能力和存储空间,以及安全性问题。同时,为了保障断点续传的准确性,应该尽量避免并发上传相同文件的情况,可以采用文件唯一标识符或用户会话标识符进行区分。
作者:狗头大军之江苏分军 https://juejin.cn/post/7255189826226602045