入门版大文件上传

482 阅读6分钟

前端大文件上传是面试的一道常考题,我也是最近才弄懂,特地来记录一下,加深印象

正文

首先准备一个前端文件夹client和一个后端文件夹server

前端:

这里我使用到的是原生html 和原生js来写的。

首先我们需要拿到文件,原生input 有一个file类型可以让我们从本地获取文件。上传到服务器还需要一个button

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

接下来,我们需要在js中获取到这个文件类型,这里可以使用onchange事件,也可以使用addEventLisner(), 去监听change事件,事件监听的函数中会有个形参e,我们可以通过e.target获取获取到当前监听的元素,在e.target中可以找到一个files属性,里面包含了上传的文件数据,里面只有一条数据,我们可以通过 e.target.files[0]来拿到这条数据,我这里使用了解构赋值const [file] = e.target.files 来获取到数据

image.png

这是这部分的代码:

        const fileInput = document.querySelector('#fileinput');
        const btn = document.getElementById('btn')

        let fileObj = null
        // 读取本地文件
        fileInput.addEventListener('change', handleFileChange);
        function handleFileChange(e) {
            console.log(e);
            const [file] = e.target.files;
            fileObj = file
        }

获取到数据后 接下来我们就要想办法进行数据的上传了,由于服务器接收一个巨大的文件可能会占用大量的内存资源。所以我们需要把文件分片上传, 那么如何进行分片呢:在js中file类型会有一个slice方法,它可以把文件类型切割成blob类型

有关于blob请看MND文档

image.png

这是切片代码:

        // 切片
        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 handleUpload() {
            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 
                }
            })

            // 发请求
            updateChunks(chunks)
        }

这里我添加了chunkName 用名字和下标来描述顺序。

当做完这些事情后,我们就可以进行发送请求了

这里我使用了axios的 CDN <script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>

        // 请求
        function updateChunks(chunks) {
            console.log(chunks) // 这个数组中的元素是对此昂,对象中有blob类型的文件对象
            const formChunks = chunks.map(({file, fileName, index, chunkName}) => {
                const formData = new FormData()
                formData.append('file', file)
                formData.append('chunkName', chunkName)
                formData.append('fileName', fileName)
                return {formData, index}
            })
            const requestList = formChunks.map(({formData, index}) => {
                return axios.post('http://localhost:3000/upload', formData,() => {
                    console.log(index);// 进度条
                })
            })
            // console.log(requestList);
            Promise.all(requestList).then(res => {
                console.log(res, '所有片段都传输成功');
                mergeChunks()
            })
            
        }

注意:通过将Blob对象加入到FormData中,可以避免将文件数据转换成字符串或其他格式,从而减少了内存开销和处理时间。

这里我使用了Promise.all()来判断请求是否全部成功(即文件全部上传成功),当全部上传成功且服务端接收后,就可以发送合并请求了:

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

前端到这里就已经大致完成了

后端:

这里我使用了原生node 的http模块和path模块,还使用了第三方库fse去替代fs模块处理文件,然后使用multiparty模块来解析上传文件以及表单数据

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

我创建了一个文件夹来存放接收到的切片

// 存放切片
const UPLOAD_DIR = path.resolve(__dirname, '.', 'qiepian')

然后使用http模块的createsever() 来创建一个服务器,

const server = http.createServer((req, res) => {
    
})

server.listen(3000, () => {
    console.log('3000端口已经启动了')
})

首先我们需要先解决跨域问题,这里我们直接在请求头中设置几个字段就可以解决了

    res.writeHead(200, {
        "access-control-allow-origin": '*',
        "access-control-allow-headers": '*',
        "access-control-allow-methods": '*'
    })

接下来我们就可以去进行对前端请求进行接收且处理, 首先是上传请求:这里使用到了multiparty来处理表单数据,然后创建一个文件夹去存储这些切片,最后返回给前端,切片上传成功的消息

        if (req.url == '/upload') {
        // 接收前端传过来的FormData
        // req.on('data', (data) => {
        //     // console.log(data);
        // })
        const form = new multiparty.Form()
        form.parse(req, (err, fields, files) => {
            // console.log(fields,'111',files);
            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.moveSync(file.path, `${chunkDir}/${chunkName}`)
            res.end(JSON.stringify({
                code: 0,
                message: '切片上传成功'
            }))
        })
    }

