前端React、后端NestJs 实现大文件分片上传和下载

文件分片上传

​ 在实际开发工作中,文件上传是非常常见的功能。有时候如果文件过大,那么上传就需要花费很多时间,这时候用户体验就会很差。

​ 所以针对大文件上传的场景,我们需要优化一下,具体方案是将大文件分成几份并行上传,上传完成后再合并到一起。

​ 那么具体怎么做呢

前端文件分片

​ 首先用户通过<input>元素来选择上传文件,通过访问该元素的files 属性可以获取到上传的文件对象,该对象是File 对象,File 对象是一种特定类型的Blob,其继承了Blob的功能,所以File可以使用Blob的实例方法。

html 复制代码
<input type="file" />

blob上有个slice方法,其可以返回一个新的 Blob 对象,其中包含调用它的 blob 的指定字节范围内的数据。我们可以通过使用Blob对象的slice方法,将文件分成多份。

​ 我们用一个20M左右的图片(下图)来模拟一下

​ 将该图片文件按1M一份分成20份,react代码如下:

tsx 复制代码
export default function FileUpload() {
  function fileSlice(file: File) {
    const singleSize = 1024 * 1024; // 设置分片大小为 1MB
    let startPos = 0;
    const sliceArr = [];
    while (startPos < file.size) {
      const sliceFile = file.slice(startPos, startPos + singleSize);
      sliceArr.push(sliceFile);
      startPos += singleSize;
    }
    return sliceArr;
  }

  function fileChange(event: React.ChangeEvent<HTMLInputElement>) {
    if (event.target.files) {
      const file = event.target.files[0];
      const fileSliceArr = fileSlice(file);
      console.log(fileSliceArr);
    }
  }

  return (
    <div className="file-upload">
      <input
        type="file"
        className="upload-input"
        onChange={(event) => fileChange(event)}
      />
    </div>
  );
}

​ 选择文件后结果如下

好,先暂停一下,我们把上传的后端接口实现一下,我们使用nest框架来实现。

后端文件上传接口实现(nestjs)

全局安装nestjs脚手架@nestjs/cli

javascript 复制代码
npm install -g @nestjs/cli

创建一个nest项目

javascript 复制代码
nest new large_file_nest

nest的文件上传基于Express的中间件multer实现。Multer 处理以 multipart/form-data 格式发布的数据,该格式主要用于通过 HTTP POST 请求上传文件。

为了处理文件上传,Nest 为 Express 提供了一个基于multer中间件包的内置模块。

首先,为了更好的类型安全,让我们安装 Multer typings 包:

javascript 复制代码
pnpm install -D @types/multer

安装完后,才可以使用 Express.Multer.File 类型

app.controller.ts中添加如下代码

typescript 复制代码
import {
  Controller,
  Post,
  UploadedFile,
  UseInterceptors,
  Body,
} from '@nestjs/common';
import { AppService } from './app.service';
import { FileInterceptor } from '@nestjs/platform-express';
import * as fs from 'fs';

@Controller()
export class AppController {
  constructor(private readonly appService: AppService) {}

  @Post('upload')
  @UseInterceptors(
    FileInterceptor('file', {
      dest: 'files', // 指定存储文件的地方
    }),
  )
  fileUpload(@UploadedFile() file: Express.Multer.File, @Body() body) {
    console.log(file)
		console.log(body)
  }
}

要上传单个文件,只需将 FileInterceptor() 拦截器绑定到路由处理程序并使用 @UploadedFile() 装饰器从 request 中提取 file

我们给前端代码加入接口调用

tsx 复制代码
import "./index.scss";
import axios from "axios";

