react+node实现大文件上传(分片上传)

什么是大文件上传?

大文件上传是指将大于常规文件大小(通常大于 100MB 或 GB 级别)的文件从客户端上传到服务器或云存储服务的过程。由于大文件的大小和上传过程中的网络限制,它通常需要特殊的技术来确保上传的稳定性、效率和可靠性。

大文件上传的优点?

(1)稳定性:避免上传中断,支持断点续传。

(2)效率:提高上传速度,支持并发上传。

(3)资源优化:节省带宽和内存,减少负载。

(4)灵活性:提高文件完整性校验,灵活管理上传内容。

大文件上传流程概述:

(1)前端切片:将文件分割成多个较小的分片。

(2)计算文件哈希值:计算整个文件的哈希值或每个分片的哈希值。

(3)上传分片:通过并发上传的方式上传每个分片。

(4)服务器接收与存储:服务器接收每个分片,并将它们存储在临时文件夹中。

(5)合并文件:当所有分片上传完成后,服务器将分片按顺序合并成一个完整的文件。

(6)文件校验:服务器验证合并后的文件完整性,确保上传文件无误。

(7)返回成功:上传和合并成功后,返回给前端通知上传成功。

接下来的代码要实现的效果

前端代码

1.文件选择和分片

用户通过 选择文件,并触发 clickFn 方法。

clickFn 中,文件通过 e.target.files 获取。获取到文件后,前端将文件进行分片,调用 createChunks 方法。每个分片的大小为 1MB (CHUNK_SIZE = 1024 * 1024),然后文件被分割成多个较小的 Blob 对象。

const CHUNK_SIZE = 1024 * 1024//切片大小为1M
    const fileHash = useRef('')//存储文件哈希值
    const fileName = useRef('')//存储文件名称
    const [progress, setProgress] = useState(0)进度条进度
    // 获取文件分片
    const createChunks = (file: File) => {
        let cur = 0
        let thunks = []
        while (cur < file.size) {
            const blob = file.slice(cur, cur + CHUNK_SIZE)
            thunks.push(blob)
            cur += CHUNK_SIZE
        }
        return thunks
    }
2.计算文件的 MD5 哈希值

在文件被分片后,前端通过 calculateHash 方法计算文件的哈希值,这个哈希值用于标识文件的唯一性。哈希值计算使用了 SparkMD5.ArrayBuffer

calculateHash 方法中,通过读取文件的每一个分片(包括前后两个字节和中间部分的字节),计算并返回整个文件的 MD5 哈希值。

  const calculateHash = (thunks: Blob[]) => {
        return new Promise(resolve => {
            // 第一个和最后一个切片全部参与计算
            // 中间的切片只计算前面两个字节、中间两个字节、最后两个字节
            const targets: Blob[] = [] //存储所有参与计算的切片
            const spark = new SparkMD5.ArrayBuffer()

            const fileReader = new FileReader()

            thunks.forEach((h, index) => {
                if (index === 0 || index === thunks.length - 1) {
                    // 第一个和最后一个切片全部参与计算
                    targets.push(h)
                } else {
                    targets.push(h.slice(0, 2)) //前面两个字节
                    targets.push(h.slice(CHUNK_SIZE / 2, CHUNK_SIZE / 2 + 2)) //中间两个字节
                    targets.push(h.slice(CHUNK_SIZE - 2, CHUNK_SIZE)) //最后两个字节
                }
            })

            fileReader.readAsArrayBuffer(new Blob(targets))
            fileReader.onload = (e) => {
                // console.log((e.target as FileReader).result);

                spark.append((e.target as FileReader).result as ArrayBuffer)
                // console.log('hash:' + spark.end());
                resolve(spark.end())
            }
        })
    }
3.分片上传

分片计算完哈希值后,前端会调用 uploadChunks 方法来上传每个分片。

uploadChunks 中,前端将每个分片和相关的哈希值(fileHashthunkHash)一起放入 FormData 中,并通过 fetch 发送到服务器。

并发上传:为了提高上传效率,前端限制最大并发数为 6(max = 6),上传过程中会实时显示进度条,通过 setProgress 更新上传进度。

