关于前端:🚀ReactNode全栈无死角解析吃透文件上传的各个场景

5次阅读

共计 13569 个字符,预计需要花费 34 分钟才能阅读完成。

前言

公众号:【可乐前端】,期待关注交换,分享一些有意思的前端常识

文件上传在平时的开发过程中很常常会遇到,本文总结了如下罕用的文件上传场景,包含前后端的代码实现,心愿你下次遇到上传文件的场景能够间接秒杀。文章稍稍有点长,倡议点赞珍藏食用🐶。

  • 上传形式

    • 点击上传
    • 拖拽上传
    • 粘贴上传
  • 上传限度
  • 单、多文件上传
  • 文件夹上传
  • 上传进度
  • oss上传
  • 大文件上传

    • 切片
    • 断点续传
    • 秒传

上传形式

上面先来介绍三种常见的上传形式:

  • 点击上传
  • 拖拽上传
  • 粘贴上传

点击上传

<div onClick={() => inputRef.current.click()} className={styles.uploadWrapper}>
    点击、拖拽、粘贴文件到此处上传
    <input
      onClick={(e) => e.stopPropagation()}
      onChange={hanldeChange}
      multiple
      type="file"
      ref={inputRef}
    />
</div>

点击上传代码非常简略,就是利用 input[type="file"] 的能力唤起文件抉择框,而后做一个本人喜爱的容器,把 input 框藏起来,点击容器的时候模仿 input 框点击即可,multiple属性是用来做多文件上传的。

拖拽上传

  const handleDrop = (event) => {event.preventDefault();
    const files = event.dataTransfer.files;
    uploadFiles(files);
  };

  const handleDragOver = (event) => {event.preventDefault();
  };
  return (<div className={styles.container}>
      <div
        onDrop={handleDrop}
        onDragOver={handleDragOver}
        className={styles.uploadWrapper}
        ref={uploadRef}
        onClick={() => inputRef.current.click()}
      >
        点击、拖拽、粘贴文件到此处上传
        <input onChange={hanldeChange} multiple type="file" ref={inputRef} />
      </div>
    </div>
  );

拖拽上传次要是实现了容器的 drop 事件,当鼠标松开时从 event.dataTransfer.files 获取到拖拽的文件

粘贴上传

  useEffect(() => {
    const container = uploadRef.current;
    const pasteUpload = (event) => {event.preventDefault();

      const items = (event.clipboardData || event.originalEvent.clipboardData)
        .items;
      let files = [];

      for (const item of items) {if (item.kind === "file") {files.push(item.getAsFile());
        }
      }
      if (files.length > 0) {uploadFiles(files);
      }
    };
    container.addEventListener("paste", pasteUpload);
    return () => {container.removeEventListener("paste", pasteUpload);
    };
  }, []);

粘贴上传的形式就是在容器中监听 paste 事件,把属于文件的粘贴内容过滤出来。

以上就是三种常见的上传形式,在这三种上传形式中,次要都是为了收集文件。最初上传的逻辑收口到一个 uploadFiles 办法中,在这个办法中能够执行一些前置的校验,比如说文件大小、文件类型、文件个数等等,校验完之后再调用后端接口进行文件上传。

上传限度

上图是一个文件对象的一些相干属性,上面须要关注的属性有:

  • name:文件名
  • size:文件大小,单位为字节,除以 1024 等于KB
  • type:文件类型

