前言

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

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

  • 上传形式

    • 点击上传
    • 拖拽上传
    • 粘贴上传
  • 上传限度
  • 单、多文件上传
  • 文件夹上传
  • 上传进度
  • 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实现炫酷的开关成果