const uploadChunks = async (thunks: Blob[]) => {
        const data = thunks.map((thunk, index) => {
            return {
                fileHash: fileHash.current,
                thunkHash: fileHash.current + '-' + index,
                thunk,
            }
        })
        const formDatas = data.map((item) => {
            const formData = new FormData()
            formData.append('fileHash', item.fileHash)
            formData.append('thunkHash', item.thunkHash)
            formData.append('thunk', item.thunk)
            // console.log(item.fileHash);

            return formData
        })



        // console.log(formDatas);
        const max = 6 //最大并发请求数
        let index = 0 //
        const taskPool: any = [] //请求池
        let totalUploaded = 0//已上传的字节数
        const totalSize = thunks.reduce((sum, thunk) => sum + thunk.size, 0)//计算所有切片的总大小
        while (index < formDatas.length) {
            const currentIndex = index;
            const currentThunk = thunks[currentIndex];
            const task = fetch('http://localhost:3000/upload', {
                method: 'POST',
                body: formDatas[index],
            }).then((res) => {
                if (res.status == 200) {
                    totalUploaded += currentThunk.size
                    const uploadProgress = ((totalUploaded / totalSize) * 100).toFixed(2)
                    setProgress(Number(uploadProgress))
                }
            })
            taskPool.splice(taskPool.findIndex((item: any) => item === task))
            taskPool.push(task)
            if (taskPool.length === max) {
                await Promise.race(taskPool)
            }
            index++
        }
        await Promise.all(taskPool)

        // 通知服务器合并文件
        mergeRequest()
    }
4.上传成功后通知合并

一旦所有的分片上传完成,前端会调用 mergeRequest 方法通知服务器开始文件的合并操作。

const mergeRequest = () => {
        fetch('http://localhost:3000/merge', {
            method: 'POST',
            headers: {
                'content-type': 'application/json'
            },
            body: JSON.stringify({
                fileHash: fileHash.current,
                fileName: fileName.current,
                size: CHUNK_SIZE
            })
        }).then((res) => {
            alert('合并成功')
        })
    }

后端代码

1.接收分片+存储分片

服务器通过 multiparty 解析前端上传的分片,每个分片的内容会存储在 files['thunk'] 中,而文件的哈希信息(fileHashthunkHash)会存储在 fields 中。

每个分片通过文件哈希(fileHash)进行分类存储,thunkHash 用于标识每个分片。

服务器将每个分片存储到以文件哈希为名的文件夹中,存储路径为 chunkDir

对于每个分片,服务器会将其从临时路径移动到 chunkDir(以 fileHash 为名的文件夹)中,文件路径为 chunkPath

一旦分片存储完成,服务器返回成功响应,表示该分片上传成功。

// 处理分片上传
router.post('/upload', function (req, res) {
  const form = new multiparty.Form();
  form.parse(req, async (err, fields, files) => {
    // fields中存储的是前端传递fileHash和thunkHash
    // files中存储的是上传文件的切片
    if (err) {
      return res.status(500).json({ ok: false, msg: "上传失败" });
    }

    // 获取fileHash和thunkHash
    const fileHash = fields['fileHash'][0];  // 文件哈希值
    const chunkHash = fields['thunkHash'][0];  // 切片哈希值

    // 存放当前文件的所有切片的路径
    const chunkDir = path.resolve(UPLOAD_DIR, fileHash);  

    // if (fse.existsSync(chunkDir)) {
    //   console.log('已存在');
    //   return
    // }

    // 创建存放切片的目录(如果不存在)
    if (!fse.existsSync(chunkDir)) {
      await fse.ensureDir(chunkDir);
    }

    // 将切片移动到thunkDir
    const oldPath = files['thunk'][0]['path'];  // 上传的临时文件路径
    const chunkPath = path.resolve(chunkDir, chunkHash);  // 目标路径

    // 移动文件到切片目录
    await fse.move(oldPath, chunkPath, { overwrite: true });

    res.status(200).json({ ok: true, msg: '切片上传成功' });
  });
});
2.文件合并

上传所有分片后,前端会通知服务器进行文件合并(通过 mergeRequest)。

服务器在 merge 路由中接收文件合并请求,首先获取文件的哈希值(fileHash)、文件名(fileName)以及每个分片的大小。

服务器会按顺序读取存储的所有分片,并通过 fse.createWriteStream 将每个分片内容写入到最终的文件中。

在合并过程中,服务器会删除每个已合并的分片,并最终删除存储分片的目录(chunkDir)。

合并完成后,服务器返回合并成功的响应。

