大文件上传方案

137 阅读2分钟

服务端-node

  • 导入所需的Node.js模块,包括Express.js用于创建Web应用程序、body-parser用于解析HTTP请求体、path用于处理文件路径、fse用于文件操作、multiparty用于处理多部分表单数据。
  • 创建Express.js应用程序的实例。
const express = require('express');
const bodyParser = require('body-parser');
const path = require('path');
const fse = require('fs-extra');
const multiparty = require('multiparty');
const app = express();
  • 定义一些配置项,包括应用程序的端口号(可以从环境变量中获取,默认为3000)、切片文件存储目录的路径(ALL_CHUNKS_PATH)以及上传完成文件存储的路径(UPLOAD_FILE_PATH)。
const PORT = process.env.PORT || 3000;
const ALL_CHUNKS_PATH = path.resolve(__dirname, 'chunks');
const UPLOAD_FILE_PATH = path.resolve(__dirname, 'public/files');
  • 配置Express.js中间件。
  • express.static 中间件用于提供静态文件服务,将/public目录中的文件公开供客户端访问。
  • bodyParser 中间件用于解析请求体数据,允许你访问请求体中的数据。
app.use(express.static(__dirname + '/public'));
app.use(bodyParser.urlencoded({ extended: true }));
app.use(bodyParser.json());
  • 启动Express.js应用程序,监听3000端口,并在成功启动时输出日志。
app.listen(3000, () => console.log('3000 端口启动成功'));
  • Express.js路由处理程序
// 处理文件切片上传
app.post('/upload', (req, res) => {
    const multipartyForm = new multiparty.Form();
    multipartyForm.parse(req, async (err, fields, files) => {
        if (err) {
            console.error('文件切片上传失败:', err);
            return res.status(500).json({ code: 0, message: '文件切片上传失败' });
        }

        // 处理切片文件信息
        const [file] = files.file;
        debugger
        const { fileName: [fileName], chunkName: [chunkName] } = fields;
        const chunksPath = path.resolve(ALL_CHUNKS_PATH, fileName);

        if (!fse.existsSync(chunksPath)) {
            fse.mkdirSync(chunksPath);
        }

        try {
            await fse.move(file.path, `${chunksPath}/${chunkName}`);
            res.status(200).json({ code: 1, message: '切片上传成功' });
        } catch (error) {
            console.error('文件切片移动失败:', error);
            res.status(500).json({ code: 0, message: '文件切片上传失败' });
        }
    });
});

// 处理切片合并
app.post('/merge', async (req, res) => {
    const { chunkSize, fileName } = req.body;
    const uploadedFile = path.resolve(UPLOAD_FILE_PATH, fileName);
    const chunksPath = path.resolve(ALL_CHUNKS_PATH, fileName);

    try {
        const chunksName = await fse.readdir(chunksPath);
        chunksName.sort((a, b) => (a - 0) - (b - 0));

        for (let index = 0; index < chunksName.length; index++) {
            const chunkPath = path.resolve(chunksPath, chunksName[index]);
            const readChunk = fse.createReadStream(chunkPath);
            const writeChunk = fse.createWriteStream(uploadedFile, {
                start: index * chunkSize,
                end: (index + 1) * chunkSize,
            });

            await new Promise((resolve) => {
                readChunk.pipe(writeChunk);
                readChunk.on('end', () => {
                    fse.unlinkSync(chunkPath);
                    resolve();
                });
            });
        }
        fse.rmdirSync(chunksPath);
        res.status(200).json({ code: 1, message: '文件上传成功' });
    } catch (error) {
        console.error('切片合并失败:', error);
        res.status(500).json({ code: 0, message: '切片合并失败' });
    }
});

// 验证文件上传状态
app.post('/verify', async (req, res) => {
    const { fileName } = req.body;
    const filePath = path.resolve(UPLOAD_FILE_PATH, fileName);
    const chunksPath = path.resolve(ALL_CHUNKS_PATH, fileName);

    if (fse.existsSync(filePath)) {
        res.status(200).json({ code: 1, message: '文件已存在,不需要重新上传' });
    } else if (fse.existsSync(chunksPath)) {
        const uploaded = await fse.readdir(chunksPath);
        res.status(200).json({ code: 0, message: '文件有部分上传数据', uploaded });
    } else {
        res.status(200).json({ code: 0, message: '文件未上传过' });
    }
});

