大文件分片上传(前后端实现Vue+node.js)

前言

b站文件上传,发现在上传过程中,在不断的向服务器发送请求,为什么?因为文件过大的话。要只发送一次请求,那么时间就会非常长,如果请求中发生问题,比如网络断开,就要重新上传,代价高,所以要对文件进行分片

上传文件比较大,会容易遇到一下问题:

  1. 上传时间久
  2. 中间一旦出错就需要重新上传
  3. 一般服务端会对文件的大小进行限制

体验不好,解决:分片上传

原理:

将一个比较大的文件分成一个一个的数据小块,每个小块大小相同,利用单文件上传,把小文件逐个传到服务器,上传的时候 ,可以同时上传多个小块,也可以一个一个的上传,上传每个小块后,服务器会保存这些小块,并记录他们的顺序和位置。

把所有小块上传完成后,在服务器会按照正确的顺序把全部小文件组装起来(后端完成组装),还原成完整的大文件,前端要做的核心:把文件进行分片

好处:

减少失败风险,如果在上传过程中遇到了问题,只需要重新上传出错的小片,而不需要重新上传完整的大文件。另外还可以提高上传速度

实现

1. 项目搭建

接下来创建一个demo项目

前端:vue3 + vite

初始化前端项目:

bash 复制代码
npm create vite@latest client -- --template vue
cd client
npm install
//启动命令
npm run dev

后端:express 框架,用到的工具包:multipartyfs-extracorsbody-parsernodemon

初始化后端项目:

bash 复制代码
mkdir server
cd server
npm init -y
npm install express multiparty fs-extra cors body-parser
npm install --save-dev nodemon

完整项目目录

latex 复制代码
fenpian/
├── client/                 # 前端 Vue 项目
│   ├── src/
│   │   ├── components/    # 组件目录
│   │   ├── App.vue
│   │   └── main.js
│   ├── index.html
│   ├── vite.config.js
│   └── package.json
└── server/                 # 后端 Express 项目
    ├── uploads/            # 上传文件存放目录(稍后创建)
    ├── app.js             # 后端入口文件
    └── package.json

后端server/app.js基础代码

javascript 复制代码
const express = require("express");
const cors = require("cors");
const bodyParser = require("body-parser");
const path = require("path");
const multiparty = require("multiparty");
const fse = require("fs-extra");

const app = express();
const PORT = 3000;

app.use(cors());
app.use(bodyParser.json());


// 路由入口
app.get('/', (req, res) => {
  res.send('大文件分片上传服务器已启动');
});

app.listen(PORT, () => {
  console.log(`服务器运行在 http://localhost:${PORT}`);
});
server/package.json
"scripts": {
  "start": "node app.js",
  "dev": "nodemon app.js"
}

2. 读取文件

通过监听inputchange事件,当选取了本地文件后,可以在回调函数中拿到对应的文件

typescript 复制代码
<template>
 <div>
  <input @change="handleUpload" type="file"></input>
 </div>
</template>

<script setup lang="ts">
  const handleUpload = (e: Event) => {
    // console.log((e.target as HTMLInputElement).files)//FileList 大括号包裹,但是有索引,像数组-->伪数组:可以通过下标获取每一项,但是没有数组的方法
    const files=(e.target as HTMLInputElement).files
    if(!files) return
    console.log(files[0]);
  }

</script>

3. 文件如何进行分片?

核心:用Blob对象的slice方法 ,在上一步获取到选择的文件是一个File 对象,他继承于Blob ,所以可以用slice对文件进行分片

typescript 复制代码
let blob=instanceOfBlob.slice([start [,end [,contentType]]])

start和end代表Blob里的下标,表示被拷贝进新的Blob的字节的起始位置和结束位置,接下来用slice方法实现对文件的分片

javascript 复制代码
const createChunks = (file: File) => {
  let cur = 0; //看看文件分到哪里了
  let chunks = [];
  //还没有分完的话就会继续分
  while (cur < file.size) {
    const blob = file.slice(cur, cur + CHUNK_SIZE); //每一片
    chunks.push(blob); //分出来的片放进数组里
    cur += CHUNK_SIZE; //下一片开始位置,下标得移动
  }
  return chunks;
};

