基于React的大文件上傳組件的開(kāi)發(fā)詳解

以前實(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è)試烁设。

image

下圖為上傳中的過(guò)程

image

前后端聯(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ù)處理

    1. 計(jì)算總文件的md5值,即fileMd5
    1. 按照固定的分片大心附亍(比如5M到忽,該值為用戶自定義),進(jìn)行切分
    1. 計(jì)算每個(gè)分片的md5值,chunkMd5,start,end,size等

第二步:用戶點(diǎn)擊上傳

    1. 發(fā)送第一步生成的json數(shù)據(jù)到requestUrl
    1. requestUrl接口返回響應(yīng)喘漏,來(lái)驗(yàn)證該文件是否已經(jīng)上傳护蝶,或者已上傳了哪些chunk。(返回的response應(yīng)該包括每個(gè)chunk的狀態(tài)翩迈,即pending or uploaded持灰,第一次上傳所有chunk狀態(tài)都為pending)
    1. 前端過(guò)濾掉已經(jīng)上傳的chunks后,對(duì)pending狀態(tài)的chunks構(gòu)成一個(gè)待上傳隊(duì)列進(jìn)行上傳负饲。
    1. 每一個(gè)chunk上傳到partUpload接口堤魁,都應(yīng)該包括,chunkMd5,start,end以及該分片的arrayBuffer數(shù)據(jù)返十。

第三步:上傳結(jié)果反饋

    1. partUpload接口會(huì)返回該分片上傳的基本情況妥泉,每一次上傳成功,上傳隊(duì)列的個(gè)數(shù)即減一洞坑,這樣也可以自定義上傳的progress盲链。
    1. 當(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ù)瞧甩。


參考文獻(xiàn)

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個(gè)濱河市吕漂,隨后出現(xiàn)的幾起案子亲配,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 206,311評(píng)論 6 481
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件吼虎,死亡現(xiàn)場(chǎng)離奇詭異犬钢,居然都是意外死亡,警方通過(guò)查閱死者的電腦和手機(jī)思灰,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 88,339評(píng)論 2 382
  • 文/潘曉璐 我一進(jìn)店門玷犹,熙熙樓的掌柜王于貴愁眉苦臉地迎上來(lái),“玉大人洒疚,你說(shuō)我怎么就攤上這事歹颓。” “怎么了油湖?”我有些...
    開(kāi)封第一講書人閱讀 152,671評(píng)論 0 342
  • 文/不壞的土叔 我叫張陵巍扛,是天一觀的道長(zhǎng)。 經(jīng)常有香客問(wèn)我乏德,道長(zhǎng)撤奸,這世上最難降的妖魔是什么? 我笑而不...
    開(kāi)封第一講書人閱讀 55,252評(píng)論 1 279
  • 正文 為了忘掉前任喊括,我火速辦了婚禮胧瓜,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘郑什。我一直安慰自己府喳,他們只是感情好,可當(dāng)我...
    茶點(diǎn)故事閱讀 64,253評(píng)論 5 371
  • 文/花漫 我一把揭開(kāi)白布蘑拯。 她就那樣靜靜地躺著钝满,像睡著了一般。 火紅的嫁衣襯著肌膚如雪申窘。 梳的紋絲不亂的頭發(fā)上舱沧,一...
    開(kāi)封第一講書人閱讀 49,031評(píng)論 1 285
  • 那天,我揣著相機(jī)與錄音偶洋,去河邊找鬼。 笑死距糖,一個(gè)胖子當(dāng)著我的面吹牛玄窝,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播悍引,決...
    沈念sama閱讀 38,340評(píng)論 3 399
  • 文/蒼蘭香墨 我猛地睜開(kāi)眼恩脂,長(zhǎng)吁一口氣:“原來(lái)是場(chǎng)噩夢(mèng)啊……” “哼!你這毒婦竟也來(lái)了趣斤?” 一聲冷哼從身側(cè)響起俩块,我...
    開(kāi)封第一講書人閱讀 36,973評(píng)論 0 259
  • 序言:老撾萬(wàn)榮一對(duì)情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒(méi)想到半個(gè)月后玉凯,有當(dāng)?shù)厝嗽跇?shù)林里發(fā)現(xiàn)了一具尸體势腮,經(jīng)...
    沈念sama閱讀 43,466評(píng)論 1 300
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 35,937評(píng)論 2 323
  • 正文 我和宋清朗相戀三年漫仆,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了捎拯。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 38,039評(píng)論 1 333
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡盲厌,死狀恐怖署照,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情吗浩,我是刑警寧澤建芙,帶...
    沈念sama閱讀 33,701評(píng)論 4 323
  • 正文 年R本政府宣布,位于F島的核電站懂扼,受9級(jí)特大地震影響禁荸,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜微王,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 39,254評(píng)論 3 307
  • 文/蒙蒙 一屡限、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧炕倘,春花似錦钧大、人聲如沸。這莊子的主人今日做“春日...
    開(kāi)封第一講書人閱讀 30,259評(píng)論 0 19
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽(yáng)。三九已至涨醋,卻和暖如春瓜饥,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背浴骂。 一陣腳步聲響...
    開(kāi)封第一講書人閱讀 31,485評(píng)論 1 262
  • 我被黑心中介騙來(lái)泰國(guó)打工乓土, 沒(méi)想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留,地道東北人溯警。 一個(gè)月前我還...
    沈念sama閱讀 45,497評(píng)論 2 354
  • 正文 我出身青樓趣苏,卻偏偏與公主長(zhǎng)得像,于是被迫代替她去往敵國(guó)和親梯轻。 傳聞我的和親對(duì)象是個(gè)殘疾皇子食磕,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 42,786評(píng)論 2 345

推薦閱讀更多精彩內(nèi)容