提问 发文

前端技术分享-文件上传

水寒

| 2023-07-31 15:18 147 0 1

前端技术分享-文件上传下载

单文件上传

之所以说是普通文件上传,主要是区别于之后的图片上传,大文件上传,多文件上传。

文件上传本质上还是向后端发起一个请求,和普通的请求不同之处只在于:

1.请求头设置的不同

2.传输内容的不同

设置Content-type

对于文件上传来说,Content-type就应该设置为multipart/form-data

如果前端发起请求,手动设置了multipart/form-data,反而会上传失败,后端报错content-type missing boundary,原因是post 请求上传文件的时候是不需要自己设置 Content-Type,并且会自动给你添加一个 boundary ,用来分割消息主体中的每个字段,这时候自己设置Content-type服务器反而不知道怎么分割每个字段了

FormData

下面是mdn对FormData的描述

https://developer.mozilla.org/zh-CN/docs/Web/API/FormData

FormData 接口提供了一种表示表单数据的键值对 key/value 的构造方式,并且可以轻松的将数据通过XMLHttpRequest.send() 方法发送出去,本接口和此方法都相当简单直接。如果送出时的编码类型被设为 "multipart/form-data",它会使用和表单一样的格式。

我们创建一个FormData实例并且将文件,文件的其他信息(文件名,文件后缀)等通过formData.append(key,value)的方式,添加进此实例中,最后再将实例放入请求里

multiparty插件

npm i multiparty

我们后端使用express,multiparty是一个express的插件,用于分析接收到文件上传的请求后进行解析

选择/获取文件

在上传文件之前,我们首先要做的是让用户选择文件,也就是我们需要获取到File对象,这里提供两种方案

input框选择

//tsx
 <input
        ref={fileInputField}
        onChange={handleFileSelect}
        style={{
          visibility: "hidden",
        }}
        type="file" //必须设置为file类型
        title=""//如果设置为空,鼠标悬浮之后不显示title
        multiple//可以选择多个文件
        accept=".png,.jpg,.jpeg" //设置可以选择文件的后缀名
      ></input>
//input的onChange方法
  const handleFileSelect = (e: ChangeEvent<HTMLInputElement>) => {
    const files = e.target.files;//获取选择到的文件
    if (files) {
      setFiles([...files]);
    }
  };

文件拖拽上传

当我们把一个文件拖到浏览器中的时候(比如pdf文档),浏览器会默认帮我们打开这个文件,也就是说,当我们把文件拖入浏览器的时候,浏览器是可以检测到这个文件对象的。

//tsx
 <div
      ref={fileInputContent}
      onDrop={handleDrop}
      onDragOver={handleDragOver}
    >
</div>
//拖拽获取 dragenter dragleave dragover drop
  //拖拽上传
  const handleDrop = (e: DragEvent<HTMLDivElement>) => {
    e.preventDefault(); //阻止浏览器默认事件,浏览器默认行为是打开文件
    const files = e.dataTransfer.files;//注意
  };
  //拖拽进入阻止默认函数
  const handleDragOver = (e: DragEvent<HTMLDivElement>) => e.preventDefault();

代码

  let formData = new FormData();
    formData.append("file", _file);//此处_file是一个File对象,需要从input框获取
    formData.append("filename", _file.name);
    fetch("http://127.0.0.1:8888/upload_single", {
      method: "POST",
      body: formData,
    }).then((res) => {
      res.json().then(() => {
        console.log("文件上传成功");
      });
    });
app.post("/upload_single", async (req, res) => {
  try {
    // console.log("req", req);
    let { files } = await multiparty_upload(req, true);
    let file = (files.file && files.file[0]) || {};
    res.send({
      code: 0,
      codeText: "upload success",
      originalFilename: file.originalFilename,
      servicePath: file.path.replace(__dirname, HOSTNAME),
    });
  } catch (err) {
    res.send({
      code: 1,
      codeText: err,
    });
  }
});
const uploadDir = `${__dirname}/upload`;
const multiparty_upload = function 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 multiparty.Form(config).parse(req, (err, fields, files) => {
      if (err) {
        reject(err);
        return;
      }
      resolve({
        fields,
        files,
      });
    });
  });
};

进度管控

其实进度管控只需要获取到:上传文件的总大小total,已经上传文件的大小loaded,我们就可以计算出总数了

