本文出自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 = () => {
}
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 (
}
spinning={preUploading}
style={{ height: 350 }}>
点击或者拖拽文件进行上传
Support for a single or bulk upload. Strictly prohibit from uploading company data or other band files
{uploadPercent>=0&&!!uploading&&
}
{!!uploadRequest&&
上传请求中... }
{!!uploaded&&
文件上传成功 }
提交上传
)
}
}
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 ,
chunks = Math .ceil(file.size / chunkSize),
currentChunk = 0 ,
spark = new SparkMD5.ArrayBuffer(),
chunkFileReader = new FileReader(),
totalFileReader = new FileReader()
let params = {chunks : [], file : {}},
arrayBufferData = []
params.file.fileName = file.name
params.file.fileSize = file.size
totalFileReader.readAsArrayBuffer(file)
totalFileReader.onload = function (e ) {
spark.append(e.target.result)
params.file.fileMd5 = spark.end()
}
chunkFileReader.onload = function (e ) {
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
}
currentChunk++;
params.chunks.push(obj)
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 {
params.file.fileChunks = params.chunks.length
_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.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/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,
uploadPercent,
uploading :true
})
if (currentChunks ===0 ){
this .checkUpload()
message.success('上传成功' )
this .setState({
uploading :false ,
uploaded:true
})
}
}
})
})
}
复制代码
总结与展望
以上就是一个简单的基于react的大文件上传组件,主要的知识点包括:分片上传技术,FileReader API,ArrayBuffer数据结构,md5加密技术,Blob对象的应用 等 知识点。读者可以自行扩展该React组件,可以跟Dva/Redux结合扩展Model层或者集中的状态管理等。同时,对于该组件中出现的异步流程是很简单粗暴的,如何建立合理的异步流程控制,也是需要去思考的。当然,对于大文件来说,文件压缩也是一个需要去考虑的点,比如使用snappy.js等工具库。
参考文献
1. 踩坑Webuploader视频上传
2.Filereader API
3.ArrayBuffer:类型化数组
文件和二进制数据的操作