对于文件类型的限度,在点击上传的场景中,能够加上一个 accept 的属性,比如说加上一个accept="image/*",这样弹出来的文件抉择框中,就只能抉择图片。然而对于其余两种形式,还是得须要在代码外面进行判断。

  const uploadFiles = (files) => {if (files.length === 0) {return;}
    const list = Array.from(files);
    if (MAX_COUNT && list.length > MAX_COUNT) {message.error(` 最多上传 ${MAX_COUNT}个文件 `);
      return;
    }
    let isOverSize = false;
    if (MAX_SIZE) {
      isOverSize =
        list.filter((file) => {return file.size > MAX_SIZE;}).length > 0;
    }

    if (isOverSize) {message.error(` 最多上传 ${MAX_SIZE / 1024 / 1024}M 大的文件 `);
      return;
    }
    let isNotMatchType = false;
    if (ACCEPS.length > 0) {
      isNotMatchType =
        list.filter((file) => {return ACCEPS.length > 0 && !ACCEPS.includes(file.type);
        }).length > 0;
    }

    if (isNotMatchType) {message.error("上传文件的类型不非法");
      return;
    }
  };

开始上传

在介绍完上传文件的形式之后,就能够真正的把选中的文件发送给后端了。上面我将以 Node 作为服务端语言,来介绍上传文件的前后端交互全流程。

在前端代码的 uploadFiles 逻辑中退出以下逻辑,把咱们下面收集到的文件填充到 formDatafiles字段中,留神这个 files 字段是跟后端约定好的字段,后端依据这个字段取到文件的信息:

setLoading(true);
const formData = new FormData();
list.forEach((file) => {formData.append("files", file);
});
const res = await uploadApi(formData);
const data = res.data.data;
const successCount = data.filter((item) => item.success).length;
message.info(` 上传实现,${successCount}个胜利,${data.length - successCount}个失败 `
);
setLoading(false);

而后后端实现咱们应用 express 来搭建一个服务,这个服务目前须要做以下的事件:

  1. 创立一个动态目录,用于存储动态文件,可通过 URL 拜访,应用的是 express 自带的 static 中间件
  2. 应用 multer 中间件,帮忙咱们在路由中获取文件参数
  3. 实现一个 writeFile 函数,将前端传过来的文件写入磁盘中

具体代码实现如下

const express = require("express");
const multer = require("multer");
const path = require("path");
const fs = require("fs");
const app = express();
const PORT = 3000;
const STATIC_PATH = path.join(__dirname, "public");
const UPLOAD_PATH = path.join(__dirname, "public/upload");
app.use(express.static(STATIC_PATH));

const upload = multer();
const writeFile = async (file) => {const { originalname} = file;
  return new Promise((resolve) => {fs.writeFile(`${UPLOAD_PATH}/${originalname}`, file.buffer, (err) => {if (err) {
        resolve({
          success: false,
          filePath: "",
        });
        return;
      }
      resolve({
        success: true,
        filePath: `http://localhost:3000/upload/${originalname}`,
      });
    });
  });
};
// 解决文件上传
app.post("/upload", upload.array("files"), async (req, res) => {
  // 'files' 参数对应于表单中文件输出字段的名称
  const files = req.files;
  const promises = files.map((file) => writeFile(file));
  const result = await Promise.all(promises);
  // 返回上传胜利的信息
  res.json({data:result});
});

app.listen(PORT, () => {console.log(`Server is running on http://localhost:${PORT}`);
});

上传进度

上传进度次要监听的是 axios 裸露的 onUploadProgress 事件,这个时候能够配合一个进度条应用

const res = await uploadApi(formData, {onUploadProgress: (progressEvent) => {
    const percentage = Math.round((progressEvent.loaded * 100) / progressEvent.total
    );
    setProgress(percentage);
  },
});

这里我把网络调整成 3G,能够更好的看到上传文件的进度过程:

上传文件夹

拖拽 / 复制文件夹与点击文件夹上传稍有不同,前者须要咱们本人去剖析文件夹与文件的门路关系,而后者浏览器的标准接口曾经帮咱们解决好文件夹相干的门路信息,咱们只须要稍作解决即可。上面来看具体的实现

拖拽 / 复制文件夹上传

