浏览器中如何实现文件上传?如何处理大文件分块上传和断点续传?

各位老铁们,晚上好!今儿咱们唠唠浏览器文件上传那点事儿,特别是大文件上传,这可是个技术活,搞不好就GG了。咱争取用最接地气的语言,把这事儿掰开了揉碎了,让大家听完就能上手。

一、文件上传的那些事儿

简单来说,文件上传就是把本地文件传到服务器上,让服务器保存起来。这听起来简单,但里面门道可不少。

  1. HTML 表单是基础

    想要上传文件,首先得有个地方让用户选文件吧?HTML 的 <input type="file"> 元素就是干这个的。

    <input type="file" id="fileInput" name="file">
    <button onclick="uploadFile()">上传</button>

    这段代码创建了一个文件选择框和一个上传按钮。name="file" 很重要,服务器端会根据这个名字来接收文件。

  2. FormData 对象是搬运工

    选好文件后,怎么把文件数据送到服务器呢? FormData 对象就是个好帮手。它可以把表单数据打包成一种特殊的格式,方便通过 XMLHttpRequestfetch 发送。

    function uploadFile() {
        const fileInput = document.getElementById('fileInput');
        const file = fileInput.files[0]; // 获取选中的文件
    
        if (!file) {
            alert('请选择文件!');
            return;
        }
    
        const formData = new FormData();
        formData.append('file', file); // 把文件添加到 FormData 中
    
        // 使用 XMLHttpRequest 或 fetch 发送 FormData
        // 下面以 fetch 为例
        fetch('/upload', {
            method: 'POST',
            body: formData
        })
        .then(response => response.json())
        .then(data => {
            console.log('上传成功:', data);
        })
        .catch(error => {
            console.error('上传失败:', error);
        });
    }

    这段代码先把选中的文件放进 FormData 对象,然后用 fetch 发送给服务器的 /upload 接口。

  3. XMLHttpRequest vs. Fetch:选哪个?

    • XMLHttpRequest (XHR): 老牌选手,兼容性好,但 API 比较繁琐。
    • Fetch API: 新秀,API 更简洁,基于 Promise,用起来更舒服。

    现在主流都推荐用 Fetch API,因为它更现代、更易用。上面的例子就是用 Fetch API 实现的。

  4. 服务器端接收文件

    服务器端需要编写代码来接收上传的文件。具体用什么语言和框架,就看你的选择了。比如,用 Node.js + Express 可以这样:

    const express = require('express');
    const multer = require('multer');
    
    const app = express();
    const upload = multer({ dest: 'uploads/' }); // 指定文件存储目录
    
    app.post('/upload', upload.single('file'), (req, res) => {
        // req.file 包含上传文件的信息
        // req.body 包含其他表单数据
        console.log('上传的文件信息:', req.file);
        res.json({ message: '文件上传成功!' });
    });
    
    app.listen(3000, () => {
        console.log('服务器启动,监听 3000 端口');
    });

    这段代码用了 multer 中间件来处理文件上传。upload.single('file') 表示只接收一个名为 "file" 的文件。

二、大文件分块上传

