面试官:如何实现大文件上传

大三实习找工作中,求内推~

前言

前阵子被一个 base 北京的小厂面到这个问题,我仅知道实现大文件上传可以通过切片的方式,但是当面试官问我具体实现细节的时候我就沉默了,这个问题本来就应该是大厂问的,心想现在行业都这么卷了吗,小厂对实习生问这问题,不行,今天必须把这个文件上传搞明白

中间的实现还是比较复杂的,主要是后端文件和文件夹名字冲突了,最后干脆摒弃文件夹了

手把手教,小白也能放心食用~

建议跟着敲一遍💕

正文

先看下不做任何处理,大文件上传会有什么问题吧~

大文件并没有一个说超过多少 M 就是大文件,我可以说 100M 就是大文件,也可以说 10M 就是大文件

直接上传带来的问题

  • 耗时很久
  • nginx反向代理映射时间超时
  • 无法得知上传的进度,无法暂停

文件若是一次性上传,耗时会比较久,切片带来的好处还有个就是你可以得知进度,比如一个文件切成5份,发一份过去就是20%

具体实现

前端 vue ,后端 node

前端

前端要实现的就是点击按钮可以上传文件

那就写一个 input 框,input 框的 type 改成 file 类型,它可以自动拿到本地的文件

xml 复制代码
<div id="app">
    <input type="file" @change="handleChange">
    <button>上传</button>
</div>

<script>
    const { createApp, ref } = Vue

    createApp({
        setup() {
            const handleChange = (e) => {
                console.log(e);
            }

            return {
                handleChange
            }
        }
    }).mount('#app')
</script>

想要清楚他拿到的文件长什么样子我们需要给 input 框绑定一个 change 事件

文件就在事件参数中,e.target.files[0]

展开来看里面的信息,里面有个 lastModified,协商缓存中提到过这个字段,表示的是上一次文件修改的时间,里面还有文件的大小 12468172 的 size 大小,单位是字节 Byte ,除两次 1024 就是 M 了,这里是 11.8M

这个文件是个视频,视频是去年女朋友给我拍的,当时参加英语配音大赛,如今已经分手了😭,其实这个账号头像也是她给我拍的,很多人都以为是网图

其实我们将文件传输给后端,就是这个 file 对象

我们将这个 file 对象进行响应式处理存起来,拿到文件后进行切片,也就是点击上传时触发这个函数,那就再写一个点击事件 handleUpload

ini 复制代码
const handleUpload = () => {
    if (!uploadFile.value) return 
    const chunkList = createChunk(uploadFile.value)
}

切片函数 createChunk 我拿出来写

arduino 复制代码
const createChunk = (file, size = 1 * 1024 * 1024) => {
    const chunkList = []
    let cur = 0  
    while (cur < file.size) {
        chunkList.push({ file: file.slice(cur, cur + size) })
        cur += size
    }
    return chunkList
}

这个函数才是切片的精髓!函数第二个参数表示默认切片大小为 1M , chunkList 用于存放切片, cur 是切的进度, while 循环切,当切完时 cur = file.size 跳出循环

注意这里, file.slice ???, slice 不是数组和字符串身上的方法吗, file 是个对象啊,别急,我们不防看看这个 file 的原型

文件的原型是一个 Blob 对象,我们再展开 Blob

嚯~原来 sliceBlob 身上的啊,我们不妨看下 cdn 上的 blob 介绍

Blob

cdn介绍Blob 对象表示一个不可变、原始数据的类文件对象。它的数据可以按文本或二进制的格式进行读取,也可以转换成 ReadableStream 来用于数据操作。

当我们选中一个文件时,浏览器默认会帮我们将文件转成一个 Blob 对象, Blob 上自带一个 slice 方法,Blob.slice()接受的参数不是下标,而是起始字节最终字节

并且 slice 最终返回一个Blob对象

好了, 11.8M 的文件按照 1M 来切就是 12 份,最后一份是 0.8 M,打印看看这个 chunkList 数组

完美!

接下来的逻辑就是拿到朝着后端发请求了,把切片都给过去

发请求

这里用 axios 发请求,自行 cdn 引入

如果想要实现进度条,就自行封装 axios ,请求函数 requestUpload ,请求方法写死 post ,并且axios 人家支持传入一个 onUploadProgress 函数用于计算上传进度