接收到前端的切片后接下来我们就要去进行一个文件的合并,这里我写了几个函数

    if (req.url == '/merge') {
        const { fileName, size } = await resolvePost(req)
        const filePath = path.resolve(UPLOAD_DIR, fileName)
        // console.log(filePath);

        // 合并切片
        const result = await mergeFileChunk(filePath, fileName, size)
        if(result) {// 切片合并完成
            res.end(JSON.stringify({
                code: 0,
                message: '文件合并完成'
            }))
        }
    }

// 解析post参数
function resolvePost(req) {
    return new Promise((resolve, reject) => {
        req.on('data', (data) => {
            resolve(JSON.parse(data.toString()))
        })
    })
}

// 合并函数
function pipeStream(path, writeStream) {
    return new Promise((resolve, reject) => {
        const readStream = fse.createReadStream(path)
        readStream.on('end', () => {
            fse.removeSync(path) // 被读取完的切片移除掉
            resolve()
        })
        readStream.pipe(writeStream)
    })
}

// 合并切片
async function mergeFileChunk(filePath, fileName, size) {
    // 拿到所有切片所在的文件夹路径
    const chunkDir = path.resolve(UPLOAD_DIR, `${fileName}-chunks`)
    // 拿到所有的切片
    let chunksList = fse.readdirSync(chunkDir)
    // console.log(chunksList);
    // 万一切片是乱序的
    chunksList.sort((a, b) => a.split('-')[1] - b.split('-')[1])
    const result = chunksList.map((chunkFileName,index) => {
        const chunkPath = path.resolve(chunkDir, chunkFileName)
        // 合并
        return pipeStream(chunkPath, fse.createWriteStream(filePath, {
            start: index * size,
            end: (index + 1) * size
        }))
    })
    await Promise.all(result)
    fse.rmdirSync(chunkDir) // 删除切片目录
    return true
}

这段代码展示了如何在Node.js中实现大文件上传的切片合并逻辑。这个过程通常用于在网络条件不佳的情况下,允许用户中断上传并在以后恢复上传,或者是为了提高上传效率,将大文件分割成多个小块(切片)进行上传。下面是代码的详细解释:

1. resolvePost 函数

这个函数用于解析HTTP请求中的POST数据。它接受一个请求对象req作为参数,并监听data事件来获取请求体的数据。当接收到数据时,它会将数据转换成字符串,再将其解析为JSON对象,并通过resolve回调返回这个对象。

function resolvePost(req) {
    return new Promise((resolve, reject) => {
        req.on('data', (data) => {
            resolve(JSON.parse(data.toString()))
        })
    })
}

2. pipeStream 函数

这个函数负责将一个可读流(readStream)的数据写入到一个可写流(writeStream)。在这个过程中,一旦读取流结束,它会删除源文件,并通过resolve回调返回一个Promise。

function pipeStream(path, writeStream) {
    return new Promise((resolve, reject) => {
        const readStream = fse.createReadStream(path)
        readStream.on('end', () => {
            fse.removeSync(path) // 被读取完的切片移除掉
            resolve()
        })
        readStream.pipe(writeStream)
    })
}

3. mergeFileChunk 函数

这个函数负责合并之前上传的所有文件切片。它接受三个参数:

  • filePath: 最终文件的目标路径。
  • fileName: 文件名。
  • size: 切片的大小。

步骤如下:

  1. 获取切片所在目录: 使用path.resolve来确定存放切片的目录路径。
  2. 列出所有切片: 使用fse.readdirSync同步读取目录下的所有文件。
  3. 排序切片: 确保切片按顺序排列。这里的命名规则是<filename>-<index>.<ext>
  4. 映射切片: 遍历所有切片文件,对于每一个切片,创建一个Promise,该Promise会使用pipeStream函数将切片数据写入目标文件的指定位置。
  5. 等待所有切片写入完成: 使用Promise.all等待所有切片写入完毕。
  6. 删除切片目录: 所有切片写入完成后,删除存放切片的目录。

那么完整的大文件上传就完成了,当然这里只实现了文件上传,没有实现暂停续传和秒传。想要学习暂停续传和秒传的小伙伴可以看看这篇文章字节跳动面试官:请你实现一个大文件上传和断点续传。后续我也会进行补充