分片过程非常快,几乎是瞬间完成,原因:

File对象,Blob对象,它里面保存的只是文件的基本信息,size,type,name等,并没有保存文件的数据,要读数据,需要使用fileReader

网络中断后,客户端和服务端要发生一次对话

客户端问:这个文件还需要传递哪些分片?

服务端答:你还需要传递9-25范围内的分片

4. hash计算

思考:那么服务端怎么知道客户端指的是那个文件呢?

  • 用文件名区分?不可以,因为文件名是可以随便修改的
  • 用文件内容区分:要找到一个能唯一代表这个文件的东西:文件hash值,根据文件内容产生一个唯一的hash值,内容发生变化,hash值就会跟着变化,所以可以用这个方法来区分不同文件

hash:任何数据-->一个固定长度的字符串(这个转化叫做hash算法)常用算法:md5

通过这个方法,可以实现秒传的功能:

服务器在处理上传文件的请求时,先判断下对应文件的hash值有没有记录。如果A和B先后上传同一份内容相容的文件,所以这两份文件的hash值是一样的,当A上传的时候会根据文件内容分生成一个对应的hash值,然后在服务器上就会有一个对应的文件,B再上传的时候,服务器就会发现这个文件的hash值之前已经有记录了,说明和之前已经上传过相同内容的文件,所以就不用处理B的这个上传请求了,给用户的感觉就像是实现了秒传。

计算hash值:

第三方库:spark-md5,需要安装

bash 复制代码
npm i spark-md5

在上一步获取到了文件的所有切片,我们可以用这些切片来计算该文件的hash值,如果一个文件特别大,每个切片的所有内容都参与计算的话会很耗时,所以采取以下策略:

  1. 第一个和最后一个切片的内容全部参与计算
  2. 中间剩余的切片,我们分别在前面,后面和中间区2 个字节参与计算

这样既保证了所有切片都参与计算,也保证不耗费很长时间

javascript 复制代码
const calculateHash = (chunks: Blob[]) => {
  return new Promise((resolve) => {
    //1. 第一个和最后一个切片全部参与计算
    //2. 中间的切片只计算前面两个字节,中间两个字节,最后两个字节
    const targets: Blob[] = []; //存储所有参与计算的切片
    const spark = new SparkMD5.ArrayBuffer();
    const fileReader = new FileReader();
    chunks.forEach((chunk, index) => {
      if (index == 0 || index == chunks.length - 1) {
        targets.push(chunk);
      } else {
        targets.push(chunk.slice(0, 2)); //前面两个字节
        targets.push(chunk.slice(CHUNK_SIZE / 2, CHUNK_SIZE / 2 + 2)); //中间两个字节
        targets.push(chunk.slice(CHUNK_SIZE - 2, CHUNK_SIZE)); //最后两个字节
      }
    });
    fileReader.readAsArrayBuffer(new Blob(targets));
    fileReader.onload = (e) => {
      spark.append((e.target as FileReader).result as ArrayBuffer);
      console.log("hash:"+spark.end());//文件的hash值
      resolve(spark.end());
    };
  });
};

5. 文件上传

5.1. 前端实现

我们以1G的文件来分析,假如每个分片的大小为1M,那么总的分片数将会是1024个,如果我们同时发送这1024个分片,浏览器肯定处理不了,原因是切片文件过多,浏览器一次性创建了太多的请求。这是没有必要的,拿 chrome 浏览器来说,默认的并发数量只有 6,过多的请求并不会提升上传速度,反而是给浏览器带来了巨大的负担。因此,我们有必要限制前端请求个数

怎么做呢,我们要创建最大并发数的请求,比如6个,那么同一时刻我们就允许浏览器只发送6个请求,其中一个请求有了返回的结果后我们再发起一个新的请求,依此类推,直至所有的请求发送完毕。

上传文件时一般还要用到 FormData 对象,需要将我们要传递的文件还有额外信息放到这个 FormData 对象里面。

