彻底掌握前端文件上传

文件上传 是业务上一个很常见的需求功能,但它涵盖的知识点其实很多。笔者结合自己的工作积累,总结了一篇关于文件上传的常见场景和实现,或许会对正在阅读的你有所帮助。

一、文件上传方式

在 Web 网站上传本地文件的方式有三种:

  1. 选择上传,点击唤起 浏览器文件选择控件 选择本地文件进行上传;
  2. 拖拽上传,将需要上传的文件拖拽到 页面的上传区域 进行上传;
  3. 粘贴上传(剪贴板),复制需要上传的文件,在页面的上传区域 使用快捷键(如:ctrl + v)粘贴进行上传。

1、选择上传

我们需要有一个 <input type="file" /> 元素,通过触发其 click() 事件,唤起浏览器文件选择控件,并选择文件进行上传。其中 input 元素可以是一个事先写好的占位元素,或是 JS 动态创建元素。

jsx 复制代码
import React, { useRef } from "react";

const FileUpload = () => {
  const fileRef = useRef(null);

  const handleChangeFile = event => {
    const files = event.target.files;
    console.log("files: ", Array.from(files)); // 拿到 files 文件对象,是一个数组
    event.target.value = ""; // 最好清除一下 value,避免在某些情况下选择文件后,不会触发 change 事件。
  }
  
  return (
    <div>
      <button onClick={() => fileRef.current.click()}>点击上传</button>
      <input style={{ display: "none" }} type="file" ref={fileRef} onChange={handleChangeFile} />
    </div>
  )
}

export default FileUpload;

2、拖拽上传

首先要提供一个 上传区域(容器),用户拖拽文件到这个容器内进行上传。

为了让容器能够接收到拖拽过来的文件,需要借助 HTML5 拖拽 API drag/drop 事件,这里用到两个相关事件:

  1. dragover, 阻止图片在浏览器上的默认行为,我们知道,图片拖拽到页面区域后,默认行为会在新标签页打开预览图片;
  2. drop , 当拖拽后鼠标在上传区域松开时触发的事件,接收拖拽给容器的图片内容,类似于 <input type="file" /> change 事件。
jsx 复制代码
import React, { useRef, useEffect } from "react";

const FileUpload = () => {
  const fileContainer = useRef(null);

  const handleDragover = event => event.preventDefault();

  const handleChangeFile = event => {
    event.preventDefault();
    const files = event.dataTransfer.files; // 拖拽上传是从 event.dataTransfer 中拿到 files 文件对象,是一个数组
    console.log("files: ", Array.from(files)); // 拿到 files 文件对象,是一个数组
  }

  useEffect(() => {
    const container = fileContainer.current;
    container.addEventListener('dragover', handleDragover); // 阻止图片拖拽到页面区域后,默认行为在新标签页打开图片。
    container.addEventListener('drop', handleChangeFile);
    // 做好事件清除工作
    return () => {
      container.removeEventListener('dragover', handleDragover);
      container.removeEventListener('drop', handleChangeFile);
    }
  }, []);
  
  return (
    <div className="file-upload-container" ref={fileContainer}></div>
  )
}

export default FileUpload;

3、粘贴上传

同样也需要提供一个上传区域(容器),用于将复制的图片在 上传区域 内粘贴上传。

监听 paste 事件可以拿到粘贴上的文件对象,具体实现如下:

jsx 复制代码
import React, { useRef, useEffect } from "react";

const FileUpload = () => {
  const containerRef = useRef(null);

  useEffect(() => {
    // 为上传区域绑定 paste 粘贴监听事件
    containerRef.current.addEventListener("paste", handlePasteFile);
    return () => {
      containerRef.current.removeEventListener("paste", handlePasteFile);
    }
  }, []);

  const handlePasteFile = (e) => {
    // 检查剪切板是否包含文件数据
    if (e.clipboardData && e.clipboardData.files && e.clipboardData.files.length > 0) {
      // 获取文件对象
      const files = Array.from(e.clipboardData.files);
      console.log("files: ", Array.from(files)); // 拿到 files 文件对象,是一个数组
    }
  }

  return (
    <div ref={containerRef} className="file-upload-container"></div>
  );
};

export default FileUpload;

二、多文件上传

对于 拖拽上传 ,上传文件的数量取决于你 要拖拽几个文件 到容器内,本身支持多文件上传。

对于 粘贴上传 ,上传的文件数量取决于你 复制了几个文件,本身支持多文件上传。

对于 选择上传 ,会唤起浏览器控件来选择本地文件,默认只支持选择单个文件,若想实现 如配合组合键 command/ctrl 来多选文件,可通过为 <input /> 元素设置 multiple 属性。

jsx 复制代码
<input 
  style={{ display: "none" }} 
  type="file" 
  multiple 
  ref={fileRef} 
  onChange={handleChangeFile} />

当开启多文件上传后,change 事件中拿到的 files 则是一个 length > 1 的对象。

三、文件上传限制

一般,结合业务场景会对用户选择的文件进行以下几种情况限制:

  1. 文件大小限制,比如不能超过 20M;
  2. 文件类型限制,比如只能上传 图片;
  3. 文件数量限制,比如只能上传 5 个。

1、文件信息

在对文件做限制前,我们先来认识一下在 change/drop 事件中 files 上传文件对象包含哪些可用信息。

  1. name, 文件名称,包含文件类型后缀;
  2. type, 文件类型,常见的有 image/xxx, video/xxx, audio/xxx, application/pdf 等;
  3. size, 文件大小,单位是 B(Byte) 字节;

2、文件大小限制

根据文件对象 file.size 属性,比较文件大小是否超过给定限制,如下面给定上传文件的大小不能超过 5 MB。

jsx 复制代码
const handleChangeFile = event => {
  if (event.target.files) {
    const files = Array.from(event.target.files);
    const file = files[0]; // 假设只允许上传单个文件,取出第一个文件
    const limitSize = 1024 * 1024 * 5; // 限制文件大小为 5 MB
    if (file.size > limitSize) {
      console.log("文件大小超过限制");
      // message.info() 提示文件大小超过限制。
    }
  }
  event.target.value = "";
}

