大文件切片上传以及切片合并

前端:vue

后端:nodejs + express

先了解一下为什么需要切片上传,在上传文件的时候,假如文件大小高达几百上千MB的时候,上传的时间就会变得很长,如果出现网络不稳定或者浏览器、程序崩溃等,上传就会失败,而此时文件切片上传以及断点续传就很重要了,当然还有大文件秒传,这是文件的 hash值 来实现的,如果服务器存在相同 hash值 的文件则直接告诉前端文件上传成功,本文先讲切片上传 以及切片合并

先来看前端代码: 首先我们需要一个 input , 并且 type="file",以及一个上传的按钮,具体代码如下

js 复制代码
<template>
  <div>
    <input type="file" @change="onFileChange" />
    <button @click="upload">Upload</button>
  </div>
</template>
<script>
import axios from 'axios';

export default {
  data() {
    return {
      file: null,//上传的文件
      filename: "",//文件名
    };
  },
  methods: {
    onFileChange(event) {
      this.file = event.target.files[0];
      console.log(event.target.files[0]);
      this.filename = this.file.name
    },
    async upload() {
      if (!this.file) {
        return;
      }
      /**
       * chunkSize 为切片大小,单位为字节
       * totalChunks 为切片总数  Math.ceil 为向上取整
       * chunkIndex 为当前切片的索引
       * chunkPromises 为所有切片的 Promise
       */
      const chunkSize = 10 * 1024 * 1024; // 10MB
      const totalChunks = Math.ceil(this.file.size / chunkSize);
      const chunkPromises = [];

      // 进行切片
      for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) {
        const start = chunkIndex * chunkSize;
        const end = Math.min(start + chunkSize, this.file.size);
        const chunk = this.file.slice(start, end);


        //  FormData 对象,可以存储二进制数据和表单数据,注意append的顺序,chunk放在最后
        //  确保后端能先解析到chunkIndex, totalChunks ,filename  用于文件命名
        const formData = new FormData();
        formData.append('chunkIndex', chunkIndex);  //key:chunkIndex  value:每个切片的索引
        formData.append('totalChunks', totalChunks);  //key:totalChunks  value:切片总数
        formData.append('filename', this.file.name);  //key:filename  value:文件名
        formData.append('file', chunk);  //key:file  value:每个切片的二进制数据
        const uploadPromise = axios.post('http://127.0.0.1:3000/upload', formData);
        chunkPromises.push(uploadPromise);

        // const fileReader = new FileReader();
        // fileReader.onload = (e) => {
        //   console.log(e);
        // }
        // fileReader.readAsDataURL(chunk);
      }
      console.log(chunkPromises);
      const res=await Promise.all(chunkPromises);
      console.log('res',res);
      

      // 所有切片上传完成后,可以发送请求告知服务器文件上传完成
      await axios.post('http://127.0.0.1:3000/upload/complete', { filename: this.filename });

      // 上传完成后的回调
      console.log('文件上传完成');
    }
  }
};
</script>

再来看下nodejs实现的后端代码,先来看下依赖:

js 复制代码
"dependencies": {
    "express": "^4.18.2",
    "multer": "1.4.5-lts.1"
  }

先来引入需要用到的包以及解决跨域问题

js 复制代码
const express = require('express');
const multer = require('multer');
const app = express();
const fs = require('fs');
const path = require('path');

app.use((req, res, next) => {
  // 允许所有域名的跨域请求
  res.header('Access-Control-Allow-Origin', '*');
  // 设置允许的HTTP头部类型
  res.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept, Authorization');
  
  // 设置允许的HTTP方法
  if (req.method === 'OPTIONS') {
    res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE');
    return res.status(200).json({});
  }

  // 继续处理请求
  next();
});

配置中间件解析 JSON 请求

js 复制代码
// express.json() 是一个中间件,用于解析 JSON 格式的请求体
app.use(express.json());

写一下接收文件切片的接口,

js 复制代码
// 很显然,这里使用了个中间件,是multer存储文件的
app.post('/upload',upload.single('file'),(req, res) => {
  console.log('返回数据---');
  
  res.send({
    code: 200,
    msg: '上传成功',
  });
});

其中upload.single('file'),这个中间件是multer提供的,下面来配置一下,每当调用这个接口的时候,都会触发下面的配置。

配置一下 multer 包,这是一个第三方库,可以配置自动将文件存储在服务器指定的目录下,记得使用中间件upload.single('file')

js 复制代码
// 配置文件存储 这个是multer库的配置,当接口使用upload.single('file')中间件时会自动调用这个配置
const storage = multer.diskStorage({
  // 设置文件存储的位置,其中回调函数cb的第一个参数表示错误信息,第二个参数表示文件的存储路径
  destination: function (req, file, cb) {
    // 创建临时文件夹
    try {
      fs.mkdirSync(path.resolve(__dirname, 'temp'), { recursive: true });
    } catch (error) {
      console.log(' 创建临时文件夹失败',error);
    }
    
    cb(null, path.resolve(__dirname, 'temp'));
  },
  // 设置文件名,同上,第一个参数表示错误信息,第二个参数表示文件名
  filename: function (req, file, cb) {
    // console.log('reqbody',req.body);//打印req.body,
    // console.log('file',file);
    cb(null, file.fieldname + '_' + req.body.chunkIndex);
  }
});

