看完这篇文章,再也不担忧网页上传大文件!

837 阅读9分钟

我们在给前端网页设计一个上传文件的按钮时,如果直接就将获取的文件通过请求交给后端,很多情况下会失败。由于网络环境的限制,直接上传大文件容易失败,且对带宽和服务器压力较大,所以我们不妨可以使用分片上传,既高效又可靠。本篇文章将详细讲解如何通过前端切片和后端合并实现大文件的上传,结合具体的代码示例,让你从原理到实现都能深入理解。

前端部分

1. 读取本地文件并切割为Blob对象

在前端,我们首先需要从用户的本地设备中读取文件。通过 <input type="file"> 元素,我们可以选择文件并生成一个 File 对象。然后,我们使用 slice 方法将文件切割为多个 Blob 对象。

function createChunk(file, size = 5 * 1024 * 1024) {
    const chunkList = [];
    let cur = 0;
    while (cur < file.size) {
        chunkList.push({ file: file.slice(cur, cur + size) });
        cur += size;
    }
    return chunkList;
}

一提起slice 方法,可能大部分第一时间想到到是分割数组和字符串,但是slice 方法也可以用于将一个大的 File 对象分割成若干个 Blob 对象。Blob 是一个二进制大对象,可以存储大量的二进制数据,是在浏览器中处理文件数据的常用方式。我们通过循环,将文件切割成指定大小的多个 Blob,并将这些 Blob 存入数组 chunkList 中。关于更多的 Blob 数据类型可以查阅官方文档Blob - Web API | MDN (mozilla.org)

2. 定义chunks数组,组织上传信息

在准备上传这些切片之前,我们需要为每个切片生成一些附加信息,包括切片的名称、大小、进度等。我们通过以下代码将切片信息封装为一个 chunks 数组:

function handleUpLoad() {
    if (!fileObject) return;
    const chunkList = createChunk(fileObject);
    const chunks = chunkList.map(({ file }, index) => {
        return {
            file,
            size: file.size,
            percent: 0,
            chunkName: `${fileObject.name}-${index}`,
            fileName: fileObject.name,
            index
        };
    });
    upLoadChunks(chunks);
}

这个方法也是我们给按钮绑定的点击事件。 这样定义 chunks 数组的目的是为了将切片与其相关的元数据(如切片的名称、文件名称、索引等)进行绑定。这些信息在上传过程中是必不可少的,比如后端需要根据这些信息将切片正确地存储,并在后续合并时按照顺序拼接。

3. 使用FormData上传切片

每一个切片在上传时都需要通过 FormData 格式进行包装,然后通过 POST 请求发送给服务器。

function upLoadChunks(chunks) {
    const formChunks = chunks.map(({ file, fileName, index, chunkName }) => {
        const formData = new FormData();
        formData.append('file', file);
        formData.append('fileName', fileName);
        formData.append('chunkName', chunkName);
        return { formData, index };
    });

    const requestList = formChunks.map(({ formData, index }) => {
        return axios.post('http://localhost:3000/upload', formData, () => {
            console.log(index);
        });
    });

    Promise.all(requestList).then(res => {
        console.log(res, "成功");
        mergeChunks();
    });
}

这里使用 FormData 的原因是它能够自动处理文件数据的上传,包括生成正确的 Content-Type,并且兼容性好,适合用于文件上传。

我们通过 POST 请求逐个发送切片,这是因为在实际网络环境中,同时上传过多的切片可能导致请求失败或拥塞。逐片发送的方式更加稳妥,也方便在后续根据上传进度进行处理。

后端部分:接收与合并切片

1. 使用multiparty解析上传的切片

后端需要处理前端传递过来的切片,并将其保存到服务器的指定位置。我们使用 multiparty 模块来解析上传的 FormData 数据,模块详细参考multiparty - npm (npmjs.com)。且我们也要使用fs-extra来处理文件操作,可以把这个模块看成是fs模块的超集,模块详细参考fs-extra - npm (npmjs.com)

先安装依赖

npm install multiparty fs-extra
const multiparty = require('multiparty');
const fse = require('fs-extra');

