React 中实现文件上传+断点续传功能

实现思路

分片上传的思路:

  1. 我们先拿到文件,在前端进行分片,将分片之后的小的文件传递给服务端。
  2. 当在客户端传送完成的时候,发送最后一个请求告诉服务端,文件已经传送完成了,然后服务端再将之前接收到文件进行合并成一个大的文件。最终再告诉客户端合并好的这个大文件。

断点续传,两种方案:

  1. 在上传之前先拉一下已经上传了那些切片在服务端了,然后客户端就可以跳过已经上传的切片了。
  2. 客户端不处理,在服务端进行处理,客户端上传所有的切片,然后服务端发现如果已经上传过了,则迅速返回成功,告诉客户端再继续传送下一个切片。

前端实现

1. axios 封装

js 复制代码
import axios from "axios";
import Qs from "qs";

let instance = axios.create();
instance.defaults.baseURL = "http://127.0.0.1:8888";
instance.defaults.headers["Content-Type"] = "multipart/form-data";

instance.defaults.transformRequest = (data, headers) => {
  const contentType = headers["Content-Type"];
  if (contentType === "application/x-www-form-urlencoded")
    return Qs.stringify(data);
  return data;
};

instance.interceptors.response.use((response) => {
  return response.data;
});

export default instance;

2. 分片上传逻辑

js 复制代码
import { useRef, useState } from "react";
import SparkMD5 from "spark-md5";

import "./large-file-upload.less";
import instance from "../utils/instance";

function LargeFileUpload() {
  const [loading, setLoading] = useState(false);
  const inputFileRef = useRef();

  const handleUploadClick = () => {
    inputFileRef.current.click();
  };

  // 根据文件生成 一个hash 值
  function changeBuffer(file) {
    return new Promise((resolve) => {
      let fileReader = new FileReader();
      // 调用读取 file 内容的函数,当读取完成的时候, readyState 变成 DONE (已完成), 并触发 loadend 时间,同时 result 属性中包含一个
      // arrayBuffer 对象表示所读取文件的数据。
      fileReader.readAsArrayBuffer(file);

      // 当读取操作完成的时候,触发 loaded 事件
      fileReader.onload = (ev) => {
        // result 表示所读取的文件数据
        let buffer = ev.target.result;
        let spark = new SparkMD5.ArrayBuffer();

        let HASH, suffix;
        spark.append(buffer);
        // 根据文件内容生成一个 hash 值
        HASH = spark.end();
        // 在文件名里面匹配 . 后面的字母
        // 第一个是匹配的 所有字符, 第二个是 匹配的第一个组
        suffix = /\.([a-zA-z0-9]+)$/.exec(file.name)[1];

        resolve({
          buffer,
          HASH,
          suffix,
          filename: `${HASH}.${suffix}`,
        });
      };
    });
  }

  const onInputFileChange = async () => {
    let file = inputFileRef.current.files[0];
    // 获取文件的 hash 值
    let already = [],
      data = null;

    // 根据文件内容生成  hash 值的时候,也是必要消耗时间的
    setLoading(true);
    // 根据文件内容生成  hash 值,和获取文件后缀
    let { HASH, suffix } = await changeBuffer(file);

    // 获取已经上传的切片信息
    try {
      data = await instance.get("/upload_already", {
        params: {
          HASH,
        },
      });
      // 拿到已经上传好的 切片列表
      if (+data.code === 0) {
        already = data.fileList;
      }
    } catch (err) {
      console.log(err);
    }

    // 实现文件的切片处理
    // 有两种策略  【固定大小  或者 固定数量】
    let max = 1024 * 100; // 每次传输的最大字节数
    let count = Math.ceil(file.size / max); // 计算一共要分多少个切片
    let index = 0;
    let chunks = [];
    // 如果计算的 切片个数大于 100 个则,就固定切片个数
    if (count > 100) {
      count = 100;
      // 重新计算切片的大小
      max = file.size / 100;
    }
    // 生成切片信息
    while (index < count) {
      chunks.push({
        file: file.slice(index * max, (index + 1) * max),
        filename: `${HASH}_${index + 1}.${suffix}`,
      });
      index++;
    }
    index = 0;

    const complate = async () => {
      index++;
      // 当如果没有达到最大的个数

      if (index < count) return;
      try {
        data = await instance.post(
          "/upload_merge",
          {
            HASH,
            count,
          },
          {
            headers: {
              "Content-Type": "application/x-www-form-urlencoded",
            },
          }
        );
        if (+data.code === 0) {
          setLoading(false);
          // 上传完成之后清除 form 的值
          inputFileRef.current.value = "";
          alert(
            `恭喜你,文件上传成功,你可以访问 ${data.servicePath} 访问该文件~~`
          );
        }
      } catch (err) {
        alert("切片合并失败,请稍后再试");
      }
    };
    // 遍历收集好的 切片信息
    chunks.forEach((chunk) => {
      // 看看是否有已经上传的切片信息
      if (already.length > 0 && already.includes(chunk.filename)) {
        // 这里的 return 表示跳过的意思
        complate();
        return;
      }
      let fm = new FormData();
      fm.append("file", chunk.file);
      fm.append("filename", chunk.filename);
      instance.post("/upload_chunk", fm).then((data) => {
        if (+data.code === 0) {
          complate();
        }
        // 如果 code 不是 0
        return Promise.reject(data.codeText);
      });
    });
  };

  return (
    <div className="large-file-upload" onClick={handleUploadClick}>
      {loading ? (
        <div className="loading">loading...</div>
      ) : (
        <span className="add">+</span>
      )}

      <input type="file" ref={inputFileRef} onChange={onInputFileChange} />
    </div>
  );
}

