切换语言为:繁体

前端利用分片原理实现稳定快速的大文件上传功能

  • 爱糖宝
  • 2024-09-08
  • 2049
  • 0
  • 0

前言

我们知道,当一个文件过大的时候,文件可能会上传失败,那么今天就来演示一下,如何进行大文件上传、断点续传...

首先我们准备一个大文件

前端利用分片原理实现稳定快速的大文件上传功能 

R星,启动!🤣🤣🤣

很好,我们回归正题,比如我们可以去下载一首歌儿,eason的陀飞轮,无损品质大概33M

前端利用分片原理实现稳定快速的大文件上传功能

前端利用分片原理实现稳定快速的大文件上传功能

大文件上传

大文件上传通常指的是在网络应用中将较大的文件(如视频、大型文档集合等)上传到服务器的过程。这个过程可能会遇到一些挑战,比如网络不稳定导致上传中断、服务器存储限制、上传速度慢等问题。

上传文件,总归是前端首先读取文件然后再上传文件

文件读取阶段

<!DOCTYPE html>
<html>

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>

<body>
    <input type="file" id="input">

    <script>
        const inp = document.getElementById('input');
        inp.addEventListener('change', handleFileChange);
        function handleFileChange(e) {
            console.log(e);
        }
    </script>
</body>

</html>


我们来看看,当我们上传这首陀飞轮,会读取到什么。

前端利用分片原理实现稳定快速的大文件上传功能 

然后,咱们往下一顿找,就会发现,e.target.files是一个数组,但是数组里面只可能有一个对象,因为我们一次上传一个文件,因此我们通过解构的方式可以得到这个file对象打开搂一眼就会发现,这首歌的一些详情都在这了。

// 读取本地文件
        function handleFileChange(e) {
            const [file] = e.target.files
            console.log(file);
        }


前端利用分片原理实现稳定快速的大文件上传功能 

到这,只到了读取的阶段,还没有进行上传,正常来说我们都是首先点击选择文件,然后点击上传才会进行提交上传

文件上传阶段

<!DOCTYPE html>
<html>

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>

<body>
    <input type="file" id="input">
    <button id="btn">上传</button>

    <script>
        const inp = document.getElementById('input');
        const btn = document.getElementById('btn');

        inp.addEventListener('change', handleFileChange);
        btn.addEventListener('click', handleUpload);

        let fileObj = null

        // 读取本地文件
        function handleFileChange(e) {
            const [file] = e.target.files
            console.log(file);
            fileObj = file
        }

        // 上传文件
        function handleUpload(e){
            if (!fileObj) return 
            
        }
    </script>
</body>

</html>


我们一起来看看,现在拿着fileObj这个大文件,如何进行处理。

事实上,当文件过大可能会导致上传失败,那咱们直接给他切片,切成很多个碎片,然后给到服务器端的时候将碎片进行重组。举个例子,这首歌大小为1G,如果直接将1G处理成buffer流,用十六进制来表示1G的资源,那这个十六进制就过于庞大了。

那么接下来,我们就来看看,如何切片,如何重组

 // 切片默认一片5M
        function createChunk(file, size = 5 * 1024 * 1024) {
            const chunkList = []
            // 当前大小
            let cur = 0
            while (cur < file.size) {
                chunkList.push({ file: file.slice(cur, cur + size) })
                cur += size
            }

            return chunkList
        }


前端利用分片原理实现稳定快速的大文件上传功能 

可以看到,通过切片拿到的都是一个个的Blob对象

Blob对象表示一个不可变、原始数据的类文件对象。它的数据可以按文本或者二进制的格式进行读取,也可以转换成ReadableStream来用于数据操作。File接口基于Blob,继承了blob的功能并将其扩展以支持用户系统上的文件。

接下来我们拿到了切片,就该发接口请求给后端上传数据了,我们通过CDN的方式引入一份axios源码

注意,我们现在把这份切片数组发送过去是直接发送这份数组过去吗?万一网络出现问题,这些blob顺序乱掉了后端也没有办法重组,因此我们还需要操作一下这个数组