const UPLOAD_DIR = path.resolve(__dirname, '.', 'qiepian');

const server = http.createServer((req, res) => {
   if (req.url === '/upload') {
    // 接收前端传过来的 formData 
    // req.on('data', (data) => {
    //   console.log(data);
    // })
    const form = new multiparty.Form();
    form.parse(req, (err, fields, files) => {
      // console.log(fields);  // 切片的描述
      // console.log(files);  // 切片的二进制资源被处理成对象
      const [file] = files.file
      const [fileName] = fields.fileName
      const [chunkName] = fields.chunkName
      // 保存切片
      const chunkDir = path.resolve(UPLOAD_DIR, `${fileName}-chunks`)
      if (!fse.existsSync(chunkDir)) { // 该路径是否有效
        fse.mkdirSync(chunkDir)
      }
      // 存入
      fse.moveSync(file.path, `${chunkDir}/${chunkName}`)

      res.end(JSON.stringify({
        code: 0,
        message: '切片上传成功'
      }))
      
    })

  }
});

其中UPLOAD_DIR用来存放切片的根目录,qiepian 是一个用来存储所有文件切片的文件夹。chunkDir是存储单个文件切片的目录,对于每个上传的文件,我们都会在 UPLOAD_DIR 下创建一个独立的文件夹来存储该文件的所有切片。filePath是合并后的完整文件的存储路径,当所有切片上传完成并合并时,我们会将合并后的文件保存到 filePath 指定的路径下。

2. 合并切片

当所有切片上传完成后,前端会发送一个合并请求,通知服务器进行切片合并操作。我们需要将这些切片按照顺序合并成一个完整的文件。这个过程涉及到对文件的读取、写入操作,为了高效地处理大文件,我们采用了的方式进行合并。

我们通过以下函数实现切片合并:

async function mergeFileChunk(filePath, fileName, size) {
  // 拿到所有切片所在文件夹的路径
  const chunkDir = path.resolve(UPLOAD_DIR, `${fileName}-chunks`)
  // 拿到所有切片
  let chunksList = fse.readdirSync(chunkDir)
  // console.log(chunksList);
  // 万一切片是乱序的
  chunksList.sort((a, b) => a.split('-')[1] - b.split('-')[1])

  const result = chunksList.map((chunkFileName, index) => {
    const chunkPath = path.resolve(chunkDir, chunkFileName)
    // !!!!!合并
    return pipeStream(chunkPath, fse.createWriteStream(filePath, {
      start: index * size,
      end: (index + 1) * size
    }))
    
  })

  // console.log(result);
  await Promise.all(result)
  fse.removeSync(chunkDir) // 删除切片目录
  return true

}

chunksList 是当前文件所有切片的列表。我们首先通过 fse.readdirSync(chunkDir) 方法读取 chunkDir 目录下的所有切片文件名。由于切片是按照顺序命名的(例如 filename-0, filename-1),我们需要对这些文件名进行排序,以确保合并时按照正确的顺序进行。

然后则是流式合并切片。流处理是 Node.js 中一种高效的文件读写方式,特别适合处理大文件。通过流式处理,我们可以逐步读取文件内容并写入到目标文件中,而不需要将整个文件加载到内存中。

const result = chunksList.map((chunkFileName, index) => {
    const chunkPath = path.resolve(chunkDir, chunkFileName);
    return pipeStream(chunkPath, fse.createWriteStream(filePath, {
        start: index * size,
        end: (index + 1) * size
    }));
});

在这段代码中,我们遍历每一个切片文件名,并为每个切片创建一个流式处理任务。chunkPath 是当前切片的路径,而 filePath 是最终完整文件的存储路径。

通过 fse.createWriteStream(filePath, { start, end }) 创建一个写入流,我们可以将当前切片的数据写入到目标文件的指定位置。startend 参数指定了写入的位置范围,这样我们可以精确地将每个切片数据放到文件的正确位置。

流的配置是流式处理的关键。fse.createWriteStream(filePath, { start, end }) 创建了一个写入流,该流会将数据写入到 filePath 指定的文件中。startend 是流的配置选项,指定了写入文件的起始和结束字节位置。

