大文件上传

大文件上传

前后端配合,前端进行文件切片,计算文件hash,作为与后端协作的唯一凭证,标明是哪个文件。

上传的切片信息需包含4个部分:切片索引,文件hash, 总分片数,分片的内容 {index,hash, total, chuck}。

后端API提供:

  • /check 文件是否上传过
  • /uploaded-chunks 已上传的分片列表(后端以chuck的索引存储)
  • /upload-chunk 上传分片
  • /merge 合并分片

文件切片

js 复制代码
const CHUNK_SIZE = 2 * 1024 * 1024; // 2MB

// 将文件切片成多个 Blob
// 返回 Blob 数组,每个 Blob 保留原始文件的 type
function sliceFile(file, chunkSize = CHUNK_SIZE) {
    const chunks = [];
    const totalChunks = Math.ceil(file.size / chunkSize);
    
    for (let i = 0; i < totalChunks; i++) {
        const start = i * chunkSize;
        const end = Math.min(file.size, start + chunkSize);
        const slicedChunk = file.slice(start, end);
        
        // file.slice() 在某些情况下可能不会保留原始文件的 type
        // 手动创建 Blob 并保留原始文件的 type
        const chunk = new Blob([slicedChunk], { 
            type: file.type || '' // 保留原始文件的 type,如果是空字符串也保留
        });
        
        chunks.push(chunk);
    }
    
    return chunks;
}

计算文件hash

js 复制代码
// 计算文件hash(使用SparkMD5库)
// 增量算法:分块计算,因为计算hash要读取整个文件的内容,一次性读取到内存中,内存会溢出OOM。读取一块计算该块的hash,最后合并。
function calculateHash(file) {
    return new Promise((resolve)=>{
        const spark = new SparkMD5()
        // 使用统一的切片函数
        const chunks = sliceFile(file, CHUNK_SIZE)

        function _read(index){
            if(index >= chunks.length){
                resolve(spark.end())  //返回文件hash
                return   //读取完成
            }
            const blob = chunks[index]
            if(blob){
                const fileReader = new FileReader()
                fileReader.onload = (e)=>{
                    const bytes = e.target.result   //读取到的字节数组ArrayBuffer
                    spark.append(bytes)
                    _read(index + 1)
                }

                fileReader.readAsArrayBuffer(blob)
            }
            
        }
        _read(0)
    })
}

分片上传

js 复制代码
// 分片上传
async function uploadFile(file) {
    if (!file) return;

    // 计算文件hash
    const fileHash = await calculateHash(file);
    console.log(fileHash)

    // 检查文件是否已经上传过(秒传)
    const checkResp = await apiClient.post('/api/check', { hash: fileHash });
    const checkResult = checkResp.data;

    if (checkResult.exists) {
        // 秒传,直接跳转到结果页面
        alert('文件已存在,秒传成功!');
        return;
    }

    // 获取已上传的分片(断点续传)
    const uploadedResp = await apiClient.post('/api/uploaded-chunks', { hash: fileHash });
    const uploadedChunks = uploadedResp.data; // 假设返回已上传的分片索引数组
    
    // 使用统一的切片函数
    const blobChunks = sliceFile(file, CHUNK_SIZE);
    const totalChunks = blobChunks.length
    
    // 将 Blob 数组转换为包含元数据的分片对象数组
    const chunks = blobChunks.map((chunk, index) => ({
        index: index,
        hash: fileHash,
        chunk: chunk,
        total: totalChunks
    }));

    // 上传分片(过滤已上传的分片)
    const chunksToUpload = chunks.filter(chunk => !uploadedChunks.includes(chunk.index));
    
    // 控制并发上传数
    // 建议值:
    // - 小文件(< 50MB):3-5
    // - 中等文件(50-500MB):3-6
    // - 大文件(> 500MB):4-8
    // 注意:浏览器 HTTP/1.1 每个域名最多 6 个并发连接
    const concurrency = Math.min(6, Math.max(3, Math.ceil(totalChunks / 10))); // 动态调整,但不超过6
    // const concurrency = 1
    console.log(`总分片数: ${totalChunks}, 并发数: ${concurrency}`);

    // console.log(uploadChunk(chunks[0]))
    
    try {
        const results = await runConcurrent(chunksToUpload, concurrency, uploadChunk);
        
        // 检查所有分片是否上传成功
        const failedChunks = [];
        results.forEach((result, index) => {
            // 如果 result 是错误对象,则认为失败
            if (result instanceof Error) {
                console.error(`分片 ${chunksToUpload[index].index} 上传失败:`, result);
                failedChunks.push({ index: chunksToUpload[index].index, error: result });
                return;
            }
            // 如果 result 为空或 undefined,认为失败
            if (!result) {
                console.error(`分片 ${chunksToUpload[index].index} 上传失败: 返回结果为空`);
                failedChunks.push({ index: chunksToUpload[index].index, error: '返回结果为空' });
                return;
            }
            // 如果后端返回了 success 字段,检查是否为 false
            if (result.success === false) {
                console.error(`分片 ${chunksToUpload[index].index} 上传失败:`, result);
                failedChunks.push({ index: chunksToUpload[index].index, error: result });
                return;
            }
        });
        
        if (failedChunks.length > 0) {
            alert(`有 ${failedChunks.length} 个分片上传失败,请重试!`);
            return;
        }
        
        console.log('所有分片上传成功,开始合并...');
        
        // 所有分片上传完成后,通知合并
        const mergeResp = await apiClient.post('/api/merge', { hash: fileHash, total: totalChunks });
        const mergeResult = mergeResp.data;
        if (mergeResult.success) {
            alert('上传成功!');
            // 跳转到结果页面等
        } else {
            alert('合并失败,请重试!');
        }
    } catch (error) {
        console.error('上传过程中发生错误:', error);
        alert('上传失败,请重试!');
    }
}