// 切片列表
            const chunkList = createChunk(fileObj)
            // console.log(chunkList);
            const chunks = chunkList.map(({file},index)=>{
                return {
                    file,
                    size:file.size,
                    percent:0,
                    chunkName:`${fileObj.name}-${index}`,
                    fileName:fileObj.name,
                    index
                }
            })
            // 发请求
            uploadChunks(chunks)


现在问题又来了,我们操作的数组中文件是一个blob对象,而blob是浏览器默认的文件类型,而后端没有blob对象,因此现在需要将这个操作成前后端都能接收的文件资源类型。也就是表单类型,然后再将这些片段一个个发送。

<!DOCTYPE html>
<html>

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
    <script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
</head>

<body>
    <input type="file" id="input">
    <button id="btn">上传</button>

    <script>
        const inp = document.getElementById('input');
        const btn = document.getElementById('btn');

        inp.addEventListener('change', handleFileChange);
        btn.addEventListener('click', handleUpload);

        let fileObj = null

        // 读取本地文件
        function handleFileChange(e) {
            const [file] = e.target.files
            // console.log(file);
            fileObj = file
        }

        // 上传文件
        function handleUpload(e) {
            if (!fileObj) return

            // 切片列表
            const chunkList = createChunk(fileObj)
            // console.log(chunkList);
            const chunks = chunkList.map(({ file }, index) => {
                return {
                    file,
                    size: file.size,
                    percent: 0,
                    chunkName: `${fileObj.name}-${index}`,
                    fileName: fileObj.name,
                    index
                }
            })
            // 发请求
            uploadChunks(chunks)
        }

        // 切片默认一片5M
        function createChunk(file, size = 5 * 1024 * 1024) {
            const chunkList = []
            // 当前大小
            let cur = 0
            while (cur < file.size) {
                chunkList.push({ file: file.slice(cur, cur + size) })
                cur += size
            }

            return chunkList
        }

        // 请求
        function uploadChunks(chunks) {
            // console.log(chunks);
            const formChunks = chunks.map(({ file, fileName, index, chunkName }) => {
                const formData = new FormData()
                formData.append('file', file)
                formData.append('fileName', fileName)
                formData.append('chunkName', chunkName)
                return {
                    formData,
                    index
                }
            })
            // console.log(formChunks);

            const requestList = formChunks.map(({ formData, index })=>{
                return axois.post('http://localhost:3000/upload',formData,()=>{
                    console.log(index); // 可以做进度条
                })
            })
            console.log(requestList);
            Promise.all(requestList).then(res => {
                console.log(res,'所有的片段都传输成功');
            })
        }
    </script>
</body>

</html>


接下来,我们写一个简单的后端。

const http = require('http');
const path = require('path');


// 存放切片的地方
// __dirname 当前文件所在的目录的绝对路径
const UPLOAD_DIR = path.resolve(__dirname, '.', 'chunks')


const server = http.createServer((req, res) => {
    // 解决跨域
    res.writeHead(200, {
        'access-control-allow-origin': '*',
        'Access-Control-Allow-Methods': 'OPTIONS, POST',
        'Access-Control-Allow-Headers': 'Content-Type'
    })

    if (req.method === 'OPTIONS') { // 请求预检
        res.status = 200
        res.end()
        return
    }

    if (req.url === '/upload') {
        req.on('data', (data) => {
            console.log(data)
        })
    }
    res.end('welcome')
});

server.listen(3000, () => {
    console.log("server started");
});


现在我们就能够看到前端传递过来的buffer流数据,但是我们没办法知道这些片段长什么样并且如何按顺序组装成原来的样子,如果是直接拿着这个data进行toString,那数据量未免也太大了转过来也没有意义,因此我们可以引入一个三方库multiparty,然后一会儿还需要保存文件,需要用到fs模块,但是我们还是直接引入第三方的fs-extra

const http = require('http');
const path = require('path');
const multiparty = require('multiparty');
const fse = require('fs-extra');

// 存放切片的地方
// __dirname 当前文件所在的目录的绝对路径
const UPLOAD_DIR = path.resolve(__dirname, '.', 'chunks')