javascript 复制代码
   const uploadChunks = async (chunks: Blob[]) => {
  //将对象数组转换成FormData对象
  const formDatas = chunks.map((chunk, index) => {
    const formData = new FormData();
    formData.append("fileHash", fileHash.value);//文件hash
    formData.append("chunkHash", fileHash.value + "-" + index);//切片文件的hash
    formData.append("chunk", chunk);//切片文件
    return formData;
  });

  // console.log(formDatas);
  //最大并发请求数
  const max = 6;
  
  const sendRequest = async (formData: FormData) => {
    try {
      const res = await fetch("http://localhost:3000/upload", {
        method: "POST",
        body: formData,
      });
      const data = await res.json();
      console.log(data);
      
    } catch (err) {
      console.log(err);
    }
  }
    const promise: Promise<any>[] = [];
    // for (let i = 0; i < formDatas.length; i++) {
    //   promise.push(sendRequest(formDatas[i]));
    //   //如果请求队列中的请求数达到最大并行请求数时,得等之前的请求完成再循环下一个
    //   if (promise.length === max || i === formDatas.length - 1) {
    //     await Promise.all(promise);
    //     promise.length = 0;
    //   }
    // }

//上述写法有缺陷,使用Promise.all(),等待六个请求全部完成,再开始下一批,会有等待最慢请求的时间;应该使用Promise.race()
 for (let i = 0; i < formDatas.length; i++) {
    const task = sendRequest(formDatas[i]);
    taskPool.push(task);

    // 完成后自动移除自己
    task.then(() => {
      const idx = taskPool.indexOf(task);
      if (idx > -1) taskPool.splice(idx, 1);
    });

    // 达到最大并发 → 任意一个完成,立刻补充新任务,不会等到,效率高
   if (taskPool.length >= max) {
      await Promise.race(taskPool);
    }
  }

  // 等待所有剩余请求结束
  await Promise.all(taskPool);
  //通知服务器去合并分片
  mergeRequest();
 
};
5.2. 后端实现
javascript 复制代码
const UPLOAD_DIR = path.resolve(__dirname, "uploads");
app.post("/upload", function (req, res) {
  console.log("req:", req.body);
  const form = new multiparty.Form();
  form.parse(req, async function (err, fields, files) {
    if (err) {
      res.status(401).json({
        ok: false,
        message: "上传失败",
      });
    }
    console.log("fields:", fields); //包含fileHash和chunkHash
    console.log("files:", files); //是一个chunk数组,里面是一个对象

    //临时存放目录
    const fileHash = fields.fileHash[0];
    const chunkHash = fields.chunkHash[0];
    //存放切片的临时文件夹
    const chunkPath = path.resolve(UPLOAD_DIR, fileHash);//这个方法返回的是一个绝对路径:UPLOAD_DIR/fileHash
    //如果不存在这个文件夹,则创建
    if (!fse.existsSync(chunkPath)) {
      //创建文件夹是异步操作
      await fse.mkdir(chunkPath);
      console.log("创建文件夹成功:", chunkPath);
    }
    //这个是临时路径
    const oldPath = files["chunk"][0]["path"];

    //将切片放到文件夹里
    //chunkHash是新的文件名,将临时文件移动到指定的chunkPath目录
    //UPLOAD_DIR/fileHash/chunkHash
    await fse.move(oldPath, path.resolve(chunkPath, chunkHash));
    res.status(200).json({
      ok: true,
      message: "上传成功",
    });
  });
});

写到这里上传成功后会发现uploads下回出现一个以fileHash命名的文件夹,里面是chunkHash

6. 文件合并

将所有切片都上传到服务器后,需要将所有的切片合并成一个完整的文件

6.1. 前端实现

只需要向服务器发送一个合并的请求,为了区分要合并的文件,需要将文件的hash值传过去

javascript 复制代码
const mergeRequest = () => {
  fetch("http://localhost:3000/merge", {
    method: "POST",
    headers: {
      "content-type": "application/json",
    },
    body: JSON.stringify({
      fileHash: fileHash.value,
      fileName: fileName.value,
      size: CHUNK_SIZE,
    }),
  }).then((res) => {
    console.log(res);
    if (res.status === 200) {
      alert("合并成功");
      resetKey.value++; // 重置 input,允许再次选择相同文件
    } else {
      alert("合并失败");
    }
  });
};
6.2. 后端实现

