寫給新手前端的各類文件上傳攻略,從小圖片到大文件斷點續傳

寫在前面

今年國慶假期終於能夠憋在家裏了不用出門了,不用出去看後腦了,真的是一種享受。這麼好的光陰怎麼浪費,睡覺、吃飯、打豆豆這怎麼可能(耍多了也煩),徹底不符合咱們程序員的做風,趕忙起來把文章寫完。html

這篇文章比較基礎,在國慶期間的業餘時間寫的,這幾天又完善了下,力求把更多的前端所涉及到的關於文件上傳的各類場景和應用都涵蓋了,如有疏漏和問題還請留言斧正和補充。前端

自測讀不讀

如下是本文所涉及到的知識點,break or continue ?html5

  • 文件上傳原理
  • 最原始的文件上傳
  • 使用 koa2 做爲服務端寫一個文件上傳接口
  • 單文件上傳和上傳進度
  • 多文件上傳和上傳進度
  • 拖拽上傳
  • 剪貼板上傳
  • 大文件上傳之分片上傳
  • 大文件上傳之斷點續傳
  • node 端文件上傳

原理概述

原理很簡單,就是根據 http 協議的規範和定義,完成請求消息體的封裝和消息體的解析,而後將二進制內容保存到文件。node

咱們都知道若是要上傳一個文件,須要把 form 標籤的enctype設置爲multipart/form-data,同時method必須爲post方法。react

那麼multipart/form-data表示什麼呢?git

multipart互聯網上的混合資源,就是資源由多種元素組成,form-data表示可使用HTML Forms 和 POST 方法上傳文件,具體的定義能夠參考RFC 7578。程序員

multipart/form-data 結構github

看下 http 請求的消息體json

  • 請求頭:

Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryDCntfiXcSkPhS4PN 表示本次請求要上傳文件,其中boundary表示分隔符,若是要上傳多個表單項,就要使用boundary分割,每一個表單項由———XXX開始,以———XXX結尾。windows

  • 消息體- Form Data 部分

每個表單項又由Content-TypeContent-Disposition組成。

Content-Disposition: form-data 爲固定值,表示一個表單元素,name 表示表單元素的 名稱,回車換行後面就是name的值,若是是上傳文件就是文件的二進制內容。

Content-Type:表示當前的內容的 MIME 類型,是圖片仍是文本仍是二進制數據。

解析

客戶端發送請求到服務器後,服務器會收到請求的消息體,而後對消息體進行解析,解析出哪是普通表單哪些是附件。

可能你們立刻能想到經過正則或者字符串處理分割出內容,不過這樣是行不通的,二進制buffer轉化爲string,對字符串進行截取後,其索引和字符串是不一致的,因此結果就不會正確,除非上傳的就是字符串。

不過通常狀況下不須要自行解析,目前已經有很成熟的三方庫可使用。

至於如何解析,這個也會佔用很大篇幅,後面的文章在詳細說。

最原始的文件上傳

使用 form 表單上傳文件

ie時代,若是實現一個無刷新的文件上傳那但是費老勁了,大部分都是用 iframe 來實現局部刷新或者使用 flash 插件來搞定,在那個時代 ie 就是最好用的瀏覽器(別無選擇)。

DEMO

這種方式上傳文件,不須要 js ,並且沒有兼容問題,全部瀏覽器都支持,就是體驗不好,致使頁面刷新,頁面其餘數據丟失。

HTML

<form method="post" action="http://localhost:8100" enctype="multipart/form-data">

        選擇文件:
            <input type="file" name="f1"/> input 必須設置 name 屬性,不然數據沒法發送<br/>
<br/>
            標題:<input type="text" name="title"/><br/><br/><br/>

        <button type="submit" id="btn-0">上 傳</button>

</form>

複製代碼

文件上傳接口

服務端文件的保存基於現有的庫koa-body結合 koa2實現服務端文件的保存和數據的返回。

在項目開發中,文件上傳自己和業務無關,代碼基本上均可通用。

在這裏咱們使用koa-body庫來實現解析和文件的保存。

koa-body 會自動保存文件到系統臨時目錄下,也能夠指定保存的文件路徑。

而後在後續中間件內獲得已保存的文件的信息,再作二次處理。

  • ctx.request.files.f1 獲得文件信息,f1input file 標籤的 name
  • 得到文件的擴展名,重命名文件

NODE

/**
 * 服務入口
 */
var http = require('http');
var koaStatic = require('koa-static');
var path = require('path');
var koaBody = require('koa-body');//文件保存庫
var fs = require('fs');
var Koa = require('koa2');

var app = new Koa();
var port = process.env.PORT || '8100';

var uploadHost= `http://localhost:${port}/uploads/`;

