本文出自perkin
以前實習的時候有做過大檔案上傳的需求,當時我們團隊用的是網宿科技的儲存服務,自然而然用的也是他們上傳的js-sdk,不管是網宿科技還是七牛等提供儲存服務的公司,他們的檔案上傳底層使用的基本上都是plupload庫。除了這個,百度FEX團隊開源的webuploader也是鼎鼎大名的,當然,對於檔案操作的庫有許多許多,本文不做過多介紹。
對於一箇中小型企業的小專案或者個人專案來說,使用第三方的儲存服務也許昂貴了點,且如果上傳的檔案涉及到隱私的話也是不安全的(各種方案都是因專案而異的)。本文主要講解在不使用WebUploader,plupload等庫的情況下,使用html5的File API來解決大檔案上傳的問題(本文主要指前端部分)。當然,由於是對內的專案,本文並沒有過多考慮瀏覽器相容性的問題,畢竟對於IE低版本瀏覽器來說,Flash可能是最適合的。
Demo演示
本文主要使用了antd為UI元件,搭建瞭如下系統。
下圖為檔案預載入時的動圖,考慮到gif時間的限制,拿了個30多M檔案測試。
下圖為上傳中的過程
前後端聯調步驟
其實之所以不使用WebUploader等庫來實現,也是因為後端的需求跟一般的大檔案上傳有一點不同,所以前端乾脆不使用庫來寫。前後端重點考慮的點,是使用分片上傳,且每個分片都需要生成md5值,以便後端去校驗。因此,每一次分片上傳,都需要上傳該片段的file,以及chunkMd5,和整個檔案的fileMd5。同時,前後端採用arrayBuffer的blob格式來進行檔案傳輸。
如下為前後端聯調的步驟
第一步:使用者選擇檔案,進行預處理
- 計算總檔案的md5值,即fileMd5
- 按照固定的分片大小(比如5M,該值為使用者自定義),進行切分
- 計算每個分片的md5值,chunkMd5,start,end,size等
第二步:使用者點選上傳
- 傳送第一步生成的json資料到requestUrl
- requestUrl介面返回響應,來驗證該檔案是否已經上傳,或者已上傳了哪些chunk。(返回的response應該包括每個chunk的狀態,即pending or uploaded,第一次上傳所有chunk狀態都為pending)
- 前端過濾掉已經上傳的chunks後,對pending狀態的chunks構成一個待上傳佇列進行上傳。
- 每一個chunk上傳到partUpload介面,都應該包括,chunkMd5,start,end以及該分片的arrayBuffer資料。
第三步:上傳結果反饋
- partUpload介面會返回該分片上傳的基本情況,每一次上傳成功,上傳佇列的個數即減一,這樣也可以自定義上傳的progress。
- 當上傳佇列個數為0時,此時呼叫checkUrl,檢查整個檔案是否上傳成功,與前端進行一個同步校驗。
程式碼拆分
總體架構
本文Demo主要是對UI元件進行描述,所以沒有考慮資料層,讀者可以自己配合dva或者redux。下文為主要的程式碼結構。
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, //預處理
chunksSize:0, // 上傳檔案分塊的總個數
currentChunks:0, // 當前上傳的佇列個數 當前還剩下多少個分片沒上傳
uploadPercent:-1, // 上傳率
preUploadPercent:-1, // 預處理率
uploadRequest:false, // 上傳請求,即進行第一個過程中
uploaded:false, // 表示檔案是否上傳成功
uploading:false, // 上傳中狀態
}
}
showConfirm = () => {
const _this = this
confirm({
title: `是否提交上傳?`,
content: `點選確認進行提交`,
onOk() {
_this.preUpload()
},
onCancel() { },
})
}
preUpload = ()=>{
// requestUrl,返回可以上傳的分片佇列
//...
}
handlePartUpload = (uploadList)=>{
// 分片上傳
// ...
}
render() {
const {preUploading,uploadPercent,preUploadPercent,uploadRequest,uploaded,uploading} = this.state
const _this = this
const uploadProp = {
onRemove: (file) => {
// ...
},
beforeUpload: (file) => {
// ...對檔案的預處理
},
fileList: this.state.fileList,
}
return (
<div className="content-inner">
<Spin tip={
<div >
<h3 style={{margin:`10px auto`,color:`#1890ff`}}>檔案預處理中...</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">點選或者拖拽檔案進行上傳</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>檔案上傳中,請勿關閉視窗</h4>
</div>}
{!!uploadRequest&&<h4 style={{color:`#1890ff`}}>上傳請求中...</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是現在主流的處理檔案上傳的方案。在使用FileReader API之前,應該瞭解一下Blob物件,Blob物件表示不可變的類似檔案物件的原始資料。File介面就是基於Blob,繼承了blob的功能並將其擴充套件使其支援使用者系統上的檔案。
-
本文前後端約束採用二進位制的ArrayBuffer 物件格式來傳輸檔案,型別話陣列(ArrayBuffer)可以直接操作記憶體,介面之間完全可以用二進位制資料通訊。
-
使用FileReader來讀取檔案,主要有5個方法:
方法名 | 引數 | 描述 |
---|---|---|
abort | none | 中斷讀取 |
readAsBinaryString | file | 將檔案讀取為二進位制碼 |
readAsDataURL | file | 將檔案讀取為DataURL |
readAsText | file,[encoding] | 將檔案讀取為文字 |
readAsArrayBuffer | file | 將檔案讀取為ArrayBuffer |
- 使用Antd的Drager(Uploader)元件,我們可以在props的beforeUpload屬性中操作file,也可以通過onChange監聽file。當然,使用beforeUpload更加方便。關鍵程式碼如下:
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) => {
// 首先清除一下各種上傳的狀態
this.setState({
uploaded:false, // 上傳成功
uploading:false, // 上傳中
uploadRequest:false // 上傳預處理
})
// 相容性的處理
let blobSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice,
chunkSize = 1024*1024*5, // 切片每次5M
chunks = Math.ceil(file.size / chunkSize),
currentChunk = 0, // 當前上傳的chunk
spark = new SparkMD5.ArrayBuffer(),
// 對arrayBuffer資料進行md5加密,產生一個md5字串
chunkFileReader = new FileReader(), // 用於計算出每個chunkMd5
totalFileReader = new FileReader() // 用於計算出總檔案的fileMd5
let params = {chunks: [], file: {}}, // 用於上傳所有分片的md5資訊
arrayBufferData = [] // 用於儲存每個chunk的arrayBuffer物件,用於分片上傳使用
params.file.fileName = file.name
params.file.fileSize = file.size
totalFileReader.readAsArrayBuffer(file)
totalFileReader.onload = function(e){
// 對整個totalFile生成md5
spark.append(e.target.result)
params.file.fileMd5 = spark.end() // 計算整個檔案的fileMd5
}
chunkFileReader.onload = function (e) {
// 對每一片分片進行md5加密
spark.append(e.target.result)
// 每一個分片需要包含的資訊
let obj = {
chunk:currentChunk + 1,
start:currentChunk * chunkSize, // 計算分片的起始位置
end:((currentChunk * chunkSize + chunkSize) >= file.size) ? file.size : currentChunk * chunkSize + chunkSize, // 計算分片的結束位置
chunkMd5:spark.end(),
chunks
}
// 每一次分片onload,currentChunk都需要增加,以便來計算分片的次數
currentChunk++;
params.chunks.push(obj)
// 將每一塊分片的arrayBuffer儲存起來,用來partUpload
let tmp = {
chunk:obj.chunk,
currentBuffer:e.target.result
}
arrayBufferData.push(tmp)
if (currentChunk < chunks) {
// 當前切片總數沒有達到總數時
loadNext()
// 計算預處理進度
_this.setState({
preUploading:true,
preUploadPercent:Number((currentChunk / chunks * 100).toFixed(2))
})
} else {
//記錄所有chunks的長度
params.file.fileChunks = params.chunks.length
// 表示預處理結束,將上傳的引數,arrayBuffer的資料儲存起來
_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,
}
複製程式碼
分片上傳
- 在預處理過程中會拿到uploadParams的json資料,如下所示
{
file:{
fileChunks:119,
fileMd5:"f5aeec69076483585f4f112223265c0c",
fileName:"xxxx.test",
fileSize:6205952600
},
chunks:[{
chunk:1,
chunkMd5:"8770f43dc59effdc8b995e4aacc8a26c",
chunks:119,
end:5242880,
start:0
},
...
]
}
複製程式碼
- 將以上資料post到RequestUrl介面中,會得到如下json資料:
{
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
}
複製程式碼
- 拿到json資料,會先對得到的Chunks進行一次過濾,將status為pengding的過濾出來。
let uploadList = res.body.Chunks.filter((value)=>{
return value.status === `Pending`
})
// 從返回結果中獲取當前還有多少個分片沒傳
let currentChunks = res.body.Total - res.body.Uploaded
// 獲得上傳進度
let uploadPercent = Number(((this.state.chunksSize - currentChunks) /this.state.chunksSize * 100).toFixed(2))
// 上傳之前,先判斷檔案是否已經上傳成功
if(uploadPercent === 100){
message.success(`上傳成功`)
this.setState({
uploaded:true, // 讓進度條消失
uploading:false
})
}else{
this.setState({
uploaded:false,
uploading:true
})
}
this.setState({
uploadRequest:false, // 上傳請求成功
currentChunks,
uploadPercent
})
//進行分片上傳
this.handlePartUpload(uploadList)
複製程式碼
- 遍歷uploadList的資料,分別將資料傳入到uploadUrl介面中。此過程最關鍵的,就是如何將分片的arrayBuffer資料如何新增到Blob物件中
handlePartUpload = (uploadList)=>{
let blobSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice
// 遍歷uploadList
uploadList.forEach((value)=>{
// 取出每個分片裡的基本屬性
let {fileMd5,chunkMd5,chunk,start,end} = value
let formData = new FormData(),
//新建一個Blob物件,將對應分片的arrayBuffer加入Blob中
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}`
// 將生成blob塞入到formdata中傳入服務端
formData.append(`chunk`, blob, chunkMd5)
request
.post(`http://X.X.X.X/api/upload_file_part?${params}`)
.send(formData)
.end((err,res)=>{
if(res.body.Code === 200){
let currentChunks = this.state.currentChunks
--currentChunks
// 計算上傳進度
let uploadPercent = Number(((this.state.chunksSize - currentChunks) /this.state.chunksSize * 100).toFixed(2))
this.setState({
currentChunks, // 同步當前所需上傳的chunks
uploadPercent,
uploading:true
})
if(currentChunks ===0){
// 呼叫驗證api
this.checkUpload()
message.success(`上傳成功`)
this.setState({
uploading:false, // 讓進度條消失
uploaded:true
})
}
}
})
})
}
複製程式碼
總結與展望
以上就是一個簡單的基於react的大檔案上傳元件,主要的知識點包括:分片上傳技術,FileReader API,ArrayBuffer資料結構,md5加密技術,Blob物件的應用等 知識點。讀者可以自行擴充套件該React元件,可以跟Dva/Redux結合擴充套件Model層或者集中的狀態管理等。同時,對於該元件中出現的非同步流程是很簡單粗暴的,如何建立合理的非同步流程控制,也是需要去思考的。當然,對於大檔案來說,檔案壓縮也是一個需要去考慮的點,比如使用snappy.js等工具庫。