export default function FileUpload() {
  function fileSlice(file: File) {
    const singleSize = 1024 * 1024; // 设置分片大小为 1MB
    let startPos = 0;
    const sliceArr = [];
    while (startPos < file.size) {
      const sliceFile = file.slice(startPos, startPos + singleSize);
      sliceArr.push(sliceFile);
      startPos += singleSize;
    }
    return sliceArr;
  }

  function fileChange(event: React.ChangeEvent<HTMLInputElement>) {
    if (event.target.files) {
      const file = event.target.files[0];
      const fileSliceArr = fileSlice(file);
      fileSliceArr.forEach((fileFragments, index) => {
        const formData = new FormData();
        formData.set("file", fileFragments);
        formData.set("name", file.name);
        formData.set("index", index + "");
        axios({
          method: "POST",
          url: "http://localhost:3000/upload",
          data: formData,
        });
      });
    }
  }

  return (
    <div className="file-upload">
      <input
        type="file"
        className="upload-input"
        onChange={(event) => fileChange(event)}
      />
    </div>
  );
}

如此,nest服务端就获取到了上传的文件和数据

我们可以把同个文件的分片放到一起,方便后续合并,完善一下后端代码

typescript 复制代码
  @Post('upload')
  @UseInterceptors(
    FileInterceptor('file', {
      dest: 'files',
    }),
  )
  fileUpload(@UploadedFile() file: Express.Multer.File, @Body() body) {
    const fileName = body.name;
    const chunksDir = `files/chunks_${fileName}`;
    if (!fs.existsSync(chunksDir)) {
      fs.mkdirSync(chunksDir);
    }
    fs.cpSync(file.path, `${chunksDir}/${fileName}-${body.index}`);
    fs.rmSync(file.path);
  }

重新上传后,结果如下:

接下来是把分片合并

文件合并分片

我们需要在前端分片上传完毕后,调用合并的接口

完善一下前端代码的change事件

tsx 复制代码
	function fileChange(event: React.ChangeEvent<HTMLInputElement>) {
    if (event.target.files) {
      const file = event.target.files[0];
      const fileSliceArr = fileSlice(file);
      const fetchList: Promise<undefined>[] = [];
      fileSliceArr.forEach((fileFragments, index) => {
        const formData = new FormData();
        formData.set("file", fileFragments);
        formData.set("name", file.name);
        formData.set("index", index + "");
        fetchList.push(
          axios({
            method: "POST",
            url: "http://localhost:3000/upload",
            data: formData,
          })
        );
      });
      Promise.all(fetchList).then(() => {
        axios({
          method: "POST",
          url: "http://localhost:3000/merge", // 调用合并接口
          data: {
            name: file.name,
          },
        });
      });
    }
  }

然后是服务端接口的实现,文件的合并方式常见的有

  • buffer方式合并
  • stream方式合并

buffer方式合并

代码如下

typescript 复制代码
  @Post('buffer_merge')
  fileBufferMerge(@Body() body: { name: string }) {
    const chunksDir = `files/chunks_${body.name}`;
    const files = fs.readdirSync(chunksDir);
    const outputFilePath = `files/${body.name}`;
    const buffers = [];
    files.forEach((file) => {
      const filePath = `${chunksDir}/${file}`;
      const buffer = fs.readFileSync(filePath);
      buffers.push(buffer);
    });
    const concatBuffer = Buffer.concat(buffers);
    fs.writeFileSync(outputFilePath, concatBuffer);
    fs.rm(chunksDir, { recursive: true }, () => {}); // 合并完删除分片文件
  }

前端上传后调用合并接口后,文件在服务端生成了

但是点开文件一看

发现文件怎么错乱了,排查一下,打印一下分片文件列表看下

tsx 复制代码
	@Post('buffer_merge')
  fileBufferMerge(@Body() body: { name: string }) {
    const chunksDir = `files/chunks_${body.name}`;
    const files = fs.readdirSync(chunksDir);
    console.log(files); // 打印文件列表看一下
    const outputFilePath = `files/${body.name}`;
    const buffers = [];
    files.forEach((file) => {
      const filePath = `${chunksDir}/${file}`;
      const buffer = fs.readFileSync(filePath);
      buffers.push(buffer);
    });
    const concatBuffer = Buffer.concat(buffers);
    fs.writeFileSync(outputFilePath, concatBuffer);
    fs.rm(chunksDir, { recursive: true }, () => {});
  }

发现文件顺序是乱的,于是我们在合并写入前将分片文件排个序,修改一下上传接口代码