//这个应该就是 multer 使用刚刚那个配置需要调用的
const upload = multer({ storage: storage });

这里的 filename回调函数是用于命名的,记得前端传过来的FormData中的切片chunk放在最后,命名格式如下:

这样可以根据后面的数字进行排序。

再来写一下切片上传完成后合并切片的接口

js 复制代码
//当前端所有切片上传完成之后,会调用这个接口进行合并
app.post('/upload/complete', (req, res) => {
  
  // console.log('filename',req.body.filename);
  const filename = req.body.filename;
  combineChunks(path.resolve(__dirname, 'acceptFiles/'+filename),path.resolve(__dirname, 'temp'))


  res.json('文件上传完成');
});

//   combineChunks 方法用于将所有切片合并到一个文件中
//   第一个参数:存放路径,其中工作目录`__dirname`,文件存放目录 `acceptFiles`,文件名 filename
//   第二个参数: 切片存放的目录路径
const combineChunks = (outputFilename, tempDir) => {
  // fs.reddir 读取文件夹中的文件 , files 返回文件列表
  fs.readdir(tempDir, (err, files) => {
    if (err) {
      console.error('Error reading directory:', err);
      return;
    }
   //将文件列表进行排序  因为每个切片命名为,举例: hhh.mp4, 每个切片 为 file_0,file_1 ,依次类推,
   //顺序对于切片合并来说非常重要,顺序错误合并的文件也会错误
    files.sort((a, b) => {
      return parseInt(a.split('_').pop()) - parseInt(b.split('_').pop())
    })
    console.log('排序后files',files);

    // 获取目录路径
    const directoryPath = path.dirname(outputFilename);
    // 创建目录,如果它不存在
    fs.mkdir(directoryPath, { recursive: true }, (err) => {
      if (err) {
        return console.error('创建目录时发生错误:', err);
      }
    });
      // 创建一个可写流用于输出文件
    const output = fs.createWriteStream(outputFilename);
    
    (async function() {
      for (const file of files) {
      console.log('file',file)
    // 对每个文件,创建一个Promise以确保按序写入
        await new Promise((resolve, reject) => {
          // 获取当前文件的完整路径
          const filePath = path.resolve(tempDir, file);
          // 创建一个读取当前文件的流
          const readStream = fs.createReadStream(filePath);
    
      // 如果读取流发生错误,拒绝这个Promise
          readStream.on('error', reject);
          // 如果写入流发生错误,也拒绝这个Promise
          output.on('error', reject);
    
      // 当读取流结束时,解决这个Promise,表示当前文件已经写入完毕
          readStream.on('end', resolve);
          // 使用 pipe 方法将读取流的内容写入 output 可写流中
          // end: false 参数表示在当前文件写入完成后不关闭输出流
          readStream.pipe(output, { end: false });
        });
      }
      // 当所有文件写入完成后,关闭输出流
      output.end();
    })()
    .then( 
      // 删除存放切片文件夹,recursive: true 表示递归删除
      () => fs.rm(tempDir, { recursive: true },
      (err) => {
        if (err) {
          // 处理错误
          console.error(err);
        } else {
          // 删除成功
          console.log('Directory removed successfully.');
        }
      })
    )
    .catch(e=>{
      console.log('发生错误',e)
    });
  })
}

下面来试一下,选择一个文件

点击 upload,可以看到,temp目录下有切片,并且切片合并好放在了 acceptFiles 目录了。

当合并完成之后,会执行 fs.rm 方法删除存放切片的 temp 目录。

后端完整代码:

js 复制代码
const express = require('express');
const multer = require('multer');
const app = express();
const fs = require('fs');
const path = require('path');

app.use((req, res, next) => {
  // 允许所有域名的跨域请求
  res.header('Access-Control-Allow-Origin', '*');
  // 设置允许的HTTP头部类型
  res.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept, Authorization');
  
  // 设置允许的HTTP方法
  if (req.method === 'OPTIONS') {
    res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE');
    return res.status(200).json({});
  }

  // 继续处理请求
  next();
});


// express.json() 是一个中间件,用于解析 JSON 格式的请求体
app.use(express.json());

// 配置文件存储 这个是multer库的配置,当接口使用upload.single('file')中间件时会自动调用这个配置
const storage = multer.diskStorage({
  // 设置文件存储的位置,其中回调函数的第一个参数表示错误信息,第二个参数表示文件的存储路径
  destination: function (req, file, cb) {
    // 创建临时文件夹
    try {
      fs.mkdirSync(path.resolve(__dirname, 'temp'), { recursive: true });
    } catch (error) {
      console.log(' 创建临时文件夹失败',error);
    }
    
    cb(null, path.resolve(__dirname, 'temp'));
  },
  // 设置文件名,同上,第一个参数表示错误信息,第二个参数表示文件名,这里有坑,下面说
  filename: function (req, file, cb) {
    // console.log('reqbody',req.body);
    // console.log('file',file);
    cb(null, file.fieldname + '_' + req.body.chunkIndex);
  }
});