上传小文件没啥问题,但遇到几个 G 的大文件,直接传肯定不行。这时候就需要把文件分成小块,一块一块地传,这就是分块上传。

  1. 为什么需要分块上传?

    • 网络不稳定: 大文件容易传到一半断掉,重新传很浪费时间。
    • 浏览器限制: 有些浏览器对上传文件大小有限制。
    • 服务器压力: 一次性上传大文件,服务器压力山大。
  2. 分块上传的流程

    1. 客户端:
      • 把文件分成若干个小块(chunk)。
      • 为每个 chunk 生成一个唯一的标识(chunkIndex)。
      • 依次上传每个 chunk。
      • 所有 chunk 上传完成后,通知服务器合并文件。
    2. 服务器端:
      • 接收并临时存储每个 chunk。
      • 记录每个 chunk 的上传状态。
      • 收到合并文件的通知后,按顺序合并所有 chunk。
  3. 客户端代码实现

    async function uploadLargeFile(file) {
        const chunkSize = 1024 * 1024 * 2; // 2MB 一个 chunk
        const totalSize = file.size;
        const chunkCount = Math.ceil(totalSize / chunkSize);
    
        for (let chunkIndex = 0; chunkIndex < chunkCount; chunkIndex++) {
            const start = chunkIndex * chunkSize;
            const end = Math.min(totalSize, start + chunkSize);
            const chunk = file.slice(start, end); // 切割文件
    
            const formData = new FormData();
            formData.append('chunk', chunk);
            formData.append('chunkIndex', chunkIndex);
            formData.append('totalChunks', chunkCount);
            formData.append('filename', file.name); // 方便服务器端合并
    
            try {
                const response = await fetch('/upload-chunk', {
                    method: 'POST',
                    body: formData
                });
    
                if (!response.ok) {
                    throw new Error(`上传 chunk ${chunkIndex} 失败: ${response.status}`);
                }
    
                console.log(`chunk ${chunkIndex} 上传成功`);
            } catch (error) {
                console.error(`chunk ${chunkIndex} 上传出错:`, error);
                // 处理上传错误,可以重试或者提示用户
            }
        }
    
        // 所有 chunk 上传完成后,通知服务器合并文件
        try {
            const response = await fetch('/merge-file', {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json'
                },
                body: JSON.stringify({ filename: file.name })
            });
    
            if (!response.ok) {
                throw new Error(`合并文件失败: ${response.status}`);
            }
    
            const data = await response.json();
            console.log('文件合并成功:', data);
        } catch (error) {
            console.error('文件合并出错:', error);
        }
    }

    这段代码把文件分成若干个 2MB 的 chunk,然后依次上传每个 chunk。上传完成后,会通知服务器合并文件。

  4. 服务器端代码实现

    const express = require('express');
    const multer = require('multer');
    const fs = require('fs');
    const path = require('path');
    
    const app = express();
    app.use(express.json()); // 解析 JSON 格式的请求体
    
    const uploadDir = 'uploads/'; // 临时存储 chunk 的目录
    const finalDir = 'final/'; // 最终文件存储目录
    
    // 创建目录(如果不存在)
    if (!fs.existsSync(uploadDir)) {
        fs.mkdirSync(uploadDir);
    }
    if (!fs.existsSync(finalDir)) {
        fs.mkdirSync(finalDir);
    }
    
    const storage = multer.diskStorage({
        destination: (req, file, cb) => {
            cb(null, uploadDir); // 临时存储目录
        },
        filename: (req, file, cb) => {
            const filename = req.body.filename;
            const chunkIndex = req.body.chunkIndex;
            cb(null, `${filename}-${chunkIndex}`); // 给每个 chunk 命名
        }
    });
    
    const upload = multer({ storage: storage });
    
    app.post('/upload-chunk', upload.single('chunk'), (req, res) => {
        // 接收并临时存储 chunk
        console.log(`接收到 chunk: ${req.body.chunkIndex}`);
        res.json({ message: 'chunk 上传成功' });
    });
    
    app.post('/merge-file', (req, res) => {
        const filename = req.body.filename;
        const totalChunks = parseInt(req.body.totalChunks); // 获取总 chunk 数量(如果客户端发送了)
        const filePath = path.join(finalDir, filename);
    
        const chunkFiles = fs.readdirSync(uploadDir)
                             .filter(file => file.startsWith(filename + '-'))
                             .sort((a, b) => { // 确保 chunk 按照顺序合并
                                 const indexA = parseInt(a.split('-')[1]);
                                 const indexB = parseInt(b.split('-')[1]);
                                 return indexA - indexB;
                             });
    
        // 检查是否所有 chunk 都已上传 (可选,更健壮的做法)
        // if (chunkFiles.length !== totalChunks) {
        //     return res.status(400).json({ message: '还有 chunk 未上传' });
        // }
    
        const writeStream = fs.createWriteStream(filePath);
    
        chunkFiles.forEach(chunkFile => {
            const chunkPath = path.join(uploadDir, chunkFile);
            const readStream = fs.createReadStream(chunkPath);
    
            readStream.pipe(writeStream, { end: false }); // 不要关闭 writeStream
    
            readStream.on('end', () => {
                fs.unlinkSync(chunkPath); // 删除已合并的 chunk
            });
        });
    
        writeStream.on('finish', () => {
            console.log(`文件 ${filename} 合并完成`);
            res.json({ message: '文件合并成功', filePath: filePath });
        });
    
        writeStream.on('error', (err) => {
            console.error('合并文件出错:', err);
            res.status(500).json({ message: '合并文件出错', error: err.message });
        });
    });
    
    app.listen(3000, () => {
        console.log('服务器启动,监听 3000 端口');
    });

    这段代码接收并临时存储每个 chunk,然后按顺序合并所有 chunk。合并完成后,会删除临时 chunk 文件。