合并的时候需要从对应的文件中获取所有的切片,然后利用文件的读写操作,实现文件的合并,合并完成后,将生成的文件以filehash值+文件后缀命名存放到对应位置就可以了

javascript 复制代码
// 提取文件后缀名
const extractExt = filename => {
	return filename.slice(filename.lastIndexOf('.'), filename.length)
}
app.post("/merge", async function (req, res) {
  const { fileHash, fileName, size } = req.body;
  console.log("fileHash:", fileHash);
  console.log("fileName:", fileName);
  console.log("size:", size);
  //如果文件已存在,就没必要合并了
  const filePath = path.resolve(UPLOAD_DIR, fileHash + extractExt(fileName));
  if (fse.existsSync(filePath)) {
    return res.status(200).json({
      ok: true,
      message: "文件已合并",
    });
  }
  //如果切片目录不存在,则无法合并切片,报异常
  const chunkDir = path.resolve(UPLOAD_DIR, fileHash);
  if (!fse.existsSync(chunkDir)) {
    return res.status(401).json({
      ok: false,
      message: "合并失败,请重新上传",
    });
  }

  //合并操作
  const chunkPaths = await fse.readdir(chunkDir);//读取存放切片的目录,获取所有切片文件名。
  console.log("chunkPaths:", chunkPaths);
  //对每个切片进行排序,文件系统读取目录时,返回的文件顺序是不确定的!
  chunkPaths.sort((a, b) => {
    return a.split("-")[1] - b.split("-")[1];
  });
  const list = chunkPaths.map((chunkName, index) => {
    return new Promise((resolve, reject) => {
      const chunkPath = path.resolve(chunkDir, chunkName);//找到该切片
      const readStream = fse.createReadStream(chunkPath);//读取切片
      const writeStream = fse.createWriteStream(filePath, {
        start: index * size,
        end: (index + 1) * size,
      });//写入目标文件,起始位置和结束位置,字节为单位

      readStream.on("error", (err) => {
        console.error("读取切片失败:", err);
        reject(err);
      });

      writeStream.on("error", (err) => {
        console.error("写入切片失败:", err);
        reject(err);
      });

      writeStream.on("finish", async () => {
        try {
          await fse.unlink(chunkPath);// 删除已合并的切片
          resolve(); // 标记该切片合并完成
        } catch (err) {
          console.error("删除切片失败:", err);
          reject(err);
        }
      });

      readStream.pipe(writeStream);//将读取流的数据直接管道传输到写入流,实现文件合并。
    });
  });
  //等所有的合并请求完成
  await Promise.all(list);
  //删除临时存放的文件夹
  await fse.remove(chunkDir);
  res.status(200).json({
    ok: true,
    message: "合并成功",
  });
});

7. 秒传

在上传之前可以加一个判断,如果有对应的这个文件,就不用再重复上传了,直接告诉用户上传成功,给用户的感觉就像是实现了秒传。

7.1. 前端实现
javascript 复制代码
const verify = async () => {
  const res = await fetch("http://localhost:3000/verify", {
    method: "POST",
    headers: {
      "content-type": "application/json",
    },
    body: JSON.stringify({
      fileHash: fileHash.value,
      fileName: fileName.value,
    }),
  });
  const data = await res.json();
  console.log(data);
  return data;// data中包含对应的表示服务器上有没有该文件的查询结果
};
const handleUpload = async (e: Event) => {
  //...

  //校验hash值,如果服务器有这个文件就不用重复上传,实现秒传功能
  const data = await verify();
  console.log(data);
  if (!data.data.shouldUpload) {
    alert("秒传成功");
    resetKey.value++; // 重置 input,允许再次选择相同文件
    return;
  }

  //服务器上不存在该文件,上传分片
  uploadChunks(chunks,data.data.existChunks);
};
7.2. 后端实现

合并文件成功后,文件名是以文件的hash值+文件后缀命名的,所以只需要看服务器上有没有对应的这个命名的那个文件就行了