3、文件类型限制

做文件类型限制,需要有两层处理:

  1. 第一层限制,考虑 选择文件 场景,打开浏览器的选择文件控件,对不满足类型的文件禁止选择,通过给 <input /> 元素设置 accept 属性指定允许上传的文件类型
    • (但要注意,浏览器文件选择控件的类型是可以被用户修改为「选择所有文件」,所以第二层限制一定要做。);
  2. 第二层限制,考虑 拖拽、粘贴上传 场景,同时兼顾 选择上传,需要在事件中判断文件类型是否满足条件。

下面以 只允许上传图片 限制为例:

jsx 复制代码
// 限制只能上传图片
const imageAccept = "image/png,image/jpg,image/jpeg,image/webp,image/gif";
// 限制可以上传 图片、视频、音频、PDF
const accept = ["image/*", "video/*", "audio/*", "application/pdf"];

// change/drop 事件触发回调,这里以 change 事件为例,判断 file.type 是否满足上传文件类型
const handleChangeFile = event => {
  if (event.target.files) {
    const files = Array.from(event.target.files);
    const file = files[0]; // 假设只允许上传单个文件,取出第一个文件
    if (!imageAccept.includes(file.type)) {
      console.log("文件类型不符合条件");
    }
  }
  event.target.value = "";
}

<input style={{ display: "none" }} type="file" accept={imageAccept} ref={fileRef} onChange={handleChangeFile} />

4、文件数量限制

files 对象是一个伪对象,通过 Array.from 可将其转换为数组,通过判断数组长度即可实现文件数量的限制。

jsx 复制代码
const handleChangeFile = event => {
  if (event.target.files) {
    const limitNum = 1;
    let files = Array.from(event.target.files);
    if (files.length > limitNum) {
      console.log("最多上传 5 个文件");
      files = files.slice(0, limitNum); // 截取前 5 个文件
    }
  }
  event.target.value = "";
}

四、预览文件

在前端 <img />、<video />、<audio /> 标签都可以通过 src 传递一个文件地址进行预览。通常这个地址是一个在线地址,这要求先将本地文件上传到服务器后生成新 url 做预览。

当我们期望,在用户选择本地文件以后立即能够在页面上预览所选文件,可以通过 URL.createObjectURL 创建一个 blob 内存 url 作为元素的 src 属性使用。

如,预览所选的图片文件。

jsx 复制代码
const [previewUrl, setPreviewUrl] = useState("");

const handleChangeFile = event => {
  if (event.target.files) {
    const file = Array.from(event.target.files)[0];
    previewUrl && URL.revokeObjectURL(previewUrl); // 记得不用时释放内存 url
    const url = URL.createObjectURL(file); // 创建 blob 内存 url(格式类似于:blob:http://127.0.0.1:5173/5101d6ab-821e-4957-93ea-f029533a182a)
    setPreviewUrl(url);
  }
  event.target.value = "";
}

<img width={500} src={previewUrl} />

Tips :当你上传并预览一个特别大的图片(比如 5MB)时,并且为图片设置了填充方式 object-fit: cover;,你会发现页面操作卡顿,可以结合 CSS3 属性 translate3d(0, 0, 0) 触发 GPU 加速提供渲染性能。

五、单文件上传至服务器

一般小文件会采用单文件上传方式,一次性全部给到后台接口上传到服务器。

下面,我们,

  1. 在客户端我们封装一个 Promise ajax,后续采用 FormData 格式上传文件;
  2. 在服务端通过 Node http 模块启动本地服务( http://localhost:8080 ),采用 multiparty 处理 FormData 数据。

1、客户端代码

jsx 复制代码
// 1. 封装 Promise ajax
const request = ({ url, method = "POST", data, headers = {} }) => {
  return new Promise((resolve) => {
    // 1、创建 xhr 对象
    const xhr = new XMLHttpRequest();
    // 2、建立连接
    xhr.open(method, url);
    // 设置请求头
    Object.keys(headers).forEach((key) =>
      xhr.setRequestHeader(key, headers[key])
    );
    // 3. 发送数据(上传数据使用 POST 请求,且 FormData 数据不需要使用 JSON.stringify 做转换,故无需设置 headers content-type)
    xhr.send(data);
    // 4. 接收数据
    xhr.onload = (e) => {
      resolve({
        data: e.target.response,
      });
    };
  });
};

// change 事件里拿到上传文件,请求后台接口
const handleChangeFile = (event) => {
  if (event.target.files) {
    const file = Array.from(event.target.files)[0];

    const formData = new FormData(); // FormData 
    formData.append("fileName", file.name);
    formData.append("file", file);

    request({
      url: "http://localhost:8080/upload/single",
      data: formData,
    });
  }
  event.target.value = "";
};

2、服务端代码

js 复制代码
// server/app.js
const http = require('http');
const path = require('path');
const fs = require('fs-extra'); // 文件处理模块
const multiparty = require('multiparty'); // 处理 FormData 对象的模块

const server = http.createServer();
// 文件存放目录
const UPLOAD_DIR = path.resolve(__dirname, '.', 'target');

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;
  }

  if (req.url === '/upload/single' && req.method === "POST") {
    const multipart = new multiparty.Form() // 解析 FormData 对象
    multipart.parse(req, async (err, fields, files) => {
      if (err) return;
      const [fileName] = fields.fileName; // 是一个数组,取出第一项 文件名
      const [file] = files.file; // 是一个数组,取出第一项 文件体

      const filePath = `${UPLOAD_DIR}/${fileName}`; // 文件存放位置
      await fs.remove(filePath); // 删除文件,如果存在
      await fs.move(file.path, filePath); // 保存文件

      res.end(JSON.stringify({
        code: 0,
        message: 'upload success.'
      }))
    });
  }
})