typescript 复制代码
const requestUpload = ({ url, method = 'post', data, headers = {}, onUploadProgress = (e) => e }) => {
    return new Promise((resolve, reject) => {
        // axios支持在请求中传入一个回调onUploadProgress,其目的就是为了知道请求的进度
        axios[method](url, data, { headers, onUploadProgress })
            .then(res => {
                resolve(res)
            })
            .catch(err => {
                reject(err)
            })
    })
}

发请求之前其实还需要对 chunkList 进行一个处理,刚才打印 chunkList 时,里面的每一个切片仅仅只有大小信息,没有其他参数,后端是需要其他信息的,因为网络原因,切片不可能是按照顺序接收的,这里我给 chunkList 再加上下标,还有文件名,切片名,如下

javascript 复制代码
const handleUpload = () => {
    if (!uploadFile.value) return
    const chunkList = createChunk(uploadFile.value)
    // console.log(chunkList);
    // 另外切片需要打上标记,保证后端正确合并
    uploadChunkList.value = chunkList.map(({ file }, index) => {
        return {
            file,
            size: file.size,
            percent: 0,
            chunkName: `${uploadFile.value.name}-${index}`,
            fileName: uploadFile.value.name,
            index
        }
    })
    console.log(uploadChunkList.value);
    // 发请求 把切片一个一个地给后端
    uploadChunks()
}

chunkList 的每一项都是个对象,里面的 file 才是我们需要的,因此进行解构

uploadFile 里面是有 name 属性的,就是文件名

uploadChunkList 就是封装好的切片,这个切片比 chunkList多了其他后端需要的信息, uploadChunkListmap 赋值后就直接发请求, uploadChunks 待会儿来写

我们先打印看下切片是否如预期所示,有这些信息

完美!

好了,现在实现函数 uploadChunks 来发请求

发请求并不是直接将封装好的切片数组 uploadChunkList 交给后端,因为后端并不认识你这个对象格式,我们需要先将其转换成数据流。

方才的 Blobmdn 的介绍中就说到了, Blob 可以按二进制的格式进行读取,也可以用ReadableStream数据操作

这里我用原生 js 的表单数据流来传递,因此将其转成表单格式的数据流,它是二进制的

go 复制代码
const uploadChunks = () => {
    const formateList = uploadChunkList.value.map(({ file, fileName, index, chunkName }) => {
        // 对象需要转成二进制数据流进行传输
        const formData = new FormData() // 创建表单格式的数据流
        // 将切片转换成了表单的数据流
        formData.append('file', file)
        formData.append('fileName', fileName)
        formData.append('chunkName', chunkName)
        return { formData, index }
    })
}

formateList 只拿封装好的切片数组中的重要信息 filefileNameindexchunkName ,并且在 map 中创建一个二进制表单数据流,将这些信息挂到 formData 中,最终赋值给 formateList

之前封装好的 uploadChunkList 被转换成了 formateList ,这个切片数组是表单数据格式,不妨看看长啥样

居然看不到长啥样!其实这就是二进制数据,浏览器不会给你看的

为何要转表单格式?

答: Blob 是 js 独有的,虽是文件类型,但是不便用于传输,后端那么多语言不一定有 Blob 。但是表单格式前后端都有,其实最早前后端传输就是这个表单格式

好了,把格式问题弄好后,现在对每一个 form 表单格式的切片进行发请求,依旧用 map 遍历,每一个表单切片都进行调用方才封装好的请求函数 requestUpload ,这个函数的里面有个进度条回调函数,我也拿出来写下

javascript 复制代码
const uploadChunks = () => {
    const formateList = uploadChunkList.value.map(({ file, fileName, index, chunkName }) => {
        // 对象需要转成二进制数据流进行传输
        const formData = new FormData() // 创建表单格式的数据流
        // 将切片转换成了表单的数据流
        formData.append('file', file)
        formData.append('fileName', fileName)
        formData.append('chunkName', chunkName)
        return { formData, index }
    })
    const requestList = formateList.map(({ formData, index }) => {
        return requestUpload({
            url: 'http://localhost:3000/upload',
            data: formData,
            onUploadProgress: createProgress(uploadChunkList.value[index]) // 进度条函数拿出来写
        })
	})
}

待会儿后端的路由就写 upload 路径

之前的 uploadChunkList 已经准备好了 percentcreateProgress 函数就是用于更改这个 percent 的,拿出来写

