文件上传 | 青训营笔记

131 阅读2分钟

这是我参与「第四届青训营」笔记创作活动的第8天

这篇文章来讲讲前端文件上传的相关方法

普通的文件上传

这里说的普通方式即 前端 传 FormData 类型的数据。 前端的处理方式很简单, 只用把它用一个 FormData 包装就行了

let formData = new FormData();
formData.append("file", _file);
formData.append("filename", _file.name);
instance
  .post("/upload_single", formData)
  .then((res) => {
    const { code, url } = res;
    if (code === 0) {
      alert(`file 上传成功${url}`);
      return;
    }
    console.log(res);
    return Promise.reject(data.codeText);
  })
  .catch((e) => {
    console.log(e);
  });

后端(node.js) 这里我用了 multipartry 来进行解析。

注意: 这里的 multiparty 它是仅限 FormData 类型的。 如果需要改变文件名称, 就等上传完成后用 fs.rename 实现。

app.post("/upload_single", async (req, res) => {
  try {
    //todo: 单文件上传核心, 用 multiparty_load 进行处理
    let { files, fields } = await multipartry_load(req, true);
    let file = (files.file && files.file[0]) || {};
    res.send({
      code: 0,
      codeText: "上传成功",
      originFilename: file.originFilename,
      url: file.path.replace(baseDir, FONTHOSTNAME),
    });
  } catch (err) {
    res.send({
      code: 1,
      codeText: err,
    });
  }
});

const multipartry_load = function (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(); //
    // 用来将客户端formData 结果解析
    new multipartry.Form(config).parse(req, (err, fields, files) => {
      if (err) {
        reject(err);
        return;
      }
      resolve({
        fields,
        files,
      });
    });
  });
};

大文件切片上传

原理其实很简单, 就是将文件转成 buffer 数组, 然后再用 slice 方法对数组进行切片, 针对每一部分单个进行上传。

通过计算每个分片的 hash 值, 最后只需要判断是否所有的 hash 都传到了后端。 也能通过 hash 来进行断点续传, 只需要重传 对应的切片即可

如果切片过多, 还可以控制请求的并发量。。。

/**
 *
 * @param {} file
 * @returns
 * 根据内容生成hash名字
 */
const changeBuffer = (file) => {
  return new Promise((resolve) => {
    let fileReader = new FileReader();
    fileReader.readAsArrayBuffer(file);
    fileReader.onload = (e) => {
      let buffer = e.target.result;
      const spark = new SparkMD5.ArrayBuffer();
      spark.append(buffer);
      const HASH = spark.end();
      const suffix = /.([0-9a-zA-Z]+)$/.exec(file.name)[1];
      resolve({
        buffer,
        HASH,
        suffix,
        filename: `${HASH}.${suffix}`,
      });
    };
  });
};




/ 点击开始上传
  let chunkList = [];
  let alreadyChunkList = [];
  console.log(_file);
  let maxSize = 1024 * 1024;
  let maxCount = Math.ceil(_file.size / maxSize); // 最大允许分割的切片数量为30
  let index = 0;
  if (!_file) return alert("请先选择图片");
  // 获取文件的hash, 整个文件的
  const { HASH, suffix } = await changeBuffer(_file);
  // 判断当前文件可以切出多少切片
  if (maxCount > 10) {
    // 如果切片数量大于最大值
    maxSize = _file.size / 10; // 则改变切片大小
    maxCount = 10;
  }
  console.log(maxCount, "maxCount");
  console.log(maxSize, "maxSize");

  // 切片
  while (index < maxCount) {
    chunkList.push({
      file: _file.slice(index * maxSize, (index + 1) * maxSize),
      filename: `${HASH}_${index + 1}.${suffix}`,
    });
    index++;
  }

  // 先获取已经上传的切片
  const data = await instance.post(
    "/upload_already",
    {
      HASH: HASH,
    },
    {
      headers: {
        "Content-Type": "application/x-www-form-urlencoded",
      },
    }
  );
  index = 0;
  // 回调
  const complate = async () => {
    index++;
    let progress = `(${index}/${maxCount})%`; // 进度条
    if (index >= maxCount) {
      console.log("ok, 切片完成");
    }
  };

  const { fileList } = data;
  alreadyChunkList = fileList; // 已经上传的切片
  console.log(chunkList, "chunkList");
  chunkList = chunkList.map((item) => {
    if (
      alreadyChunkList.length > 0 &&
      alreadyChunkList.includes(item.filename)
    ) {
      debugger;
      // 表示切片已经存在
      complate();
      return;
    }

    const fm = new FormData();
    fm.append("file", item.file);
    fm.append("filename", item.filename);
    return new Promise((sovle) => {
      instance
        .post("/upload_chunk", fm)
        .then(() => {
          complate();
          sovle();
        })
        .catch(() => {
          //
        });
    });
  });
  Promise.all(chunkList).then(() => {
    instance
      .post(
        "/upload_merge",
        {
          HASH: HASH,
          count: maxCount,
        },
        {
          headers: {
            "Content-Type": "application/x-www-form-urlencoded",
          },
        }
      )
      .then((res) => {
        console.log("ok");
      });
  });
});