javascript 复制代码
app.post("/verify", async function (req, res) {
  const { fileHash, fileName } = req.body;
  console.log(fileHash, fileName);
  const filePath = path.resolve(UPLOAD_DIR, fileHash + extractExt(fileName));
 
  //如果存在,不用上传
  if (fse.existsSync(filePath)) {
    console.log("文件已存在");
    return res.status(200).json({
      ok: true,
      data: {
        shouldUpload: false,
      },
    });
  }else{
    //不存在,重新上传
    console.log("文件不存在");
    return res.status(200).json({
      ok: true,
      data: {
        shouldUpload: true,
        existChunks:chunkPaths
      },
    });
  }
});

8. 断点续传

对于网络中断需要重新上传的问题没有解决,那该如何解决呢?

如果我们之前已经上传了一部分分片了,我们只需要再上传之前拿到这部分分片,然后再过滤掉是不是就可以避免去重复上传这些分片了,也就是只需要上传那些上传失败的分片,所以,再上传之前还得加一个判断。

8.1. 前端实现
javascript 复制代码
const uploadChunks = async (chunks: Blob[],existChunks:string[]) => {
  //将对象数组转换成FormData对象
  //把服务器上已经存在的切片过滤掉
  console.log("已上传的切片",existChunks)
  const formDatas = chunks.filter((index)=>!existChunks.includes(fileHash.value + "-" + index))
  .map((item, index) => {
    const formData = new FormData();
    formData.append("fileHash", fileHash.value);
    formData.append("chunkHash", fileHash.value + "-" + index);
    formData.append("chunk", item);
    return formData;
  });
//...
};
const handleUpload = async (e: Event) => {
  //...
  //校验hash值,如果服务器有这个文件就不用重复上传,实现秒传功能
  const data = await verify();
  console.log(data);
  if (!data.data.shouldUpload) {
    alert("秒传成功");
    resetKey.value++; // 重置 input,允许再次选择相同文件
    return;
  }

  //上传分片
  uploadChunks(chunks,data.data.existChunks);
};
8.2. 后端实现

只需要在 /verify 这个接口中加上已经上传成功的所有切片的名称就可以,因为所有的切片都存放在以文件的hash值命名的那个文件夹,所以需要读取这个文件夹中所有的切片的名称。

javascript 复制代码
app.post("/verify", async function (req, res) {
  const { fileHash, fileName } = req.body;
  console.log(fileHash, fileName);
  const filePath = path.resolve(UPLOAD_DIR, fileHash + extractExt(fileName));

  //返回服务器上已经上传的切片
  const chunkDir = path.join(UPLOAD_DIR, fileHash);
  let chunkPaths = [];
  if (fse.existsSync(chunkDir)) {
    chunkPaths = await fse.readdir(chunkDir);
    console.log("chunkPaths:", chunkPaths);
  }
  //如果存在,不用上传
  if (fse.existsSync(filePath)) {
    console.log("文件已存在");
    return res.status(200).json({
      ok: true,
      data: {
        shouldUpload: false,
      },
    });
  } else {
    //不存在,重新上传
    console.log("文件不存在");
    return res.status(200).json({
      ok: true,
      data: {
        shouldUpload: true,
        //返回服务器上已经存在的切片
        existChunks: chunkPaths,
      },
    });
  }
});
相关推荐
Csvn4 小时前
前端技术 - 跨端方案对比
前端
liu_bees4 小时前
nvm 极简教程:告别Node版本冲突!Windows下一键切换Node.js版本nvm安装与常用命令
windows·node.js·nvm
七夜zippoe4 小时前
OpenClaw Chrome 扩展:Browser Relay 配置
前端·chrome·openclaw·brower
之歆5 小时前
DAY_12JavaScript DOM 完全指南(三):高级工程篇
开发语言·前端·javascript·ecmascript
来恩10035 小时前
EL表达式应用
前端·javascript·vue.js
希冀1235 小时前
【CSS学习第十篇】
前端·css
小飞侠是个胖子5 小时前
在 WebGL 中构建高性能 3D 沉浸式系统的三套高阶方案
前端·3d
wh_xia_jun5 小时前
Vue3 + Vitest 浏览器测试 从零开发指南
前端·javascript·vue.js
FlyWIHTSKY5 小时前
区块链前端技术栈介绍
前端·区块链