基於React的大檔案上傳元件的開發詳解

Perkin發表於2019-03-04

本文出自perkin

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

對於一箇中小型企業的小專案或者個人專案來說,使用第三方的儲存服務也許昂貴了點,且如果上傳的檔案涉及到隱私的話也是不安全的(各種方案都是因專案而異的)。本文主要講解在不使用WebUploader,plupload等庫的情況下,使用html5的File API來解決大檔案上傳的問題(本文主要指前端部分)。當然,由於是對內的專案,本文並沒有過多考慮瀏覽器相容性的問題,畢竟對於IE低版本瀏覽器來說,Flash可能是最適合的。

Demo演示

本文主要使用了antd為UI元件,搭建瞭如下系統。

下圖為檔案預載入時的動圖,考慮到gif時間的限制,拿了個30多M檔案測試。

image

下圖為上傳中的過程

image

前後端聯調步驟

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

如下為前後端聯調的步驟

第一步:使用者選擇檔案,進行預處理

  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。下文為主要的程式碼結構。

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等工具庫。


參考文獻

相關文章