后端

/**
 * 上传切片
 */
app.post("/upload_chunk", async (req, res) => {
  try {
    const { fields, files } = await multipartry_load(req);
    const file = (files.file && files.file[0]) || {};
    const filename = (fields.filename && fields.filename[0]) || "";
    // const path = `${uploadDir}/${filename}`
    let isExists = false;
    // 创建存放切片的临时目录
    const [, HASH] = /^([^_]+)_(\d+)/.exec(filename);
    let path = `${uploadDir}/${HASH}`; // 用hash生成一个临时文件夹
    !fs.existsSync(path) ? fs.mkdirSync(path) : null; // 判断该文件夹是否存在,不存在的话,新建一个文件夹
    path = `${uploadDir}/${HASH}/${filename}`; // 将切片存到临时目录中
    isExists = await exists(path);
    if (isExists) {
      res.send({
        code: 0,
        codeText: "file is already exists",
        url: path.replace(FONTHOSTNAME, HOSTNAME),
      });
      return;
    }
    writeFile(res, path, file, filename, true);
  } catch (e) {
    res.send({
      code: 1,
      codeText: e,
    });
  }
});


/**
 * 合并切片
 */
app.post("/upload_merge", async (req, res) => {
  const { HASH, count } = req.body;
  try {
    const { filname, path } = await merge(HASH, count);
    res.send({
      code: 0,
      codeText: "merge sucessfully",
      url: path.replace(baseDir, FONTHOSTNAME),
    });
  } catch (e) {
    res.send({
      code: 1,
      codeText: e,
    });
  }
});

流的形式

那么, 如果在没有FormData的情况怎么上传呢? 最近我在写 webpack 文件上传插件时遇到了一个 Content-Type: application/octet-stream 类型的文件

它获取文件的方法如下, 即通过字节流的形式获取

// 上传这个文件

const req = http.request(`${url}?name=${path.basename(file)}`, {
  method: "POST",
  headers: {
    "Content-Type": "application/octet-stream",
    Connection: "keep-alive",
    "Transfer-Encoding": "chunked",
  },
});
fs.createReadStream(file)
  .on("data", (chunk) => {
    req.write(chunk);
  })
  .on("end", () => {
    req.end();
    fs.unlink(file, () => {
      console.log("删除成功");
    });
    resolve();
  });

那么后端如何进行合并呢? 这边是用到了formidable这个包来实现, 具体用法如下. 用法与 multipartry 一样, 只是它也支持流类型。

app.post("/upload", async (req, res) => {
  var form = new formidable.IncomingForm(); // 创建上传表单
  form.encoding = "utf-8"; // 设置编辑
  form.uploadDir = `${__dirname}/map`; // 设置上传目录
  form.keepExtensions = true; // 保留后缀
  form.maxFieldsSize = 2 * 1024 * 1024; // 文件大小(默认20M)

  form.parse(req, function (err, fields, files) {
    if (err) {
      res.send({
        status: 201,
        message: err,
      });
      return;
    }
    try {
      var newPath = form.uploadDir + "/" + req.query.name;
      // 若文件流的键名为uplaodFile,则fs.renameSync(files.uplaodFile.path, newPath)
      fs.renameSync(files.file.filepath, newPath); //重命名
      res.send({ status: 200, message: "文件上传成功" });
    } catch (err) {
      res.send({
        status: 201,
        message: err,
      });
      return;
    }
  });
});

总结

这次主要是学到了 文件的上传, 切片上传, 以及node环境下的上传。 原理都一样的, 只要知道了怎么获取文件, 传输时需要什么请求头, 后端如何处理, 一般就都解决了。

然后想要优化的话, 可能就是用 webworker 创建一个后台线程去上传等等

参考文章