三、断点续传

分块上传已经很好了,但如果传到一半断了,下次还得从头开始传,这也很烦。断点续传就是解决这个问题的。

  1. 断点续传的原理

    • 记录已上传的 chunk: 客户端和服务器都要记录哪些 chunk 已经上传成功。
    • 下次上传时跳过已上传的 chunk: 客户端在上传前先检查哪些 chunk 已经上传了,然后只上传未上传的 chunk。
  2. 客户端代码实现

    async function uploadLargeFileWithResume(file) {
        const chunkSize = 1024 * 1024 * 2; // 2MB 一个 chunk
        const totalSize = file.size;
        const chunkCount = Math.ceil(totalSize / chunkSize);
        const filename = file.name;
    
        // 获取已上传的 chunk 列表
        const uploadedChunks = await getUploadedChunks(filename);
    
        for (let chunkIndex = 0; chunkIndex < chunkCount; chunkIndex++) {
            // 如果 chunk 已经上传,就跳过
            if (uploadedChunks.includes(chunkIndex)) {
                console.log(`chunk ${chunkIndex} 已上传,跳过`);
                continue;
            }
    
            const start = chunkIndex * chunkSize;
            const end = Math.min(totalSize, start + chunkSize);
            const chunk = file.slice(start, end);
    
            const formData = new FormData();
            formData.append('chunk', chunk);
            formData.append('chunkIndex', chunkIndex);
            formData.append('totalChunks', chunkCount);
            formData.append('filename', filename);
    
            try {
                const response = await fetch('/upload-chunk', {
                    method: 'POST',
                    body: formData
                });
    
                if (!response.ok) {
                    throw new Error(`上传 chunk ${chunkIndex} 失败: ${response.status}`);
                }
    
                console.log(`chunk ${chunkIndex} 上传成功`);
            } catch (error) {
                console.error(`chunk ${chunkIndex} 上传出错:`, error);
                // 处理上传错误,可以重试或者提示用户
            }
        }
    
        // 所有 chunk 上传完成后,通知服务器合并文件
        try {
            const response = await fetch('/merge-file', {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json'
                },
                body: JSON.stringify({ filename: filename })
            });
    
            if (!response.ok) {
                throw new Error(`合并文件失败: ${response.status}`);
            }
    
            const data = await response.json();
            console.log('文件合并成功:', data);
        } catch (error) {
            console.error('文件合并出错:', error);
        }
    }
    
    // 获取已上传的 chunk 列表
    async function getUploadedChunks(filename) {
        try {
            const response = await fetch(`/get-uploaded-chunks?filename=${filename}`);
            if (!response.ok) {
                throw new Error(`获取已上传 chunk 列表失败: ${response.status}`);
            }
            const data = await response.json();
            return data.uploadedChunks;
        } catch (error) {
            console.error('获取已上传 chunk 列表出错:', error);
            return []; // 默认返回空数组
        }
    }

    这段代码在上传前先调用 getUploadedChunks 函数,获取已上传的 chunk 列表。然后跳过已上传的 chunk,只上传未上传的 chunk。

  3. 服务器端代码实现

    const express = require('express');
    const multer = require('multer');
    const fs = require('fs');
    const path = require('path');
    
    const app = express();
    app.use(express.json());
    
    const uploadDir = 'uploads/';
    const finalDir = 'final/';
    
    // 创建目录(如果不存在)
    if (!fs.existsSync(uploadDir)) {
        fs.mkdirSync(uploadDir);
    }
    if (!fs.existsSync(finalDir)) {
        fs.mkdirSync(finalDir);
    }
    
    const storage = multer.diskStorage({
        destination: (req, file, cb) => {
            cb(null, uploadDir);
        },
        filename: (req, file, cb) => {
            const filename = req.body.filename;
            const chunkIndex = req.body.chunkIndex;
            cb(null, `${filename}-${chunkIndex}`);
        }
    });
    
    const upload = multer({ storage: storage });
    
    app.post('/upload-chunk', upload.single('chunk'), (req, res) => {
        // 接收并临时存储 chunk
        console.log(`接收到 chunk: ${req.body.chunkIndex}`);
        res.json({ message: 'chunk 上传成功' });
    });
    
    app.post('/merge-file', (req, res) => {
        const filename = req.body.filename;
        const filePath = path.join(finalDir, filename);
    
        const chunkFiles = fs.readdirSync(uploadDir)
                             .filter(file => file.startsWith(filename + '-'))
                             .sort((a, b) => {
                                 const indexA = parseInt(a.split('-')[1]);
                                 const indexB = parseInt(b.split('-')[1]);
                                 return indexA - indexB;
                             });
    
        const writeStream = fs.createWriteStream(filePath);
    
        chunkFiles.forEach(chunkFile => {
            const chunkPath = path.join(uploadDir, chunkFile);
            const readStream = fs.createReadStream(chunkPath);
    
            readStream.pipe(writeStream, { end: false });
    
            readStream.on('end', () => {
                fs.unlinkSync(chunkPath);
            });
        });
    
        writeStream.on('finish', () => {
            console.log(`文件 ${filename} 合并完成`);
            res.json({ message: '文件合并成功', filePath: filePath });
        });
    
        writeStream.on('error', (err) => {
            console.error('合并文件出错:', err);
            res.status(500).json({ message: '合并文件出错', error: err.message });
        });
    });
    
    // 获取已上传的 chunk 列表
    app.get('/get-uploaded-chunks', (req, res) => {
        const filename = req.query.filename;
        const uploadedChunks = fs.readdirSync(uploadDir)
                                 .filter(file => file.startsWith(filename + '-'))
                                 .map(file => parseInt(file.split('-')[1])); // 提取 chunkIndex
    
        res.json({ uploadedChunks: uploadedChunks });
    });
    
    app.listen(3000, () => {
        console.log('服务器启动,监听 3000 端口');
    });

    这段代码添加了一个 /get-uploaded-chunks 接口,用于返回已上传的 chunk 列表。