server.listen(8080, () => console.log('服务已启动, port 8080.'));

上传成功后,在服务端 target 目录下就能看到上传的文件。

六、目录上传

<input type="file" /> 除了可以选择上传 单/多文件外,还可以支持上传一个目录,将目录下所有 文件/子目录 内容递归进行上传。

这在云文件平台比较常见,如 飞书的云文档,可将本地的一个目录作为上传任务,上传到云文档中。

通过设置 webkitdirectory 属性来支持选择上传目录。

jsx 复制代码
<input 
  webkitdirectory="true" 
  style={{ display: "none" }} 
  type="file" 
  ref={fileRef} 
  onChange={handleChangeFile} />

注意 ,上传文件 和 上传目录 需要使用两个 <input /> 元素来实现;当给元素设置了 webkitdirectory 属性以后,将无法选择文件,只能选择目录。

在你选择目录之后,它会弹出确认框是否要将目录中的文件进行上传,比如我本地的 folder 文件夹,它下面有 6 个文件:

在确认上传以后,change 事件中可以获取到该目录下的文件列表(注意,即使是存在多级目录,也只有 **扁平** 后的文件,没有子目录),列表中每个文件对象上都包含一个 webkitRelativePath 属性,代表文件的相对路径

对应本地文件夹 folder 的 Tree 形式展示如下:

bash 复制代码
.folder
├── image
│   └── image1.jpeg
├── other
│   └── zip
│       └── zip1.zip
├── pdf
│   └── pdf1.pdf
└── video
    └── video1.mp4

由于文件列表中没有子目录的数据,我们该如何构建此目录的原始结构呢?基于 webkitRelativePath 属性,我们可以推断出此文件的层级目录,来构建文件夹的内容层级。

这里利用一个 Map 数据结构来实现:

jsx 复制代码
const handleChangeFile = (event) => {
  if (event.target.files) {
    const files = Array.from(event.target.files).filter(file => file.name !== ".DS_Store");

    const folderTree = {}; // 目录结构树。如果是目录,值为对象;如果是文件,文件名作为属性 key,值为常量字符串 "file"。
    let rootFolderName = ""; // 目录名称
    
    files.forEach(file => {
      const [folderName, ...fileLevel] = file.webkitRelativePath.split("/");
      // 1. 拿到上传的目录名称
      if (!rootFolderName) {
        rootFolderName = folderName;
      }
      // 2. 构建目录 Tree
      fileLevel.reduce((pre, cur, index) => {
        const isFolder = index < fileLevel.length - 1; // 非最后一层级
        if (isFolder) { // 是目录
          if (!pre[cur]) { // 若目录不存在,则创建目录(以 对象{} 表示)
            pre[cur] = {};
          }
        } else { // 是文件(以 常量字符串 "file" 表示)
          pre[cur] = "file";
        }
        return pre[cur];
      }, folderTree);
    });

    console.log(`${rootFolderName} folderTree: `, folderTree);
  }
  event.target.value = "";
};

输入如下:

尽管 webkitdirectory 属性可以实现选择目录的功能,但在实际项目中我们还需要考虑它的兼容性。当所处浏览器环境不支持 目录上传 时,我们在网站不开放「上传文件夹」入口,这在 云文件 服务平台非常必要。

js 复制代码
const isSupportedDirectoryUpload = (function () {
  let el = document.createElement('input');
  el.type = 'file';
  return 'webkitdirectory' in el || 'directory' in el;
})();

七、上传进度

上传文件是一个比较耗时的过程,网站都会在页面给用户呈现进度展示。

1、单文件,单进度

单文件单进度这个好理解,展示单个文件的上传进度。

我们需要一种方式能够监听文件上传的进度,XMLHttpRequest.upload.onprogress 可以实现。

不过要注意它的绑定需要在 xhr.send 发送数据前做好。

我们改造上面 Promise ajax,传入 onProgress 监听进度事件:

diff 复制代码
const request = ({ 
  url, 
  method = "POST", 
  data, 
  headers = {}, 
+ onProgress 
}) => {
  return new Promise((resolve) => {
    // 1、创建 xhr 对象
    const xhr = new XMLHttpRequest();
    // 2、建立连接
    xhr.open(method, url);
    // 设置请求头
    Object.keys(headers).forEach((key) =>
      xhr.setRequestHeader(key, headers[key])
    );
    // 绑定 upload.onprogress 监听上传进度事件(必须放在 xhr.send 之前)
+   xhr.upload.onprogress = onProgress;
    // 3. 发送数据(上传数据使用 POST 请求,且 FormData 数据不需要使用 JSON.stringify 做转换,也无需设置 headers content-type)
    xhr.send(data);
    // 4. 接收数据
    xhr.onload = (e) => {
      resolve({
        data: e.target.response,
      });
    };
  });
};

onProgress event 事件对象中有两个信息可以用于计算进度,它们的单位和文件大小一样都是 Byte 字节。

  1. event.loaded, 已上传的文件大小;
  2. event.total, 总的文件大小。

通过 event.loaded / event.total 就可以计算出上传进度 progress。

此外,event.total 可以使用文件大小 file.size 来代替,不过根据测试发现 event.total 要比实际文件大小 file.size 要大一点。

进度实现代码如下:

jsx 复制代码
const [progress, setProgress] = useState(0);

const handleChangeFile = async (event) => {
  if (event.target.files) {
    const file = Array.from(event.target.files)[0];

    const formData = new FormData();
    formData.append("filename", file.name);
    formData.append("file", file);

    await request({
      url: "http://localhost:8080/upload/single",
      data: formData,
      onProgress: (event) => {
        const loadedSize = Math.min(event.loaded, file.size); // 已上传进度(event.loaded 在上传完成以后有可能比 fize.size 大一点,所以取最小值)
        const progress = Math.floor((loadedSize / file.size) * 100);
        setProgress(progress); // 设置进度
      }
    });
  }
  event.target.value = "";
};

// 视图展示进度
<h2>上传进度:{progress}</h2>