先以拖拽为例,复制的逻辑与拖拽差不多。下面咱们拖拽一般文件的时候是应用 event.dataTransfer.files, 这个api 是拿不到文件夹的信息的。咱们要换一个 apievent.dataTransfer.items。在遍历这个数组时须要用到一个webkitGetAsEntry 办法,它能够获取到文件或者文件夹的相干信息。

比方上图是一个文件夹,具体看一下须要关注的属性:

  • createReader:文件夹独有,能够递归获取文件夹下的文件夹或文件
  • isDirectory:是否为文件夹
  • isFile:是否为文件

上图是一个文件,须要关注的是

  • file:异步办法,获取文件的内容信息
  • isFile:是否为文件

这样咱们就能够递归的获取文件夹,以拖拽上传为例:

  const processFiles = async (items) => {const folderFiles = [];

    const promises = Array.from(items).map((item) => {return new Promise(async (resolve) => {const entry = item.webkitGetAsEntry();
        if (entry.isFile) {await getFileFromEntry(entry, folderFiles);
        } else if (entry.isDirectory) {await traverseDirectory(entry, folderFiles, entry.name); // 传递文件夹名称
        }

        resolve();});
    });

    await Promise.all(promises);
    return folderFiles;
  };

  const getFileFromEntry = (entry, folderFiles, folderName) => {return new Promise((resolve) => {entry.file((file) => {if (folderName) {file.folder = folderName;}
        folderFiles.push(file);
        resolve();});
    });
  };

  const traverseDirectory = async (directory, folderFiles, folderName) => {return new Promise((resolve) => {const reader = directory.createReader();
      reader.readEntries(async (entries) => {const entryPromises = entries.map((entry) => {return new Promise(async (entryResolve) => {if (entry.isFile) {await getFileFromEntry(entry, folderFiles, folderName);
            } else if (entry.isDirectory) {
              await traverseDirectory(
                entry,
                folderFiles,
                `${folderName}#${entry.name}`
              );
            }
            entryResolve();});
        });

        await Promise.all(entryPromises);
        resolve();});
    });
  };

  const handleDrop = async (event) => {event.preventDefault();

    const items = event.dataTransfer.items;
    const files = await processFiles(items);

    uploadFiles(files);
  };

解释一下下面的流程:

  • 首先判断是文件还是文件夹,是文件的话,则调用 file 办法拿到文件内容;是文件夹的话则调用 createReader 来读取文件夹上面的信息
  • 递归过程中须要把文件夹的名称手动拼成一个门路
  • 这里留神咱们应用 # 来作为文件门路之间的宰割符,因为尝试了一下如果应用/,后端会接管不到
  • 并在读文件的时候,给文件对象赋予一个 folder 属性

而后来革新一下上传文件的逻辑

const buildFile = (file) => {if (file.folder) {
    const originalFile = file;
    const fileName = originalFile.name;
    const newFileName = `${file.folder}#${encodeURIComponent(fileName)}`;
    const newFile = new File([originalFile], newFileName, {
      type: originalFile.type,
      lastModified: originalFile.lastModified,
    });
    return newFile;
  }
  return null;
};

list.forEach((file) => {let newFile = buildFile(file);

  formData.append("files", newFile ? newFile : file);
});

上传之前预处理文件,如果文件中存在 folder 属性,则把文件夹的信息拼在文件名中,因为 file.name 是一个只读属性,无奈批改,所以这里须要拷贝一个文件,赋予新的文件名。这里留神文件名称中能够存在 # 字符,所以须要应用 encodeURIComponent 转一下。

复制的逻辑跟拖拽的解决逻辑大同小异,只有后面解决粘贴板的逻辑是不一样的:

const pasteUpload = async (event) => {event.preventDefault();

  const items = (event.clipboardData || event.originalEvent.clipboardData)
    .items;
  const fileItems = Array.from(items).filter((item) => item.kind === "file"
  );
  const files = await processFiles(fileItems);
  if (files.length > 0) {uploadFiles(files);
  }
};

点击文件夹上传