const server = http.createServer((req, res) => {
    // 解决跨域
    res.writeHead(200, {
        'access-control-allow-origin': '*',
        'Access-Control-Allow-Methods': 'OPTIONS, POST',
        'Access-Control-Allow-Headers': 'Content-Type'
    })

    if (req.method === 'OPTIONS') { // 请求预检
        res.status = 200
        res.end()
        return
    }

    if (req.url === '/upload') {
        // req.on('data', (data) => {
        //     console.log(data)
        // })
        const form = new multiparty.Form();
        form.parse(req, (err, fields, files) => {
            // console.log(fields, files); // 1. 切片的描述 2. 切片的二进制资源被处理成对象
            const [file] = files.file
            const [fileName] = fields.fileName
            const [chunkName] = fields.chunkName
            // 保存切片
            const chunkDir = path.resolve(UPLOAD_DIR, `${fileName}-chunks`)
            if (!fse.existsSync(chunkDir)) { // 判断路径是否有效
                fse.mkdirSync(chunkDir)
            }
            // 存入
            fse.move(file.path, `${chunkDir}/${chunkName}`)
            res.end(JSON.stringify({
                code: 0,
                message: '上传成功'
            }))
        })
    }
});

server.listen(3000, () => {
    console.log("server started");
});


前端利用分片原理实现稳定快速的大文件上传功能

此时我们已经拿到了所有的切片,但是需要做合并,而后端并不知道前端切片是否发送完毕,所以我们回到前端,因为前端promise.all可以知道是否发送完毕,发送完毕之后,前端可以再发送一个合并请求给后端

// 合并请求
        function mergeChunks(size=5*1024*1024) {
            axios.post('http://localhost:3000/merge',{
                fileName:fileObj.name,
                size
            }).then(res=>{
                console.log(`${fileObj.name}合并完成`);
            })
            
        }


那么这个函数的调用,就应该放到promise.all的then调用中了。(所有的切片的传输成功之后)

接下来,后端开始合并操作。

if (req.url === '/merge') {
        const { fileName,size} = await resolvePost(req) // 解析post参数
        const filePath = path.resolve(UPLOAD_DIR, fileName) // 完整文件路径
        // console.log(filePath);
        // 合并切片
        await mergeFileChunk(filePath,fileName,size)

    }


// 合并切片
async function mergeFileChunk(filePath,fileName,size) {
    // 拿到所有切片所在的文件夹的路径
    const chunkDir = path.resolve(UPLOAD_DIR,`${fileName}-chunks`)
    // 拿到所有的切片
    let chunksList = fse.readdirSync(chunkDir)
    console.log(chunksList);
}


具体操作我做一个小总结。

小结

  • 前端操作的大致流程

  1. 读取本地文件,读成一个文件对象

  2. 使用slice对文件进行切割,并得到Blob类型的文件对象

  3. 将Blob类型的文件转成FormData表单类型对象

  4. 对切片一个一个发送给后端

  • 后端操作的一个大致流程:

  1. 接受前端传递的切片并解析得到数据multiparty

  2. 保存切片到某个文件夹

  3. 当接收到前端的合并请求后开始合并切片

  4. 创建可写流,将所有的切片读成流类型并汇入到可写流中得到完整的文件资源

那么如何进行断点续传呢?

  • 事实上也很简单,即前端在upload也就是对切片一个个进行传输时,例如此时传输了5个切片,但是由于没有传输完就暂停了,前端也不会发送merge请求给后端,当用户点击继续上传时怎么办

  • 例如前端可以在点击暂停之前就记录好有哪些切片已经传输过去了,例如存储在浏览器本地(虽然不是很优雅),事实上就是在用户点击继续上传时,过滤掉已经上传好了的切片,要么前端过滤要么后端过滤

  • 大致流程:例如用户点击继续上传时,前端发送一个额外的请求,检验已经传输的切片有哪些,后端可以返回目前已经接受到了哪些切片,前端再过滤掉这些切片,然后继续传输

0条评论

您的电子邮件等信息不会被公开,以下所有项均必填

OK! You can skip this field.