
寫在前面
今年國慶假期終于可以憋在家裡了不用出門了,不用出去看後腦了,真的是一種享受。這麼好的光陰怎麼浪費,睡覺、吃飯、打豆豆這怎麼可能(耍多了也煩),完全不符合我們程式員的作風,趕緊起來把文章寫完。
這篇文章比較基礎,在國慶期間的業餘時間寫的,這幾天又完善了下,力求把更多的前端所涉及到的關于檔案上傳的各種場景和應用都涵蓋了,若有疏漏和問題還請留言斧正和補充。
自測讀不讀
以下是本文所涉及到的知識點,break or continue ?
- 檔案上傳原理
- 最原始的檔案上傳
- 使用 koa2 作為服務端寫一個檔案上傳接口
- 單檔案上傳和上傳進度
- 多檔案上傳和上傳進度
- 拖拽上傳
- 剪貼闆上傳
- 大檔案上傳之分片上傳
- 大檔案上傳之斷點續傳
- node 端檔案上傳
原理概述
原理很簡單,就是根據 http 協定的規範和定義,完成請求消息體的封裝和消息體的解析,然後将二進制内容儲存到檔案。
我們都知道如果要上傳一個檔案,需要把 form 标簽的enctype設定為multipart/form-data,同時method必須為post方法。
那麼multipart/form-data表示什麼呢?
multipart網際網路上的混合資源,就是資源由多種元素組成,form-data表示可以使用HTML Forms 和 POST 方法上傳檔案,具體的定義可以參考RFC 7578。
multipart/form-data 結構
看下 http 請求的消息體
- 請求頭:
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryDCntfiXcSkPhS4PN 表示本次請求要上傳檔案,其中boundary表示分隔符,如果要上傳多個表單項,就要使用boundary分割,每個表單項由———XXX開始,以———XXX結尾。
- 消息體- Form Data 部分
每一個表單項又由Content-Type和Content-Disposition組成。
Content-Disposition: form-data 為固定值,表示一個表單元素,name 表示表單元素的 名稱,回車換行後面就是name的值,如果是上傳檔案就是檔案的二進制内容。
Content-Type:表示目前的内容的 MIME 類型,是圖檔還是文本還是二進制資料。
解析
用戶端發送請求到伺服器後,伺服器會收到請求的消息體,然後對消息體進行解析,解析出哪是普通表單哪些是附件。
可能大家馬上能想到通過正則或者字元串處理分割出内容,不過這樣是行不通的,二進制buffer轉化為string,對字元串進行截取後,其索引和字元串是不一緻的,是以結果就不會正确,除非上傳的就是字元串。
不過一般情況下不需要自行解析,目前已經有很成熟的三方庫可以使用。
至于如何解析,這個也會占用很大篇幅,後面的文章在詳細說。
最原始的檔案上傳
使用 form 表單上傳檔案
在 ie時代,如果實作一個無重新整理的檔案上傳那可是費老勁了,大部分都是用 iframe 來實作局部重新整理或者使用 flash 插件來搞定,在那個時代 ie 就是最好用的浏覽器(别無選擇)。
DEMO
這種方式上傳檔案,不需要 js ,而且沒有相容問題,所有浏覽器都支援,就是體驗很差,導緻頁面重新整理,頁面其他資料丢失。
HTML
選擇檔案: input 必須設定 name 屬性,否則資料無法發送
标題:上 傳複制代碼
檔案上傳接口
服務端檔案的儲存基于現有的庫koa-body結合 koa2實作服務端檔案的儲存和資料的傳回。
在項目開發中,檔案上傳本身和業務無關,代碼基本上都可通用。
在這裡我們使用koa-body庫來實作解析和檔案的儲存。
koa-body 會自動儲存檔案到系統臨時目錄下,也可以指定儲存的檔案路徑。
然後在後續中間件内得到已儲存的檔案的資訊,再做二次處理。
- ctx.request.files.f1 得到檔案資訊,f1為input 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
https://github.com/Bigerfe/fe-learn-code/tree/master/src/upfiles-demo
多檔案上傳
在 ie 時代的多檔案上傳是需要建立多個 input file 标簽,現在 html5隻需要一個标簽加個屬性就搞定了,file 标簽開啟multiple。
DEMO
HTML
//設定 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
https://github.com/Bigerfe/fe-learn-code/tree/master/src/upfiles-demo
局部重新整理 - iframe
這裡說的是在 ie 時代的上傳檔案局部重新整理,借助 iframe 實作。
DEMO
- 局部重新整理
頁面内放一個隐藏的 iframe,或者使用 js 動态建立,指定 form 表單的 target 屬性值為iframe标簽 的 name 屬性值,這樣 form 表單的 shubmit 行為的跳轉就會在 iframe 内完成,整體頁面不會重新整理。
- 拿到接口資料
然後為 iframe 添加load事件,得到 iframe 的頁面内容,将結果轉換為 JSON 對象,這樣就拿到了接口的資料
HTML
選擇檔案(可多選):
input 必須設定 name 屬性,否則資料無法發送
标題:上 傳 複制代碼
NODE
服務端代碼不需要改動,略.
CODE
https://github.com/Bigerfe/fe-learn-code/tree/master/src/upfiles-demo
無重新整理上傳
無重新整理上傳檔案肯定要用到XMLHttpRequest,在 ie 時代也有這個對象,單隻 支援文本資料的傳輸,無法用來讀取和上傳二進制資料。
現在已然更新到了XMLHttpRequest2,較1版本有非常大的更新,首先就是可以讀取和上傳二進制資料,可以使用·FormData·對象管理表單資料。
當然也可使用 fetch 進行上傳。
DEMO
HTML
選擇檔案(可多選): 上 傳
複制代碼
JS xhr
複制代碼
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
https://github.com/Bigerfe/fe-learn-code/tree/master/src/upfiles-demo
多檔案,單進度
借助XMLHttpRequest2的能力,實作多個檔案或者一個檔案的上傳進度條的顯示。
DEMO
說明
- 頁面内增加一個用于顯示進度的标簽 div.progress
- js 内處理增加進度處理的監聽函數xhr.upload.onprogress
- event.lengthComputable這是一個狀态,表示發送的長度有了變化,可計算
- event.loaded表示發送了多少位元組
- event.total表示檔案總大小
- 根據event.loaded和event.total計算進度,渲染div.progress
PS 特别提醒
xhr.upload.onprogress要寫在xhr.send方法前面,否則event.lengthComputable狀态不會改變,隻有在最後一次才能獲得,也就是100%的時候.
HTML
選擇檔案(可多選): 上 傳
複制代碼
JS
複制代碼
CODE
https://github.com/Bigerfe/fe-learn-code/tree/master/src/upfiles-demo
多檔案上傳+預覽+取消
上一個栗子的多檔案上傳隻有一個進度條,有些需求可能會不大一樣,需要觀察到每個檔案的上傳進度,并且可以終止上傳。
DEMO
說明
- 為了預覽的需要,我們這裡選擇上傳圖檔檔案,其他類型的也一樣,隻是預覽不友善
- 頁面内增加一個多圖預覽的容器div.img-box
- 根據選擇的檔案資訊動态建立所屬的預覽區域和進度條以及取消按鈕
- 為取消按鈕綁定事件,調用xhr.abort();終止上傳
- 使用window.URL.createObjectURL預覽圖檔,在圖檔加載成功後需要清除使用的記憶體window.URL.revokeObjectURL(this.src);
HTML
選擇檔案(可多選):
添加檔案
上 傳
複制代碼
JS
複制代碼
問題1
這裡沒有做上傳的并發控制,可以通過控制同時可上傳檔案的個數(這裡控制為最多6個)或者上傳的時候做好并發處理,也就是同時隻能上傳 X 個檔案。
問題2
在測試過程中,取消請求的方法xhr.abort()調用後,xhr.readyState會立即變為4,而不是0,是以這裡需要做容錯處理。
MDN 上說是0.
如果大家有不同的結果,歡迎留言。
CODE
https://github.com/Bigerfe/fe-learn-code/tree/master/src/upfiles-demo
拖拽上傳
html5的出現,讓拖拽上傳互動成為可能,現在這樣的體驗也屢見不鮮。
DEMO
說明
- 定義一個允許拖放檔案的區域div.drop-box
- 取消drop 事件的預設行為e.preventDefault();,不然浏覽器會直接打開檔案
- 為拖拽區域綁定事件,滑鼠在拖拽區域上 dragover, 滑鼠離開拖拽區域dragleave, 在拖拽區域上釋放檔案drop
- drop事件内獲得檔案資訊e.dataTransfer.files
HTML
拖動檔案到這裡,開始上傳
上 傳複制代碼
JS
複制代碼
CODE
https://github.com/Bigerfe/fe-learn-code/tree/master/src/upfiles-demo
剪貼闆上傳
掘金的寫文編輯器是支援粘貼上傳圖檔的,比如我從磁盤粘貼或者從網頁上右鍵複制圖檔。
DEMO
說明
- 頁面内增加一個可編輯的編輯區域div.editor-box,開啟contenteditable
- 為div.editor-box綁定paste事件
- 處理paste 事件,從event.clipboardData || window.clipboardData獲得資料
- 将資料轉換為檔案items[i].getAsFile()
- 實作在編輯區域的光标處插入内容 insertNodeToEditor 方法
問題1
測試中發現複制多個檔案無效,隻有最後一個檔案上傳,在掘金的編輯器裡也同樣存在,在坐有知道原因的可以留言說下。
問題2
mac系統可以支援從磁盤複制檔案後上傳,windows 系統測試未通過,剪貼闆的資料未拿到。
HTML
可以直接粘貼圖檔到這裡直接上傳
複制代碼
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
CODE
https://github.com/Bigerfe/fe-learn-code/tree/master/src/upfiles-demo
大檔案上傳-分片
在 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);//儲存分段資料 }複制代碼
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
https://github.com/Bigerfe/fe-learn-code/tree/master/src/upfiles-demo
大檔案上傳-斷點續傳
在上面我們實作了大檔案的分片上傳,解決了大檔案上傳逾時和伺服器的限制。
但是仍然不夠完美,大檔案上傳并不是短時間内就上傳完成,如果期間斷網,頁面重新整理了仍然需要重頭上傳,這種時間的浪費怎麼能忍?
是以我們實作斷點續傳,已上傳的部分跳過,隻傳未上傳的部分。
方法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 的外觀比較傳統,很多地方都需要進行美化。
- 定義好一個外觀,然後将 file input 定位到該元素上,讓他的透明度為0。
- 使用 label 标簽
Choose file to upload 複制代碼
- 隐藏 input file 标簽,然後調用 input 元素的 click 方法
PS
file 标簽隐藏後在 ie 下無法獲得檔案内容,建議還是方法1 相容性強。
源碼在這裡
以上代碼均已上傳 github
https://github.com/Bigerfe/fe-learn-code/