前端

<div>
    <input type="file" id="chooseFile" />
    <button id="uploadFile">上传</button>
</div>
<div style="margin: 20px 0">
    <progress value="0" id="progress"></progress>
    <span id="message"></span>
</div>
<div>
    <button id="stopUpload">暂停</button>
    <button id="keepUpload">续传</button>
</div>
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
    <script>
        axios.defaults.baseURL = "http://localhost:3000"; // 设置服务器地址
    </script>
    <script>
        const chooseFile = document.getElementById("chooseFile");
        const uploadFile = document.getElementById("uploadFile");
        const progress = document.getElementById("progress");
        const message = document.getElementById("message");
        const stopUpload = document.getElementById("stopUpload");
        const keepUpload = document.getElementById("keepUpload");

        let checkedFile = null; // 用户选中的文件

        chooseFile.addEventListener("change", function (e) {
            const [file] = e.target.files;
            if (file) {
                checkedFile = file;
            }
        });

        const chunkSize = 5 * 1024 * 1024; // 每一个分片的大小:5M
        let chunksList = []; // 用户选中文件的切片文件

        // 上传切片
        const uploadChunks = async () => {
            // 依次上传每一个切片文件
            for (let i = 0; i < chunksList.length; i++) {
                const formData = new FormData();
                formData.append("file", chunksList[i]);
                formData.append("fileName", checkedFile.name);
                formData.append("chunkName", i);

                try {
                    await axios({
                        url: "/upload",
                        data: formData,
                        method: "POST",
                    });
                    // 每一个切片上传成功后,进度条 +1
                    progress.value++;
                } catch (error) {
                    console.error("切片上传失败: " + error.message);
                    break; // 如果有切片上传失败,停止上传并处理错误
                }
            }
            mergeChunks(); // 上传完成后合并切片
        };

        // 合并切片
        const mergeChunks = async () => {
            try {
                const { data } = await axios({
                    url: "/merge",
                    method: "POST",
                    data: {
                        chunkSize,
                        fileName: checkedFile.name,
                    },
                });
                if (data.code) {
                    message.innerText = "文件上传成功";
                }
            } catch (error) {
                console.error("切片合并失败: " + error.message);
                message.innerText = "文件上传失败";
            }
        };

        // 上传文件
        uploadFile.addEventListener("click", async function () {
            if (!checkedFile) {
                alert("请选择文件");
                return;
            }

            // 验证文件之前是否上传成功过
            const res = await verifyFile();
            if (res.code) {
                message.innerText = "上传成功(秒传)";
                progress.value = progress.max;
                return;
            }

            // 创建文件切片
            chunksList = createChunks();
            // 上传切片
            uploadChunks();
        });

        // 验证文件是否上传过
        // 可优化使用Web Worker计算hash检查上传与否
        const verifyFile = async () => {
            try {
                const { data } = await axios({
                    url: "/verify",
                    method: "POST",
                    data: {
                        fileName: checkedFile.name,
                    },
                });
                return data;
            } catch (error) {
                console.error("文件验证失败: " + error.message);
                return { code: 0 }; // 默认返回未上传过
            }
        };

        const CancelToken = axios.CancelToken;
        let source = CancelToken.source();

        // 暂停上传
        stopUpload.addEventListener("click", function () {
            // 取消后续所有“切片上传”请求的发送
            source.cancel("终止上传!");
            // 重置 source
            source = CancelToken.source();
            message.innerText = "暂停上传";
        });

        // 点击续传按钮
        keepUpload.addEventListener("click", async function () {
            const res = await verifyFile();
            if (!res.code) {
                // 只要没有上传成功,不管是否有之前的上传记录,都需要继续上传
                uploadChunks();
            }
        });

        // 创建文件切片
        const createChunks = () => {
            let start = 0; // 切片文件的起始位置
            const chunks = [];
            while (start < checkedFile.size) {
                // 文件切片
                const chunkItem = checkedFile.slice(start, start + chunkSize);
                // 将切片保存到全局的切片数组中
                chunks.push(chunkItem);
                start += chunkSize;
            }
            return chunks;
        };
    </script>