typescript 复制代码
	@Post('buffer_merge')
  fileBufferMerge(@Body() body: { name: string }) {
    const chunksDir = `files/chunks_${body.name}`;
    const files = fs.readdirSync(chunksDir).sort((a, b) => {
      const aIndex = a.slice(a.lastIndexOf('-'));
      const bIndex = b.slice(b.lastIndexOf('-'));
      return Number(bIndex) - Number(aIndex);
    });
    const outputFilePath = `files/${body.name}`;
    const buffers = [];
    files.forEach((file) => {
      const filePath = `${chunksDir}/${file}`;
      const buffer = fs.readFileSync(filePath);
      buffers.push(buffer);
    });
    const concatBuffer = Buffer.concat(buffers);
    fs.writeFileSync(outputFilePath, concatBuffer);
    fs.rm(chunksDir, { recursive: true }, () => {});
  }

重新跑下nest服务,再重新上传文件后发现文件正常了。

至此合并文件成功。

stream流方式合并

代码如下,主要方案是用fs.createReadStream创建可读流,用fs.createWriteStream创建可写流,fs.createWriteStream的第二个参数options中有个start选项,其可以指定在文件开头之后的某个位置写入数据。然后通过管道方法pipe一个一个将可读流传输到可写流中,以此来达到合并文件的效果。

typescript 复制代码
	@Post('stream_merge')
  fileMerge(@Body() body: { name: string }) {
    const chunksDir = `files/chunks_${body.name}`;
    const files = fs.readdirSync(chunksDir).sort((a, b) => {
      const aIndex = a.slice(a.lastIndexOf('-'));
      const bIndex = b.slice(b.lastIndexOf('-'));
      return Number(bIndex) - Number(aIndex);
    });
    let startPos = 0;
    const outputFilePath = `files/${body.name}`;
    files.forEach((file, index) => {
      const filePath = `${chunksDir}/${file}`;
      const readStream = fs.createReadStream(filePath);
      const writeStream = fs.createWriteStream(outputFilePath, {
        start: startPos,
      });
      readStream.pipe(writeStream).on('finish', () => {
        if (index === files.length - 1) {
          fs.rm(chunksDir, { recursive: true }, () => {}); // 合并完删除分片文件
        }
      });
      startPos += fs.statSync(filePath).size;
    });
  }

buffer方式和stream流方式对比

buffer方式合并时,读取的文件有多大,合并的过程占用的内存就有多大,相当于把这个大文件的全部内容都一次性载入到内存中,很吃内存,效率很低。

stream流方式,不同于buffer,无需一次性的把文件数据全部放入内存,所以用stream流方式处理会更高效。

文件分片下载

​ 遇到大文件下载时,可以通过将大文件拆分成多个小文件并同时下载来提高效率。下面是一个简单的前后端文件分片下载的简单实现。

后端接口实现

后端需要两个接口,一个是获取需要下载的文件信息的接口,另一个是获取文件分片的接口。

获取下载的文件信息的接口比较简单,使用fs.statSync获取需要下载的文件信息然后返回即可

typescript 复制代码
  @Get('file_size')
  fileDownload() {
    const filePath = `files/banner.jpg`;
    if (fs.existsSync(filePath)) {
      const stat = fs.statSync(filePath);
      return {
        size: stat.size,
        fileName: 'banner.jpg',
      };
    }
  }

获取文件分片的接口是根据前端传递的startend参数,使用fs.createReadStream读取指定位置的可读流并传输到返回数据中。

typescript 复制代码
  @Get('file_chunk')
  fileGet(@Query() params, @Res() res) {
    const filePath = `files/banner.jpg`;
    const fileStream = fs.createReadStream(filePath, {
      start: Number(params.start),
      end: Number(params.end),
    });
    fileStream.pipe(res);
  }

前端实现文件分片下载

代码如下,主要过程是先获取需要下载的文件信息,根据下载的文件大小和设定的分片大小批量请求分片文件,最后在请求完毕后再将文件合并下载。