app.use(koaBody({
    formidable: {
        //設置文件的默認保存目錄,不設置則保存在系統臨時目錄下  os
        uploadDir: path.resolve(__dirname, '../static/uploads')
    },
    multipart: true // 開啓文件上傳,默認是關閉
}));

//開啓靜態文件訪問
app.use(koaStatic(
    path.resolve(__dirname, '../static') 
));

//文件二次處理,修更名稱
app.use((ctx) => {
    var file = ctx.request.files.f1;//得道文件對象
    var path = file.path;
    var fname = file.name;//原文件名稱
    var nextPath = path+fname;
    if(file.size>0 && path){
        //獲得擴展名
        var extArr = fname.split('.');
        var ext = extArr[extArr.length-1];
        var nextPath = path+'.'+ext;
        //重命名文件
        fs.renameSync(path, nextPath);
    }
    //以 json 形式輸出上傳文件地址
    ctx.body = `{
        "fileUrl":"${uploadHost}${nextPath.slice(nextPath.lastIndexOf('/')+1)}"
    }`;
});

/**
 * http server
 */
var server = http.createServer(app.callback());
server.listen(port);
console.log('demo1 server start ...... ');
複製代碼

CODE

github.com/Bigerfe/fe-…

多文件上傳

ie 時代的多文件上傳是須要建立多個 input file 標籤,如今 html5只須要一個標籤加個屬性就搞定了,file 標籤開啓multiple

DEMO

HTML

//設置 multiple屬性
<input type="file" name="f1" multiple/> 
複製代碼

NODE

服務端也須要進行簡單的調整,由單文件對象變爲多文件數組,而後進行遍歷處理。

//二次處理文件,修更名稱
app.use((ctx) => {
    
    var files = ctx.request.files.f1;// 多文件, 獲得上傳文件的數組
    var result=[];

    //遍歷處理
    files && files.forEach(item=>{
        var path = item.path;
        var fname = item.name;//原文件名稱
        var nextPath = path + fname;
        if (item.size > 0 && path) {
            //獲得擴展名
            var extArr = fname.split('.');
            var ext = extArr[extArr.length - 1];
            var nextPath = path + '.' + ext;
            //重命名文件
            fs.renameSync(path, nextPath);
            
            //文件可訪問路徑放入數組
            result.push(uploadHost+ nextPath.slice(nextPath.lastIndexOf('/') + 1));
        }
    });
    
    //輸出 json 結果
    ctx.body = `{
        "fileUrl":${JSON.stringify(result)}
    }`;
})
複製代碼

CODE

github.com/Bigerfe/fe-…

局部刷新 - iframe

這裏說的是在 ie 時代的上傳文件局部刷新,藉助 iframe 實現。

DEMO

  • 局部刷新

頁面內放一個隱藏的 iframe,或者使用 js 動態建立,指定 form 表單的 target 屬性值爲iframe標籤 的 name 屬性值,這樣 form 表單的 shubmit 行爲的跳轉就會在 iframe 內完成,總體頁面不會刷新。

  • 拿到接口數據

而後爲 iframe 添加load事件,獲得 iframe 的頁面內容,將結果轉換爲 JSON 對象,這樣就拿到了接口的數據

HTML

<iframe id="temp-iframe" name="temp-iframe" src="" style="display:none;"></iframe>
        <form method="post" target="temp-iframe" action="http://localhost:8100" enctype="multipart/form-data">
        選擇文件(可多選):
            <input type="file" name="f1" id="f1" multiple/><br/> input 必須設置 name 屬性,不然數據沒法發送<br/>
<br/>
            標題:<input type="text" name="title"/><br/><br/><br/>

        <button type="submit" id="btn-0">上 傳</button>

        </form>
        
        
<script>

var iframe = document.getElementById('temp-iframe');
iframe.addEventListener('load',function () {
      var result = iframe.contentWindow.document.body.innerText;
      //接口數據轉換爲 JSON 對象
      var obj = JSON.parse(result);
      if(obj && obj.fileUrl.length){
          alert('上傳成功');
          
      }
      console.log(obj);
});


</script>
複製代碼

NODE

服務端代碼不須要改動,略.

CODE

github.com/Bigerfe/fe-…

無刷新上傳

無刷新上傳文件確定要用到XMLHttpRequest,在 ie 時代也有這個對象,單隻 支持文本數據的傳輸,沒法用來讀取和上傳二進制數據。

如今已然升級到了XMLHttpRequest2,較1版本有很是大的升級,首先就是能夠讀取和上傳二進制數據,可使用·FormData·對象管理表單數據。

固然也可以使用 fetch 進行上傳。

DEMO

HTML

<div>
        選擇文件(可多選):
        <input type="file" id="f1" multiple/><br/><br/>
        <button type="button" id="btn-submit">上 傳</button>
