大文件为什么要切片上传
-
前端上传文件很大时,会出现各种问题,比如连接超时了,网断了,都会导致上传失败
-
服务端限制了单次上传文件的大小
项目实际场景
-
客户端需要上传一个算法包文件到服务器,这个算法包实测
3.7G -
nginx配置文件 上传文件大小最大值为
100M,
切片上传原理
-
通过
file.slice将大文件chunks切成许多个大小相等的chunk -
将每个
chunk上传到服务器 -
服务端接收到许多个
chunk后,合并为chunks
第一版
- 先对文件按指定大小进行切片
/**
* file: 需要切片的文件
* chunkSize: 每片文件大小,1024*1024=1M
*/
chunkSlice(file, chunkSize) {
const chunks = [],
size = file.size,
total = Math.ceil(size / chunkSize)
for (let i = 0; i < size; i += chunkSize) {
chunks.push({
total,
blob: file.slice(i, i + chunkSize),
})
}
return chunks
}
-
处理切片后的文件,后端想要我传给他一个json对象,所以使用
readAsDataURL读取文件 -
这里使用了一个插件
spark-md5来生成每个切片的MD5
async handleFile(chunks) {
const res = []
for (const item of chunks) {
const { bytes, md5 } = await this.addMark(item.blob)
item.blob = bytes
item.md5 = md5
res.push(md5)
}
return res
},
// 使用FileReader读取每一片数据,并生成MD5编码
async addMark(chunk) {
return new Promise((resolve, reject) => {
const reader = new FileReader()
const spark = new SparkMD5()
reader.readAsDataURL(chunk)
reader.onload = function (e) {
const bytes = e.target.result
spark.append(bytes)
const md5 = spark.end()
resolve({ bytes, md5 })
}
})
},
- 组装数据,包括每一片的排列顺序
index,总共切了多少片total,文件IDfileID,每一片的md5编码md5,每一片数据fileData
mergeData(chunks) {
const fileId = this.getUUID()
const data = []
for (let i = 0; i < chunks.length; i++) {
const obj = {
fileId,
fileData: chunks[i].blob,//每片切片的数据
fileIndex: i + 1,//每片数据索引
fileTotal: chunks[i].total + '',
md5: chunks[i].md5,
}
data.push(obj)
}
return { data, fileId }
},
- 上传文件,这里使用并发上传文件,提升文件上传速度
const chunks = chunkSlice(file,1024*1024)
this.handleFile(chunks)
const data = this.mergeData(chunks)
for(let i = 0; i < data.length; i++){
this.uplload(data[i])
}
第一版遇到的问题
- 文件太大,切片太小,上传接口的
timeout太短,并发请求时,全都在pendding,导致请求出错
第一版问题解决
-
对上传文件接口的
timeout修改,调整时长,大一点 -
限制每次并发的数量,我用的是500个每次
第二版,切片 + web worker
-
为什么要使用web worker
-
在生成文件
MD5编码时,需要读文件,是一个I/O操作,会阻塞页面,文件太大,导致页面卡死 -
将耗时操作转移到
worker线程,主页面就不会卡住
vue2,使用worker
- yarn add worker-loader
- vue.config.js 配置
// vue.config.js
chainWebpack(config) {
config.module.rule('worker')
.test(/\.worker\.js$/)
.use('worker-loader')
.loader('worker-loader')
// .options({ inline: 'fallback' })// 这个配置是个坑,不要加
},
- 新建
file.worker.js
// file.worker.js
import SparkMD5 from 'spark-md5'
const chunkSlice = (file, chunkSize) => {
const chunks = [],
size = file.size,
total = Math.ceil(size / chunkSize)
for (let i = 0; i < size; i += chunkSize) {
chunks.push({
total,
blob: file.slice(i, i + chunkSize),
})
}
return chunks
}
const handleFile = async (chunks) => {
const res = []
for (const item of chunks) {
const { bytes, md5 } = await addMark(item.blob)
item.blob = bytes
item.md5 = md5
res.push(md5)
}
return res
}
const addMark = (chunk) => {
return new Promise((resolve, reject) => {
const reader = new FileReader()
const spark = new SparkMD5()
reader.readAsDataURL(chunk)
reader.onload = function (e) {
const bytes = e.target.result
spark.append(bytes)
const md5 = spark.end()
resolve({ bytes, md5 })
}
})
}
const mergeData = (chunks, fileName, options) => {
const fileId = getUUID() // 这里更好的方式是读整个文件的 MD5
const data = []
for (let i = 0; i < chunks.length; i++) {
const obj = {
...options,
suffix: '.tar.gz',
fileId,
fileName,
fileData: chunks[i].blob,
fileIndex: i + 1 + '',
fileTotal: chunks[i].total + '',
md5: chunks[i].md5,
}
data.push(obj)
}
return { data, fileId }
}
const getUUID = () => {
return ([1e7] + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, (c) =>
(c ^ (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (c / 4)))).toString(16)
)
}
const dataSlice = (data, step, fileId) => {
const total = Math.ceil(data.length / step)
let index = 1
for (let i = 0; i < data.length; i += step) {
const params = {
type: 'workerFile',
index,
total,
fileId,
data: data.slice(i, i + step),
}
self.postMessage(params)
index++
}
}
self.addEventListener('error', (event) => {
console.log('worker error', event)
})
self.addEventListener('message', async (event) => {
// 确保接受的是我想要的消息
if (!event.data.type) return
if (event.data.type != 'file') return
console.log('worker success', event)
const { file, chunkSize } = event.data
const chunks = chunkSlice(file, chunkSize)
const allMD5 = await handleFile(chunks)
console.log(allMD5)
// 此处 allMD5 可用来做后续的断点续传
const { data, fileId } = mergeData(chunks, file.name)
// 这里对处理好的数据进行切片,分片传递给主线程,是由于 Web Worker 试图将大量数据复制到主线程中,会导致内存溢出。
dataSlice(data, 100, fileId)
})
这个报错一般是在使用 JavaScript Web Worker 时出现的,通常是由于 Web Worker 试图将大量数据复制到主线程中,导致内存溢出所引起的。
- 主进程使用
// xxx.vue文件
import Worker from '@/utils/worker/file.worker.js'
const worker = new Worker()
worker.postMessage({ type: 'file', file: this.curFile, chunkSize: 1024 * 1024 })
worker.onerror = (error) => {
console.log('main error', error)
worker.terminate()
}
const finalData = []
worker.onmessage = async (event) => {
console.log('main success', event)
if (event.data.type != 'workerFile') return
const fileId = mergeWorkerData(finalData, event.data)
if (fileId) {
worker.terminate()
const status = await stepLoad(finalData, 500)
if (!status) {
this.$message.error('文件上传失败')
} else {
this.$message.success('文件上传成功')
}
}
}
mergeWorkerData = (res, params) => {
res.push(...params.data)
return params.index == params.total ? params.fileId : false
}
const stepLoad = async (data, step) => {
const res = []
for (let i = 0; i < data.length; i += step) {
res.push(data.slice(i, i + step))
}
for (const item of res) {
const chunkRes = await Promise.all(item.map((v) => this.$api.upload(v)))
if (chunkRes.some((v) => v.httpCode != 0)) {
return false
}
const isEnd = chunkRes.filter((v) => v.finish)
if (isEnd.length) {
return true
}
}
}
第三版 - 优化
-
项目中使用的是第一版,没有使用
worker版 -
第一版在公司内网测试时,一切正常,现场测试会出现内存过大,页面崩溃的问题
-
上传的文件大小为
806M -
现网
1.8g左右就会崩溃,公司测试使用的文件为2.4g内存占用超3.5g都正常 -
为什么现网内存不到
2g就崩了呢?暂时不清楚,但是这个地方内存占用也太大了,赶快优化,解决崩溃 -
经过一步一步的排查,发现吃内存的大头在
handleFile这个函数
async handleFile(chunks) {
const res = []
for (const item of chunks) {
const { bytes, md5 } = await this.addMark(item.blob)
item.blob = bytes
item.md5 = md5
res.push(md5)
}
return res
},
// 使用FileReader读取每一片数据,并生成MD5编码
async addMark(chunk) {
return new Promise((resolve, reject) => {
const reader = new FileReader()
const spark = new SparkMD5()
reader.readAsDataURL(chunk)
reader.onload = function (e) {
const bytes = e.target.result
spark.append(bytes)
const md5 = spark.end()
resolve({ bytes, md5 })
}
})
},
-
这个函数的作用是读取每一片文件数据,并将其转成
base64,后端说他就要base64,还有每一片的md5 -
吃内存的原因就在于这个函数保存了每一片数据转换成
base64后的数据,相当于存了和副本 -
修改为如下代码,只计算文件的
md5,在上传的时候也不一次并发500条了,一条一条的来,在上传文件的时候才去将文件转换为base64
async handleFile(file) {
return new Promise((resolve, reject) => {
const chunkSize = 1024 * 1024
const total = Math.ceil(file.size / chunkSize)
const reader = new FileReader()
const spark = new SparkMD5.ArrayBuffer()
let start = 0
let end = start + chunkSize
reader.onload = function (e) {
console.log(
'index---',
start / (1024 * 1024),
'---start---',
start,
'---end---',
end,
'---size---',
file.size
)
spark.append(e.target.result)
if (end > file.size || end == file.size) {
const md5 = spark.end()
resolve({ total, md5 })
} else {
readFileChunks()
}
}
const readFileChunks = () => {
start += chunkSize
end = start + chunkSize
reader.readAsArrayBuffer(file.slice(start, end))
}
readFileChunks()
})
},
- 后面的代码也有部分改动,给一份稍微完整点的代码,如下
const handleFile = async (file) => {
return new Promise((resolve, reject) => {
const chunkSize = 1024 * 1024
const total = Math.ceil(file.size / chunkSize)
const reader = new FileReader()
const spark = new SparkMD5.ArrayBuffer()
let start = 0
let end = 0
reader.onload = function (e) {
spark.append(e.target.result)
if (end > file.size || end == file.size) {
const md5 = spark.end()
resolve({ total, md5 })
} else {
readFileChunks()
}
}
const readFileChunks = () => {
start += chunkSize
end = start + chunkSize
reader.readAsArrayBuffer(file.slice(start, end))
}
readFileChunks()
})
}
const stepLoad = async (options) => {
const arr = new Array(options.fileTotal).fill(1).map((v, i) => i)
const chunkSize = 1024 * 1024
let start = 0
let end = start + chunkSize
for (const item of arr) {
const res = await this.uptest(options, item, start, end)
if (res.resultCode != 0) return false
if (res.finish) return true
start += chunkSize
end = start + chunkSize
}
}
const uptest = (options, item, start, end) => {
return new Promise((resolve, reject) => {
const red = new FileReader()
red.onload = async (e) => {
const params = {
...options,
fileIndex: item + 1,
fileData: e.target.result,
}
const res = await this.$api.uploadApi(params)
resolve(res)
}
red.readAsDataURL(this.curFile.slice(start, end))
})
}
const uploadFile = async () => {
console.log('start...', new Date().getTime())
const { md5, total } = await this.handleFile(this.curFile)
console.log('fileReader...', new Date().getTime())
const options = {
fileId: md5,
fileName: this.curFile.name,
fileIndex: 1,
fileTotal: total,
fileData: '',
}
const state = await this.stepLoad(options)
console.log(state)
console.log('upload...', new Date().getTime())
}
uploadFile()
第四版 - worker - 优化
-
第三版读取文件
MD5耗时太长 -
这一版加上
worker,缩短读取文件时长 -
我准备了一个
10.5g的测试文件 -
还是先看一下没有使用
worker的代码的文件读取耗时 -
目录结构
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<script src="./sparkMD5.js"></script>
</head>
<body>
<input type="file" id="input">
<script>
const input = document.getElementById('input')
input.addEventListener('change', (e) => {
const file = e.target.files[0]
console.log('fileSize', Math.ceil(file.size / 1024 / 1024) + 'MB')
const size = 1024 * 1024,
total = Math.ceil(file.size / size)
const option = { file, size, total }
getFileMD5(option)
})
const getFileMD5 = ({ file, size, total }) => {
const spark = new SparkMD5.ArrayBuffer(); //追加数组缓冲区。
const fileReader = new FileReader()
let sIndex = 0, eIndex = sIndex + size, count = 0;
fileReader.onload = (e => {
spark.append(e.target.result);
count++
if (count == total) {
const fileHash = spark.end();
console.log(fileHash);
} else {
sIndex = eIndex
eIndex = sIndex + size
fileReader.readAsArrayBuffer(file.slice(sIndex, eIndex))
}
})
fileReader.readAsArrayBuffer(file.slice(sIndex, eIndex))
}
</script>
</body>
</html>
-
不使用
worker花费136s,内存基本上不增加 -
下面是使用
worker的代码
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<script src="./sparkMD5.js"></script>
</head>
<body>
<input type="file" id="input">
<script>
const input = document.getElementById('input')
input.addEventListener('change', (e) => {
const file = e.target.files[0]
console.log('fileSize', Math.ceil(file.size / 1024 / 1024) + 'MB')
const size = 1024 * 1024,
total = Math.ceil(file.size / size)
const option = { file, size, total }
getFileMD5_worker(option)
})
const getFileMD5_worker = ({ file, size, total }) => {
const cpus = navigator.hardwareConcurrency || 4;//当前电脑cpu内核数量
const threadSize = Math.ceil(total / cpus)
for (let i = 0; i < cpus; i++) {
const worker = new Worker('./worker.js')
worker.postMessage({
file,
start: i * threadSize,
end: (i + 1) * threadSize > total ? total : (i + 1) * threadSize,
size
})
worker.onerror = (error) => {
console.log('main error', error)
worker.terminate()
}
worker.onmessage = (e) => {
console.log(e)
worker.terminate()
}
}
}
</script>
</body>
</html>
// worker.js
self.onmessage = (e) => {
importScripts('sparkMD5.js')
const spark = new SparkMD5.ArrayBuffer();
const {
file,
start,
end,
size
} = e.data
let sIndex = start * size, eIndex = sIndex + size, count = 0;
const fileReader = new FileReader()
fileReader.onload = (e => {
spark.append(e.target.result);
count++
if (start + count == end) {
const fileHash = spark.end();
self.postMessage(fileHash)
} else {
sIndex = eIndex
eIndex = sIndex + size
fileReader.readAsArrayBuffer(file.slice(sIndex, eIndex))
}
})
fileReader.readAsArrayBuffer(file.slice(sIndex, eIndex))
}
- 使用
worker耗时22s,我的电脑16核,开了16个线程,内存增加1个g左右
总结
-
worker引入脚本或三方库可以使用importScript(),但是我没弄成功,一使用importScript()就会报错,Renference: importScript() xxxxxxxxxxxx,如果你们弄出来了,或者知道为什么,可以在下面留言 -
在
html原生代码里面使用importScript()正常,项目里面使用importScript(),会导致worker读取文件路径出错,所以需要插件解决读取路径问题 -
减少
worker线程数量会增加耗时,减少内存消耗,根据具体情况来选择要不要使用worker,开几个线程