React+Node全栈无死角解析,吃透文件上传的各个场景

🚀React+Node全栈无死角解析,吃透文件上传的各个场景

前言

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

文件上传在平时的开发过程中很经常会遇到,本文总结了如下常用的文件上传场景,包括前后端的代码实现,希望你下次遇到上传文件的场景可以直接秒杀。文章稍稍有点长,建议点赞收藏食用🐶。

  • 上传方式
    • 点击上传
    • 拖拽上传
    • 粘贴上传
  • 上传限制
  • 单、多文件上传
  • 文件夹上传
  • 上传进度
  • oss上传
  • 大文件上传
    • 切片
    • 断点续传
    • 秒传

image.png

上传方式

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

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

点击上传

<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方法中,在这个方法中可以执行一些前置的校验,比如说文件大小、文件类型、文件个数等等,校验完之后再调用后端接口进行文件上传。

上传限制

image.png

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

  • 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作为服务端语言,来介绍上传文件的前后端交互全流程。

image.png

在前端代码的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方法,它可以获取到文件或者文件夹的相关信息。

image.png

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

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

image.png

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

  • 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}
/>

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

image.png

可以看到在点击上传文件夹的时候会有一个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是文件的本地地址。

image.png

大文件上传

下面我们来讨论大文件上传,主要有分片上传,秒传,断点续传等。

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

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

分片上传

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

  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: "上传失败" });
  }
});

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

image.png

这里再介绍一下上面定义的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;
}

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

最后

以上就是本文介绍的所有场景,如果你有一些不同的想法,欢迎评论区交流~如果你觉得有所收获的话,点点关注点点赞吧~

文章推荐:
  • 27
    点赞
  • 20
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值