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

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

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

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

js 复制代码
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 函数来实现安全的"拆解"工作:

js 复制代码
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指针像裁纸刀一样匀速推进,确保无重叠、无遗漏地覆盖整个文件。

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

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

给切片贴上身份标签

js 复制代码
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函数,负责做到封装切片,以及并发上传&合并触发

js 复制代码
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:安全接收切片

js 复制代码
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:按序拼接成完整文件

js 复制代码
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的切片。当你能彻底搞明白这段代码,理解更高级方案也不在话下了。

相关推荐
画画的阿飞8 小时前
里程碑三:基于 Vue3 领域模型架构建设
前端·node.js
用户4099322502129 小时前
Composable的命名规矩和参数约定,别再瞎写了
前端·javascript·后端
用户游民9 小时前
Flutter Provider原理以及用法
前端·flutter
Rust研习社9 小时前
告别环境混乱!使用 mise 管理你的开发环境
前端·后端·rust
小小荧9 小时前
Vue Native多分支迭代,Vue跨端原生生态迎来革新
前端·javascript·vue.js
EntyIU9 小时前
uv工程化项目指南
前端·python·uv
WebGirl9 小时前
如何在VS code中添加SKill
前端
梦醒沉醉9 小时前
1、JavaScript入门和语法类型
javascript
怕浪猫9 小时前
国内最赚钱的 IT 公司排行
面试