</div>

複製代碼

JS xhr

<script>
    function submitUpload() {
        //得到文件列表,注意這裏不是數組,而是對象
        var fileList = document.getElementById('f1').files;
        if(!fileList.length){
            alert('請選擇文件');
            return;
        }
        var fd = new FormData();   //構造FormData對象
        fd.append('title', document.getElementById('title').value);

        //多文件上傳須要遍歷添加到 fromdata 對象
        for(var i =0;i<fileList.length;i++){
            fd.append('f1', fileList[i]);//支持多文件上傳
        }

        var xhr = new XMLHttpRequest();   //建立對象
        xhr.open('POST', 'http://localhost:8100/', true);

        xhr.send(fd);//發送時  Content-Type默認就是: multipart/form-data; 
        xhr.onreadystatechange = function () {
            console.log('state change', xhr.readyState);
            if (this.readyState == 4 && this.status == 200) {
                var obj = JSON.parse(xhr.responseText);   //返回值
                console.log(obj);
                if(obj.fileUrl.length){
                    alert('上傳成功');
                }
            }
        }

    }

    //綁定提交事件
    document.getElementById('btn-submit').addEventListener('click',submitUpload);
</script>

複製代碼

JS Fetch

fetch('http://localhost:8100/', {
            method: 'POST',
            body: fd
        })
            .then(response => response.json())
            .then(response =>{
                console.log(response);
                if (response.fileUrl.length) {
                    alert('上傳成功');
                }
            } )
            .catch(error => console.error('Error:', error));
複製代碼

CODE

github.com/Bigerfe/fe-…

多文件,單進度

藉助XMLHttpRequest2的能力,實現多個文件或者一個文件的上傳進度條的顯示。

DEMO

說明

  • 頁面內增長一個用於顯示進度的標籤 div.progress
  • js 內處理增長進度處理的監聽函數xhr.upload.onprogress
  • event.lengthComputable這是一個狀態,表示發送的長度有了變化,可計算
  • event.loaded表示發送了多少字節
  • event.total表示文件總大小
  • 根據event.loadedevent.total計算進度,渲染div.progress

PS 特別提醒

xhr.upload.onprogress要寫在xhr.send方法前面,不然event.lengthComputable狀態不會改變,只有在最後一次才能得到,也就是100%的時候.

HTML

<div>

        選擇文件(可多選):
            <input type="file" id="f1" multiple/><br/><br/>

            <div id="progress">
                <span class="red"></span>
            </div>

        <button type="button" id="btn-submit">上 傳</button>

    </div>
複製代碼

JS

<script>

    function submitUpload() {
        var progressSpan = document.getElementById('progress').firstElementChild;
        var fileList = document.getElementById('f1').files;
        progressSpan.style.width='0';
        progressSpan.classList.remove('green');

        if(!fileList.length){
            alert('請選擇文件');
            return;
        }

        var fd = new FormData();   //構造FormData對象
        fd.append('title', document.getElementById('title').value);

        for(var i =0;i<fileList.length;i++){
            fd.append('f1', fileList[i]);//支持多文件上傳
        }

        var xhr = new XMLHttpRequest();   //建立對象
        xhr.open('POST', 'http://10.70.65.235:8100/', true);

        xhr.onreadystatechange = function () {
            console.log('state change', xhr.readyState);
            if (xhr.readyState == 4) {
                var obj = JSON.parse(xhr.responseText);   //返回值
                console.log(obj);
                if(obj.fileUrl.length){
                    //alert('上傳成功');
                }
            }
        }

        xhr.onprogress=updateProgress;
        xhr.upload.onprogress = updateProgress;
        function updateProgress(event) {
            console.log(event);
            if (event.lengthComputable) {
                var completedPercent = (event.loaded / event.total * 100).toFixed(2);
                progressSpan.style.width= completedPercent+'%';
                progressSpan.innerHTML=completedPercent+'%';
                if(completedPercent>90){//進度條變色
                    progressSpan.classList.add('green');
                }
                console.log('已上傳',completedPercent);
            }
        }
        //注意 send 必定要寫在最下面,不然 onprogress 只會執行最後一次 也就是100%的時候
        xhr.send(fd);//發送時  Content-Type默認就是: multipart/form-data; 
    }
    //綁定提交事件
    document.getElementById('btn-submit').addEventListener('click',submitUpload);


</script>
複製代碼

CODE

github.com/Bigerfe/fe-…

多文件上傳+預覽+取消

上一個栗子的多文件上傳只有一個進度條,有些需求可能會不大同樣,須要觀察到每一個文件的上傳進度,而且能夠終止上傳。

DEMO