javascript 复制代码
const createProgress = (item) => {
    return (e) => {
        // 为何函数需要return出来,因为axios的onUploadProgress就是个函数体
        // 并且这个函数体参数e就是进度
        item.percent = parseInt(String(e.loaded / e.total) * 100) // axios提供的
    }
}

axios 提供好了写法,直接用即可

好了,目前前端先写到这里,现在转入后端,其实前端还有接口要写,待会儿再补充

后端

后端要实现的是拿到前端传过来切片进行合并

后端待会儿要安装依赖,自行npm init -y

先简单写下,把 http 服务跑起来

javascript 复制代码
const http = require('http')

const server = http.createServer((req, res) => {
    if (req.url === '/upload') {
        res.end('hello world')
    }
})

server.listen(3000, () => {
    console.log('listening on port 3000');
})

不用看,这里前端拿不到数据,还没处理跨域呢,前端用 live server 跑在 5501 端口,后端在 3000 端口

解决跨域

这里用 Cors 解决

arduino 复制代码
// 解决跨域
res.setHeader('Access-Control-Allow-Origin', '*') // 允许所有的请求源来跨域
res.setHeader('Access-Control-Allow-Headers', '*') // 允许所有的请求头来跨域 

需要设置两个,一个 请求源,一个 请求头

预检请求

为了保证跨域请求的安全性,比如 csrf 攻击,这里再写个预检请求。跨域请求时,浏览器默认会发一种 options 请求,用于向服务端请求许可,以确定实际请求是否安全,通过预检请求,服务端可以检查跨域请求的来源,请求的方法,请求的头部等信息,再来决定是否允许请求

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

拿到切片

前端请求的方法是 post ,原生 node 想要拿到 post 请求需要用上中间件 body-parser 进行解析,其实这里我们也不需要这个 body-parser ,因为前端传过来的请求数据不是正常的对象,而是表单数据

为何 post 请求要特殊点,因为 post 支持更多的编码类型,并且不对数据类型做限制

前端将数据处理成表单数据后发给传给后端,请求头中自动会多出一个Content-Type: multipart/form-data;字段,目的就是告诉后端此时你要接收的数据是表单格式

后端想要解析这个格式,需要npm i multiparty,调用 parse 函数,直接把请求体 req 丢给它,它自动帮你解析

多方 - NPM --- multiparty - npm (npmjs.com)

如果直接拿 req.request 你是拿不到的,是 undefined ,毕竟是 二进制 数据,不会给你看的

javascript 复制代码
if (req.url === '/upload') {
    const form = new multiparty.Form()
    form.parse(req, (err, fields, files) => {
        if (err) {
            console.log(err);
            return 
        }
        console.log(fields, files);
    })
}

fieldsfiles 就是被解析出来的数据, fields 是文件名和切片名,files 是切片的详细数据,打印下看看

看到没有,切片顺序是乱的,因此待会儿不能直接合并

先把切片,文件名,切片名都拿到

ini 复制代码
const file = files.file[0] 
const fileName = fields.fileName[0]
const chunkName = fields.chunkName[0]

存入切片

这里我存到 server 文件目录下,方便演示

先提前准备一个路径UPLOAD_DIR用于存放切片

arduino 复制代码
const UPLOAD_DIR = path.resolve(__dirname, '.', 'chunks') // 准备好地址用来存切片

resolve 的作用是将路径进行合并, __dirname 是当前文件的绝对路径,.是下一级,文件夹就叫 chunks

记得提前引入 path 模块

创建文件夹用 fse 模块,这个模块是 fs 模块的加强版

fs-extra - npm (npmjs.com)

fse 模块是 fs 模块的扩展,它增加了异步递归的操作、 Promise 支持以及额外的方法

先要判断UPLOAD_DIR切片目录是否存在,不存在则创建这个文件夹

scss 复制代码
if (!fse.existsSync(UPLOAD_DIR)) { // 判断目录是否存在  
    fse.mkdirsSync(UPLOAD_DIR) 
}

node 中很多方法都有同步版本,比如这里 mkdirsSync 是同步创建目录以及父目录, Sync 就是表示同步,没有这个就是异步

现在可以运行试试,前端请求是否真实给我们创建一个 chunk 用于存放切片的目录

完美!

其实这里本来应该多创建一层目录的,比如比赛现场.mp4视频应该还包裹一个文件夹,但是这个文件夹名会与待会儿存入的切片名相冲突,我就干脆摒弃了

