以前實(shí)習(xí)的時(shí)候有做過(guò)大文件上傳的需求箱季,當(dāng)時(shí)我們團(tuán)隊(duì)用的是網(wǎng)宿科技的存儲(chǔ)服務(wù),自然而然用的也是他們上傳的js-sdk,不管是網(wǎng)宿科技還是七牛等提供存儲(chǔ)服務(wù)的公司,他們的文件上傳底層使用的基本上都是plupload庫(kù)藏姐。除了這個(gè),百度FEX團(tuán)隊(duì)開(kāi)源的webuploader也是鼎鼎大名的该贾,當(dāng)然羔杨,對(duì)于文件操作的庫(kù)有許多許多,本文不做過(guò)多介紹杨蛋。
對(duì)于一個(gè)中小型企業(yè)的小項(xiàng)目或者個(gè)人項(xiàng)目來(lái)說(shuō)兜材,使用第三方的存儲(chǔ)服務(wù)也許昂貴了點(diǎn),且如果上傳的文件涉及到隱私的話也是不安全的(各種方案都是因項(xiàng)目而異的)逞力。本文主要講解在不使用webuploader,plupload等庫(kù)的情況下曙寡,使用html5的File API來(lái)解決大文件上傳的問(wèn)題(本文主要指前端部分)。當(dāng)然寇荧,由于是對(duì)內(nèi)的項(xiàng)目举庶,本文并沒(méi)有過(guò)多考慮瀏覽器兼容性的問(wèn)題,畢竟對(duì)于IE低版本瀏覽器來(lái)說(shuō)揩抡,F(xiàn)lash可能是最適合的户侥。
本文主要使用了antd為UI組件,搭建了如下系統(tǒng)峦嗤。
Demo演示
下圖為文件預(yù)加載時(shí)的動(dòng)圖蕊唐,考慮到gif時(shí)間的限制,拿了個(gè)30多M文件測(cè)試烁设。
下圖為上傳中的過(guò)程
前后端聯(lián)調(diào)步驟
其實(shí)之所以不使用WebUploader等庫(kù)來(lái)實(shí)現(xiàn)替梨,也是因?yàn)楹蠖说男枨蟾话愕拇笪募蟼饔幸稽c(diǎn)不同,所以前端干脆不使用庫(kù)來(lái)寫装黑。
前后端重點(diǎn)考慮的點(diǎn)副瀑,是使用分片上傳,且每個(gè)分片都需要生成md5值恋谭,以便后端去校驗(yàn)糠睡。因此,每一次分片上傳箕别,都需要上傳該片段的file,以及chunkMd5铜幽,和整個(gè)文件的fileMd5。同時(shí)串稀,前后端采用arrayBuffer的blob格式來(lái)進(jìn)行文件傳輸除抛。
如下為前后端聯(lián)調(diào)的步驟
第一步:用戶選擇文件,進(jìn)行預(yù)處理
- 計(jì)算總文件的md5值,即fileMd5
- 按照固定的分片大心附亍(比如5M到忽,該值為用戶自定義),進(jìn)行切分
- 計(jì)算每個(gè)分片的md5值,chunkMd5,start,end,size等
第二步:用戶點(diǎn)擊上傳
- 發(fā)送第一步生成的json數(shù)據(jù)到requestUrl
- requestUrl接口返回響應(yīng)喘漏,來(lái)驗(yàn)證該文件是否已經(jīng)上傳护蝶,或者已上傳了哪些chunk。(返回的response應(yīng)該包括每個(gè)chunk的狀態(tài)翩迈,即pending or uploaded持灰,第一次上傳所有chunk狀態(tài)都為pending)
- 前端過(guò)濾掉已經(jīng)上傳的chunks后,對(duì)pending狀態(tài)的chunks構(gòu)成一個(gè)待上傳隊(duì)列進(jìn)行上傳负饲。
- 每一個(gè)chunk上傳到partUpload接口堤魁,都應(yīng)該包括,chunkMd5,start,end以及該分片的arrayBuffer數(shù)據(jù)返十。
第三步:上傳結(jié)果反饋
- partUpload接口會(huì)返回該分片上傳的基本情況妥泉,每一次上傳成功,上傳隊(duì)列的個(gè)數(shù)即減一洞坑,這樣也可以自定義上傳的progress盲链。
- 當(dāng)上傳隊(duì)列個(gè)數(shù)為0時(shí),此時(shí)調(diào)用checkUrl迟杂,檢查整個(gè)文件是否上傳成功刽沾,與前端進(jìn)行一個(gè)同步校驗(yàn)。
代碼拆分
總體架構(gòu)
本文Demo主要是對(duì)UI組件進(jìn)行描述逢慌,所以沒(méi)有考慮數(shù)據(jù)層悠轩,讀者可以自己配合dva或者redux。下文為主要的代碼結(jié)構(gòu)
import React, { Component } from 'react'
import PropTypes from 'prop-types'
import { Upload, Icon, Button, Progress,Checkbox, Modal, Spin, Radio, message } from 'antd'
import request from 'superagent'
import SparkMD5 from 'spark-md5'
const confirm = Modal.confirm
const Dragger = Upload.Dragger
class FileUpload extends Component {
constructor(props) {
super(props)
this.state = {
preUploading:false, //預(yù)處理
chunksSize:0, // 上傳文件分塊的總個(gè)數(shù)
currentChunks:0, // 當(dāng)前上傳的隊(duì)列個(gè)數(shù) 當(dāng)前還剩下多少個(gè)分片沒(méi)上傳
uploadPercent:-1, // 上傳率
preUploadPercent:-1, // 預(yù)處理率
uploadRequest:false, // 上傳請(qǐng)求攻泼,即進(jìn)行第一個(gè)過(guò)程中
uploaded:false, // 表示文件是否上傳成功
uploading:false, // 上傳中狀態(tài)
}
}
showConfirm = () => {
const _this = this
confirm({
title: '是否提交上傳?',
content: '點(diǎn)擊確認(rèn)進(jìn)行提交',
onOk() {
_this.preUpload()
},
onCancel() { },
})
}
preUpload = ()=>{
// requestUrl,返回可以上傳的分片隊(duì)列
//...
}
handlePartUpload = (uploadList)=>{
// 分片上傳
// ...
}
render() {
const {preUploading,uploadPercent,preUploadPercent,uploadRequest,uploaded,uploading} = this.state
const _this = this
const uploadProp = {
onRemove: (file) => {
// ...
},
beforeUpload: (file) => {
// ...對(duì)文件的預(yù)處理
},
fileList: this.state.fileList,
}
return (
<div className="content-inner">
<Spin tip={
<div >
<h3 style={{margin:'10px auto',color:'#1890ff'}}>文件預(yù)處理中...</h3>
<Progress width={80} percent={preUploadPercent} type="circle" status="active" />
</div>
}
spinning={preUploading}
style={{ height: 350 }}>
<div style={{ marginTop: 16, height: 250 }}>
<Dragger {...uploadProp}>
<p className="ant-upload-drag-icon">
<Icon type="inbox" />
</p>
<p className="ant-upload-text">點(diǎn)擊或者拖拽文件進(jìn)行上傳</p>
<p className="ant-upload-hint">Support for a single or bulk upload. Strictly prohibit from uploading company data or other band files</p>
</Dragger>
{uploadPercent>=0&&!!uploading&&<div style={{marginTop:20,width:'95%'}}>
<Progress percent={uploadPercent} status="active" />
<h4>文件上傳中,請(qǐng)勿關(guān)閉窗口</h4>
</div>}
{!!uploadRequest&&<h4 style={{color:'#1890ff'}}>上傳請(qǐng)求中...</h4>}
{!!uploaded&&<h4 style={{color:'#52c41a'}}>文件上傳成功</h4>}
<Button type="primary" onClick={this.showConfirm} disabled={!!(this.state.preUploadPercent <100)}>
<Icon type="upload" />提交上傳
</Button>
</div>
</Spin>
</div>
)
}
}
FileUpload.propTypes = {
//...
}
export default FileUpload
文件分片
使用Html5 的File API是現(xiàn)在主流的處理文件上傳的方案鉴象。在使用FileReader API之前忙菠,應(yīng)該了解一下Blob對(duì)象,Blob對(duì)象表示不可變的類似文件對(duì)象的原始數(shù)據(jù)纺弊。File接口就是基于Blob牛欢,繼承了blob的功能并將其擴(kuò)展使其支持用戶系統(tǒng)上的文件。
本文前后端約束采用二進(jìn)制的ArrayBuffer 對(duì)象格式來(lái)傳輸文件,類型話數(shù)組(ArrayBuffer)可以直接操作內(nèi)存淆游,接口之間完全可以用二進(jìn)制數(shù)據(jù)通信傍睹。
使用FileReader來(lái)讀取文件,主要有5個(gè)方法:
方法名 | 參數(shù) | 描述 |
---|---|---|
abort | none | 中斷讀取 |
readAsBinaryString | file | 將文件讀取為二進(jìn)制碼 |
readAsDataURL | file | 將文件讀取為DataURL |
readAsText | file,[encoding] | 將文件讀取為文本 |
readAsArrayBuffer | file | 將文件讀取為ArrayBuffer |
- 使用Antd的Drag(Uploader)組件犹菱,我們可以在props的beforeUpload屬性中操作file拾稳,也可以通過(guò)onChange監(jiān)聽(tīng)file。當(dāng)然腊脱,使用beforeUpload更加方便访得。關(guān)鍵代碼如下:
const uploadProp = {
onRemove: (file) => {
this.setState(({ fileList }) => {
const index = fileList.indexOf(file)
const newFileList = fileList.slice()
newFileList.splice(index, 1)
return {
fileList: newFileList,
}
})
},
beforeUpload: (file) => {
// 首先清除一下各種上傳的狀態(tài)
this.setState({
uploaded:false, // 上傳成功
uploading:false, // 上傳中
uploadRequest:false // 上傳預(yù)處理
})
// 兼容性的處理
let blobSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice,
chunkSize = 1024*1024*5, // 切片每次5M
chunks = Math.ceil(file.size / chunkSize),
currentChunk = 0, // 當(dāng)前上傳的chunk
spark = new SparkMD5.ArrayBuffer(),
// 對(duì)arrayBuffer數(shù)據(jù)進(jìn)行md5加密,產(chǎn)生一個(gè)md5字符串
chunkFileReader = new FileReader(), // 用于計(jì)算出每個(gè)chunkMd5
totalFileReader = new FileReader() // 用于計(jì)算出總文件的fileMd5
let params = {chunks: [], file: {}}, // 用于上傳所有分片的md5信息
arrayBufferData = [] // 用于存儲(chǔ)每個(gè)chunk的arrayBuffer對(duì)象,用于分片上傳使用
params.file.fileName = file.name
params.file.fileSize = file.size
totalFileReader.readAsArrayBuffer(file)
totalFileReader.onload = function(e){
// 對(duì)整個(gè)totalFile生成md5
spark.append(e.target.result)
params.file.fileMd5 = spark.end() // 計(jì)算整個(gè)文件的fileMd5
}
chunkFileReader.onload = function (e) {
// 對(duì)每一片分片進(jìn)行md5加密
spark.append(e.target.result)
// 每一個(gè)分片需要包含的信息
let obj = {
chunk:currentChunk + 1,
start:currentChunk * chunkSize, // 計(jì)算分片的起始位置
end:((currentChunk * chunkSize + chunkSize) >= file.size) ? file.size : currentChunk * chunkSize + chunkSize, // 計(jì)算分片的結(jié)束位置
chunkMd5:spark.end(),
chunks
}
// 每一次分片onload,currentChunk都需要增加陕凹,以便來(lái)計(jì)算分片的次數(shù)
currentChunk++;
params.chunks.push(obj)
// 將每一塊分片的arrayBuffer存儲(chǔ)起來(lái)悍抑,用來(lái)partUpload
let tmp = {
chunk:obj.chunk,
currentBuffer:e.target.result
}
arrayBufferData.push(tmp)
if (currentChunk < chunks) {
// 當(dāng)前切片總數(shù)沒(méi)有達(dá)到總數(shù)時(shí)
loadNext()
// 計(jì)算預(yù)處理進(jìn)度
_this.setState({
preUploading:true,
preUploadPercent:Number((currentChunk / chunks * 100).toFixed(2))
})
} else {
//記錄所有chunks的長(zhǎng)度
params.file.fileChunks = params.chunks.length
// 表示預(yù)處理結(jié)束鳄炉,將上傳的參數(shù),arrayBuffer的數(shù)據(jù)存儲(chǔ)起來(lái)
_this.setState({
preUploading:false,
uploadParams:params,
arrayBufferData,
chunksSize:chunks,
preUploadPercent:100
})
}
}
fileReader.onerror = function () {
console.warn('oops, something went wrong.');
};
function loadNext() {
var start = currentChunk * chunkSize,
end = ((start + chunkSize) >= file.size) ? file.size : start + chunkSize;
fileReader.readAsArrayBuffer(blobSlice.call(file, start, end));
}
loadNext()
// 只允許一份文件上傳
this.setState({
fileList: [file],
file: file
})
return false
},
fileList: this.state.fileList,
}
分片上傳
- 在預(yù)處理過(guò)程中會(huì)拿到uploadParams的json數(shù)據(jù)搜骡,如下所示
{
file:{
fileChunks:119,
fileMd5:"f5aeec69076483585f4f112223265c0c",
fileName:"xxxx.test",
fileSize:6205952600
},
chunks:[{
chunk:1,
chunkMd5:"8770f43dc59effdc8b995e4aacc8a26c",
chunks:119,
end:5242880,
start:0
},
...
]
}
將以上數(shù)據(jù)post到RequestUrl接口中拂盯,會(huì)得到如下json數(shù)據(jù):
{
Chunks:[
{
chunk: 1,
chunkMd5:"8770f43dc59effdc8b995e4aacc8a26c",
fileMd5:"f5aeec69076483585f4f672223265c0c",
end: 5242880,
start:0,
status:"pending"
},
…
],
Code:200,
FileMd5:"f5aeec69076483585f4f672223265c0c"
MaxThreads:1,
Message:"OK",
Total:119,
Uploaded:0
}
- 拿到j(luò)son數(shù)據(jù),會(huì)先對(duì)得到的Chunks進(jìn)行一次過(guò)濾记靡,將status為pengding的過(guò)濾出來(lái)谈竿。
let uploadList = res.body.Chunks.filter((value)=>{
return value.status === 'Pending'
})
// 從返回結(jié)果中獲取當(dāng)前還有多少個(gè)分片沒(méi)傳
let currentChunks = res.body.Total - res.body.Uploaded
// 獲得上傳進(jìn)度
let uploadPercent = Number(((this.state.chunksSize - currentChunks) /this.state.chunksSize * 100).toFixed(2))
// 上傳之前,先判斷文件是否已經(jīng)上傳成功
if(uploadPercent === 100){
message.success('上傳成功')
this.setState({
uploaded:true, // 讓進(jìn)度條消失
uploading:false
})
}else{
this.setState({
uploaded:false,
uploading:true
})
}
this.setState({
uploadRequest:false, // 上傳請(qǐng)求成功
currentChunks,
uploadPercent
})
//進(jìn)行分片上傳
this.handlePartUpload(uploadList)
- 遍歷uploadList的數(shù)據(jù)簸呈,分別將數(shù)據(jù)傳入到uploadUrl接口中榕订。此過(guò)程最關(guān)鍵的,就是如何將分片的arrayBuffer數(shù)據(jù)如何添加到Blob對(duì)象中蜕便。
為了減輕服務(wù)器的壓力劫恒,這里可以采用分治的思想去處理每個(gè)分片。對(duì)于如何實(shí)現(xiàn)分治的思想轿腺,請(qǐng)參考本人之前寫的博客由requestAnimationFrame談瀏覽器渲染優(yōu)化两嘴。
handlePartUpload = (uploadList)=>{
let blobSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice
const _this = this
const batchSize = 4, // 采用分治思想,每批上傳的片數(shù)族壳,越大越卡
total = uploadList.length, //獲得分片的總數(shù)
batchCount = total / batchSize // 需要批量處理多少次
let batchDone = 0 //已經(jīng)完成的批處理個(gè)數(shù)
doBatchAppend()
function doBatchAppend() {
if (batchDone < batchCount) {
let list = uploadList.slice(batchSize*batchDone,batchSize*(batchDone+1))
setTimeout(()=>silcePart(list),2000);
}
}
function silcePart(list){
batchDone += 1;
doBatchAppend();
list.forEach((value)=>{
let {fileMd5,chunkMd5,chunk,start,end} = value
let formData = new FormData(),
blob = new Blob([_this.state.arrayBufferData[chunk-1].currentBuffer],{type: 'application/octet-stream'}),
params = `fileMd5=${fileMd5}&chunkMd5=${chunkMd5}&chunk=${chunk}&start=${start}&end=${end}&chunks=${_this.state.arrayBufferData.length}`
formData.append('chunk', blob, chunkMd5)
request
.post(`http://x.x.x.x/api/contest/upload_file_part?${params}`)
.send(formData)
.withCredentials()
.end((err,res)=>{
if(res.body.Code === 200){
let currentChunks = this.state.currentChunks
--currentChunks
// 計(jì)算上傳進(jìn)度
let uploadPercent = Number(((this.state.chunksSize - currentChunks) /this.state.chunksSize * 100).toFixed(2))
this.setState({
currentChunks, // 同步當(dāng)前所需上傳的chunks
uploadPercent,
uploading:true
})
if(currentChunks ===0){
// 調(diào)用驗(yàn)證api
this.checkUploadStatus(this.state.fileMd5)
}
}
})
})
}
}
總結(jié)與展望
以上就是一個(gè)簡(jiǎn)單的基于react的大文件上傳組件憔辫,主要的知識(shí)點(diǎn)包括:分片上傳技術(shù),F(xiàn)ileReader API仿荆,ArrayBuffer數(shù)據(jù)結(jié)構(gòu)贰您,md5加密技術(shù),Blob對(duì)象的應(yīng)用等 知識(shí)點(diǎn)拢操。讀者可以自行擴(kuò)展該React組件锦亦,可以跟Dva/Redux結(jié)合擴(kuò)展Model層或者集中的狀態(tài)管理等。同時(shí)令境,對(duì)于該組件中出現(xiàn)的異步流程是很簡(jiǎn)單粗暴的杠园,如何建立合理的異步流程控制,也是需要去思考的舔庶。當(dāng)然抛蚁,對(duì)于大文件來(lái)說(shuō),文件壓縮也是一個(gè)需要去考慮的點(diǎn)惕橙,比如使用snappy.js等工具庫(kù)瞧甩。