說明

  • 爲了預覽的須要,咱們這裏選擇上傳圖片文件,其餘類型的也同樣,只是預覽不方便
  • 頁面內增長一個多圖預覽的容器div.img-box
  • 根據選擇的文件信息動態建立所屬的預覽區域和進度條以及取消按鈕
  • 爲取消按鈕綁定事件,調用xhr.abort();終止上傳
  • 使用window.URL.createObjectURL預覽圖片,在圖片加載成功後須要清除使用的內存window.URL.revokeObjectURL(this.src);

HTML

<div>
        選擇文件(可多選):
        <div class="addfile">添加文件
            <input type="file" id="f1" multiple />
        </div>
        <div class="img-box"></div>
        <button type="button" id="btn-submit">上 傳</button>
    </div>
複製代碼

JS

<script>
    //更改網絡 爲慢3g,就能夠比較明顯的看到進度條了
    var fileMaxCount=6;
    var imgBox =document.getElementsByClassName('img-box')[0];
    var willUploadFile=[];//保存待上傳的文件以及相關附屬信息
    document.getElementById('f1').addEventListener('change',function (e) {
        var fileList = document.getElementById('f1').files;

        if (willUploadFile.length > fileMaxCount || fileList.length>fileMaxCount || (willUploadFile.length+ fileList.length>fileMaxCount)) {
            alert('最多隻能上傳' + fileMaxCount + '張圖');
            return;
        }
        for (var i = 0; i < fileList.length; i++) {
            var f = fileList[i];//先預覽圖片
            var img = document.createElement('img');
            var item = document.createElement('div');
            var progress = document.createElement('div');
            progress.className='progress';
            progress.innerHTML = '<span class="red"></span><button type="button">Abort</button>';
            item.className='item';
            img.src = window.URL.createObjectURL(f);
            img.onload = function () {
                //顯示要是否這塊兒內存
                window.URL.revokeObjectURL(this.src);
            }
            
            item.appendChild(img);
            item.appendChild(progress);
            imgBox.appendChild(item);

            willUploadFile.push({
                file:f,
                item,
                progress
            });
        }
    });


    function xhrSend({file, progress}) {

        var progressSpan = progress.firstElementChild;
        var btnCancel = progress.getElementsByTagName('button')[0];
        var abortFn=function(){
              if(xhr && xhr.readyState!==4){
               //取消上傳
               xhr.abort();
           } 
        }
        btnCancel.removeEventListener('click',abortFn);
        btnCancel.addEventListener('click',abortFn);
        
        progressSpan.style.width='0';
        progressSpan.classList.remove('green');

        var fd = new FormData();   //構造FormData對象
        fd.append('f1',file);

        var xhr = new XMLHttpRequest();   //建立對象
        xhr.open('POST', 'http://localhost:8100/', true);

        xhr.onreadystatechange = function () {
            console.log('state change', xhr.readyState);
            //調用 abort 後,state 當即變成了4,並不會變成0
            //增長自定義屬性  xhr.uploaded
            if (xhr.readyState == 4 &&  xhr.uploaded) {
                var obj = JSON.parse(xhr.responseText);   //返回值
                console.log(obj);
                if(obj.fileUrl.length){
                    //alert('上傳成功');
                }
            }
        }

        xhr.onprogress=updateProgress;
        xhr.upload.onprogress = updateProgress;
        function updateProgress(event) {
            if (event.lengthComputable) {
                var completedPercent = (event.loaded / event.total * 100).toFixed(2);
                progressSpan.style.width= completedPercent+'%';
                progressSpan.innerHTML=completedPercent+'%';
                if(completedPercent>90){//進度條變色
                    progressSpan.classList.add('green');
                }
                if(completedPercent>=100){
                    xhr.uploaded=true;
                }
                console.log('已上傳',completedPercent);
            }
        }
        //注意 send 必定要寫在最下面,不然 onprogress 只會執行最後一次 也就是100%的時候
        xhr.send(fd);//發送時  Content-Type默認就是: multipart/form-data; 
        return xhr;
    }

    //文件上傳
    function submitUpload(willFiles) {
        if(!willFiles.length){
            return;
        }
        //遍歷文件信息進行上傳
        willFiles.forEach(function (item) {
             xhrSend({
                 file:item.file,
                 progress:item.progress
             });
        });
    }
    //綁定提交事件
    document.getElementById('btn-submit').addEventListener('click',function () {
        submitUpload(willUploadFile);
    });

</script>
複製代碼

問題1

這裏沒有作上傳的併發控制,能夠經過控制同時可上傳文件的個數(這裏控制爲最多6個)或者上傳的時候作好併發處理,也就是同時只能上傳 X 個文件。

問題2

在測試過程當中,取消請求的方法xhr.abort()調用後,xhr.readyState會當即變爲4,而不是0,因此這裏須要作容錯處理。