现在文件目录已经创建好了,接下来就是将切片写入这个目录下

刚才打印的 files ,里面有个 path,细心的小盆友应该发现了,前端传入的切片,先是被默认放入到 C盘 的 temp 目录下,这是操作系统给我们做的,我们现在需要将存放在 temp 下的切片挪到这个 chunks

javascript 复制代码
fse.moveSync(file.path, `${UPLOAD_DIR}/${chunkName}`)

现在再试试看,是否帮我们把每个切片的目录给生成好

很奇怪,明明有12个切片,仅仅给我生成了 6 个,并且下标为 5 的切片还丢了

我这里试了很久,不管怎么试,最终都是 6 个,后面才知道原来这是操作系统的限制问题。操作系统在处理文件和目录时通常会限制每个目录中的子项数量,这是为确保文件系统的稳定性。既然如此,那肯定有其他办法,这里我从简处理,在前端将切片的大小由原来的 1M 改成 2M ,这样最终就是 6 个切片了😂

const createChunk = (file, size = 2 * 1024 * 1024) => ......

切片已经从 C盘 挪到我 chunk 中了,接下来要干的就是合并切片,合并之前一定要将顺序捋正来,从刚刚的打印就可以看出,切片的顺序是乱的,不过我们已经处理好了,因为切片名 chunkName 最后是有个下标的,这个下标和左边的一部分被-分隔开,因此我们可以用 split 将字符串分割成数组,传入-就是取到数组第二项,就是下标

合并切片

后端什么时候合并切片呢?

这里有几个方案可以实现

  1. 后端监听上传请求,当所有的请求都上传完毕时,出发合并请求操作
  2. 前端发完切片后,最后发一个合并请求
  3. 后端设置一个预期切片数量,达标后合并切片

普遍方案都是第二个,前端发完切片后发一个合并请求,开干!

后端

ini 复制代码
if (req.url === '/upload') {
	......
} else if (req.url === '/merge') {
	......
}

前端

前端刚才已经发完了所有的切片,继续在下面发一个合并切片的请求 mergeChunks

前端用 map 格式化好 formateList 切片数组后得到的 requestList 就是一个一个的切片请求数组,刚好放入 Promise.all 中实现并发, then 中写入请求函数 mergeChunks ,完美!

javascript 复制代码
const uploadChunks = () => {
	......
    const requestList = formateList.map(({ formData, index }) => {
	......
	})
	Promise.all(requestList).then(mergeChunks())
}

这也就是切片为何速度更快, Promise.all 实现并发请求

面试官当时问我的就是万一有个切片失败了怎么办,沉默许久,现在心中已经有答案了,后端 fsepromise 实现了封装,里面可以捕获错误,如果切片上传失败,我可以记录好这个失败切片的索引,告诉前端让其重传😂

脑补:面试官追问:一旦断网就要重传,如何解决?

断点续传,它允许传输文件时,若中断或失败,可以从上一次中断的地方继续传输,而非重新上传。等我学完再出期文章专门讲断电~

合并请求 mergeChunks 如下

php 复制代码
const mergeChunks = () => {
    requestUpload({
        url: 'http://localhost:3000/merge',
        data: JSON.stringify({
            fileName: uploadFile.value.name,
            size: 2 * 1024 * 1024
        })
    })
}

后端不是已经有了切片名和切片大小嘛,为何还要再传一次?

再上一次保险,另外可以防止传输过程中不被篡改,这是为了安全性,当然自己写的时候完全可以不写

好了,前后端联调下,若你是前端,你检查网络是很难看到 merge 请求的,因为这相当于是提交表单格式数据,浏览器向服务端发这个请求,页面会重定向刷新的,这需要后端在 merge 路由中看是否有打印

好了,现在前端已经发 merge 请求了,后端需要把前端 post 请求的内容拿到,这里写个函数 resolvePost去解析 post 请求的内容

我将请求体给到 resolvePost ,希望它能解析出 post 参数

javascript 复制代码
const resolvePost = (req) => {
    return new Promise((resolve, reject) => {
        let chunk = ''  // 参数数据包
        req.on('data', (data) => {
            chunk += data
        })
        req.on('end', () => {
            resolve(JSON.parse(chunk))
        })
    })
}

这里简单提一嘴,像是这种工具函数用 const 声明了,最好写到前面去,没有声明提升

