Vue 文件切片上传 🔥

170 阅读2分钟

js文件切片上传

此文为完整的单文件上传多文件上传就当是课后作业了🥳;
这里重点说一下,由于是切片上传,所有后端拿到的是 blob 的数据流
前端后端两个部分组成
参考一些资料,记不清是那些了,总结如下:

20230818113314.jpg

学习流程

选择大文件 => 文件切片 => 上传切片 => 合并切片 => 返回URL
下载demo 源码边看边学

前端

vue2 + el

选择大文件

  <el-upload
    ref="file"
    :http-request="handleFileUpload"
    action="#"
    class="avatar-uploader"
    :show-file-list="false"
  >
    <el-button type="primary">上传文件</el-button>
  </el-upload>
  
......

/**
 * @description: 自定义处理要上传的文件
 * @param {*} e
 */
async handleFileUpload(e) {
  console.log("① 要上传的文件:", e);
  const { file = null } = e;
  if (!file) {
    return;
  }
  this.file = file;
  this.upload();
},
    
......

文件切片

  1. 对文件计算切片信息
  2. 根据切片信息对文件进行分割,返回ArrayBuffer数组
 /**
 * @description: 开始切片上传
 */
async upload() {
  // 根据文件大小计算切片长度
  const chunksTemp = createFileChunk(this.file);
  console.log("② 切片长度计算完成:", chunksTemp);
  // 根据切片长度进行切片 、 对整个文件进行hash
  const self = this;
  const hash = await calculateHash(chunksTemp, (progress) => {
    self.hashProgress = progress;
  });
  this.hash = hash;
  console.log("③ 要上传文件hash映射值:", hash);
  ......
},

分析切片信息

  1. 若已存在:返回 url
  2. 断点续传
  3. 第一次上传
// 查询是否上传,或者是否继续断点上传
  this.$http
    .post("/checkfile", {
      hash,
      ext: this.file.name.split(".").pop(),
    })
    .then((res) => {
      if (!res || !res.data) {
        return;
      }
      const { uploaded, uploadedList } = res.data;
      if (uploaded) {
        console.log("④ 文件已存在,无需上传");
        return this.$message.success("秒传成功");
      }
      // 组装上传数据
      const { chunks, requests } = splicingUploadParams(
        chunksTemp,
        this.hash,
        uploadedList
      );
      // 将拼接后的切片数据赋值给原始数据
      this.chunks = chunks;
      // 上传需要上传的切片信息
      this.uploadChunks(requests);
    });

上传切片数组每一个文件

/**
 * @description: 上传需要上传的切片信息
 * @param {*} requests
 */
async uploadChunks(requests) {
  console.log("需要上传的切片:", requests);
  // 并发,发送切片请求 3 代表一次并发3个请求上传
  startUpload("/uploadfile", this.chunks, requests, 3).then(() => {
    console.log("所有切片上传完成✅");
    this.mergeFile();
  });
},

合并上传的切片

/**
 * @description: 发送合并请求
 */
mergeFile() {
  this.$http
    .post("/mergeFile", {
      ext: this.file.name.split(".").pop(),
      size: CHUNK_SIZE,
      hash: this.hash,
    })
    .then((res) => {
      if (res && res.data) {
        console.log(res.data);
      }
    });
},

后端

koa + fs-extra

  1. /checkfile分析切片信息
  2. /uploadfile上传接口
  3. /mergeFile合并接口
  4. 合并切片,并保存
const Koa = require("koa");
const Router = require("koa-router");
const koaBody = require("koa-body");
const path = require("path");
const fse = require("fs-extra");

const app = new Koa();
const router = new Router();
const UPLOAD_DIR = path.resolve(__dirname, "public");

app.use(
  koaBody({
    multipart: true, // 支持文件上传
  })
);

router.post("/checkfile", async (ctx) => {
  const body = ctx.request.body;
  console.log(body);
  const { ext, hash } = body;
  const filePath = path.resolve(UPLOAD_DIR, `${hash}.${ext}`);
  let uploaded = false;
  let uploadedList = [];

  if (fse.existsSync(filePath)) {
    uploaded = true;
  } else {
    uploadedList = await getUploadedList(path.resolve(UPLOAD_DIR, hash));
  }
  ctx.body = {
    code: 0,
    data: {
      uploaded, // 是否存在该文件
      uploadedList, // 已上传的切片数组
    },
  };
});

/**
 * @description: 获取目录下已上传的切片数组
 * @param {*} dirPath
 */
async function getUploadedList(dirPath) {
  return fse.existsSync(dirPath)
    ? (await fse.readdir(dirPath)).filter((name) => name[0] !== ".")
    : [];
}

router.post("/uploadfile", async (ctx) => {
  const body = ctx.request.body;
  const file = ctx.request.files.chunk;
  // console.log('上传接收到的:body:',body);
  
  const { hash, name, totalBlock } = body;

  const chunkPath = path.resolve(UPLOAD_DIR, hash);
  if (!fse.existsSync(chunkPath)) {
    await fse.mkdir(chunkPath);
  }
  
  await fse.move(file.filepath, `${chunkPath}/${name}`);

  ctx.body = {
    code: 0,
    message: `切片上传成功`,
  };
});

/**
 * @description: 合并所有切片
 * @param {*} ext 文件扩展名
 * @param {*} size 设定的切片大小
 * @param {*} hash 文件的hash值
 */
router.post("/mergeFile", async (ctx) => {
  const body = ctx.request.body;
  const { ext, size, hash } = body;
  // 文件最终路径
  const filePath = path.resolve(UPLOAD_DIR, `${hash}.${ext}`);
  await mergeFile(filePath, size, hash);
  ctx.body = {
    code: 0,
    data: {
      url: `/public/${hash}.${ext}`,
    },
  };
});

/**
 * @description: 读取所有切片数据,并排序
 * @param {*} filePath 文件最终路径
 */
async function mergeFile(filePath, size, hash) {
  // 1. 读取所有切片
  const chunkDir = path.resolve(UPLOAD_DIR, hash);
  let chunks = await fse.readdir(chunkDir);
  // 2. 排序切片,得到切片地址数组
  chunks = chunks.sort((a, b) => a.split("-")[1] - b.split("-")[1]);
  chunks = chunks.map((cpath) => path.resolve(chunkDir, cpath));
  await mergeChunks(chunks, filePath, size);
}

function mergeChunks(files, dest, CHUNK_SIZE) {
  /**
   * @description: 
   * @param {*} filePath 切片地址
   * @param {*} writeStream 切片写入目标信息实例
   */  
  const pipeStream = (filePath, writeStream) => {
    return new Promise((resolve, reject) => {
      // 读取切片数据
      const readStream = fse.createReadStream(filePath);
      // 读取完成删除切片
      readStream.on("end", () => {
        fse.unlinkSync(filePath);
        resolve();
      });
      // 切片数据拼接到目标地址中,用于合并生成新的文件
      readStream.pipe(writeStream);
    });
  };

  const pipes = files.map((file, index) => {
    return pipeStream(
      file,
      fse.createWriteStream(dest, {
        start: index * CHUNK_SIZE,
        end: (index + 1) * CHUNK_SIZE,
      })
    );
  });
  return Promise.all(pipes);
}

app.use(router.routes());
app.listen(7001, () => {
  console.log("server running at 7001");
});

总结

此文为最基础的大文件分割上传,希望对家有所帮助。
由于代码写的太久了,忘记了参考的文章,如有雷同请告知;