export default LargeFileUpload;

实现文件上传切片处理的方式

  1. 固定数量。
  2. 固定大小。(设置一个每次传输的最大字节数,如果根据最大字节数计算出来的切片数量超过最大切片数量的话,则按照最大的切片数量重新计算每次传输的最大字节数)

后端实现(nodejs)

1.引用包

body-parser:bodyParser用于解析客户端请求的body中的内容,内部使用JSON编码处理,url编码,处理以及对于文件的上传处理。

express: 创建 api服务

multiparty: 解析Content-Type multipart/form-data的HTTP请求,也被称为文件上传。

spark-md5:

  • MD5计算将整个文件或者字符串,通过其不可逆的字符串变换计算,产生文件或字符串的MD5散列值。任意两个文件、字符串不会有相同的散列值(即"很大可能"是不一样的,理论上要创造出两个散列值相同的字符串是很困难的)。

  • 因此MD5常用于校验字符串或者文件,以防止文件、字符串被"篡改"。因为如果文件、字符串的MD5散列值不一样,说明文件内容也是不一样的,即经过修改的,如果发现下载的文件和给的MD5值不一样,需要慎重使用。

2. 代码实现

js 复制代码
// 使用 express 编写 api 程序

const express = require("express");
const fs = require("fs");
const bodyParser = require("body-parser");
const multipart = require("multiparty");
const sparkMd5 = require("spark-md5");

// 创建服务

const app = express();
const PORT = 8888;
const HOST = "http://127.0.0.1";
const HOSTNAME = `${HOST}:${PORT}`;

app.listen(PORT, () => {
  console.log(`上传服务启动,请访问${HOSTNAME}`);
});

// 中间件

app.use((req, res, next) => {
  res.header("Access-Control-allow-origin", "*");
  // 如果是 options 请求则放行
  req.method === "OPTIONS"
    ? res.send("current services support cross domain requests!")
    : next();
});

app.use(
  bodyParser.urlencoded({
    extended: false,
    limit: "1024mb",
  })
);

// API

// 延时函数

function delay(interval) {
  typeof interval !== "number" ? (interval = 1000) : null;
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve();
    }, interval);
  });
}

// 检测文件是否存在
const exists = function exists(path) {
  return new Promise((resolve) => {
    fs.access(path, fs.constants.F_OK, (err) => {
      if (err) {
        resolve(false);
        return;
      }
      resolve(true);
    });
  });
};

//  大文件上传 & 合并切片
const merge = (HASH, count) => {
  return new Promise(async (resolve, reject) => {
    let path = `${uploadDir}/${HASH}`;
    let fileList = [];
    let suffix = "";
    let isExists;
    // 看当前路径是否存在
    isExists = await exists(path);
    // 根据 hash 值没有找到
    if (!isExists) {
      reject("HASH path is not found!");
      return;
    }
    fileList = fs.readdirSync(path);
    if (fileList.length < count) {
      reject("ths slice has not been uploaded!");
      return;
    }
    fileList
      .sort((a, b) => {
        let reg = /_(\d+)/;
        return reg.exec(a)[1] - reg.exec(b)[1];
      })
      .forEach((item) => {
        !suffix ? (suffix = /\.([0-9a-zA-Z]+)$/.exec(item)[1]) : null;
        // 把切片合并成一个文件
        fs.appendFileSync(
          `${uploadDir}/${HASH}.${suffix}`,
          fs.readFileSync(`${path}/${item}`)
        );
        // 删除切片
        fs.unlinkSync(`${path}/${item}`);
      });
    // 移除临时空的文件夹
    fs.rmdirSync(path);
    resolve({
      path: `${uploadDir}/${HASH}.${suffix}`,
      filename: `${HASH}.${suffix}`,
    });
  });
};

// 创建文件 并写入到指定的目录 并且返回给客户端结果
const writeFile = (res, path, file, filename, stream) => {
  return new Promise((resolve, reject) => {
    if (stream) {
      try {
        // 创建可读,可写 流
        let readStream = fs.createReadStream(file.path);
        let writeStream = fs.createWriteStream(path);
        // 将可写流交给可读流管道
        // 上面三行代码的作用是,将文件从 file.path 复制到 path
        readStream.pipe(writeStream);
        readStream.on("end", () => {
          resolve();
          // 然后删除掉 file.path 下面的文件
          fs.unlinkSync(file.path);
          res.send({
            code: 0,
            codeText: "upload success",
            originalFilename: filename,
            servicePath: path.replace(__dirname, HOSTNAME),
          });
        });
      } catch (err) {
        reject(err);
        res.send({
          code: 1,
          codeText: err,
        });
      }
      return;
    }
    fs.writeFile(path, file, (err) => {
      if (err) {
        reject(err);
        res.send({
          code: 1,
          codeText: err,
        });
        return;
      }
      resolve();
      res.send({
        code: 0,
        codeText: "upload success",
        originalFilename: filename,
        servicePath: path.replace(__dirname, HOSTNAME),
      });
    });
  });
};