// 处理文件合并
router.post('/merge', async (req, res) => {
  const { fileHash, fileName, size } = req.body;
  // fileHash文件唯一id
  // fileName文件名
  // size切片大小

  // 合成后文件路径
  const filePath = path.resolve(UPLOAD_DIR, fileHash + extractExt(fileName));
  // 存放切片的目录
  const chunkDir = path.resolve(UPLOAD_DIR, fileHash);

  if (!fse.existsSync(chunkDir)) {
    return res.status(400).json({ ok: false, msg: '切片目录不存在' });
  }

  // 获取所有切片文件
  const chunkPaths = await fse.readdir(chunkDir);
  // 对切片进行排序
  chunkPaths.sort((a, b) => parseInt(a.split('-')[1]) - parseInt(b.split('-')[1]));

  // 逐个合并切片
  for (let i = 0; i < chunkPaths.length; i++) {
    // 当前切片的路径
    const chunkPath = path.resolve(chunkDir, chunkPaths[i]);
    // 创建写入流,使用追加方式
    const writeStream = fse.createWriteStream(filePath, { flags: "a" });
    await new Promise((resolve) => {
      // 异步方法,读取当前切片内容
      const readStream = fse.createReadStream(chunkPath);
      // 将readStream的值进行追加
      readStream.pipe(writeStream);
      // 监听end事件,将合并完的切片进行删除
      readStream.on("end", async () => {
        await fse.unlink(chunkPath);  // 删除切片
        resolve();
      });
    });
  }

以上就是分片上传的流程,下面是完整代码

前端完整代码

import { useEffect, useRef, useState } from "react"
import SparkMD5 from 'spark-md5'

export default function Index() {
    const CHUNK_SIZE = 1024 * 1024//1m
    const fileHash = useRef('')
    const fileName = useRef('')
    const [progress, setProgress] = useState(0)
    // 文件分片
    const createChunks = (file: File) => {
        let cur = 0
        let thunks = []
        while (cur < file.size) {
            const blob = file.slice(cur, cur + CHUNK_SIZE)
            thunks.push(blob)
            cur += CHUNK_SIZE
        }
        return thunks
    }

    // 计算hash值函数
    const calculateHash = (thunks: Blob[]) => {
        return new Promise(resolve => {
            // 第一个和最后一个切片全部参与计算
            // 中间的切片只计算前面两个字节、中间两个字节、最后两个字节
            const targets: Blob[] = [] //存储所有参与计算的切片
            const spark = new SparkMD5.ArrayBuffer()

            const fileReader = new FileReader()

            thunks.forEach((h, index) => {
                if (index === 0 || index === thunks.length - 1) {
                    // 第一个和最后一个切片全部参与计算
                    targets.push(h)
                } else {
                    targets.push(h.slice(0, 2)) //前面两个字节
                    targets.push(h.slice(CHUNK_SIZE / 2, CHUNK_SIZE / 2 + 2)) //中间两个字节
                    targets.push(h.slice(CHUNK_SIZE - 2, CHUNK_SIZE)) //最后两个字节
                }
            })

            fileReader.readAsArrayBuffer(new Blob(targets))
            fileReader.onload = (e) => {
                // console.log((e.target as FileReader).result);

                spark.append((e.target as FileReader).result as ArrayBuffer)
                // console.log('hash:' + spark.end());
                resolve(spark.end())
            }
        })
    }

    const mergeRequest = () => {
        fetch('http://localhost:3000/merge', {
            method: 'POST',
            headers: {
                'content-type': 'application/json'
            },
            body: JSON.stringify({
                fileHash: fileHash.current,
                fileName: fileName.current,
                size: CHUNK_SIZE
            })
        }).then((res) => {
            alert('合并成功')
        })
    }

    // 上传分片
    const uploadChunks = async (thunks: Blob[]) => {
        const data = thunks.map((thunk, index) => {
            return {
                fileHash: fileHash.current,
                thunkHash: fileHash.current + '-' + index,
                thunk,
            }
        })
        const formDatas = data.map((item) => {
            const formData = new FormData()
            formData.append('fileHash', item.fileHash)
            formData.append('thunkHash', item.thunkHash)
            formData.append('thunk', item.thunk)
            // console.log(item.fileHash);

            return formData
        })



        // console.log(formDatas);
        const max = 6 //最大并发请求数
        let index = 0 //
        const taskPool: any = [] //请求池
        let totalUploaded = 0//已上传的字节数
        const totalSize = thunks.reduce((sum, thunk) => sum + thunk.size, 0)//计算所有切片的总大小
        while (index < formDatas.length) {
            const currentIndex = index;
            const currentThunk = thunks[currentIndex];
            const task = fetch('http://localhost:3000/upload', {
                method: 'POST',
                body: formDatas[index],
            }).then((res) => {
                if (res.status == 200) {
                    totalUploaded += currentThunk.size
                    const uploadProgress = ((totalUploaded / totalSize) * 100).toFixed(2)
                    setProgress(Number(uploadProgress))
                }
            })
            taskPool.splice(taskPool.findIndex((item: any) => item === task))
            taskPool.push(task)
            if (taskPool.length === max) {
                await Promise.race(taskPool)
            }
            index++
        }
        await Promise.all(taskPool)

        // 通知服务器合并文件
        mergeRequest()
    }

    const clickFn = async (e: any) => {
        const files = e.target.files
        if (!files) return
        // 读取文件
        // console.log(files[0]);
        fileName.current = files[0].name

        // 文件分片操作
        const thunks = createChunks(files[0])
        // console.log(thunks);
        // hash计算
        const hash = await calculateHash(thunks)
        fileHash.current = hash as string

        // console.log(hash);
        // 上传分片
        uploadChunks(thunks)
    }
    return <>

         { clickFn(e) }} />
        
        {progress}%
    
}

后端完整代码

var express = require('express');
var router = express.Router();
var multiparty = require('multiparty')
var fse = require('fs-extra')
var path = require('path');

// 定义uploads目录
const UPLOAD_DIR = path.resolve(__dirname, 'uploads')

// 提取文件扩展名
const extractExt = filename => filename.slice(filename.lastIndexOf('.'))

// 处理分片上传
router.post('/upload', function (req, res) {
  const form = new multiparty.Form();
  form.parse(req, async (err, fields, files) => {
    // fields中存储的是前端传递fileHash和thunkHash
    // files中存储的是上传文件的切片
    if (err) {
      return res.status(500).json({ ok: false, msg: "上传失败" });
    }

    // 获取fileHash和thunkHash
    const fileHash = fields['fileHash'][0];  // 文件哈希值
    const chunkHash = fields['thunkHash'][0];  // 切片哈希值

    // 存放当前文件的所有切片的路径
    const chunkDir = path.resolve(UPLOAD_DIR, fileHash);  

    // if (fse.existsSync(chunkDir)) {
    //   console.log('已存在');
    //   return
    // }

    // 创建存放切片的目录(如果不存在)
    if (!fse.existsSync(chunkDir)) {
      await fse.ensureDir(chunkDir);
    }

    // 将切片移动到thunkDir
    const oldPath = files['thunk'][0]['path'];  // 上传的临时文件路径
    const chunkPath = path.resolve(chunkDir, chunkHash);  // 目标路径

    // 移动文件到切片目录
    await fse.move(oldPath, chunkPath, { overwrite: true });

    res.status(200).json({ ok: true, msg: '切片上传成功' });
  });
});


// 处理文件合并
router.post('/merge', async (req, res) => {
  const { fileHash, fileName, size } = req.body;
  // fileHash文件唯一id
  // fileName文件名
  // size切片大小

  // 合成后文件路径
  const filePath = path.resolve(UPLOAD_DIR, fileHash + extractExt(fileName));
  // 存放切片的目录
  const chunkDir = path.resolve(UPLOAD_DIR, fileHash);

  if (!fse.existsSync(chunkDir)) {
    return res.status(400).json({ ok: false, msg: '切片目录不存在' });
  }

  // 获取所有切片文件
  const chunkPaths = await fse.readdir(chunkDir);
  // 对切片进行排序
  chunkPaths.sort((a, b) => parseInt(a.split('-')[1]) - parseInt(b.split('-')[1]));

  // 逐个合并切片
  for (let i = 0; i < chunkPaths.length; i++) {
    // 当前切片的路径
    const chunkPath = path.resolve(chunkDir, chunkPaths[i]);
    // 创建写入流,使用追加方式
    const writeStream = fse.createWriteStream(filePath, { flags: "a" });
    await new Promise((resolve) => {
      // 异步方法,读取当前切片内容
      const readStream = fse.createReadStream(chunkPath);
      // 将readStream的值进行追加
      readStream.pipe(writeStream);
      // 监听end事件,将合并完的切片进行删除
      readStream.on("end", async () => {
        await fse.unlink(chunkPath);  // 删除切片
        resolve();
      });
    });
  }

  await fse.remove(chunkDir);  // 删除存放切片的目录

  res.status(200).json({ ok: true, msg: '文件合并成功' });
});



module.exports = router;

你可能感兴趣的:(react.js,node.js)