监听请求体的 data 事件来获取参数数据,请求接收时,触发 end 事件,最后将 data ,这个 data 是前端 stringifyjson 对象,需要 parse 回来,因为拿到数据后还需要进行合并,合并是 I/O 操作, node 中的 I/O 是异步宏,这里又是同步,需要 promise 来捋成同步

arduino 复制代码
const { fileName, size } = await resolvePost(req)

前端发合并请求时传过来的就是切片名和切片大小,这里解构出来

记得在 http.createServer 中的回调形参前写 async 关键字

好,现在去合并切片 mergeFileChunks ,我告诉这个函数路径 chunk ,以及方才解构出的切片名和切片大小,让其帮我合并

scss 复制代码
await mergeFileChunks(UPLOAD_DIR, fileName, size)

现在实现 mergeFileChunks 函数,这个函数同样需要 return 一个 promise ,这里直接写个 async 关键字,就相当于函数体中 return了一个 promise

既然要合并切片那就需要先读取

javascript 复制代码
const mergeFileChunks = async(filePath, fileName, size) => {
    // 读取filePath下所有的切片
    const chunks = await fse.readdir(filePath) // 读文件夹的所有文件
    console.log(chunks)
}

readdir 是读取文件夹中的所有文件, readfile是读取文件内容

我们打印下看看 readdir 给我们读成了什么样子

刚好是个数组,这次读的顺序是对的,但是网络情况你也不清楚,因此我们需要将其排序

前面已经提到过,用 split 拿到最后的 index,然后 sort

css 复制代码
chunks.sort((a, b) => a.split('-')[1] - b.split('-')[1]) 

sort 会影响原数组,此时的 chunks 已经是有序的了

现在进行合并

这个 chunks 别看打印出来是个数组,里面每个切片是个字符串,其实这是真实的文件资源,有后缀的

一个形象的比喻,这些切片都是冰块,我们需要将其融化成水流,然后才能汇聚在一起

合并之前需要将切片转换成流类型,我再写个函数 pipeStream 将这些切片转成流类型,我们需要告诉这个函数切片的路径以及切片名

arduino 复制代码
const arr = chunks.map((chunkPath, index) => {
    return pipeStream(
        path.resolve(filePath, chunkPath), 
        fse.createWriteStream( // 合并地点
            path.resolve(filePath, fileName),
            {
                start: index * size,  // 0 - 2M, 2M - 4M, ......
                end: (index + 1) * size
            }
        )
    ) 
})

这里的 filePathUPLOAD_DIR,需要将其与 chunkPath 合并给到 pipeStream ,这个路径就是合并切片的文件,就在 chunk 下面。第二个参数指定最终合并到哪里去,需要借助createWriteStream来创建一个可写流,就相当于创建一个杯子,可以倒水进去进行合并,杯子就是合并的地方,这个 杯子 需要接受两个参数,一个是路径,还有个对象,对象里面写入起始位置和终止位置,这就像是给这个水杯打上刻度, 0-2M 一个刻度, 2-4M 一个刻度......

好了,现在去实现 pipeStream ,第一个参数是合并的路径,所以依旧是 filePath ,也就是UPLOAD_DIR,第二个参数是可写流

合并写入的路径我最终还是写在 chunk

javascript 复制代码
const pipeStream = (filePath, writeStream) => {
    console.log(filePath);
    return new Promise((resolve, reject) => {

    })
}

先看下是否帮我们生成了这个文件

没问题,只不过我们还没将数据合并进去,这个文件还是空的

现在将所有的切片先读到,然后监听 end 事件,把切片都移除掉,读到的流最终汇入到可写流中,也就是第二个参数

javascript 复制代码
const pipeStream = (filePath, writeStream) => {
    console.log(filePath);
    return new Promise((resolve, reject) => {
        const readStream = fse.createReadStream(filePath)
        readStream.on('end', () => {
            fse.unlinkSync(filePath) // 移除切片
            resolve()
        })
        readStream.pipe(writeStream) // 将切片读成流汇入到可写流中
    })
}

最后赋值得到的 arr ,都是一个个的 promise 对象,保证每个切片 resolve 即可

await Promise.all(arr)

最后见证奇迹的时刻到了

切片汇总了

在文件资源管理器中打开看看

完美!

总结

实现过程

前端拿到整个文件后利用文件 Blob 原型上的 slice 方法进行切割,将得到的切片数组 chunkList 添加一些信息,比如文件名和下标,得到 uploadChunkList ,但是 uploadChunkList 想要传给后端还需要将其转换成表单数据格式,通过 Promise.all 并发发给后端,传输完毕后发送一个合并请求,合并请求带上文件名和切片大小信息