// 上传单个分片
async function uploadChunk(chunk) {
    const formData = new FormData();
    formData.append('hash', chunk.hash);
    formData.append('index', chunk.index);
    formData.append('total', chunk.total);
    formData.append('chunk', chunk.chunk); 

    const resp = await apiClient.post('/api/upload-chunk', formData);
    return resp.data;
}

// 并发控制
// tasks: 任务数组(数据)
// concurrency: 最大并发数
// taskFn: 执行任务的函数
async function runConcurrent(tasks, concurrency, taskFn) {
    const results = [];
    const executing = [];
    
    for (const task of tasks) {
        // 立即执行任务函数,创建 Promise(任务会立即开始执行)
        const promise = Promise.resolve().then(() => taskFn(task));
        results.push(promise);
        
        // 创建一个清理函数,当任务完成时从 executing 数组中移除
        const cleanup = promise.finally(() => {
            // promise.finally 会在任务完成(成功或失败)后执行
            // 从 executing 数组中移除这个已完成的任务
            const index = executing.indexOf(cleanup);
            if (index > -1) {
                executing.splice(index, 1);
            }
        });
        
        executing.push(cleanup);
        
        // 如果达到并发上限,等待至少一个任务完成
        // Promise.race 会等待 executing 中任意一个 Promise 完成。阻塞循环
        if (executing.length >= concurrency) {
            await Promise.race(executing);
        }
    }
    
    // 等待所有任务完成(使用 allSettled 确保即使有失败也能获取所有结果)
    const settledResults = await Promise.allSettled(results);
    // 将 allSettled 的结果转换为普通结果或错误
    return settledResults.map((result, index) => {
        if (result.status === 'fulfilled') {
            return result.value; // 成功的结果
        } else {
            return result.reason; // 失败的错误对象
        }
    });
}

后端

js 复制代码
const express = require('express');
const multer = require('multer');
const fs = require('fs');
const path = require('path');
const cors = require('cors');

const app = express();
app.use(cors());
app.use(express.json()); //只负责解析 Content-Type: application/json 的请求

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

// 确保上传目录存在
if (!fs.existsSync(UPLOAD_DIR)) {
    fs.mkdirSync(UPLOAD_DIR);
}

// 存储分片的临时目录
const CHUNK_DIR = path.resolve(UPLOAD_DIR, 'chunks');
if (!fs.existsSync(CHUNK_DIR)) {
    fs.mkdirSync(CHUNK_DIR);
}