2、多文件,单进度

多文件同时上传共用一个进度展示,可以有两种方式实现:

  1. 根据总文件大小计算,已上传的大小 / 所有文件总大小
  2. 求已上传进度的平均值,已上传的进度 / 上传的文件数量

小提示:在多任务下,最好为每个文件定义一个独立数据结构,来记录这个文件上传过程所需的数据状态。便于串联每个独立任务的数据信息。

1、方式一:已上传的大小 / 所有文件总大小

jsx 复制代码
const [, forceUpdate] = useReducer(v => v + 1, 0); // 配合数据修改更新视图
const uploadTasks = useRef([]); // 记录每个上传文件的数据信息

const handleChangeFile = async (event) => {
  if (event.target.files) {
    const files = Array.from(event.target.files);
    // 1、多任务下,最好为每个文件定义一个独立数据,来记录这个文件上传过程所需的数据状态。
*   uploadTasks.current = files.map(file => ({
      file,
      name: file.name,
*     size: file.size,
*     loaded: 0, // 单个文件已上传的大小
    }));

    const uploadRequests = uploadTasks.current.map(task => {
      const formData = new FormData();
      formData.append("filename", task.file.name);
      formData.append("file", task.file);
      return request({
        url: "http://localhost:8080/upload/single",
        data: formData,
        onProgress: (event) => {
          // 2、记录每个文件已上传的大小
*         task.loaded = Math.min(event.loaded, task.file.size);
          forceUpdate(true); // 更新视图,渲染 progress
        }
      });
    });

    Promise.all(uploadRequests); // 多文件并行上传
  }
  event.target.value = "";
};

// 方式一:用多个文件已上传的大小 / 多个文件总大小
let totalSize = 0, loadedSize = 0;
uploadTasks.current.forEach(task => {
  totalSize += task.size;
  loadedSize += task.loaded;
});
const progress =  Math.floor((loadedSize / totalSize) * 100) || 0;

2、方式二:求已上传进度的平均值

jsx 复制代码
const [, forceUpdate] = useReducer(v => v + 1, 0);
const uploadTasks = useRef([]); // 记录每个上传文件的数据信息

const handleChangeFile = async (event) => {
  if (event.target.files) {
    const files = Array.from(event.target.files);

    // 1、多任务下,最好为每个文件定义一个独立数据,来记录这个文件上传过程所需的数据状态。
*   uploadTasks.current = files.map(file => ({
      file,
      name: file.name,
*     progress: 0, // 该文件的上传进度
    }));

    const uploadRequests = uploadTasks.current.map(task => {
      const formData = new FormData();
      formData.append("filename", task.file.name);
      formData.append("file", task.file);
      return request({
        url: "http://localhost:8080/upload/single",
        data: formData,
        onProgress: (event) => {
          const loaded = Math.min(event.loaded, task.file.size);
          const progress = Math.floor((loaded / task.file.size) * 100);
          // 2、记录每个任务的上传进度
*         task.progress = progress;
          forceUpdate(true); // 更新视图,渲染 progress
        }
      });
    });

    Promise.all(uploadRequests); // 多文件并行上传
  }
  event.target.value = "";
};

// 方式二:已上传的进度 / 上传的文件数量。
const allProgress = uploadTasks.current.reduce((pre, cur) => {
  const res = pre + cur.progress;
  return res;
}, 0);
const progress = Math.floor(allProgress / uploadTasks.current.length) || 0;

八、上传状态

若想实现一个完整的上传文件功能模块,上传状态一定要考虑进来:将文件处于上传的各个阶段,在视图上呈现给用户,是一个很不错的使用体验。

常见的文件上传视图状态有:

  • 等待上传(多文件上传时,可能需要排队上传)
  • 生成 sha1 阶段(上传的准备阶段,前端处理中)
  • 上传阶段(上传中,展示上传进度)
  • 上传暂停(可用于恢复上传)
  • 服务端处理阶段(解析中)
  • 上传成功
  • 上传失败

这里简单以 4 个上传状态为例,向用户展示上传过程。

diff 复制代码
+ // 1. 默认 wait 等待状态
+ // wait(等待上传)、inProgress(上传中)、process(服务端解析中)、success(上传成功)
+ const [uploadStatus, setUploadStatus] = useState("wait");

const handleChangeFile = async (event) => {
  if (event.target.files) {
    const file = Array.from(event.target.files)[0];

    const formData = new FormData();
    formData.append("filename", file.name);
    formData.append("file", file);

    await request({
      url: "http://localhost:8080/upload/single",
      data: formData,
      onProgress: (event) => {
        const loadedSize = Math.min(event.loaded, file.size); // 已上传进度(event.loaded 在上传完成以后有可能比 fize.size 大一点,所以取最小值)
        const progress = Math.floor((loadedSize / file.size) * 100);
+       // 2. 根据上传进度控制 inProgress 上传中 和 process 服务端解析中
+       setUploadStatus(progress === 100 ? "process" : "inProgress");
        setProgress(progress); // 设置进度
      }
    });

+   // 3. 上传完成
    setUploadStatus("success");
  }
  event.target.value = "";
};

+ // 视图渲染上传状态
<h2>
  {(() => {
    switch (uploadStatus) {
      case "wait": return "等待上传";
      case "inProgress": return "上传中";
      case "process": return "服务端解析中";
      case "success": return "上传成功";
    }
  })()}
</h2>

九、取消/暂停 上传

上传中的文件可以被随时取消上传,即中断 HTTP 请求。取消请求由客户端发起,服务端可以监听到上传终止做一些重置动作。

1、客户端暂停上传

ajax XMLHttpRequest 有一个方法 xhr.abort 可用于终止所发起的 xhr 请求。

我们在 request 方法基础上扩展 abortRef 参数,用于保存 xhr.abort

注意,保存时一定要 把 xhr 保存进来 ,因为只有通过 xhr.abort 方式调用才可正常被取消,仅通过 abort 函数自执行将会报错:Uncaught TypeError: Illegal invocation