// 基于 multiparty 插件实现文件上传处理 & form-data 解析

const uploadDir = `${__dirname}/upload`;

const multiparty_upload = (req, auto) => {
  typeof auto !== "boolean" ? (auto = false) : null;
  let config = {
    maxFieldsSize: 200 * 1024 * 1024,
  };
  if (auto) config.uploadDir = uploadDir;

  return new Promise(async (resolve, reject) => {
    await delay();
    new multipart.Form(config).parse(req, (err, fields, files) => {
      if (err) {
        reject(err);
        return;
      }
      // 解析出文件,和文件名
      resolve({
        fields,
        files,
      });
    });
  });
};

// 合并文件
app.post("/upload_merge", async (req, res) => {
  let { HASH, count } = req.body;

  // 尝试合并文件
  try {
    let { filename, path } = await merge(HASH, count);
    res.send({
      code: 0,
      codeText: "merge success",
      originalFilename: filename,
      servicePath: path.replace(__dirname, HOSTNAME),
    });
  } catch (err) {
    res.send({
      code: 1,
      codeText: err,
    });
  }
});

// 请求已经上传好的分片
app.get("/upload_already", async (req, res) => {
  let { HASH } = req.query;

  let path = `${uploadDir}/${HASH}`;
  let fileList = [];

  try {
    // 读取文件目录
    fileList = fs.readdirSync(path);
    // 对文件 进行一个排序
    fileList = fileList.sort((a, b) => {
      // 匹配数字
      let reg = /_(\d)+/;
      return reg.exec(a)[1] - reg.exec(b)[1];
    });

    // 发送给前端
    res.send({
      code: 0,
      codeText: "",
      fileList,
    });
  } catch (err) {
    res.send({
      code: 0,
      codeText: "",
      fileList: fileList,
    });
  }
});

// 上传分片的接口
app.post("/upload_chunk", async (req, res) => {
  try {
    let { fields, files } = await multiparty_upload(req);
    let file = (files.file && files.file[0]) || {};
    let filename = (fields.filename && fields.filename[0]) || "";
    let path = "";
    let isExists = false;
    // 创建存放 切片的临时目录
    let [, HASH] = /^([^_]+)_(\d+)/.exec(filename);
    path = `${uploadDir}/${HASH}`;
    !fs.existsSync(path) ? fs.mkdirSync(path) : null;
    // 把切片存储发哦临时目录中
    path = `${uploadDir}/${HASH}/${filename}`;
    isExists = await exists(path);
    if (isExists) {
      res.send({
        // 0 表示成功
        code: 0,
        codeText: "file is exists",
        originalFilename: filename,
        servicePath: path.replace(__dirname, HOSTNAME),
      });
      return;
    }
    writeFile(res, path, file, filename, true);
  } catch (err) {
    res.send({
      code: 1,
      codeText: err,
    });
  }
});

app.use(express.static("./"));
app.use((req, res) => {
  res.status(404);
  res.send("not found!");
});
shell 复制代码
## 求职广告^_^

如有大佬招前端(西安)的可评论留言。

私信或者微信:xiuxiuyifanf,跪求一个前端开发坑位。
相关推荐
程序员爱技术8 分钟前
Vue 2 + JavaScript + vue-count-to 集成案例
前端·javascript·vue.js
并不会1 小时前
常见 CSS 选择器用法
前端·css·学习·html·前端开发·css选择器
悦涵仙子1 小时前
CSS中的变量应用——:root,Sass变量,JavaScript中使用Sass变量
javascript·css·sass
衣乌安、1 小时前
【CSS】居中样式
前端·css·css3
兔老大的胡萝卜1 小时前
ppk谈JavaScript,悟透JavaScript,精通CSS高级Web,JavaScript DOM编程艺术,高性能JavaScript pdf
前端·javascript
低代码布道师1 小时前
CSS的三个重点
前端·css
耶啵奶膘3 小时前
uniapp-是否删除
linux·前端·uni-app
王哈哈^_^4 小时前
【数据集】【YOLO】【目标检测】交通事故识别数据集 8939 张,YOLO道路事故目标检测实战训练教程!
前端·人工智能·深度学习·yolo·目标检测·计算机视觉·pyqt
cs_dn_Jie5 小时前
钉钉 H5 微应用 手机端调试
前端·javascript·vue.js·vue·钉钉
开心工作室_kaic5 小时前
ssm068海鲜自助餐厅系统+vue(论文+源码)_kaic
前端·javascript·vue.js