四、优化与注意事项

  1. 前端优化

    • 使用 Web Workers: 把文件切割和上传逻辑放在 Web Workers 中,避免阻塞主线程,提高用户体验。
    • 进度条显示: 实时显示上传进度,让用户心里有数。
    • 错误处理: 完善的错误处理机制,及时提示用户。
    • 取消上传: 允许用户取消上传。
  2. 后端优化

    • CDN 加速: 使用 CDN 加速文件上传,提高上传速度。
    • 存储优化: 选择合适的存储方案,比如对象存储服务 (OSS)。
    • 安全: 检查文件类型和大小,防止恶意上传。
    • 并发控制: 限制并发上传数量,防止服务器过载。
  3. 通用优化

    • 唯一标识: 为每个上传任务生成一个唯一的标识符 (UUID),方便跟踪和管理。

五、总结

文件上传是个看似简单,实则水很深的技术。掌握了分块上传和断点续传,就能轻松应对大文件上传的挑战。当然,实际项目中还需要考虑更多细节,比如安全性、性能优化等等。

技术点 优点 缺点 适用场景
普通上传 简单易用 无法上传大文件、网络不稳定容易失败 小文件上传,网络环境良好
分块上传 可以上传大文件、提高上传成功率 客户端和服务器端都需要复杂逻辑 大文件上传,网络环境不稳定
断点续传 在分块上传的基础上,支持断点续传,节省时间 需要记录已上传的 chunk,实现更复杂 超大文件上传,网络环境非常不稳定
Web Workers 避免阻塞主线程,提高用户体验 增加了代码复杂度 需要在后台处理文件切割和上传逻辑的场景
CDN 加速 提高上传速度 增加成本 对上传速度有较高要求的场景
对象存储 可靠性高、扩展性好 增加成本、需要学习新的 API 需要长期存储大量文件的场景

希望今天的分享对大家有所帮助! 记住,实践是检验真理的唯一标准,多动手写代码,才能真正掌握这些技术。 下课!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注