diff 复制代码
const request = ({ 
  url, 
  method = "POST", 
  data, 
  headers = {}, 
  onProgress, 
+ abortRef 
}) => {
  return new Promise((resolve) => {
    // 1、创建 xhr 对象
    const xhr = new XMLHttpRequest();
    // 2、建立连接
    xhr.open(method, url);
    // 设置请求头
    Object.keys(headers).forEach((key) =>
      xhr.setRequestHeader(key, headers[key])
    );
    // 存储 xhr.abort,用于中断请求
+   abortRef && (abortRef.current = () => xhr.abort());
    // 绑定 upload.onprogress 监听上传进度事件(必须放在 xhr.send 之前)
    xhr.upload.onprogress = onProgress;
    // 3. 发送数据(上传数据使用 POST 请求,且 FormData 数据不需要使用 JSON.stringify 做转换,也无需设置 headers content-type)
    xhr.send(data);
    // 4. 接收数据
    xhr.onload = (e) => {
      resolve({
        data: e.target.response,
      });
    };
  });
};

点击取消按钮 执行 xhr.abort() 来 取消/暂停 上传。

diff 复制代码
const FileUpload = () => {
  const fileRef = useRef(null);
+ const abortRef = useRef(null);

  const handleChangeFile = (event) => {
    if (event.target.files) {
      const file = Array.from(event.target.files)[0];
      const formData = new FormData();
      formData.append("filename", file.name);
      formData.append("file", file);
      request({
        url: "http://localhost:8080/upload/single",
        data: formData,
+       abortRef: abortRef,
      });
    }
    event.target.value = "";
  };

  return (
    <div>
      <button onClick={() => fileRef.current.click()}>点击上传</button>
+     <button onClick={() => abortRef.current && abortRef.current()}>点击 取消/暂停 上传</button>
      <input style={{ display: "none" }} multiple type="file" ref={fileRef} onChange={handleChangeFile} />
    </div>
  );
};

2、服务端监听上传暂停

在 Node 中可以通过 req.on('aborted', cb) 监听请求中断;同时,multipart.parse() 中的 err 信息为 Error: Request aborted,所以判断 err 不存在时停止上传,非常有必要。

diff 复制代码
// server/app.js
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;
  }

+ req.on('aborted', () => { 
+   console.log("监听到请求终止。");
+  });

  if (req.url === '/upload/single') {
    const multipart = new multiparty.Form() // 解析 FormData 对象
    multipart.parse(req, async (err, fields, files) => {
      console.log("info: ", err, fields);
*     if (err) return; // 若上传被中断,err 信息输出为:Error: Request aborted
      ...
    });
  }
})

十、切片上传(大文件)

切片上传,也叫分段上传,将一个大文件,按照预定的切片大小(如:10M),切分成一个个切片,借助 http 并发特性同时上传多个切片,从而大大减少大文件的上传时间。

1、思路

在前端客户端 ,对文件拆分为切片的方案是利用 Blob.prototype.slice 方法,和数组的 slice 用法相似,它返回原文件的某一块切片内容。

接下来是将每个文件切片 并行 上传到服务器。注意,因为是并行,每个切片要记录上各自的编号顺序。

在服务端,负责接收前端传输的切片,并在接收到所有切片后合并这些切片为一个完整文件。这里涉及两个过程:

  1. 何时合并切片 ,这里需要前端配合,在所有切片上传成功以后,主动发送一个 merge 合并切片请求,通知服务端进行切片合并;
  2. 如何合并切片 ,服务端以 Nodejs 为例,可通过 读写流(readStream/writeStream),将所有切片的流传输到最终文件的流里。

2、客户端实现

在客户端,我们需要实现:

  1. 创建切片
  2. 生成文件 hash
  3. 并发上传切片(控制异步并发请求的数量)
  4. 发起合并切片请求

1、基础结构

jsx 复制代码
// 定义切片大小为 10M
const SIZE = 10 * 1024 * 1024; 

const FileUpload = () => {
  const fileRef = useRef(null);

  const handleChangeFile = async (event) => {
    if (event.target.files) {
      const file = Array.from(event.target.files)[0];
      ...
    }
  };

  return (
    <div>
      <button onClick={() => fileRef.current.click()}>点击上传</button>
      <input style={{ display: "none" }} multiple type="file" ref={fileRef} onChange={handleChangeFile} />
    </div>
  );
};

export default FileUpload;

2、创建文件切片

文件 file 继承自 Blob 对象,使用 Blob.prototype.slice 将文件分成多个小块。

diff 复制代码
const FileUpload = () => {
  const fileRef = useRef(null);
+ const uploadFile = useRef({ file: null, hash: "" });

  // 生成文件切片
+ const createFileChunks = (file, chunkSize = SIZE) => {
+   const chunks = [];
+   let cur = 0;
+   while (cur < file.size) {
+     chunks.push(file.slice(cur, cur + chunkSize));
+     cur += chunkSize;
+   }
+   return chunks;
+ }

  const handleChangeFile = async (event) => {
    if (event.target.files) {
      const file = Array.from(event.target.files)[0];
+     uploadFile.current.file = file;
      
+     // 1、对大文件拆分切片
+     const chunks = createFileChunks(file);

      ...
    }

    event.target.value = "";
  };

  return ...;
};

3、生成文件唯一标识 hash 值

hash 值是根据文件内容生成的唯一标识,只要文件内容不变,生成的 hash 值永远是同一个。

生成 hash 值的目的是,在服务端可基于 hash 作为存储名称,也为后续实现 「秒传」 做铺垫。

生成 hash 过程如下:

  1. 基于切片来生成文件 hash 值,这样可以控制生成 hash 的 进度 展示在页面上;
  2. hash 值的生成算法工具,使用 spark-md5 第三方包来完成;
  3. 利用 web worker 特性执行 hash 生成工作,worker 用于开启一个新的工作线程来计算文件 hash,避免因为计算文件 hash(比较耗时)导致阻塞页面主流程的交互。

