基於React的大文件上傳組件的開發詳解

本文出自perkinjavascript

之前實習的時候有作過大文件上傳的需求,當時咱們團隊用的是網宿科技的存儲服務,天然而然用的也是他們上傳的js-sdk,不論是網宿科技仍是七牛等提供存儲服務的公司,他們的文件上傳底層使用的基本上都是plupload庫。除了這個,百度FEX團隊開源的webuploader也是鼎鼎大名的,固然,對於文件操做的庫有許多許多,本文不作過多介紹。html

對於一箇中小型企業的小項目或者我的項目來講,使用第三方的存儲服務也許昂貴了點,且若是上傳的文件涉及到隱私的話也是不安全的(各類方案都是因項目而異的)。本文主要講解在不使用WebUploader,plupload等庫的狀況下,使用html5的File API來解決大文件上傳的問題(本文主要指前端部分)。固然,因爲是對內的項目,本文並無過多考慮瀏覽器兼容性的問題,畢竟對於IE低版本瀏覽器來講,Flash多是最適合的。前端

Demo演示

本文主要使用了antd爲UI組件,搭建了以下系統。html5

下圖爲文件預加載時的動圖,考慮到gif時間的限制,拿了個30多M文件測試。java

image

下圖爲上傳中的過程react

image

先後端聯調步驟

其實之因此不使用WebUploader等庫來實現,也是由於後端的需求跟通常的大文件上傳有一點不一樣,因此前端乾脆不使用庫來寫。先後端重點考慮的點,是使用分片上傳,且每一個分片都須要生成md5值,以便後端去校驗。所以,每一次分片上傳,都須要上傳該片斷的file,以及chunkMd5,和整個文件的fileMd5。同時,先後端採用arrayBuffer的blob格式來進行文件傳輸。git

以下爲先後端聯調的步驟github

第一步:用戶選擇文件,進行預處理

  1. 計算總文件的md5值,即fileMd5
  2. 按照固定的分片大小(好比5M,該值爲用戶自定義),進行切分
  3. 計算每一個分片的md5值,chunkMd5,start,end,size等

第二步:用戶點擊上傳

  1. 發送第一步生成的json數據到requestUrl
  2. requestUrl接口返回響應,來驗證該文件是否已經上傳,或者已上傳了哪些chunk。(返回的response應該包括每一個chunk的狀態,即pending or uploaded,第一次上傳全部chunk狀態都爲pending)
  3. 前端過濾掉已經上傳的chunks後,對pending狀態的chunks構成一個待上傳隊列進行上傳。
  4. 每個chunk上傳到partUpload接口,都應該包括,chunkMd5,start,end以及該分片的arrayBuffer數據。

第三步:上傳結果反饋

  1. partUpload接口會返回該分片上傳的基本狀況,每一次上傳成功,上傳隊列的個數即減一,這樣也能夠自定義上傳的progress。
  2. 當上傳隊列個數爲0時,此時調用checkUrl,檢查整個文件是否上傳成功,與前端進行一個同步校驗。

代碼拆分

整體架構

本文Demo主要是對UI組件進行描述,因此沒有考慮數據層,讀者能夠本身配合dva或者redux。下文爲主要的代碼結構。web

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的功能並將其擴展使其支持用戶系統上的文件。json

  • 本文先後端約束採用二進制的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等工具庫。


參考文獻

相關文章
相關標籤/搜索