tsx 复制代码
import "./index.scss";
import axios from "axios";

export default function FileDownload() {
  function fileDownload() {
    const singleSize = 1024 * 1024; // 设置分片大小为 1MB
    axios({
      method: "GET",
      url: "http://localhost:3000/file_size",
    }).then((res) => {
      if (res.data) {
        const fileSize = res.data.size;
        const fileName = res.data.fileName;
        let startPos = 0;
        const fetchList: Promise<Blob>[] = [];
        while (startPos < fileSize) {
          fetchList.push(
            new Promise((resolve) => {
              axios({
                method: "GET",
                url: "http://localhost:3000/file_chunk",
                params: {
                  start: startPos,
                  end: startPos + singleSize,
                },
                responseType: "blob",
              }).then((res) => {
                resolve(res.data);
              });
            })
          );
          startPos += singleSize;
        }
        Promise.all(fetchList).then((res) => {
          const mergedBlob = new Blob(res);
          const downloadUrl = window.URL.createObjectURL(mergedBlob);
          const link = document.createElement("a");
          link.href = downloadUrl;
          link.setAttribute("download", fileName);
          link.click();
          window.URL.revokeObjectURL(downloadUrl);
        });
      }
    });
  }

  return (
    <div className="file-download">
      <button onClick={fileDownload}>下载</button>
    </div>
  );
}

这时候文件就下载好了,打开文件一看

发现文件怎么错乱了,仔细检查发现,分片下载接口的参数start值和上一个接口end值重复了

所以修改前端代码如下

tsx 复制代码
import "./index.scss";
import axios from "axios";

export default function FileDownload() {
  function fileDownload() {
    const singleSize = 1024 * 1024; // 设置分片大小为 1MB
    axios({
      method: "GET",
      url: "http://localhost:3000/file_size",
    }).then((res) => {
      if (res.data) {
        const fileSize = res.data.size;
        const fileName = res.data.fileName;
        let startPos = 0;
        const fetchList: Promise<Blob>[] = [];
        while (startPos < fileSize) {
          fetchList.push(
            new Promise((resolve) => {
              axios({
                method: "GET",
                url: "http://localhost:3000/file_chunk",
                params: {
                  start: startPos,
                  end: startPos + singleSize,
                },
                responseType: "blob",
              }).then((res) => {
                resolve(res.data);
              });
            })
          );
          startPos = startPos + singleSize + 1; // 修改的地方
        }
        Promise.all(fetchList).then((res) => {
          const mergedBlob = new Blob(res);
          const downloadUrl = window.URL.createObjectURL(mergedBlob);
          const link = document.createElement("a");
          link.href = downloadUrl;
          link.setAttribute("download", fileName);
          link.click();
          window.URL.revokeObjectURL(downloadUrl);
        });
      }
    });
  }

  return (
    <div className="file-download">
      <button onClick={fileDownload}>下载</button>
    </div>
  );
}

再次下载,发现参数start值和end值正确了。

打开图片检查,没有问题,是对的。

完整代码

github.com/chenkai77/l...

相关推荐
m0_748240251 小时前
前端如何检测用户登录状态是否过期
前端
black^sugar1 小时前
纯前端实现更新检测
开发语言·前端·javascript
寻找沙漠的人1 小时前
前端知识补充—CSS
前端·css
GISer_Jing2 小时前
2025前端面试热门题目——计算机网络篇
前端·计算机网络·面试
m0_748245522 小时前
吉利前端、AI面试
前端·面试·职场和发展
理想不理想v2 小时前
webpack最基础的配置
前端·webpack·node.js
pubuzhixing2 小时前
开源白板新方案:Plait 同时支持 Angular 和 React 啦!
前端·开源·github
2401_857600952 小时前
SSM 与 Vue 共筑电脑测评系统:精准洞察电脑世界
前端·javascript·vue.js
2401_857600952 小时前
数字时代的医疗挂号变革:SSM+Vue 系统设计与实现之道
前端·javascript·vue.js
GDAL2 小时前
vue入门教程:组件透传 Attributes
前端·javascript·vue.js