function pipeStream(path, writeStream) {
    return new Promise((resolve, reject) => {
        const readStream = fse.createReadStream(path);
        readStream.on('end', () => {
            fse.removeSync(path);  // 合并完成后删除切片文件
            resolve();
        });
        readStream.pipe(writeStream);  // 将读取的流内容写入到目标写入流中
    });
}

pipeStream 函数封装了从一个文件流中读取数据并将其写入到另一个文件流的操作。fse.createReadStream(path) 创建一个读取流,从 path 指定的文件读取数据。readStream.pipe(writeStream) 将读取的数据通过管道写入到 writeStream 指定的文件中。

pipeStream 的设计思路是利用事件驱动的机制,当读取流 readStream 完成读取时(即触发 end 事件),我们删除已经合并的切片文件,并通过 resolve 结束当前 Promise。

使用流合并文件的原理

流式处理在大文件处理中的优势在于它能够节省内存。当我们合并切片时,实际上是将每个切片的二进制数据逐个读入,然后写入到目标文件的指定位置。由于文件数据是以二进制格式存储的,流式处理允许我们边读边写,而不需要一次性加载整个文件到内存中。

二进制文件的合并可以看作是一个“拼接”过程,我们将每个切片的数据按照顺序逐个写入到一个完整的文件中。由于切片的顺序和大小是预先定义好的,流式处理可以精确地控制每个切片数据在目标文件中的位置,从而实现准确的合并。

通过这种方式,即使是非常大的文件,也能高效地进行切片上传和合并,而不会因为内存占用过大导致服务器崩溃。

完整代码及效果

inxex.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<body>
    <input type="file" id="input"  >
    <button id="btn">上传</button>

    <script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
    <script>
        const input = document.getElementById('input');
        const btn = document.getElementById('btn');
        btn.addEventListener('click',handleUpLoad)
        let fileObject = null;

        // 读取文件
        input.addEventListener('change', handleFileChange);
        function handleFileChange(e){
            console.log(e);
            const [file] = e.target.files;
            fileObject = file;
        }

        // 上传文件
        function handleUpLoad(){
            if(!fileObject) return
            const chunkList = createChunk(fileObject)
            // console.log(chunkList);
            const chunks = chunkList.map(({file},index)=>{
                return {
                    file,
                    size:file.size,
                    percent:0,
                    chunkName: `${fileObject.name}-${index}`,
                    fileName: fileObject.name,
                    index
                }
            })
            // 发请求
            upLoadChunks(chunks)
      
        }

        // 切片
        function createChunk(file,size = 5*1024*1024){
            const chunkList = [];
            let cur = 0;
            while(cur < file.size){
                // slice切 
                chunkList.push({file:file.slice(cur,cur+size)})
                cur += size
            }

            return chunkList;
        }

        // 请求
        function upLoadChunks(chunks){
            console.log(chunks); // 这个数组中的元素是对象,对象中有Blob类型的文件对象
         const formChunks =   chunks.map(({file,fileName,index,chunkName})=>{
                const formData = new FormData()
                formData.append('file',file)
                formData.append('fileName',fileName)
                formData.append('chunkName',chunkName)
                return {formData,index}
            })
            // console.log(formChunks); // 后端能识别得了的类型
          const requestList =  formChunks.map(({formData,index})=>{ // 一个一个片段发
              return  axios.post('http://localhost:3000/upload',formData,()=>{
                    console.log(index);
                })
            })
            console.log(requestList);
            Promise.all(requestList).then(res=>{
                console.log(res,"成功");
                mergeChunks()

                
            })

            // 合并请求
            function mergeChunks(size = 5 * 1024 * 1024){
                axios.post('http://localhost:3000/merge',{
                    fileName:fileObject.name,
                    size
                }).then(res => {
                    console.log(`${fileObject.name}合并成功}`);
                   
                })
            }
            
            
            // axios.post()
        }



    </script>

</body>
</html>

index.js

const http = require('http');
const path = require('path');
const multiparty = require('multiparty');
const fse = require('fs-extra');

// 存放切片的地方
const UPLOAD_DIR = path.resolve(__dirname, '.', 'qiepian')