public/hash.js 作为 web worker 的执行文件,内容如下:

js 复制代码
// 导入 spark-md5
self.importScripts("/spark-md5.min.js");

// 生成文件 hash
self.onmessage = e => {
  const { chunks } = e.data;
  const spark = new self.SparkMD5.ArrayBuffer();
  let percentage = 0; // 进度
  let count = 0; // 记录访问切片次数,用作递归的终止条件
  const loadNext = index => {
    const reader = new FileReader();
    reader.readAsArrayBuffer(chunks[index]);
    reader.onload = e => {
      count++;
      spark.append(e.target.result);
      if (count === chunks.length) {
        self.postMessage({
          percentage: 100,
          hash: spark.end()
        });
        self.close();
      } else {
        percentage += 100 / chunks.length;
        self.postMessage({
          percentage
        });
        loadNext(count);
      }
    };
  };
  loadNext(0);
};

在拿到切片以后,根据切片来计算文件的 hash:

jsx 复制代码
// 生成文件 hash
const calculateHash = (chunks) => {
  return new Promise(resolve => {
    // 添加 worker 属性
    const worker = new Worker("/hash.js");
    worker.postMessage({ chunks });
    worker.onmessage = e => {
      const { percentage, hash } = e.data;
      console.log("生成 hash 的进度:", percentage);
      if (hash) {
        console.log("生成的 hash 值:", hash);
        resolve(hash);
      }
    };
  });
}

const handleChangeFile = async (event) => {
  if (event.target.files) {
    const file = Array.from(event.target.files)[0];
    uploadFile.current.file = file;
    
    // 1、对大文件拆分切片
    const chunks = createFileChunks(file);
    // 2、根据切片,生成文件总的 hash(文件唯一标识(根据文件内容生成的 hash))
    const hash = await calculateHash(chunks);
    uploadFile.current.hash = hash;

    ...
  }
};

4、并发上传切片(控制异步并发请求的数量)

尽管并发上传文件切片可以提高上传效率,但一个大文件若生成了几百个切片请求,同时并发进行 TPC 连接也可能会导致浏览器卡死。

所以并发请求的数量我们需要控制。我们可以模拟一个队列,比如同时上传 5 个切片,当有一个切片请求完成以后,从队列中再取出一个发起请求。

diff 复制代码
+ const uploadFileChunks = useRef([]);

const handleChangeFile = async (event) => {
  if (event.target.files) {
    const file = Array.from(event.target.files)[0];
    uploadFile.current.file = file;
    
    // 1、对大文件拆分切片
    const chunks = createFileChunks(file);
    // 2、根据切片,生成文件总的 hash(文件唯一标识(根据文件内容生成的 hash))
    const hash = await calculateHash(chunks);
    uploadFile.current.hash = hash;
+   // 3、映射 chunks 结构
+   uploadFileChunks.current = chunks.map((chunk, index) => ({
+     fileName: file.name,
+     fileHash: hash,
+     chunk,
+     hash: `${hash}-${index}`, // 切片要记录各自的顺序
+   }));

+   // 4、异步请求并发控制策略
+   await startUploadRequest(uploadFileChunks.current);

    ...
  }
};

+ const startUploadRequest = async (chunks, max = 5) => {
+   // max = 5:假设并发请求数量最大是 5(有 5 个请求通道)
+   return new Promise(resolve => {
+     const total = chunks.length; // 请求总数量
+     let index = 0; // 发起请求的数量
+     let counter = 0; // 完成请求的数量
+     const start = () => {
+       // 有请求任务,并且有请求通道
+       while (index < total && max > 0) {
+         const chunk = chunks[index];
+         max --; // 进入请求,占用一个通道
+         index ++;
+         createChunkRequest(chunk).then(() => {
+           max ++; // 请求结束,释放一个通道
+           counter ++;
+           if (counter === total) {
+             resolve();
+           } else if (index < total) {
+             start(); // 继续取出剩余请求并发出
+           }
+         })
+       }
+     }
+     start();
+   });
+ }

+ const createChunkRequest = (chunk) => {
+   const formData = new FormData();
+   formData.append("fileName", chunk.fileName);
+   formData.append("fileHash", chunk.fileHash);
+   formData.append("chunk", chunk.chunk);
+   formData.append("hash", chunk.hash);
+   return request({
+     url: "http://localhost:8080/upload/chunks",
+     data: formData,
+   });
+ }

5、合并切片

最后,在切片全部上传完成以后,发起 merge 合并切片请求,并将要合并的文件信息作为参数传递给服务端。

jsx 复制代码
const handleChangeFile = async (event) => {
  if (event.target.files) {
    ...
    // 4、异步请求并发控制策略
    await startUploadRequest(uploadFileChunks.current);
    // 5、合并切片
    await mergeChunks();
  }
};

const mergeChunks = async () => {
  await request({
    url: "http://localhost:8080/merge/chunks",
    headers: {
      "content-type": "application/json"
    },
    data: JSON.stringify({ 
      fileName: uploadFile.current.file.name,
      fileHash: uploadFile.current.hash,
      chunkSize: SIZE, // 切片大小
    })
  });
}

3、服务端实现

服务端需要做以下事情:

  1. 接收切片并存储;
  2. 合并切片为完整文件。

1、存储切片

target 目录用于存储所有上传文件及其切片。采用 固定前缀 + 文件 hash 作为每个文件的切片的存放目录,每个切片命名采用客户端传递过来的 hash + 切片索引 命名。

js 复制代码
// server/app.js
const http = require('http');
const path = require('path');
const fs = require('fs-extra'); // 文件处理模块
const multiparty = require('multiparty'); // 处理 FormData 对象