后端拿到前端传过来的表单格式数据需要 multiparty 依赖来解析这个表单数据,然后把切片解析出来去存入切片,存入到提前创建好的目录中,最后将切片按照下标进行排序再来合并切片,合并切片的实现比较复杂,需要创建一个可以写入流的文件,将每个片段读成流类型,再写入到可写流中

切片带来的优点

  • 将大文件切割成片实现并发传输,可以提高传输速度
  • 可以实现传输进度功能,提高用户体验

丢下代码

前端index.html

xml 复制代码
<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
    <script src="https://unpkg.com/axios/dist/axios.min.js"></script>
    <title>Document</title>
</head>

<body>
    <div id="app">
        <input type="file" @change="handleChange">
        <button @click="handleUpload">上传</button>
    </div>

    <script>
        const { createApp, ref } = Vue

        createApp({
            setup() {
                const uploadFile = ref(null) // 文件
                const uploadChunkList = ref([])

                const handleChange = (e) => {
                    if (!e.target.files[0]) return
                    uploadFile.value = e.target.files[0]

                }

                const handleUpload = () => {
                    if (!uploadFile.value) return
                    const chunkList = createChunk(uploadFile.value)
                    // console.log(chunkList);
                    // 另外切片需要打上标记,保证后端正确合并
                    uploadChunkList.value = chunkList.map(({ file }, index) => {
                        return {
                            file,
                            size: file.size,
                            percent: 0,
                            chunkName: `${uploadFile.value.name}-${index}`,
                            fileName: uploadFile.value.name,
                            index
                        }
                    })
                    console.log(uploadChunkList.value);
                    // 发请求 把切片一个一个地给后端
                    uploadChunks()
                }

                // 上传切片
                const uploadChunks = () => {
                    const formateList = uploadChunkList.value.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(formateList); // 浏览器不给你展示二进制流,但是得清楚确实拿到了
                    // 发接口请求
                    const requestList = formateList.map(({ formData, index }) => {
                        return requestUpload({
                            url: 'http://localhost:3000/upload',
                            data: formData,
                            onUploadProgress: createProgress(uploadChunkList.value[index]) // 进度条函数拿出来写
                        })
                    })

                    // 合并切片请求
                    Promise.all(requestList).then(mergeChunks())
                }
                // 合并切片
                const mergeChunks = () => {
                    requestUpload({
                        url: 'http://localhost:3000/merge',
                        data: JSON.stringify({
                            fileName: uploadFile.value.name,
                            size: 2 * 1024 * 1024
                        })
                    })
                }

                // 上传的进度
                const createProgress = (item) => {
                    return (e) => {
                        // 为何函数需要return出来,因为axios的onUploadProgress就是个函数体
                        // 并且这个函数体参数e就是进度
                        item.percent = parseInt(String(e.loaded / e.total) * 100) // axios提供的
                    }
                }

                // 为了实现进度条,封装请求
                const requestUpload = ({ url, method = 'post', data, headers = {}, onUploadProgress = (e) => e }) => {
                    return new Promise((resolve, reject) => {
                        // axios支持在请求中传入一个回调onUploadProgress,其目的就是为了知道请求的进度
                        axios[method](url, data, { headers, onUploadProgress })
                            .then(res => {
                                resolve(res)
                            })
                            .catch(err => {
                                reject(err)
                            })
                    })
                }

                const createChunk = (file, size = 2 * 1024 * 1024) => {
                    const chunkList = []
                    let cur = 0  // 当前切片
                    while (cur < file.size) {
                        chunkList.push({ file: file.slice(cur, cur + size) })
                        cur += size
                    }
                    return chunkList
                }

                return {
                    handleChange,
                    handleUpload,
                    createChunk
                }
            }
        }).mount('#app')
    </script>
</body>

</html>

后端app.js

javascript 复制代码
const http = require('http')
const multiparty = require('multiparty')
const path = require('path')
const fse = require('fs-extra')

const UPLOAD_DIR = path.resolve(__dirname, '.', 'chunks') // 准备好地址用来存切片

const resolvePost = (req) => {
    return new Promise((resolve, reject) => {
        let chunk = ''  // 参数数据包
        req.on('data', (data) => {
            chunk += data
        })
        req.on('end', () => {
            resolve(JSON.parse(chunk))
        })
    })
}