// 解析post参数
function resolvePost(req) {
  return new Promise((resolve, reject) => {
    req.on('data', (data) => {
      resolve(JSON.parse(data.toString()))
    })
  })
}

// 
function pipeStream(path, writeStream) {
  return new Promise((resolve, reject) => {
    const readStream = fse.createReadStream(path)
    readStream.on('end', () => {
      fse.removeSync(path)  // 被读取完的切片移除掉
      resolve()
    })
    readStream.pipe(writeStream)
  })
}

// 合并切片
async function mergeFileChunk(filePath, fileName, size) {
  // 拿到所有切片所在文件夹的路径
  const chunkDir = path.resolve(UPLOAD_DIR, `${fileName}-chunks`)
  // 拿到所有切片
  let chunksList = fse.readdirSync(chunkDir)
  // console.log(chunksList);
  // 万一切片是乱序的
  chunksList.sort((a, b) => a.split('-')[1] - b.split('-')[1])

  const result = chunksList.map((chunkFileName, index) => {
    const chunkPath = path.resolve(chunkDir, chunkFileName)
    // !!!!!合并
    return pipeStream(chunkPath, fse.createWriteStream(filePath, {
      start: index * size,
      end: (index + 1) * size
    }))
    
  })

  // console.log(result);
  await Promise.all(result)
  fse.removeSync(chunkDir) // 删除切片目录
  return true

}


const server = http.createServer( async (req, res) => {
  res.writeHead(200, {
    'access-control-allow-origin': '*',
    'access-control-allow-headers': '*',
    'access-control-allow-methods': '*'
  })
  if (req.method === 'OPTIONS') { // 请求预检
    res.status = 200
    res.end()
    return
  }

  if (req.url === '/upload') {
    // 接收前端传过来的 formData 
    // req.on('data', (data) => {
    //   console.log(data);
    // })
    const form = new multiparty.Form();
    form.parse(req, (err, fields, files) => {
      // console.log(fields);  // 切片的描述
      // console.log(files);  // 切片的二进制资源被处理成对象
      const [file] = files.file
      const [fileName] = fields.fileName
      const [chunkName] = fields.chunkName
      // 保存切片
      const chunkDir = path.resolve(UPLOAD_DIR, `${fileName}-chunks`)
      if (!fse.existsSync(chunkDir)) { // 该路径是否有效
        fse.mkdirSync(chunkDir)
      }
      // 存入
      fse.moveSync(file.path, `${chunkDir}/${chunkName}`)

      res.end(JSON.stringify({
        code: 0,
        message: '切片上传成功'
      }))
      
    })

  }

  if (req.url === '/merge') {
    const { fileName, size } = await resolvePost(req) // 解析post参数
    const filePath = path.resolve(UPLOAD_DIR, fileName)  // 完整文件的路径
    // 合并切片
    const result = await mergeFileChunk(filePath, fileName, size)
    if (result) { // 切片合并完成
      res.end(JSON.stringify({
        code: 0,
        message: '文件合并完成'
      }))
    }
  }
})

server.listen(3000, () => {
  console.log('listening on port 3000');
})

效果演示

这里我们上传周董的搁浅

image.png

可以看到成功在qiepian文件夹中合并了

image.png

总结

通过本文的详细讲解,我们深入了解了大文件分片上传与合并的整个流程。在前端,我们通过切片和 FormData 逐片上传的方式,确保了上传的稳定性和兼容性。在后端,我们利用 multiparty 解析切片数据,通过流的方式高效合并切片,实现了完整文件的重建。

流处理是 Node.js 中处理大文件的重要工具,它能够在内存占用最小的情况下完成文件的读取与写入操作,使得我们能够轻松处理大文件的上传需求。理解这些路径的作用和流的处理方式,是实现这一功能的核心所在。希望通过本文的详细分析,你能够更好地掌握大文件上传的实现原理,并将其应用到实际项目中。如果这篇文章对你有帮助的话,可以点个赞哦😊!

src=http___c-ssl.duitang.com_uploads_item_201807_27_20180727225355_ckpfo.gif&refer=http___c-ssl.duitang.gif