MDN 上說是0.

若是你們有不一樣的結果,歡迎留言。

CODE

github.com/Bigerfe/fe-…

拖拽上傳

html5的出現,讓拖拽上傳交互成爲可能,如今這樣的體驗也家常便飯。

DEMO

說明

  • 定義一個容許拖放文件的區域div.drop-box
  • 取消drop 事件的默認行爲e.preventDefault();,否則瀏覽器會直接打開文件
  • 爲拖拽區域綁定事件,鼠標在拖拽區域上 dragover, 鼠標離開拖拽區域dragleave, 在拖拽區域上釋放文件drop
  • drop事件內得到文件信息e.dataTransfer.files

HTML

<div class="drop-box" id="drop-box">
       拖動文件到這裏,開始上傳
    </div>
    
    <button type="button" id="btn-submit">上 傳</button>
複製代碼

JS

<script>

    var box = document.getElementById('drop-box');
    
    //禁用瀏覽器的拖放默認行爲
    document.addEventListener('drop',function (e) {
        console.log('document drog');
        e.preventDefault();
    });
   
    //設置拖拽事件
    function openDropEvent() {
        box.addEventListener("dragover",function (e) {
            console.log('elemenet dragover');
             box.classList.add('over');
               e.preventDefault();
        });
         box.addEventListener("dragleave", function (e) {
              console.log('elemenet dragleave');
            box.classList.remove('over');
              e.preventDefault();
        });

        box.addEventListener("drop", function (e) {
            e.preventDefault(); //取消瀏覽器默認拖拽效果

            var fileList = e.dataTransfer.files; //獲取拖拽中的文件對象
            var len=fileList.length;//用來獲取文件的長度(實際上是得到文件數量)
            
            //檢測是不是拖拽文件到頁面的操做
            if (!len) {
                box.classList.remove('over');
                return;
            }

            box.classList.add('over');

            window.willUploadFileList=fileList;

        }, false);
    }

    openDropEvent();

    function submitUpload() {

        var fileList = window.willUploadFileList||[];
        if(!fileList.length){
            alert('請選擇文件');
            return;
        }

        var fd = new FormData();   //構造FormData對象

        for(var i =0;i<fileList.length;i++){
            fd.append('f1', fileList[i]);//支持多文件上傳
        }

        var xhr = new XMLHttpRequest();   //建立對象
        xhr.open('POST', 'http://localhost:8100/', true);
        xhr.onreadystatechange = function () {
            if (xhr.readyState == 4) {
                var obj = JSON.parse(xhr.responseText);   //返回值
                if(obj.fileUrl.length){
                    alert('上傳成功');
                }
            }
        }
        xhr.send(fd);//發送
    }
    //綁定提交事件
    document.getElementById('btn-submit').addEventListener('click',submitUpload);

</script>
複製代碼

CODE

github.com/Bigerfe/fe-…

剪貼板上傳

掘金的寫文編輯器是支持粘貼上傳圖片的,好比我從磁盤粘貼或者從網頁上右鍵複製圖片。

DEMO

說明

  • 頁面內增長一個可編輯的編輯區域div.editor-box,開啓contenteditable
  • div.editor-box綁定paste事件
  • 處理paste 事件,從event.clipboardData || window.clipboardData得到數據
  • 將數據轉換爲文件items[i].getAsFile()
  • 實如今編輯區域的光標處插入內容 insertNodeToEditor 方法

問題1

測試中發現複製多個文件無效,只有最後一個文件上傳,在掘金的編輯器裏也一樣存在,在坐有知道緣由的能夠留言說下。

問題2

mac系統能夠支持從磁盤複製文件後上傳,windows 系統測試未經過,剪貼板的數據未拿到。

HTML

<div class="editor-box" id="editor-box" contenteditable="true" >
       能夠直接粘貼圖片到這裏直接上傳
 </div>
複製代碼

JS