xhr

xhr其实是比较简单的,有一个onprogress事件,如果使用axios也是同理,也有相应的事件。

/*
XMLHttpRequest.upload下的onprogress方法,得到目前上传的文件大小和总文件大小
*/
// @/server/index.js
export function upload({ methods, url, form }, cb, index) {
    return new Promise(resolve => {
        const xhr = new XMLHttpRequest();
        xhr.open(methods, url);
        xhr.onload = function () {
            resolve(xhr.responseText);
        }
      //onprogress方法可以获取到loaded,total,再计算其进度即可
        xhr.upload.onprogress = function (e) {
            //传递进度条需要的数据,并执行进度条函数
            cb(index, e.loaded, e.total)
        }
        xhr.send(form);
    })
}

fetch

很抱歉,fetch不能实现上传进度的功能。

但是我们就想用fetch发送请求,就是想知道上传进度,我们也可以使用切片上传。

我们知道,无论是请求或者响应,它的 body 属性的类型都是一个叫做 ReadableStream 的可读流。

这种可读流都有一个特点,就是在同一时间只能被一个人读取,那么你想想,请求里的流是不是被浏览器读取了?浏览器把这个流读出来,然后发送到了服务器,所以说我们就读不了了,就是这个问题。

而且浏览器在读的过程中又不告诉我们它读了多少,但是目前 W3C 正在讨论一种方案,这种方案是附带在 ServiceWorker 里边的,它里边有一套 API 叫做,BackgroundFetchManager目前这套 API 里可以实现请求进度的监听,但是这套 API 还在试验中,不能用于生产环境。

切片上传

如果我们有一个比较大的文件,用户上传的时候需要花费较多时间,如果在此期间,网络发生波动,或者用户关闭网页,上传的接口停止执行,那么用户之前传的都白传了,这用户不得气死?

切片上传就是解决这个问题的,使用切片上传的好处:

1.传输大文件的时候用户可以保留之前的进度(断点续传)

2.fetch也能做到进度感知

切片上传示意图

文件切片

第一件事就是把一个完整的文件切成一片一片

核心的方法 file.slice(start, end)

/**
   * 文件切片处理
   * @param file 文件
   * @param maxSliceSize 切片大小
   * @param maxSliceCount 最大切片数量
   * @returns 切片后数据块数组
   */
function fileSclieHandle(
  file: File,
  maxSliceSize = 1024 * 100,
  maxSliceCount = 100
) {
  let sliceSize = maxSliceSize; //切片大小
  let sliceCount = Math.ceil(file.size / sliceSize); //切片数量
  let index = 0;
  let chunks = [];
  //设置最大切片数量
  if (sliceCount > maxSliceCount) {
    sliceSize = file.size / maxSliceCount;
    sliceCount = maxSliceCount;
  }

  while (index < sliceCount) {
    chunks.push({
      //file.sclice作为切片的核心方法
      file: file.slice(index * sliceSize, (index + 1) * sliceSize),
      filename: `${HASH}_${index + 1}.${suffix}`,
    });
    index++;
  }
  return {
    chunks,
    sliceCount,
    sliceSize,
  };
}

文件切片的三个关键接口

/upload_already获取已经上传的切片信息

/upload_chunk上传每一个切片,和文件上传一样

/upload_merge所有切片上传完成通知后端进行切片合并