//这个应该就是 multer 使用刚刚那个配置需要调用的
const upload = multer({ storage: storage });

app.post('/upload',upload.single('file'),(req, res) => {
  console.log('返回数据---');
  
  res.send({
    code: 200,
    msg: '上传成功',
  });
});

//当前端所有切片上传完成之后,会调用这个接口进行合并
app.post('/upload/complete', (req, res) => {
  
  // console.log('filename',req.body.filename);
  const filename = req.body.filename;
  combineChunks(path.resolve(__dirname, 'acceptFiles/'+filename),path.resolve(__dirname, 'temp'))


  res.json('文件上传完成');
});

app.listen(3000, () => {
  console.log('服务器已启动');
});


//   这个方法用于将所有切片合并到一个文件中
//   第一个参数:存放路径,其中工作目录`__dirname`,文件存放目录 `acceptFiles`,文件名 filename
//   第二个参数: 切片存放的目录路径
const combineChunks = (outputFilename, tempDir) => {
  // fs.reddir 读取文件夹中的文件 , files 返回文件列表
  fs.readdir(tempDir, (err, files) => {
    if (err) {
      console.error('Error reading directory:', err);
      return;
    }
    /**
      将文件列表进行排序  因为每个切片命名为,举例: hhh.mp4, 每个切片 为 hhh.mp4_0,hhh.mp4_1 ,依次类推,
      顺序对于切片合并来说非常重要,顺序错误合并的文件也会错误
     *  */ 
    files.sort((a, b) => {
      return parseInt(a.split('_').pop()) - parseInt(b.split('_').pop())
    })
    console.log('排序后files',files);

    // 获取目录路径
    const directoryPath = path.dirname(outputFilename);
    // 创建目录,如果它不存在
    fs.mkdir(directoryPath, { recursive: true }, (err) => {
      if (err) {
        return console.error('创建目录时发生错误:', err);
      }
    });
      // 创建一个可写流用于输出文件
    const output = fs.createWriteStream(outputFilename);
    
    (async function() {
      for (const file of files) {
      console.log('file',file)
    // 对每个文件,创建一个Promise以确保按序写入
        await new Promise((resolve, reject) => {
          // 获取当前文件的完整路径
          const filePath = path.resolve(tempDir, file);
          // 创建一个读取当前文件的流
          const readStream = fs.createReadStream(filePath);
    
      // 如果读取流发生错误,拒绝这个Promise
          readStream.on('error', reject);
          // 如果写入流发生错误,也拒绝这个Promise
          output.on('error', reject);
    
      // 当读取流结束时,解决这个Promise,表示当前文件已经写入完毕
          readStream.on('end', resolve);
          // 使用 pipe 方法将读取流的内容写入 output 可写流中
          // end: false 参数表示在当前文件写入完成后不关闭输出流
          readStream.pipe(output, { end: false });
        });
      }
      // 当所有文件写入完成后,关闭输出流
      output.end();
    })()
    .then( 
      // 删除临时文件夹,recursive: true 表示递归删除
      () => fs.rm(tempDir, { recursive: true },
      (err) => {
        if (err) {
          // 处理错误
          console.error(err);
        } else {
          // 删除成功
          console.log('Directory removed successfully.');
        }
      })
    )
    .catch(e=>{
      console.log('发生错误',e)
    });
    
  })
}
相关推荐
小满zs4 分钟前
Next.js第一章(入门)
前端
摇滚侠5 分钟前
CSS(层叠样式表)和SCSS(Sassy CSS)的核心区别
前端·css·scss
不爱吃糖的程序媛8 分钟前
Electron 桌面应用开发入门指南:从零开始打造 Hello World
前端·javascript·electron
Dontla15 分钟前
前端状态管理,为什么要状态管理?(React状态管理、zustand)
前端·react.js·前端框架
编程猪猪侠16 分钟前
前端根据文件后缀名智能识别文件类型的实用函数
前端
yinuo23 分钟前
基于 Git Submodule 的代码同步融合方案
前端
伶俜monster35 分钟前
大模型 “万能接口” MCP 横空出世!打破数据孤岛,重塑 AI 交互新规则
前端·mcp
你听得到1135 分钟前
肝了半个月,我用 Flutter 写了个功能强大的图片编辑器,告别image_cropper
android·前端·flutter
Macbethad1 小时前
Typora 精通指南:掌握高效 Markdown 写作的艺术
前端·macos·前端框架
F_Director1 小时前
Webpack DLL动态链接库的应用和思考
前端·webpack·性能优化