点击上传的时候,文件夹跟文件是不能够同时上传的,拖拽 / 复制的时候是能够的。所以点击文件夹上传的时候须要辨别开来

<Button onClick={() => folderInputRef.current.click()} type="primary">
  上传文件夹
</Button>
<input
  className={styles.hide}
  directory=""webkitdirectory=""
  onClick={(e) => e.stopPropagation()}
  onChange={handleFolderChange}
  multiple
  type="file"
  ref={folderInputRef}
/>

所以这里我另外做了一个按钮来实现点击文件夹的上传。

能够看到在点击上传文件夹的时候会有一个 webkitRelativePath 属性,这个就是蕴含了文件的所有门路信息。所以咱们只须要稍作解决,就能够间接调用uploadFiles

  const handleFolderChange = (e) => {const list = Array.from(e.target.files);
    const files = list.map((file) => {if (file.webkitRelativePath) {const path = file.webkitRelativePath.split("/");
        const folders = path.slice(0, -1);
        file.folder = folders.join("#");
      }
      return file;
    });
    if (files.length > 0) {uploadFiles(files);
    }
    folderInputRef.current.value = "";
  };

后端实现

好的,下面就是前端局部的实现形式,上面咱们来看后端的实现形式。后端要革新的点有如下几点:

  • 给定一个key,示意这次的上传动作,给文件夹 / 文件起一个惟一名称
  • 如果文件名中存在#,则认为该文件是处于某个文件夹下的,须要先创立好文件夹再写文件

具体代码如下:

const writeFile = async (file, key) => {const { originalname} = file;
  /** 组装文件的惟一名称 */
  const fileName = getFileName(originalname);
  /** 组装文件夹的惟一名称 */
  const folders = originalname
    .split("#")
    .slice(0, -1)
    .map((item) => `${item}-${key}`);
  let path = `${UPLOAD_PATH}/${fileName}`;
  /** 前端读取的门路 */
  let resPath = `${fileName}`;
  let folderFormat = [];
  for (let i = 0; i < folders.length; i++) {const folderName = folders.slice(0, i + 1).join("/");
    folderFormat.push(folderName);
  }
  const folderName = folderFormat[folderFormat.length - 1];
  /** 如果存在文件夹信息 */
  if (folderFormat.length > 0) {
    /** 创立文件夹 */
    if (!fs.existsSync(`${UPLOAD_PATH}/${folderName}`)) {fs.mkdirSync(`${UPLOAD_PATH}/${folderName}`);
      path = `${UPLOAD_PATH}/${folderName}/${fileName}`;
      resPath = `${folderName}/${fileName}`;
    }
  }

  return new Promise((resolve) => {fs.writeFile(path, file.buffer, (err) => {if (err) {
        resolve({
          success: false,
          filePath: "",
        });
        return;
      }
      resolve({
        success: true,
        filePath: `http://localhost:3000/upload/${resPath}`,
      });
    });
  });
};

上传至OSS

在这个上云的时代,很少会间接把文件写在文件系统外面了,因为容器一重启文件就会丢,除非挂载了额定的磁盘门路。大多数还是把文件上传到对象存储服务里边,这里我以阿里云的 oss 为例,把咱们的文件从磁盘上传到对象存储。

const OSS = require("ali-oss");
const client = new OSS({
  region: 'your-oss-region',
  accessKeyId: 'your-access-key-id',
  accessKeySecret: 'your-access-key-secret',
  bucket: 'your-bucket-name'
});

fs.writeFile(path, file.buffer, async (err) => {const res = await client.put(resPath, path);
  if (err) {
    resolve({
      success: false,
      filePath: "",
    });
    return;
  }
  resolve({
    success: true,
    filePath: res.url,
  });
});

写入文件后调用 client.put 办法就能够把资源传输到 oss 中,其中 resPath 是阿里云 oss 的存储地址,path是文件的本地地址。

大文件上传