// 检查文件是否存在(秒传)
app.post('/api/check', (req, res) => {
    const { hash } = req.body;
    const filePath = path.resolve(UPLOAD_DIR, hash);
    if (fs.existsSync(filePath)) {
        res.json({ exists: true });
    } else {
        res.json({ exists: false });
    }
});

// 获取已上传的分片列表
app.post('/api/uploaded-chunks', (req, res) => {
    const { hash } = req.body;
    const chunkDir = path.resolve(CHUNK_DIR, hash);
    if (!fs.existsSync(chunkDir)) {
        return res.json([]);
    }
    const chunks = fs.readdirSync(chunkDir);
    // 假设分片文件名就是索引
    const uploadedChunks = chunks.map(chunk => parseInt(chunk));
    res.json(uploadedChunks);
});

// 上传分片
//multer:只负责解析 带文件的 multipart/form-data 请求
const upload = multer({ dest: CHUNK_DIR });
app.post('/api/upload-chunk', upload.single('chunk'), (req, res) => {
    // 检查文件是否成功上传
    if (!req.file) {
        return res.status(400).json({ 
            success: false, 
            message: '文件上传失败:未检测到文件。请确保前端使用 FormData 发送,且文件字段名为 "chunk"' 
        });
    }

    const { hash, index } = req.body;
    console.log('upload-chunk:', { hash, index, file: req.file.path }); 
    
    // 检查必要参数
    if (!hash || index === undefined) {
        return res.status(400).json({ 
            success: false, 
            message: '缺少必要参数:hash 或 index' 
        });
    }

    const chunkDir = path.resolve(CHUNK_DIR, hash);
    if (!fs.existsSync(chunkDir)) {
        fs.mkdirSync(chunkDir, { recursive: true });
    }
    
    // 将上传的临时文件移动到对应的分片目录,并以索引命名
    const tempPath = req.file.path;
    const targetPath = path.resolve(chunkDir, index);
    fs.renameSync(tempPath, targetPath);
    res.json({ success: true });
});

// 合并分片
app.post('/api/merge', async (req, res) => {
    const { hash, total } = req.body;
    const chunkDir = path.resolve(CHUNK_DIR, hash);
    const filePath = path.resolve(UPLOAD_DIR, hash);

    // 检查分片是否全部上传完成
    const chunks = fs.readdirSync(chunkDir);
    // console.log(chunks.length)
    if (chunks.length !== total) {
        return res.status(400).json({ success: false, message: '分片数量不符' });
    }

    // 按索引排序分片
    const sortedChunks = chunks
        .map(chunk => parseInt(chunk))
        .sort((a, b) => a - b);

    // 合并分片
    const writeStream = fs.createWriteStream(filePath);
    for (const chunkIndex of sortedChunks) {
        const chunkPath = path.resolve(chunkDir, chunkIndex.toString());
        const chunkBuffer = fs.readFileSync(chunkPath);
        writeStream.write(chunkBuffer);
        fs.unlinkSync(chunkPath); // 删除分片
    }
    writeStream.end();

    // 删除分片目录
    fs.rmdirSync(chunkDir);

    // 这里可以调用后续处理日志文件的函数,并返回处理结果

    res.json({ success: true });
});

app.listen(3000, () => {
    console.log('Server is running on port 3000');
});
相关推荐
再学一点就睡6 小时前
前端网络实战手册:15个高频工作场景全解析
前端·网络协议
C_心欲无痕7 小时前
有限状态机在前端中的应用
前端·状态模式
C_心欲无痕7 小时前
前端基于 IntersectionObserver 更流畅的懒加载实现
前端
candyTong7 小时前
深入解析:AI 智能体(Agent)是如何解决问题的?
前端·agent·ai编程
柳杉7 小时前
建议收藏 | 2026年AI工具封神榜:从Sora到混元3D,生产力彻底爆发
前端·人工智能·后端
weixin_462446237 小时前
使用 Puppeteer 设置 Cookies 并实现自动化分页操作:前端实战教程
运维·前端·自动化
CheungChunChiu8 小时前
Linux 内核动态打印机制详解
android·linux·服务器·前端·ubuntu
GIS之路9 小时前
GDAL 创建矢量图层的两种方式
前端
小目标一个亿9 小时前
Windows平台Nginx配置web账号密码验证
linux·前端·nginx
rocky1919 小时前
网页版时钟
前端·javascript·html