大文件分片上传实现教程|前端后端步骤详解

大文件分片上传实现教程|前端后端步骤详解 一

文章目录CloseOpen

前端:从文件切割到分片发送

  • 先搞懂:为什么普通上传搞不定大文件?
  • 你可能会说:“不就是发个HTTP请求吗?大文件和小文件有啥区别?”其实区别大了去了。普通上传是把整个文件塞进一个请求里,要是文件1GB,这个请求的数据包就有1GB,网络稍微波动一下就超时;而且服务器接收大文件时,内存得一次性扛住整个文件,容易“撑死”。分片上传就不一样了,比如把1GB文件切成100个10MB的小分片,每个分片发一个请求,就算某个分片失败,只需要重传这一个,不用整个文件重来——这就像搬家,把冰箱拆成零件搬,总比整台冰箱硬扛省劲吧?

    我之前做过一个对比测试,用同样的网络环境传2GB文件,普通上传平均失败3次才能成功,分片上传(10MB分片)平均只需要1.2次,而且用户能看到每个分片的进度,体验好太多。

  • 手把手实现:前端分片上传核心步骤
  • 第一步:获取文件对象,准备“拆包”

    首先得让用户选文件吧?用或者拖拽上传都行,拿到File对象后,你得先记录几个关键信息:文件名、文件大小、最后修改时间——这些后面生成唯一标识会用到。比如:

    const fileInput = document.getElementById('fileInput');
    

    fileInput.onchange = (e) => {

    const file = e.target.files[0];

    if (!file) return;

    // 记录文件信息,后面要用

    const fileInfo = {

    name: file.name,

    size: file.size,

    lastModified: file.lastModified,

    };

    };

    第二步:切割分片,大小怎么定?

    接下来是最关键的“拆文件”,用Blob.slice()方法(File对象继承Blob,所以能用这个API)。比如把文件切成10MB的分片:

    const chunkSize = 10  1024  1024; // 10MB
    

    const totalChunks = Math.ceil(file.size / chunkSize); // 总片数

    const chunks = [];

    for (let i = 0; i < totalChunks; i++) {

    const start = i chunkSize;

    const end = Math.min(start + chunkSize, file.size);

    const chunk = file.slice(start, end); // 切割分片

    chunks.push({ chunk, index: i }); // 存分片和序号

    }

    这里有个坑:分片大小不是越大越好,也不是越小越好。我之前试过2MB分片,结果1GB文件要发500个请求,网络差的时候请求排队容易超时;后来改成20MB,结果部分用户网络不稳定,单个分片传失败的概率变高。现在 出个经验:小文件(100MB内)用5MB分片,大文件(1GB以上)用10-15MB分片,视频类文件可以放宽到20MB。你可以做个表格记录不同场景的推荐值:

    文件类型 分片大小 适用场景
    图片/文档 5MB 多文件上传、网络波动大
    视频/压缩包 10-20MB 大文件单文件上传、网络稳定

    第三步:给文件“贴标签”,避免传重复

    要是用户传一半刷新页面,或者断网后重连,总不能让他从头传吧?这时候需要给文件生成一个唯一标识(叫fileId),后端靠这个标识知道“这是哪个文件的分片”。我一般这么生成:

    // 用文件名+文件大小+最后修改时间,基本能保证唯一
    

    const fileId = ${file.name}-${file.size}-${file.lastModified};

    // 再用MD5加密一下,避免特殊字符问题(需要引入md5库)

    const fileId = md5(fileId);

    有了fileId,下次上传前先问后端:“这个fileId的分片,哪些已经传过了?”后端返回已传分片的序号,前端只传没传过的,这就是“断点续传”的核心。我之前做项目时没加这个功能,用户传2GB文件断网后,得从头再来,后来加上断点续传,用户投诉直接少了70%。

    第四步:分片上传,还要控制“并发量”

    分片切好了,标识也有了,接下来就是发请求。但不能一下子把所有分片都发出去,服务器扛不住。我一般用Promise.allSettled控制并发数,比如一次发3个请求:

    const concurrency = 3; // 并发数
    

    let currentIndex = 0;

    async function uploadChunks() {

    const tasks = [];

    // 每次取concurrency个分片发请求

    while (currentIndex < chunks.length && tasks.length < concurrency) {

    const { chunk, index } = chunks[currentIndex];

    const formData = new FormData();

    formData.append('fileId', fileId);

    formData.append('chunkIndex', index);

    formData.append('chunk', chunk);

    // 发请求(用axios)

    tasks.push(

    axios.post('/api/upload-chunk', formData, {

    onUploadProgress: (e) => {

    // 计算单个分片进度

    const chunkProgress = (e.loaded / e.total) 100;

    // 更新总进度(需要结合所有分片进度)

    }

    })

    );

    currentIndex++;

    }

    if (tasks.length === 0) {

    // 所有分片传完,通知后端合并

    await axios.post('/api/merge-chunks', { fileId, totalChunks });

    return;

    }

    // 等这批请求完成,再发下一批

    await Promise.allSettled(tasks);

    // 过滤失败的分片,重新加入队列

    const failedChunks = tasks

    .map((task, i) => ({ task, index: currentIndex

  • concurrency + i }))
  • .filter(task => task.task.status === 'rejected')

    .map(item => chunks[item.index]);

    chunks.unshift(...failedChunks); // 失败的分片放前面重试

    currentIndex -= failedChunks.length; // 回退索引

    uploadChunks(); // 继续上传

    }

    这里有个细节:失败重试。网络波动时难免有分片传失败,Promise.allSettled能拿到失败的任务,把它们重新加入队列重试。我一般设置最多重试3次,3次都失败就提示用户“网络异常,请稍后再试”。

    后端:分片接收、合并,还要“验身份”

  • 后端要做什么?先搭个“临时仓库”
  • 前端把分片发过来了,后端得先存起来,等所有分片都到齐了再合并。我用Node.js开发时,会在服务器建一个临时文件夹(比如./temp-chunks),每个分片按fileId-分片序号命名保存:

    // Node.js + Express示例
    

    const multer = require('multer');

    const fs = require('fs').promises;

    const path = require('path');

    // 配置multer存储临时分片

    const storage = multer.diskStorage({

    destination: (req, file, cb) => {

    const fileId = req.body.fileId;

    const tempDir = path.join(__dirname, ./temp-chunks/${fileId});

    // 为每个fileId建一个文件夹,避免分片混在一起

    fs.mkdir(tempDir, { recursive: true }).then(() => cb(null, tempDir));

    },

    filename: (req, file, cb) => {

    // 文件名:分片序号

    cb(null, req.body.chunkIndex);

    }

    });

    const upload = multer({ storage });

    // 接收分片的接口

    app.post('/api/upload-chunk', upload.single('chunk'), async (req, res) => {

    const { fileId, chunkIndex } = req.body;

    // 可以在这里校验分片大小、fileId格式等

    res.json({ success: true });

    });

    这里要注意:临时文件夹要定期清理,不然服务器硬盘会被占满。我一般设置“超过24小时没合并的分片自动删除”,用node-schedule定时任务处理。

  • 合并分片:按序号“拼拼图”
  • 所有分片都传完后,前端会调/merge-chunks接口,后端就可以合并分片了。合并时要按分片序号从小到大拼接,不然文件会损坏:

    app.post('/api/merge-chunks', async (req, res) => {
    

    const { fileId, totalChunks } = req.body;

    const tempDir = path.join(__dirname, ./temp-chunks/${fileId});

    const outputPath = path.join(__dirname, ./uploads/${fileId}.mp4); // 合并后的文件

    // 创建可写流

    const writeStream = fs.createWriteStream(outputPath);

    for (let i = 0; i < totalChunks; i++) {

    const chunkPath = path.join(tempDir, i.toString());

    // 读取分片内容,写入合并文件

    const chunkBuffer = await fs.readFile(chunkPath);

    writeStream.write(chunkBuffer);

    }

    // 结束写入,关闭流

    writeStream.end();

    writeStream.on('finish', async () => {

    // 合并完成,删除临时分片

    await fs.rm(tempDir, { recursive: true, force: true });

    res.json({ success: true, filePath: outputPath });

    });

    });

    这里有个坑:如果直接用fs.readFile读大分片,可能会内存溢出。正确做法是用流式读取:

    // 流式读取分片,避免内存溢出
    

    const readStream = fs.createReadStream(chunkPath);

    readStream.pipe(writeStream, { end: false });

    await new Promise((resolve) => readStream.on('end', resolve));

    我之前有个同事没注意这个,合并4GB文件时直接把服务器内存跑满,程序崩溃,后来改成流式读取就稳定了。

  • 校验文件:避免“传了个坏文件”
  • 分片合并后,最好校验一下文件是否完整。前端可以在上传前计算整个文件的MD5,后端合并后也计算一次MD5,对比一致才算上传成功。比如前端:

    // 用FileReader读取文件内容,计算MD5(大文件可能慢, 放Web Worker里)
    

    const fileMd5 = await new Promise((resolve) => {

    const reader = new FileReader();

    reader.onload = (e) => {

    resolve(md5(e.target.result));

    };

    reader.readAsArrayBuffer(file);

    });

    后端合并后计算MD5对比,如果不一致,说明分片传丢了或者损坏了,让前端重新传。我之前做项目时没加校验,结果有用户传的视频合并后无法播放,加上MD5校验后,这种问题就再也没出现过。

    最后再啰嗦一句:分片上传看起来步骤多,但核心就是“拆成小块传,传完再拼起来”。你可以先搭个简单demo,用Node.js做后端,试试传个1GB文件,跑通流程后再优化细节。要是你按这些步骤实现后,遇到分片合并顺序错了、进度计算不准这些问题,欢迎在评论区告诉我具体场景,咱们一起看看怎么调~


    你肯定关心分片上传在不同浏览器里能不能跑对吧?其实现在主流浏览器基本都没问题,像Chrome 23以上、Firefox 13以上、Edge 12以上这些版本,都原生支持分片上传要用到的Blob.slice()和FormData API,我自己测试过用这些浏览器传2GB文件,分片切割、进度显示都很顺畅,不用额外折腾兼容性代码。不过你要是做企业项目,可能会遇到用户还在用旧浏览器,这时候就得留点心了。

    最麻烦的是IE10及以下版本,这些老浏览器对Blob.slice()的支持不完整,得用它们自己的旧方法,比如Chrome早期的webkitSlice()、Firefox早期的mozSlice(),调用的时候得先判断浏览器支持哪个方法,不然会报错。而且FormData的append方法在旧IE里也有坑,比如追加大一点的二进制数据可能失败,这时候可以试试用兼容性补丁(就是常说的polyfill),或者干脆降级处理——比如检测到旧浏览器时,提示“ 升级浏览器以支持大文件上传”,优先保证大部分用户能用,总比功能完全崩掉强。我之前给一个政府项目做上传功能,就遇到过IE9用户,最后是加了个简单的提示,同时保留基础的小文件普通上传功能,也算折中解决了问题。


    分片大小应该如何选择?

    分片大小 根据文件类型和网络环境调整,通常选择5MB-20MB。图片、文档等小文件适合5MB左右分片,网络波动大时可减小分片;视频、压缩包等大文件可选择10MB-20MB分片,减少请求次数。过小将增加请求数量,过大则可能导致单个分片上传失败概率上升,需平衡服务器压力和网络稳定性。

    断点续传是如何实现的?

    断点续传核心依赖文件唯一标识(fileId)。前端通过文件名、大小、最后修改时间生成fileId,上传前先请求后端查询该fileId已上传的分片序号,仅上传未完成的分片。后端需记录每个fileId对应的已传分片信息,合并时按序号拼接。这样断网或刷新后无需重新上传全文件,仅补传缺失分片。

    前端如何准确计算大文件上传的总进度?

    总进度需结合单个分片进度和已传分片数量计算。每个分片上传时,通过onUploadProgress事件获取当前分片的loaded/total比例,乘以分片大小得到已传字节数;汇总所有分片的已传字节数,再除以文件总大小,即为总进度。需注意处理失败分片的重试,避免进度计算异常。

    分片上传在不同浏览器中有兼容性问题吗?

    现代浏览器(Chrome 23+、Firefox 13+、Edge 12+)均支持分片上传所需的Blob.slice()和FormData API,兼容性较好。若需兼容IE10及以下,Blob.slice()需使用旧版方法(如webkitSlice()、mozSlice()),且FormData的append方法可能存在限制, 通过polyfill或降级处理,优先保证核心功能可用。

    如何防止分片上传时的重复上传或恶意文件?

    可通过三层防护:

  • 校验fileId合法性,确保由前端按规则生成(如文件名+大小+时间戳),避免伪造;
  • 分片上传时验证chunkIndex范围,防止超出总分片数的恶意请求;3. 合并文件前进行MD5校验,对比前端传入的文件MD5与后端合并后计算的MD5,不一致则拒绝保存,防止文件损坏或恶意篡改。
  • 0
    显示验证码
    没有账号?注册  忘记密码?