大文件上传的基石:切片上传原理与实现详解

0 阅读4分钟

作为刚接触文件上传的JS学习者,一定会遇到这样的困境:当用户尝试上传一个大文件时,占满内存导致页面卡顿、网络波动时整个文件重传、触发服务器请求超时……这些问题就像试图用一根吸管喝完一整桶水——方法不对,体验必然糟糕。今天,就用最基础的切片上传方案解决这个问题。

为什么切片是大文件上传的“安全绳”

当用户点击文件选择框并确认后,浏览器会通过input元素的change事件捕获文件对象。这段简单的交互代码是整个上传流程的起点:

const input = document.getElementById('input');
const upload = document.getElementById('upload');
let fileObj = null;

input.addEventListener('change', (e) => {
  const [file] = e.target.files;
  fileObj = file; // 保存文件引用供后续切片使用
});

upload.addEventListener('click', () => {
  if (!fileObj) return alert('请选择文件');
  const chunkList = createChunk(fileObj); // 触发切片逻辑
  uploadChunks(chunkList.map(...)); // 启动上传流程
});

接下来,我们通过 createChunk 函数来实现安全的“拆解”工作:

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; // 指针向前推进 5MB
  }
  return chunkList;
}
  • file.slice()是浏览器的“切割机”,它不会复制原始文件,而是创建指向原文件片段的引用(类似书签标记段落)。
  • 每次切5MB(5 * 1024 * 1024字节),既避免单次请求过大,又防止切片过多增加管理成本。
  • cur指针像裁纸刀一样匀速推进,确保无重叠、无遗漏地覆盖整个文件。

前端:如何安全打包并发送切片

切片完成后,每个碎片需要贴上“快递单”(元数据)才能被正确识别。

给切片贴上身份标签

const chunks = chunkList.map(({file}, index) => ({
  file,
  size: file.size,
  chunkName: `${fileObj.name}-${index}`, // 如 "video.mp4-0"
  fileName: fileObj.name,
  index
}));
  • chunkName是切片的唯一身份证(文件名+序号);
  • index确保服务端能按顺序重组文件;
  • 这像给每块雕塑碎片编号:“左臂-1”、“左臂-2”……

下面可以建一个uploadChunks函数,负责做到封装切片,以及并发上传&合并触发

function uploadChunks(chunks) {
     //用FormData封装切片
     const formChunks = chunks.map(({file, fileName, chunkName, size, index}) => {
       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)
     })
     //并发上传与合并触发
     Promise.all(requestList).then(res => {
       axios.post('http://localhost:3000/merge', {
         fileName: fileObj.name,
         size: 5 * 1024 * 1024
       }).then(res => {
         console.log(res.data);
       })
     })
     
   }

FormData它像定制的快递盒,把二进制切片和文字标签(元数据)安全打包。普通JSON无法传输二进制数据,而FormData能自动处理MIME类型编码。

  • Promise.all同时发起所有切片请求;
  • 仅当所有切片确认送达后,才通知服务端合并;
  • 合并请求携带了原始文件名和切片大小,这是重组的关键参数。

重要提醒:此处Promise.all在切片数量极大时可能引发问题(如1000个切片同时请求),但作为基础实现完全合理。进阶方案会控制并发数,但代码目标明确——先跑通核心逻辑。

后端:如何接收并精准拼合切片

前端发得再规范,后端接不住也是徒劳。Node.js服务端代码精准实现了两个关键动作。

动作1:安全接收切片

if (req.url === '/upload') {
  const form = new multiparty.Form();
  form.parse(req, (err, fields, files) => {
    const [file] = files.file;
    const [fileName] = fields.fileName;
    const [chunkName] = fields.chunkName;

    // 创建专属切片目录:qiepian/原文件名-chunks
    const chunkDir = path.resolve(__dirname, 'qiepian', `${fileName}-chunks`);
    if (!fs.existsSync(chunkDir)) fs.mkdirsSync(chunkDir);
    
    // 保存切片:移动临时文件到目标路径
    fs.moveSync(file.path, path.resolve(chunkDir, chunkName));
  });
}
  • 目录隔离:每个文件的切片存入独立文件夹(如video.mp4-chunks),避免不同文件切片混淆。
  • 无损移动fs.moveSync直接转移系统临时文件,比复制更高效安全。
  • 关键细节chunkName保留了序号(如video.mp4-0),这是后续排序的依据。

动作2:按序拼接成完整文件

if (req.url === '/merge') {
  const { fileName, size } = await resolvePost(req);
  const filePath = path.resolve(__dirname, 'qiepian', `${fileName}-chunks`);
  await mergeChunks(filePath, fileName, size);
}

// 核心合并函数
const mergeChunks = async (filePath, fileName, size) => {
  let chunksPath = fs.readdirSync(filePath);
  chunksPath.sort((a, b) => a.split('-')[1] - b.split('-')[1]); // 按序号排序

  const arr = chunksPath.map((chunkPath, index) => 
    pipeStream(
      path.resolve(filePath, chunkPath),
      fs.createWriteStream(path.resolve(filePath, '..', fileName), {
        start: index * size,
        end: (index + 1) * size 
      })
    )
  );
  await Promise.all(arr);
};

// 流式传输关键实现(原被省略的代码)
function pipeStream(readPath, writeStream) {
  return new Promise((resolve, reject) => {
    const readStream = fs.createReadStream(readPath);
    // 核心流管道操作:这里正是readStream.pipe(writeStream)的实际应用
    readStream
      .pipe(writeStream)
      .on('finish', resolve)
      .on('error', reject);
  });
}

拼接的精妙之处

  1. 严格排序split('-')[1]提取序号0,1,2...确保切片按原始顺序写入。

  2. 精准定位fs.createWriteStreamstart/end参数像手术刀,将第n个切片精确插入目标文件的[n*size, (n+1)*size]区间。

  3. 流式处理pipeStream函数内部通过readStream.pipe(writeStream)建立管道传输:

    • fs.createReadStream创建文件读取流(从切片文件读取数据)
    • fs.createWriteStream创建带偏移量的写入流(定位到目标文件指定位置)
    • 管道操作符pipe直接将读取流数据导向写入流,全程不经过内存缓冲,避免大文件处理时的内存溢出风险

结语

代码完美实现了切片上传的最小可行方案:

  • 前端切片→打包元数据→并发上传→服务端归类→按序合并。
  • 没有多余的装饰,只有清晰的因果链条。

所有复杂的上传系统,都始于这5MB的切片。当你能彻底搞明白这段代码,理解更高级方案也不在话下了。