//光標處插入 dom 節點
    function  insertNodeToEditor(editor,ele) {
        //插入dom 節點
        var range;//記錄光標位置對象
        var node = window.getSelection().anchorNode;
        // 這裏判斷是作是否有光標判斷,由於彈出框默認是沒有的
        if (node != null) {
            range = window.getSelection().getRangeAt(0);// 獲取光標起始位置
            range.insertNode(ele);// 在光標位置插入該對象
        } else {
            editor.append(ele);
        }
    }
    
    var box = document.getElementById('editor-box');
    //綁定paste事件
    box.addEventListener('paste',function (event) {
        var data = (event.clipboardData || window.clipboardData);

        var items = data.items;
        var fileList = [];//存儲文件數據
        if (items && items.length) {
            // 檢索剪切板items
            for (var i = 0; i < items.length; i++) {
                console.log(items[i].getAsFile());
                fileList.push(items[i].getAsFile());
            }
        }

        window.willUploadFileList = fileList;
        event.preventDefault();//阻止默認行爲

        submitUpload();
    }); 

    function submitUpload() {

        var fileList = window.willUploadFileList||[];
        var fd = new FormData();   //構造FormData對象
        for(var i =0;i<fileList.length;i++){
            fd.append('f1', fileList[i]);//支持多文件上傳
        }
        var xhr = new XMLHttpRequest();   //建立對象
        xhr.open('POST', 'http://localhost:8100/', true);
        xhr.onreadystatechange = function () {
            if (xhr.readyState === 4) {
                var obj = JSON.parse(xhr.responseText);   //返回值
                console.log(obj);
                if(obj.fileUrl.length){
                    var img = document.createElement('img');
                    img.src= obj.fileUrl[0];
                    img.style.width='100px';
                    insertNodeToEditor(box,img);
                   // alert('上傳成功');
                }
            }
        }

        xhr.send(fd);//發送
    }
    
複製代碼

CODE

github.com/Bigerfe/fe-…

大文件上傳-分片

ie 時代因爲沒法使用xhr上傳二進制數據,上傳大文件須要藉助瀏覽器插件來完成。 如今來看實現大文件上傳簡直soeasy。

若是太大的文件,好比一個視頻1g 2g那麼大,直接採用上面的栗子中的方法上傳可能會出連接現超時的狀況,並且也會超過服務端容許上傳文件的大小限制,因此解決這個問題咱們能夠將文件進行分片上傳,每次只上傳很小的一部分 好比2M。

DEMO

說明

相信你們都對Blob 對象有所瞭解,它表示原始數據,也就是二進制數據,同時提供了對數據截取的方法slice,而 File 繼承了Blob的功能,因此能夠直接使用此方法對數據進行分段截圖。

  • 把大文件進行分段 好比2M,發送到服務器攜帶一個標誌,暫時用當前的時間戳,用於標識一個完整的文件
  • 服務端保存各段文件
  • 瀏覽器端全部分片上傳完成,發送給服務端一個合併文件的請求
  • 服務端根據文件標識、類型、各分片順序進行文件合併
  • 刪除分片文件

HTML

代碼略,只須要一個 input file 標籤。

JS

//分片邏輯  像操做字符串同樣
      
    var start=0,end=0;
    while (true) {
        end+=chunkSize;
        var blob = file.slice(start,end);
        start+=chunkSize;
        
        if(!blob.size){//截取的數據爲空 則結束
            //拆分結束
            break;
        }
        
        chunks.push(blob);//保存分段數據
    }

<script>
    function submitUpload() {
        var chunkSize=2*1024*1024;//分片大小 2M
        var file = document.getElementById('f1').files[0];
        var chunks=[], //保存分片數據
         token = (+ new Date()),//時間戳
         name =file.name,chunkCount=0,sendChunkCount=0;

        //拆分文件 像操做字符串同樣
        if(file.size>chunkSize){
            //拆分文件
            var start=0,end=0;
            while (true) {
                end+=chunkSize;
                var blob = file.slice(start,end);
                start+=chunkSize;
                
                if(!blob.size){//截取的數據爲空 則結束
                    //拆分結束
                    break;
                }
                
                chunks.push(blob);//保存分段數據
            }
        }else{
            chunks.push(file.slice(0));
        }

        chunkCount=chunks.length;//分片的個數 
        
        //沒有作併發限制,較大文件致使併發過多,tcp 連接被佔光 ,須要作下併發控制,好比只有4個在請求在發送
        
        for(var i=0;i< chunkCount;i++){
            var fd = new FormData();   //構造FormData對象
            fd.append('token', token);
            fd.append('f1', chunks[i]);
            fd.append('index', i);
            xhrSend(fd,function () {
                sendChunkCount+=1;
                if(sendChunkCount===chunkCount){//上傳完成,發送合併請求
                    console.log('上傳完成,發送合併請求');
                    var formD = new FormData();
                    formD.append('type','merge');
                    formD.append('token',token);
                    formD.append('chunkCount',chunkCount);
                    formD.append('filename',name);
                    xhrSend(formD);
                }
            });
        }
    }

    function xhrSend(fd,cb) {
        
        var xhr = new XMLHttpRequest();   //建立對象
        xhr.open('POST', 'http://localhost:8100/', true);
        xhr.onreadystatechange = function () {
            console.log('state change', xhr.readyState);
            if (xhr.readyState == 4) {
                console.log(xhr.responseText);
                cb && cb();
            }
        }
        xhr.send(fd);//發送
    }

    //綁定提交事件
    document.getElementById('btn-submit').addEventListener('click',submitUpload);