export const fileSliceUpload = async (file: File, userID: string) => {
  if (!file) {
    alert("未选择文件");
    return;
  }
  const { HASH, suffix } = (await changeBuffer(file)) as changeBufferType;
  let already: string | any[] = [];

  // 获取已经上传的切片信息
  try {
    const res = await fetch(
      `${BASE_URL}/upload_already?HASH=${HASH}?userID=${userID}`,
      {
        method: "GET",
      }
    );
    let data: { code: number; fileList: any[] } = await res.json();
    if (+data.code === 0) {
      already = data.fileList;
      console.log("已经上传的切片信息", already);
    }
  } catch (err) {}

  // 已经上传的切片(数据块)数量
  let chunksUploadIndex = 0;
  const { chunks, sliceCount } = fileSclieHandle(file);
  chunksUpload();

  // 每次上传成功后执行
  async function uploadComplate() {
    // 管控进度条
    chunksUploadIndex++;
    console.log(`上传进度:${(chunksUploadIndex / sliceCount) * 100}%`);

    // 当所有切片都上传成功,我们合并切片
    if (chunksUploadIndex < sliceCount) return;
    try {
      let res = await fetch(`${BASE_URL}/upload_merge`, {
        method: "POST",
        body: JSON.stringify({
          count: sliceCount,
          HASH: HASH,
          userID,
          suffix,
        }),

        headers: {
          "content-type": "application/json",
        },
      });
      const data = await res.json();
      if (+data.code === 0) {
        console.log("成功!!!!!!!!!!!");
        return;
      }
      throw data.codeText;
    } catch (err) {
      console.log("err", err);
    }
  }

  // 把每一个切片都上传到服务器上
  function chunksUpload() {
    chunks.forEach((chunk) => {
      // 已经上传的无需在上传
      if (already.length > 0 && already.includes(chunk.filename)) {
        uploadComplate();
        return;
      }
      let fm = new FormData();
      fm.append("file", chunk.file);
      fm.append("filename", chunk.filename);
      fm.append("userID", userID);
      fm.append("suffix", suffix as string);
      fetch(`${BASE_URL}/upload_chunk`, {
        method: "POST",
        body: fm,
      })
        .then((res) => res.json())
        .then((data) => {
          if (+data.code === 0) {
            uploadComplate();
            return;
          }
          return Promise.reject(data.codeText);
        })
        .catch(() => {
          console.log("err");
          // alert("当前切片上传失败,请您稍后再试~~");
        });
    });
  }

  /**
   * 文件切片处理
   * @param file 文件
   * @param maxSliceSize 切片大小
   * @param maxSliceCount 最大切片数量
   * @returns 切片后数据块数组
   */
  function fileSclieHandle(
    file: File,
    maxSliceSize = 1024 * 100,
    maxSliceCount = 100
  ) {
    let sliceSize = maxSliceSize; //切片大小
    let sliceCount = Math.ceil(file.size / sliceSize); //切片数量
    let index = 0;
    let chunks = [];
    //设置最大切片数量
    if (sliceCount > maxSliceCount) {
      sliceSize = file.size / maxSliceCount;
      sliceCount = maxSliceCount;
    }

    while (index < sliceCount) {
      chunks.push({
        //file.sclice作为切片的核心方法
        file: file.slice(index * sliceSize, (index + 1) * sliceSize),
        filename: `${HASH}_${index + 1}.${suffix}`,
      });
      index++;
    }
    return {
      chunks,
      sliceCount,
      sliceSize,
    };
  }
};
//文件自动上传插件
const multiparty_upload = (req) => {
  const config = {};
  return new Promise((resolve, reject) => {
    //multiparty.Form(config).parse方法来将文件(req)写入文件夹(config.uploadDir)
    new multiparty.Form(config).parse(req, (err, fields, files) => {
      if (err) {
        reject(err);
        return;
      } else {
        resolve({ fields, files });
      }
    });
  });
};
// 创建文件并写入到指定的目录 & 返回客户端结果
const writeFile = function writeFile(res, path, file, filename, stream) {
  return new Promise((resolve, reject) => {
    if (stream) {
      try {
        let readStream = fs.createReadStream(file.path),
          writeStream = fs.createWriteStream(path);
        readStream.pipe(writeStream);
        readStream.on("end", () => {
          resolve();
          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),
      });
    });
  });
};
// 检测文件是否存在
const exists = function exists(path) {
  return new Promise((resolve) => {
    fs.access(path, fs.constants.F_OK, (err) => {
      if (err) {
        resolve(false);
        return;
      }
      resolve(true);
    });
  });
};

/**
 * 文件切片写入
 * TODO:设置过期时间,过期后删除临时文件
 */
