手摸手来封装一个大文件分片上传组件何如

467 阅读7分钟

概述

image.png 可耐滴居居↑↑

  • 大文件的分片上传原理:
  • 文件数据的本质是字节数组
  • 既然是数组,就可以分割为一个个的子数组
  • 要上传的文件太大时,一次性上传既阻塞网络,给服客两端的网络IO同时带来巨大压力的同时,还容易造成上传失败,来回来去地重传听起来十分不靠谱!
  • 解决方案就是:把大文件给分割为片段,编好序号,一片一片地进行上传
  • 服务端接收完毕以后,再按序号重新组装为大文件

本文以React为例,带诸位来封装一个简单地体现核心原理的大文件上传组件,LETS GO!

image.png

居居分割示意图↑↑

设置布局

image.png

  • 一个input + 一个上传按钮即可
  • 样子暂时比较丑,但是很温柔!
import React, { useState } from 'react';
import axios from 'axios';

const StoUploader = () => {
    ...

    /* 组件布局JSX */
    return (
        <div className='sto-uploader-root'>
            {/* 选择文件 选择完毕后触发handleFileChange */}
            <input type="file" onChange={handleFileChange} id='file-input'/>

            {/* 点击开始上传 */}
            <button onClick={handleUpload} disabled={!selectedFile}>上传文件</button>
        </div>
    );
};

export default StoUploader;

选择文件

  • 用一个状态存储用户选择的文件
const [selectedFile, setSelectedFile] = useState(null);
  • 用户选中的文件存储在状态中
{/* 选择文件 选择完毕后触发handleFileChange */}
<input type="file" onChange={handleFileChange} id='file-input'/>
/* 用户一旦选择了文件... */
const handleFileChange = (e: any) => {
    // 将用户选择的文件交给selectedFile这个state保存
    setSelectedFile(e.target.files[0]);
};

开始上传

  • 用户点击上传
{/* 点击开始上传 */}
<button onClick={handleUpload} disabled={!selectedFile}>上传文件</button>
  • 执行上传:将大文件分割为一堆切片,然后并发地对每个切片进行上传
    /* 用户点击了上传... */
    const handleUpload = () => {

        // 分片大小
        const chunkSize = 1024 * 10; // 每片10K(生产环境1M左右为宜)

        // 读取用户选择文件的大小(字节)
        const fileSize = selectedFile!.size;

        // 文件的原始数据是一个【字节数组】
        // 我们根据chunkSize的大小 从中截取分片
        // [字节,字节,字节,【字节,...分片数据...,字节】,字节,字节,字节,字节,字节,字节...]

        // 第一个分片的起始位置 [0,10240*1)
        // 第二个分片的起始位置 [10240*1,10240*2) 依次类推
        let start = 0; //开始字节
        let end = chunkSize;//结束字节
        let index = 0; //分片序号

        /* 并发地上传分片 */
        function uploadChunksAsync() {
            ...
        }
        uploadChunksAsync()
    };

分片上传

  • 核心代码来也!
  • 分片:无非你要理解一个文件的本质就是一个字节数组,既然是数组,就可以slice切片它,分割为一堆子数组,也就是分片
  • 并发上传:将每个分片按顺序编号,上传到服务端就是了,服务端需要根据编号将分片数据重新组合为文件;
  • 由于是多个Promise任务并行,我们需要清楚地知道每个任务的执行结果
  • 其实并没有什么luan东西...