const server = http.createServer(async (req, res) => {
    // 解决跨域
    res.setHeader('Access-Control-Allow-Origin', '*') // 允许所有的请求源来跨域
    res.setHeader('Access-Control-Allow-Headers', '*') // 允许所有的请求头来跨域 

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

    if (req.url === '/upload') {
        const form = new multiparty.Form()
        form.parse(req, (err, fields, files) => {
            if (err) {
                console.log(err);
                return 
            }
            const file = files.file[0] // 切片的内容
            const fileName = fields.fileName[0]
            const chunkName = fields.chunkName[0]

            // 拿到切片先存入再合并,存入的目的就是防止顺序错乱
            // const chunkDir = path.resolve(UPLOAD_DIR, `${fileName}-chunks`)  // 文件名不同,文件目录就不同
            if (!fse.existsSync(UPLOAD_DIR)) { // 判断目录是否存在  
                fse.mkdirsSync(UPLOAD_DIR) // 创建这个文件
            }
            // 将切片写入到文件夹中
            fse.moveSync(file.path, `${UPLOAD_DIR}/${chunkName}`)

        })
    } else if (req.url === '/merge') { // 让前端请求这个地址表明传输完成去合并切片
        console.log('merge');
        const { fileName, size } = await resolvePost(req) // 解析前端传过来的参数
        console.log(fileName, size);
        await mergeFileChunks(UPLOAD_DIR, fileName, size)
        res.end('合并成功')
    }
})

const mergeFileChunks = async(filePath, fileName, size) => { // 写个async就相当于new promise
    // 读取filePath下所有的切片
    const chunks = await fse.readdir(filePath) // 读文件夹的所有文件
    console.log(chunks);
    // 防止切片顺序错乱
    chunks.sort((a, b) => a.split('-')[1] - b.split('-')[1])  // sort会影响原数组,无需赋值

    // 合并片段:转换成流类型
    const arr = chunks.map((chunkPath, index) => {
        return pipeStream(
            path.resolve(filePath, chunkPath), 
            fse.createWriteStream( // 合并
                path.resolve(filePath, fileName),
                {
                    start: index * size,  // 0 - 3M, 3M - 6M, ......
                    end: (index + 1) * size
                }
            )
        ) 
    })
    await Promise.all(arr)
}

const pipeStream = (filePath, writeStream) => {
    console.log(filePath);
    return new Promise((resolve, reject) => {
        const readStream = fse.createReadStream(filePath)
        readStream.on('end', () => {
            fse.unlinkSync(filePath) // 移除切片
            resolve()
        })
        readStream.pipe(writeStream) // 将切片读成流汇入到可写流中
    })
}

server.listen(3000, () => {
    console.log('listening on port 3000');
})

最后

大三实习找工作中,求内推💕~

如果你对面试感兴趣,可以加我的个人微信:Dolphin_Fung,我和我的小伙伴们有个面试群,可以进群讨论你面试过程中遇到的问题,我们一起解决

另外有不懂之处欢迎在评论区留言,如果觉得文章对你学习有所帮助,还请"点赞+评论+收藏"一键三连,感谢支持!

相关推荐
理想不理想v2 分钟前
vue种ref跟reactive的区别?
前端·javascript·vue.js·webpack·前端框架·node.js·ecmascript
知孤云出岫2 分钟前
web 渗透学习指南——初学者防入狱篇
前端·网络安全·渗透·web
贩卖纯净水.8 分钟前
Chrome调试工具(查看CSS属性)
前端·chrome
测试19981 小时前
2024软件测试面试热点问题
自动化测试·软件测试·python·测试工具·面试·职场和发展·压力测试
栈老师不回家1 小时前
Vue 计算属性和监听器
前端·javascript·vue.js
前端啊龙1 小时前
用vue3封装丶高仿element-plus里面的日期联级选择器,日期选择器
前端·javascript·vue.js
一颗松鼠1 小时前
JavaScript 闭包是什么?简单到看完就理解!
开发语言·前端·javascript·ecmascript
马剑威(威哥爱编程)1 小时前
MongoDB面试专题33道解析
数据库·mongodb·面试
小远yyds1 小时前
前端Web用户 token 持久化
开发语言·前端·javascript·vue.js
阿伟来咯~2 小时前
记录学习react的一些内容
javascript·学习·react.js