const server = http.createServer();
// 文件存放目录
const UPLOAD_DIR = path.resolve(__dirname, '.', 'target');

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;
  }

  if (req.url === "/upload/chunks" && req.method === "POST") {
    const multipart = new multiparty.Form() // 解析 FormData 对象
    multipart.parse(req, async (err, fields, files) => {
      if (err) return; // 若上传被中断,err 信息输出为:
      // const [fileName] = fields.fileName; // 是一个数组,取出第一项 文件名
      const [fileHash] = fields.fileHash; // 是一个数组,取出第一项
      const [chunk] = files.chunk; // chunk 文件是在 files 中读取
      const [hash] = fields.hash;

      // 创建 chunk 文件目录(固定前缀 + hash 作为切片文件夹名)
      const chunkDir = path.resolve(UPLOAD_DIR, `chunkDir_${fileHash}`);
      if (!fs.existsSync(chunkDir)) {
        await fs.mkdirs(chunkDir);
      }
      // 存储切片(使用 hash(客户端设置的 fileHash + index) 作为切片名称)
      await fs.move(chunk.path, `${chunkDir}/${hash}`);

      res.end(JSON.stringify({
        code: 0,
        message: 'upload success.'
      }));
    });
  }
})

server.listen(8080, () => console.log('服务已启动, port 8080.'));

2、合并切片

读取该文件的切片目录下的所有切片,以 文件 hash + 文件后缀 作为合并后的文件名。合并切片的方式采用 读写流(readStream/writeStream) 来完成,不过注意:合并时要根据切片索引进行排序,以保证切片合并的顺序正确。

jsx 复制代码
server.on('request', async (req, res) => {
  ...

  // 提取后缀名
  const extractExt = fileName =>
    fileName.slice(fileName.lastIndexOf("."), fileName.length);

  // 提取 request.body
  const resolvePost = req => {
    return new Promise(resolve => {
      let chunk = "";
      req.on("data", data => {
        chunk += data;
      });
      req.on("end", () => {
        resolve(JSON.parse(chunk));
      });
    });
  }

  // 写入文件流
  const pipeStream = (path, writeStream) => {
    return new Promise(resolve => {
      const readStream = fs.createReadStream(path);
      readStream.on("end", () => {
        fs.unlinkSync(path);
        resolve();
      });
      readStream.pipe(writeStream);
    });
  }

  // 合并切片
  const mergeFileChunk = async (filePath, fileHash, chunkSize) => {
    const chunkDir = path.resolve(UPLOAD_DIR, `chunkDir_${fileHash}`);;
    const chunkPaths = await fs.readdir(chunkDir);
    // 根据切片下标进行排序,然后进行合并
    chunkPaths.sort((a, b) => a.split("-")[1] - b.split("-")[1]);
    // 并发合并写入文件
    await Promise.all(
      chunkPaths.map((chunkPath, index) =>
        pipeStream(
          path.resolve(chunkDir, chunkPath),
          // 根据 chunkSize 在指定位置创建可写流
          fs.createWriteStream(filePath, {
            start: index * chunkSize
          })
        )
      )
    );
    // 合并后删除保存切片的目录
    fs.rmdirSync(chunkDir);
  };

  if (req.url === "/merge/chunks" && req.method === "POST") {
    const data = await resolvePost(req);
    const { fileHash, fileName, chunkSize } = data;
    const ext = extractExt(fileName);
    // 使用 hash + 扩展名,作为合并后的文件名。
    const filePath = path.resolve(UPLOAD_DIR, `${fileHash}${ext}`);
    await mergeFileChunk(filePath, fileHash, chunkSize);
    res.end(
      JSON.stringify({
        code: 0,
        message: "file merged success"
      })
    );
  }
})

十一、秒传(大文件)

所谓文件秒传,即在服务端已经存在了上传的资源,当用户再次上传时会直接提示上传成功。这一般用在较大的文件中,避免重复消耗时间和流量。

秒传功能需要依赖文件 hash,根据 hash 判定是否文件是否存在,进而实现秒传。

具体过程:在上传文件之前,需要先将 hash 值发送给服务端验证文件是否被上传过,一旦找到与 hash 相同的文件,直接返回上传成功信息。

1、客户端

在上传切片之前,先将生成的文件 hash 传给后台校验文件是否存在,若存在终止后续上传过程。

diff 复制代码
+ // 验证文件是否已上传
+ const checkUpload = async (fileName, fileHash) => {
+   const { data } = await request({
+     url: "http://localhost:8080/check/upload",
+     headers: {
+       "content-type": "application/json"
+     },
+     // 服务端根据 文件 hash + 文件后缀 查找是否已被上传过
+     data: JSON.stringify({
+       fileName,
+       fileHash,
+     })
+   });
+   return JSON.parse(data);
+ }

const handleChangeFile = async (event) => {
  if (event.target.files) {
    const file = Array.from(event.target.files)[0];
    uploadFile.current.file = file;
    
    // 1、对大文件拆分切片
    const chunks = createFileChunks(file);
    // 2、根据切片,生成文件总的 hash(文件唯一标识(根据文件内容生成的 hash))
    const hash = await calculateHash(chunks);
    uploadFile.current.hash = hash;
    // 校验文件是否上传过
+   const { shouldUpload } = await checkUpload(file.name, hash);
+   if (!shouldUpload) {
+     console.log("文件在服务器上已被上传过,进入秒传场景。");
+     return;
+   }
    
    ...
  }

  event.target.value = "";
};

2、服务端

diff 复制代码
server.on('request', async (req, res) => {
  ...

  // 提取后缀名
  const extractExt = fileName =>
    fileName.slice(fileName.lastIndexOf("."), fileName.length);

  // 提取 request.body
  const resolvePost = req => {
    return new Promise(resolve => {
      let chunk = "";
      req.on("data", data => {
        chunk += data;
      });
      req.on("end", () => {
        resolve(JSON.parse(chunk));
      });
    });
  }

+ if (req.url === "/check/upload" && req.method === "POST") {
+   const data = await resolvePost(req);
+   const { fileHash, fileName } = data;
+   const ext = extractExt(fileName);
+   // 根据 文件 hash 和 文件后缀,在服务器上查找相同文件
+   const filePath = path.resolve(UPLOAD_DIR, `${fileHash}${ext}`);
+   const shouldUpload = !fs.existsSync(filePath);
+   res.end(
+     JSON.stringify({
+       code: 0,
+       shouldUpload
+     })
+   );
+ }

  ...
})