</script>

複製代碼

NODE

服務端須要作一些改動,保存分片文件、合併分段文件、刪除分段文件。

PS

合併文件這裏使用 stream pipe 實現,這樣更節省內存,邊讀邊寫入,佔用內存更小,效率更高,代碼見fnMergeFile方法。

//二次處理文件,修更名稱
app.use((ctx) => {
    var body = ctx.request.body;
    var files = ctx.request.files ? ctx.request.files.f1:[];//獲得上傳文件的數組
    var result=[];
    var fileToken = ctx.request.body.token;// 文件標識
    var fileIndex=ctx.request.body.index;//文件順序

    if(files &&  !Array.isArray(files)){//單文件上傳容錯
        files=[files];
    }

    files && files.forEach(item=>{
        var path = item.path;
        var fname = item.name;//原文件名稱
        var nextPath = path.slice(0, path.lastIndexOf('/') + 1) + fileIndex + '-' + fileToken;
        if (item.size > 0 && path) {
            //獲得擴展名
            var extArr = fname.split('.');
            var ext = extArr[extArr.length - 1];
            //var nextPath = path + '.' + ext;
            //重命名文件
            fs.renameSync(path, nextPath);
            result.push(uploadHost+nextPath.slice(nextPath.lastIndexOf('/') + 1));
        }
    });

    if(body.type==='merge'){//合併分片文件
        var filename = body.filename,
        chunkCount = body.chunkCount,
            folder = path.resolve(__dirname, '../static/uploads')+'/';
        
        var writeStream = fs.createWriteStream(`${folder}${filename}`);

        var cindex=0;
        //合併文件
        function fnMergeFile(){
            var fname = `${folder}${cindex}-${fileToken}`;
            var readStream = fs.createReadStream(fname);
            readStream.pipe(writeStream, { end: false });
            readStream.on("end", function () {
                fs.unlink(fname, function (err) {
                    if (err) {
                        throw err;
                    }
                });
                if (cindex+1 < chunkCount){
                    cindex += 1;
                    fnMergeFile();
                }
            });
        }
        fnMergeFile();
        ctx.body='merge ok 200';
    }
  
});
複製代碼

CODE

github.com/Bigerfe/fe-…

大文件上傳-斷點續傳

在上面咱們實現了大文件的分片上傳,解決了大文件上傳超時和服務器的限制。

可是仍然不夠完美,大文件上傳並非短期內就上傳完成,若是期間斷網,頁面刷新了仍然須要重頭上傳,這種時間的浪費怎麼能忍?

因此咱們實現斷點續傳,已上傳的部分跳過,只傳未上傳的部分。

方法1

在上面咱們實現了文件分片上傳和最終的合併,如今要作的就是如何檢測這些分片,再也不從新上傳便可。 這裏咱們能夠在本地進行保存已上傳成功的分片,從新上傳的時候使用spark-md5來生成文件 hash,區分此文件是否已上傳。

  • 爲每一個分段生成 hash 值,使用 spark-md5
  • 將上傳成功的分段信息保存到本地
  • 從新上傳時,進行和本地分段 hash 值的對比,若是相同的話則跳過,繼續下一個分段的上傳

PS

生成 hash 過程確定也會耗費資源,可是和從新上傳相比能夠忽略不計了。

DEMO

HTML

代碼略 
複製代碼

JS

模擬分段保存,本地保存到localStorage

//得到本地緩存的數據
    function getUploadedFromStorage(){
        return JSON.parse( localStorage.getItem(saveChunkKey) || "{}");
    }

    //寫入緩存
    function setUploadedToStorage(index) {
        var obj =  getUploadedFromStorage();
        obj[index]=true;      
        localStorage.setItem(saveChunkKey, JSON.stringify(obj) );
    }
    
    //分段對比
    
    var uploadedInfo = getUploadedFromStorage();//得到已上傳的分段信息

    for(var i=0;i< chunkCount;i++){
            console.log('index',i, uploadedInfo[i]?'已上傳過':'未上傳');
            
            if(uploadedInfo[i]){//對比分段
                sendChunkCount=i+1;//記錄已上傳的索引
                continue;//若是已上傳則跳過
            }
            var fd = new FormData();   //構造FormData對象
            fd.append('token', token);
            fd.append('f1', chunks[i]);
            fd.append('index', i);
           
           (function (index) {
                    xhrSend(fd, function () {
                    sendChunkCount += 1;
                    //將成功信息保存到本地
                    setUploadedToStorage(index);
                    if (sendChunkCount === chunkCount) {
                        console.log('上傳完成,發送合併請求');
                        var formD = new FormData();
                        formD.append('type', 'merge');
                        formD.append('token', token);
                        formD.append('chunkCount', chunkCount);
                        formD.append('filename', name);
                        xhrSend(formD);
                    }
                });
            })(i);
    }