上面咱们来探讨大文件上传,次要有分片上传,秒传,断点续传等。

  • 分片上传:上传大文件时,如果整个文件一次性上传,网络故障或其余中断可能导致整个上传过程失败,用户须要从新上传整个文件。分片上传容许将文件拆分成小块,每个小块独立上传,如果其中一个小块上传失败,只需从新上传该小块,而不是整个文件。
  • 秒传:如果该文件曾经上传过,则间接返回胜利
  • 断点续传:只上传还没有上传过的文件片段

上面以单文件上传为例,探讨下面的三个性能

分片上传

先介绍一个分片上传的一整个流程:

  1. 前端将文件依照肯定的大小规定进行切片
  2. 前端算出文件的 md5,这个md5 会始终作为文件的惟一 id 标识,用这个 md5 向后端换一个 uploadId
  3. 前端拿到这个 uploadId 之后向后端传输所有分片
  4. 所有分片传输完之后发动合并分片申请
  5. 合并实现,上传完结

前端实现

这里我定义了 1M 大小一个分片,通过 SparkMD5 去计算文件的 MD5,而后通过file.slice 办法对文件进行切片,最初开始发动上传申请。

先用 md5 换取一个uploadId,随后把所有的分片发送过来,最初发送合并申请。

  import SparkMD5 from "spark-md5";
  //....
  const calculateMD5 = (file) => {return new Promise((resolve) => {const reader = new FileReader();

      reader.onload = (e) => {const spark = new SparkMD5.ArrayBuffer();
        spark.append(e.target.result);
        const md5 = spark.end();
        resolve(md5);
      };

      reader.onerror = (error) => {console.error(error);
      };

      reader.readAsArrayBuffer(file);
    });
  };

  const getFileExtension = (file) => {
    const fileName = file.name;
    const dotIndex = fileName.lastIndexOf(".");

    if (dotIndex !== -1) {return fileName.substring(dotIndex + 1).toLowerCase();}

    return null; // No file extension found
  };
  const CHUNK_SIZE = 1 * 1024 * 1024;
  const uploadBigFile = async (file) => {const md5 = await calculateMD5(file);
    const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
    const fileName = `${md5}.${getFileExtension(file)}`;
    const res = await initUpload({
      fileName,
      fileMD5: md5,
      totalChunks,
    });
    const uploadId = res.data.uploadId;
    const promises = [];
    for (let chunkNumber = 1; chunkNumber <= totalChunks; chunkNumber++) {const start = (chunkNumber - 1) * CHUNK_SIZE;
      const end = Math.min(chunkNumber * CHUNK_SIZE, file.size);

      const chunk = file.slice(start, end);
      const formData = new FormData();
      formData.append("file", chunk);
      formData.append("fileName", fileName);
      formData.append("uploadId", uploadId);
      formData.append("partNumber", chunkNumber);
      formData.append("fileMD5", md5);
      promises.push(uploadPart(formData));
    }
    await Promise.all(promises);
    await completeUpload({
      uploadId,
      fileMD5: md5,
      fileName,
    });

对于大文件的 md5 计算,能够有以下的拓展思考,本文就不再开展

  • 把计算逻辑放到web worker,不要阻塞主线程
  • rust 等语言实现 wasm 放到前端应用,能够减速 md5 的计算过程

后端实现

后端须要实现三个接口:

  1. 初始化上传工作,返回uploadId
  2. 接管各个分片,上传到oss
  3. 所有分片上传完之后,向 oss 发动合并申请
const fileMap = {};
app.post("/initUpload", async (req, res) => {const { fileMD5, fileName, totalChunks} = req.body;
  const result = await client.initMultipartUpload(fileName);
  const uploadId = result.uploadId;
  fileMap[fileMD5] = {
    md5: fileMD5,
    uploadId,
    totalChunks,
    uploadedChunks: [],
    parts: [],
    url: "",
  };
  res.json({uploadId});
});

app.post("/uploadPart", upload.array("file"), async (req, res) => {const { fileName, uploadId, partNumber, fileMD5} = req.body;
  if (fileMap[fileMD5].uploadedChunks.includes(partNumber)) {res.json({ success: true});
    return;
  }
  try {
    const partResult = await client.uploadPart(
      fileName,
      uploadId,
      partNumber,
      req.files[0].buffer
    );
    fileMap[fileMD5].uploadedChunks.push(partNumber);
    fileMap[fileMD5].parts.push({
      number: partNumber,
      etag: partResult.etag,
    });
    res.json({success: true});
  } catch (error) {res.status(500).json({error: "上传失败"});
  }
});

app.post("/completeUpload", async (req, res) => {const { fileName, uploadId, fileMD5} = req.body;
  try {const parts = fileMap[fileMD5].parts.sort((a, b) => a.number - b.number);
    const completeResult = await client.completeMultipartUpload(
      fileName,
      uploadId,
      parts
    );
    res.json({completeResult});
  } catch (error) {res.status(500).json({error: "上传失败"});
  }
});

这里再介绍一下下面定义的 fileMap 对象,这个对象次要用来记录一些大文件上传的相干信息,用于做前面的秒传和断点续传。

  • md5:文件的md5
  • uploadIdoss上传的uploadId
  • totalChunks:一共分多少片
  • uploadedChunks:目前曾经上传的 chunk 下标
  • parts:目前上传的文件局部
  • url:上传实现的URL

秒传

秒传就是对于曾经上传过的文件立马返回上传胜利以及上传后的链接,这样前端就不必再走分片上传合并的逻辑。

只须要革新一下 initUpload 接口以及 completeUpload 接口,在 initUpload 的时候如果能在 fileMap 中拿到 url 就间接返回 url,前端拿到url 之后就不走分片上传逻辑;completeUpload合并实现之后把 url 填入 fileMap 中。

app.post("/initUpload", async (req, res) => {const { fileMD5, fileName, totalChunks} = req.body;
  let uploadId;
  let url;
  if (!fileMap[fileMD5]) {const result = await client.initMultipartUpload(fileName);
    uploadId = result.uploadId;
    fileMap[fileMD5] = {
      md5: fileMD5,
      uploadId,
      totalChunks,
      uploadedChunks: [],
      parts: [],
      url: null,
    };
  } else {uploadId = fileMap[fileMD5].uploadId;
    if (fileMap[fileMD5].url) {url = fileMap[fileMD5].url;
    }
  }
  res.json({uploadId, url});
});

app.post("/completeUpload", async (req, res) => {const { fileName, uploadId, fileMD5} = req.body;
  try {const parts = fileMap[fileMD5].parts.sort((a, b) => a.number - b.number);
    const completeResult = await client.completeMultipartUpload(
      fileName,
      uploadId,
      parts
    );
    const url = completeResult.res.requestUrls[0];
    fileMap[fileMD5].url = url;
    res.json({url});
  } catch (error) {res.status(500).json({error: "上传失败"});
  }
});

断点续传

断点续传的逻辑就是不须要再次上传曾经传过的片段,次要革新一下 uploadPart 接口。如果以后分片能够在 fileMap 中找到,则间接返回。

if (fileMap[fileMD5].uploadedChunks.includes(partNumber)) {res.json({ success: true});
    return;
}

最初

以上就是本文介绍的所有场景,如果你有一些不同的想法,欢送评论区交换~如果你感觉有所播种的话,点点关注点点赞吧~

文章举荐:

  • 🚀自定义属于你的脚手架并公布到 NPM 仓库
  • 🔥Electron 打造你本人的录屏软件🔥
  • 🐲龙年大吉——AIGC 生成龙年春联🐲
  • 🌟前端应用 Lottie 实现炫酷的开关成果🌟
正文完
 0