十二、断点续传(大文件)

即在文件暂停上传以后,可以继续上次上传的位置来完成上传。

1、思路

按照上面的实现,服务端会建立一个文件夹存储所有上传的切片。那么在恢复上传之前可以向服务端查询一次,服务端将已上传过的切片返回,前端上传时则可以跳过它们,只将未上传的切片进行上传。这个逻辑可以和 秒传 接口放在一起。

2、客户端取消上传

创建 requestTasks 时,我们将 chunk.abortRef 绑定到 xhr.abort 函数的引用,在需要取消的时候执行它。(关于 xhr.abort 在上面 取消/暂停上传 一节有介绍)

diff 复制代码
const handleChangeFile = async (event) => {
  if (event.target.files) {
    ...

    // 3、映射 chunks 结构
    uploadFileChunks.current = chunks.map((chunk, index) => ({
      fileName: file.name,
      fileHash: hash,
      chunk,
      hash: `${hash}-${index}`, // 切片要记录各自的顺序
+     abortRef: { current: null }
    }));

    // 4、创建请求任务
    const requestTasks = uploadFileChunks.current.map(chunk => {
      const formData = new FormData();
      formData.append("fileName", chunk.fileName);
      formData.append("fileHash", chunk.fileHash);
      formData.append("chunk", chunk.chunk);
      formData.append("hash", chunk.hash);
      return request({
        url: "http://localhost:8080/upload/chunks",
        data: formData,
+       abortRef: chunk.abortRef,
      });
    });
    
    // 5、并发上传
    await Promise.all(requestTasks);
    // 6、合并切片
    await mergeChunks();
  }

  event.target.value = "";
};

+ const handlePause = () => {
+   uploadFileChunks.current.forEach(chunk => {
+     chunk.abortRef.current(); // 中断切片上传请求
+   });
+ }

+ <button onClick={handlePause}>暂停</button>

3、服务端返回已上传的切片列表

我们先来看服务端的实现:当客户端继续上传时先请求 checkUpload,服务端根据文件 hash 返回已上传的切片列表。

diff 复制代码
server.on('request', async (req, res) => {
  ...

  // 返回已上传的所有切片名
+ const createUploadedList = async (fileHash) =>
+   fs.existsSync(path.resolve(UPLOAD_DIR, `chunkDir_${fileHash}`))
+     ? await fs.readdir(path.resolve(UPLOAD_DIR, `chunkDir_${fileHash}`))
+     : [];

  if (req.url === "/check/upload" && req.method === "POST") {
    const data = await resolvePost(req);
    const { fileHash, fileName } = data;
    const ext = extractExt(fileName);
    // 根据 文件 hash 和 文件后缀,在服务器上查找相同文件
    const filePath = path.resolve(UPLOAD_DIR, `${fileHash}${ext}`);
    const shouldUpload = !fs.existsSync(filePath);
    res.end(
      JSON.stringify({
        code: 0,
        shouldUpload,
+       uploadedChunkList: await createUploadedList(fileHash),
      })
    );
  }
})

4、客户端跳过已上传切片

客户端在拿到已上传切片列表后,可过滤掉已上传的部分,只上传未上传的切片。

diff 复制代码
<button onClick={handleResume}>继续</button>

const handleResume = async () => {
+ const { uploadedChunkList } = await checkUpload(
+   uploadFile.current.file.name,
+   uploadFile.current.hash,
+ );

+ // 上传切片,过滤已上传的切片
+ const chunksState = uploadFileChunks.current
+   .filter(({ hash }) => !uploadedChunkList.includes(hash));

  // 异步请求并发控制策略
  await startUploadRequest(chunksState);
  // 合并切片
  await mergeSlices();
}

十三、扩展和思考

大文件上传 借鉴了 @yeyan1996 作者的思路和代码,以上内容基本可以覆盖日常开发文件上传场景。但还有 优化 或者是 进一步提升的空间。比如:

  1. 计算 hash 除了使用 web-workder 让出主线程进行计算外,还可以参考 React Fiber 架构 利用浏览器空闲时间来计算 hash;
  2. 完整计算一个文件 hash 非常耗时,采用 抽样 hash 牺牲一点命中率来提升计算 hash 效率;
  3. 根据网络情况,动态调整切片大小;
  4. 报错重试机制;
  5. 了解服务端如何做 文件碎片清理 工作;
  6. 使用体验优化,如:上传中离开页面给出拦截提示(beforeunload)、展示上传网速、展示上传剩余时间 等。

以上扩展内容可以参考 大圣老师 这篇文章(字节跳动面试官,我也实现了大文件上传和断点续传)给出的思路,再结合自己的思考来完善功能。

参考资料

1. 字节跳动面试官:请你实现一个大文件上传和断点续传
2. 字节跳动面试官,我也实现了大文件上传和断点续传

相关推荐
清云随笔3 分钟前
axios 实现 无感刷新方案
前端
鑫宝Code5 分钟前
【React】状态管理之Redux
前端·react.js·前端框架
忠实米线13 分钟前
使用pdf-lib.js实现pdf添加自定义水印功能
前端·javascript·pdf
pink大呲花15 分钟前
关于番外篇-CSS3新增特性
前端·css·css3
少年维持着烦恼.19 分钟前
第八章习题
前端·css·html
我是哈哈hh22 分钟前
HTML5和CSS3的进阶_HTML5和CSS3的新增特性
开发语言·前端·css·html·css3·html5·web
田本初40 分钟前
如何修改npm包
前端·npm·node.js
hzw05101 小时前
nrm的安装及使用
node.js
明辉光焱1 小时前
[Electron]总结:如何创建Electron+Element Plus的项目
前端·javascript·electron