/* 并发地上传分片 */
function uploadChunksAsync() {

    // 预备存储分片
    const chunks = []

    /* 截取分片 */
    while (start < fileSize) {
        // 截取一个分片的字节数据
        const chunk = selectedFile!.slice(start, start + chunkSize);

        // 丢入分片数组
        chunks.push(chunk)

        // 起始游标移动到下一个分片开始处
        start += chunkSize
    }
    console.log("chunks=", chunks);

    /* 分片数组 => 上传任务数组 */
    const tasks = chunks.map(
        (chunk, index) => {

            /* 构造一个表单数据对象 */
            const formData = new FormData();

            // 携带关键数据
            formData.append('chunk', chunk);//分片的字节数据
            formData.append('index', index);//分片序号
            formData.append('total', Math.ceil(fileSize / chunkSize));//文件大小
            formData.append('fileName', selectedFile!.name);//文件名称

            // 分片名称:文件名-分片序号
            const chunkName = selectedFile!.name + "-" + index
            console.log("chunkName", chunkName);
            formData.append('chunkName', chunkName);//文件大小

            /* 
            返回一个AJAX任务 其类型是一个Promise对象 
            注意:此时多个上传任务已经并发执行起来了
            serverApi = 服务端接收接口
            formData = 分片的表单数据
            */
            return axios.post(serverApi, formData)
                .then(response => {
                    console.log(response.data);
                })
                .catch(error => {
                    console.error(error);
                });
        }
    )

    /* 使用Promise.allSettled “详查”每个任务的结果 */
    Promise.allSettled(tasks)
        .then((results) => {
            results.forEach((result) => {
                console.log(result.status)

                // 如果任务状态为成功/fulfilled,进度+1
                // 如果任务状态为失败/rejected,我们应该对对应的分片进行重传
                // 已经不是最核心的代码了,相信各位看官都能自行推导出了!
                // (难道我会承认在赶时间...)
            })
        })
}

服务端代码

  • 首先...
  • 让我们直接上代码吧!
const express = require('express');
const fs = require('fs');

// 接收表单的中间件
const multer = require('multer');

const app = express();

/* 配置multer */
const storage = multer.diskStorage({
    // 文件保存在 uploads/ 目录中
    destination: (req, file, cb) => {
        cb(null, 'uploads/'); 
    },
    
    // 胡乱一配 这里其实并没有用到
    filename: (req, file, cb) => {
        cb(null, file.originalname + Date.now() + parseInt(Math.random() * 1000000000));
    },
});
const upload = multer({ storage: storage });

/* 同意跨域简单粗暴版 */
// 处理跨域请求的中间件
app.use((req, res, next) => {
    res.header('Access-Control-Allow-Origin', '*'); // 允许所有来源访问
    res.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept'); // 允许特定的请求头字段
    res.header('Access-Control-Allow-Methods', 'POST'); // 只允许 POST 方法
    next();
});

/* 静态资源支持 */
app.use(express.static('public'));

/* 处理上传接口 */
app.post('/upload', upload.single('chunk'), (req, res) => {

    /* 从请求中读取切片信息 */
    const chunk = req.file;
    const index = req.body.index;
    const total = req.body.total;
    const fileName = req.body.fileName;
    const chunkName = req.body.chunkName;

    // 构建切片存储位置
    const filePath = `uploads/${chunkName}`;

    // 重命名分片文件
    fs.renameSync(chunk.path, filePath); 

    if (index == total - 1) {
        // 凑齐分片后 重新组装为文件
        const combinedFilePath = combineChunks(fileName, total); // 组合分片
        console.log("combinedFilePath", combinedFilePath);

        if (combinedFilePath) {
            console.log('文件上传完成');
            res.send('文件上传完成');
        } else {
            console.log('分片上传失败');
            res.send('分片上传失败');
        }
    } else {
        console.log('分片上传成功');
        res.send('分片上传成功');
    }

});

/* 
组装分片为文件 
无非是按顺序读入分片文件字节数据 再一股脑怼到目标文件里
大家需要熟悉一下文件读写操作 都是基本功
*/
function combineChunks(filename, total) {
    const combinedFilePath = `uploads/${filename}`;

    // 创建写出流
    const writeStream = fs.createWriteStream(combinedFilePath, { flags: 'a' });

    /* 按顺序读取分片 怼入写出流 */
    for (let i = 0; i < total; i++) {
        // 读取分片数据
        const chunkPath = `uploads/${filename}-${i}`;
        let chunkData = fs.readFileSync(chunkPath);

        // 怼入写出流
        writeStream.write(chunkData);
        fs.unlinkSync(chunkPath);
    }

    // 搞定
    writeStream.end();

    // 返回组装好的文件路径
    return combinedFilePath;
}

/* 监听在指定端口 */
app.listen(8000, () => {
    console.log('服务器已启动,监听端口 8000');
});

组件完整代码

import React, { useState } from 'react';
import axios from 'axios';

// import "./StoUploader.scss"

type StoUploaderProps = {
    serverApi: string
}