router.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 userID = (fields.userID && fields.userID[0]) || "";
    let suffix = (fields.suffix && fields.suffix[0]) || "";
    let path = "";
    let isExists = false;
    let fileIsExists = false;
    // 创建存放切片的临时目录
    let [, HASH] = /^([^_]+)_(\d+)/.exec(filename);
    //上传切片之前判断文件是否已经存在,如果已经存在就不进行上传
    fileIsExists = await exists(
      `${HOME_DIR}/${userID}/${INGEO_DIR_NAME}/${HASH}.${suffix}`
    );
    if (fileIsExists) {
      res.send({
        code: 0,
        msg: "文件已经上传,无需合并切片",
      });
      return;
    }

    path = `${HOME_DIR}/${userID}/${INGEO_DIR_NAME}/${HASH}`;
    !fs.existsSync(path) ? fs.mkdirSync(path) : null;
    // 把切片存储到临时目录中
    path = `${HOME_DIR}/${userID}/${INGEO_DIR_NAME}/${HASH}/${filename}`;

    isExists = await exists(path);
    if (isExists) {
      res.send({
        code: 0,
        codeText: "file is exists",
        originalFilename: filename,
        servicePath: `${HOME_DIR}/${userID}/${INGEO_DIR_NAME}/${HASH}/${filename}`,
      });
      return;
    }
    writeFile(res, path, file, filename, true);
  } catch (err) {
    console.log(err);
    res.send({
      code: 1,
      codeText: err,
    });
  }
});

/**
 * 从临时文件中获取已经上传的切片
 */
router.get("/upload_already", async (req, res) => {
  let { HASH, userID } = req.query;
  let path = `${HOME_DIR}/${userID}/${INGEO_DIR_NAME}/${HASH}`,
    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: fileList,
    });
  } catch (err) {
    res.send({
      code: 0,
      codeText: "",
      fileList: fileList,
    });
  }
});

// 大文件切片上传 & 合并切片
const merge = async function merge(HASH, count, suffix, userID) {
  return new Promise(async (resolve, reject) => {
    let path = `${HOME_DIR}/${userID}/${INGEO_DIR_NAME}/${HASH}`;
    let fileList = [];
    let isChunksExists;
    let fileIsExists;
    isChunksExists = await exists(path);
    fileIsExists = await exists(
      `${HOME_DIR}/${userID}/${INGEO_DIR_NAME}/${HASH}.${suffix}`
    );
    if (fileIsExists) {
      if (isChunksExists) {
        fs.rmdir(path, (err) => {
          if (err) {
            console.log(err);
          } else {
            console.log("文件已存在,文件夹删除成功");
          }
        });
      }
      resolve({
        path: `${HOME_DIR}/${userID}/${INGEO_DIR_NAME}/${HASH}.${suffix}`,
        filename: `${HASH}.${suffix}`,
      });
      return;
    }
    if (!isChunksExists) {
      reject("HASH path is not found!");
      return;
    }
    fileList = fs.readdirSync(path);
    if (fileList.length < count) {
      reject("the slice has not been uploaded!");
      return;
    }
    fileList
      .sort((a, b) => {
        let reg = /_(\d+)/;
        return reg.exec(a)[1] - reg.exec(b)[1];
      })
      .forEach((item) => {
        fs.appendFileSync(
          `${HOME_DIR}/${userID}/${INGEO_DIR_NAME}/${HASH}.${suffix}`,
          fs.readFileSync(`${path}/${item}`)
        );
        fs.unlinkSync(`${path}/${item}`);
      });
    fs.rmdirSync(path);
    resolve({
      path: `${HOME_DIR}/${userID}/${INGEO_DIR_NAME}/${HASH}.${suffix}`,
      filename: `${HASH}.${suffix}`,
    });
  });
};

/**
 * 切片合并
 */
router.post("/upload_merge", async (req, res) => {
  let { HASH, count, userID, suffix } = req.body;

  try {
    let { filename, path } = await merge(HASH, count, suffix, userID);
    res.send({
      code:0,
      msg:'切片合并成功!'
    })
  } catch (err) {
    res.send({
      code: 1,
      codeText: err,
    });
  }
});
收藏 0
分享
分享方式
微信

评论

游客

全部 0条评论

1

文章

513

人气

0

粉丝

0

关注

官方媒体

轻松设计高效搭建,减少3倍设计改稿与开发运维工作量

开始免费试用 预约演示

扫一扫关注公众号 扫一扫联系客服

©Copyrights 2016-2022 杭州易知微科技有限公司 浙ICP备2021017017号-3 浙公网安备33011002011932号

互联网信息服务业务 合字B2-20220090

400-8505-905 复制
免费试用
微信社区
易知微-数据可视化
微信扫一扫入群