複製代碼

方法2

爲何還有方法2呢,正常狀況下方法1沒問題,可是須要將分片信息保存在客戶端,保存在客戶端是最不保險的,說不定出現各類神奇的幺蛾子。

因此這裏有一個更完善的實現,只提供思路,代碼就不寫了,也是基於上面的實現,只是服務端須要增長一個接口。

基於上面一個栗子進行改進,服務端已保存了部分片斷,客戶端上傳前須要從服務端獲取已上傳的分片信息(上面是保存在了本地瀏覽器),本地對比每一個分片的 hash 值,跳過已上傳的部分,只傳未上傳的分片。

方法1是從本地獲取分片信息,這裏只須要將此方法的能力改成從服務端獲取分片信息就好了。

-getUploadedFromStorage
+getUploadedFromServer(fileHash)
複製代碼

另外服務端增長一個獲取分片的接口供客戶端調用,思路最重要,代碼就不貼了。

node 端上傳圖片

不僅會從客戶端上傳文件到服務器,服務器也會上傳文件到其餘服務器。

  • 讀取文件buffer fs
  • 構建 form-data form-data
  • 上傳文件 node-fetch

NODE

/**
     * filepath = 相對根目錄的路徑便可
     */
   async function getFileBufer(filePath) => {

        return new Promise((resolve) => {
            fs.readFile(filePath, function (err, data) {
                var bufer = null;
                if (!err) {
                    resolve({
                        err: err,
                        data: data
                    });
                }

            });

        });
    }
    
    
    /**
     * 上傳文件
     */
    let fetch = require('node-fetch');
    let formData = require('form-data');
    
    module.exports = async (options) => {
        let {
            imgPath
        } = options;
        let data = await getFileBufer(imgPath);
        if (data.err) {
            return null;
        }
        let form = new formData();
        form.append('xxx', xxx);
        form.append('pic', data.data);
        return fetch('http://xx.com/upload', {
            body: form,
            method: 'POST',
            headers: form.getHeaders()//要活的 form-data的頭,不然沒法上傳
        }).then(res => {
            return res.json();
        }).then(data => {
            return data;
        })
    }
複製代碼

其餘

在瀏覽器端對文件的類型、大小、尺寸進行判斷

  • file.type判斷類型
  • file.size判斷大小
  • 經過動態建立 img 標籤,圖片加載後得到尺寸,naturalWidth naturalHeightor width height

JS

var file = document.getElementById('f1').files[0];
    
    //判斷類型
    if(f.type!=='image/jpeg' &&  f.type !== 'image/jpg'  ){
        alert('只能上傳 jpg 圖片');
        flag=false;
        break;
    }
    
    //判斷大小
    if(file.size>100*1024){
        alert('不能大於100kb');
    }
    
    //判斷圖片尺寸
    var img =new Image();
    img.onload=function(){
         console.log('圖片原始大小 width*height', this.width, this.height);
        if(this.naturalWidth){
        console.log('圖片原始大小 naturalWidth*naturalHeight', this.naturalWidth, this.naturalHeight);
        }else{
          console.log('oImg.width*height', this.width, this.height);
        }
    }
複製代碼

input file 外觀更改

因爲input file 的外觀比較傳統,不少地方都須要進行美化。

  1. 定義好一個外觀,而後將 file input 定位到該元素上,讓他的透明度爲0。
  2. 使用 label 標籤
<label for="file">Choose file to upload</label>
    <input type="file" id="file" name="file" multiple>
複製代碼
  1. 隱藏 input file 標籤,而後調用 input 元素的 click 方法

PS

file 標籤隱藏後在 ie 下沒法得到文件內容,建議仍是方法1 兼容性強。

源碼在這裏

以上代碼均已上傳 github

github.com/Bigerfe/fe-…

參考資料

developer.mozilla.org/zh-CN/docs/…

developer.mozilla.org/zh-CN/docs/…

cloud.tencent.com/developer/n…

最 後

但願本文能夠給你帶了一些幫助,文中若有錯誤,歡迎在評論區指。

若是這篇文章幫助到了你,歡迎點贊和關注。

推薦關注個人微信公衆號【前端張大胖】,天天推送高質量文章和自學經驗和心得,咱們一塊兒交流成長。

其餘

你對 SSR 有興趣嗎?

若是你對 服務端渲染 ssr 技術有興趣,能夠關注個人開源項目:Zz.js

github:github.com/Bigerfe/koa…

官網:zz.bigerfe.com/

SSR 技術原理

juejin.im/post/5d7dee…

相關文章
相關標籤/搜索