const StoUploader: React.FC<StoUploaderProps> = ({ serverApi }) => {

    // 用户选中的文件
    const [selectedFile, setSelectedFile] = useState<File | null>(null);

    /* 用户一旦选择了文件... */
    const handleFileChange = (e: any) => {
        // 将用户选择的文件交给selectedFile这个state保存
        setSelectedFile(e.target.files[0]);
    };

    /* 用户点击了上传... */
    const handleUpload = () => {

        // 分片大小
        const chunkSize = 1024 * 10; // 每片10K(生产环境1M左右为宜)

        // 读取用户选择文件的大小(字节)
        const fileSize = selectedFile!.size;

        // 文件的原始数据是一个【字节数组】
        // 我们根据chunkSize的大小 从中截取分片
        // [字节,字节,字节,【字节,...分片数据...,字节】,字节,字节,字节,字节,字节,字节...]

        // 第一个分片的起始位置 [0,10240*1)
        // 第二个分片的起始位置 [10240*1,10240*2) 依次类推
        let start = 0; //开始字节
        let end = chunkSize;//结束字节
        let index = 0; //分片序号

        function uploadChunkSync() {

            if (start < fileSize) {

                // 一个分片的字节数据
                const chunk = selectedFile.slice(start, end);

                const formData = new FormData();
                formData.append('chunk', chunk);//chunk的字节数据
                formData.append('index', index);//chunk的序号
                formData.append('total', Math.ceil(fileSize / chunkSize));//文件大小

                axios.post('http://localhost:8000/upload', formData)
                    .then(response => {
                        console.log(response.data);

                        start = end;
                        end = start + chunkSize;
                        index++;

                        uploadChunkSync();
                    })
                    .catch(error => {
                        console.error(error);
                    });


            } else {
                console.log('分片上传完成');
            }
        }
        // uploadChunkSync();

        /* 并发地上传分片 */
        function uploadChunksAsync() {

            // 预备存储分片
            const chunks = []

            /* 截取分片 */
            while (start < fileSize) {
                // 截取一个分片的字节数据
                const chunk = selectedFile!.slice(start, start + chunkSize);

                // 丢入分片数组
                chunks.push(chunk)

                // 起始游标移动到下一个分片开始处
                start += chunkSize
            }
            console.log("chunks=", chunks);

            /* 分片数组 => 上传任务数组 */
            const tasks = chunks.map(
                (chunk, index) => {

                    /* 构造一个表单数据对象 */
                    const formData = new FormData();

                    // 携带关键数据
                    formData.append('chunk', chunk);//分片的字节数据
                    formData.append('index', index);//分片序号
                    formData.append('total', Math.ceil(fileSize / chunkSize));//文件大小
                    formData.append('fileName', selectedFile!.name);//文件名称

                    // 分片名称:文件名-分片序号
                    const chunkName = selectedFile!.name + "-" + index
                    console.log("chunkName", chunkName);
                    formData.append('chunkName', chunkName);//文件大小

                    /* 
                    返回一个AJAX任务 其类型是一个Promise对象 
                    注意:此时多个上传任务已经并发执行起来了
                    serverApi = 服务端接收接口
                    formData = 分片的表单数据
                    */
                    return axios.post(serverApi, formData)
                        .then(response => {
                            console.log(response.data);
                        })
                        .catch(error => {
                            console.error(error);
                        });
                }
            )

            /* 使用Promise.allSettled “详查”每个任务的结果 */
            Promise.allSettled(tasks)
                .then((results) => {
                    results.forEach((result) => {
                        console.log(result.status)

                        // 如果任务状态为成功/fulfilled,进度+1
                        // 如果任务状态为失败/rejected,我们应该对对应的分片进行重传
                        // 已经不是最核心的代码了,相信各位看官都能自行推导出了!
                        // (难道我会承认在赶时间...)
                    })
                })
        }
        uploadChunksAsync()
    };

    /* 组件布局JSX */
    return (
        <div className='sto-uploader-root'>
            {/* 选择文件 选择完毕后触发handleFileChange */}
            <input type="file" onChange={handleFileChange} id='file-input' />

            {/* 点击开始上传 */}
            <button onClick={handleUpload} disabled={!selectedFile}>上传文件</button>
        </div>
    );
};

export default StoUploader;

组件的部署

import StoUploader from '@/components/StoUploader/StoUploader'

export default function UploaderDemo() {
    return (
        <div>
            <h3>UploaderDemo</h3>
            <StoUploader serverApi={'http://localhost:8000/upload'}></StoUploader>
        </div>
    )
}

据说罗伯·史塔克看了兰尼斯特家的文章没点赞收藏转发...

image.png