檔案上傳,8種場景
------------恢復內容開始------------
-
單檔案上傳:利用
input
元素的accept
屬性限制上傳檔案的型別、利用 JS 檢測檔案的型別及使用 Koa 實現單檔案上傳的功能; -
多檔案上傳:利用
input
元素的multiple
屬性支援選擇多檔案及使用 Koa 實現多檔案上傳的功能; -
目錄上傳:利用
input
元素上的webkitdirectory
屬性支援目錄上傳的功能及使用 Koa 實現目錄上傳並按檔案目錄結構存放的功能; -
壓縮目錄上傳:在目錄上傳的基礎上,利用 JSZip 實現壓縮目錄上傳的功能;
-
拖拽上傳:利用拖拽事件和
-
剪貼簿上傳:利用剪貼簿事件和 Clipboard API 實現剪貼簿上傳的功能;
-
大檔案分塊上傳:利用 Blob.slice、SparkMD5 和第三方庫 async-pool 實現大檔案併發上傳的功能;
-
服務端上傳:利用第三方庫 form-data 實現服務端檔案流式上傳的功能。
一、單檔案上傳
對於單檔案上傳的場景來說,最常見的是圖片上傳的場景,所以我們就以圖片上傳為例,先來介紹單檔案上傳的基本流程。
1.1 前端程式碼
html
在以下程式碼中,我們通過 input
元素的 accept
屬性限制了上傳檔案的型別。這裡使用 image/*
image/png
或 image/png,image/jpeg
。
<input id="uploadFile" type="file" accept="image/*" />
<button id="submit" onclick="uploadFile()">上傳檔案</button>
需要注意的是,雖然我們把 input 元素的 accept
屬性設定為 image/png
。但如果使用者把 jpg/jpeg
格式的圖片字尾名改為 .png
,就可以成功繞過這個限制。要解決這個問題,我們可以通過讀取檔案中的二進位制資料來識別正確的檔案型別。
要檢視圖片對應的二進位制資料,我們可以藉助一些現成的編輯器,比如 Windows 平臺下的 WinHex 或 macOS 平臺下的 Synalyze It! Pro 十六進位制編輯器。這裡我們使用 Synalyze It! Pro 這個編輯器,來檢視阿寶哥頭像對應的二進位制資料。
const uploadFileEle = document.querySelector("#uploadFile"); const request = axios.create({ baseURL: "http://localhost:3000/upload", timeout: 60000, }); async function uploadFile() { if (!uploadFileEle.files.length) return; const file = uploadFileEle.files[0]; // 獲取單個檔案 // 省略檔案的校驗過程,比如檔案型別、大小校驗 upload({ url: "/single", file, }); } function upload({ url, file, fieldName = "file" }) { let formData = new FormData(); formData.set(fieldName, file); request.post(url, formData, { // 監聽上傳進度 onUploadProgress: function (progressEvent) { const percentCompleted = Math.round( (progressEvent.loaded * 100) / progressEvent.total ); console.log(percentCompleted); }, }); }
在以上程式碼中,我們先把讀取的File物件封裝成FormData物件,然後利用Axios例項的post
方法實現檔案上傳的功能。 在上傳前,通過設定請求配置物件的onUploadProgress
屬性,就可以獲取檔案的上傳進度。
二、多檔案上傳
要上傳多個檔案,首先我們需要允許使用者同時選擇多個檔案。要實現這個功能,我們可以利用input
元素的multiple
屬性。跟前面介紹的accept
屬性一樣,該屬性也存在相容性問題,具體如下圖所示:
2.1 前端程式碼
html
相比單檔案上傳的程式碼,多檔案上傳場景下的input
元素多了一個multiple
屬性:
<input id="uploadFile" type="file" accept="image/*" multiple />
<button id="submit" onclick="uploadFile()">上傳檔案</button>
js
在單檔案上傳的程式碼中,我們通過uploadFileEle.files[0]
獲取單個檔案,而對於多檔案上傳來說,我們需要獲取已選擇的檔案列表,即通過uploadFileEle.files
來獲取,它返回的是一個FileList物件。
async function uploadFile() { if (!uploadFileEle.files.length) return; const files = Array.from(uploadFileEle.files); upload({ url: "/multiple", files, }); }
因為要支援上傳多個檔案,所以我們需要同步更新一下upload
函式。對應的處理邏輯就是遍歷檔案列表,然後使用FormData物件的append
方法來新增多個檔案,具體程式碼如下所示:
function upload({ url, files, fieldName = "file" }) { let formData = new FormData(); files.forEach((file) => { formData.append(fieldName, file); }); request.post(url, formData, { // 監聽上傳進度 onUploadProgress: function (progressEvent) { const percentCompleted = Math.round( (progressEvent.loaded * 100) / progressEvent.total ); console.log(percentCompleted); }, }); }
三、目錄上傳
可能你還不知道,input
元素上還有一個的webkitdirectory
屬性。當設定了webkitdirectory
屬性之後,我們就可以選擇目錄了。
<input id="uploadFile" type="file" accept="image/*" webkitdirectory />
當我們選擇了指定目錄之後,比如阿寶哥桌面上的images
目錄,就會顯示以下確認框:
點選上傳按鈕之後,我們就可以獲取檔案列表。列表中的檔案物件上含有一個webkitRelativePath
屬性,用於表示當前檔案的相對路徑。
3.1 前端程式碼
為了讓服務端能按照實際的目錄結構來存放對應的檔案,在新增表單項時我們需要把當前檔案的路徑提交到服務端。此外,為了確保@koa/multer
能正確處理檔案的路徑,我們需要對路徑進行特殊處理。即把 /
斜槓替換為 @
符號。對應的處理方式如下所示:
function upload({ url, files, fieldName = "file" }) { let formData = new FormData(); files.forEach((file, i) => { formData.append( fieldName, files[i], files[i].webkitRelativePath.replace(/\//g, "@"); ); }); request.post(url, formData); // 省略上傳進度處理 }
四、壓縮目錄上傳
在 JavaScript 如何線上解壓 ZIP 檔案? 這篇文章中,介紹了在瀏覽器端如何使用 JSZip 這個庫實現線上解壓 ZIP 檔案的功能。 JSZip 這個庫除了可以解析 ZIP 檔案之外,它還可以用來 建立和編輯 ZIP 檔案。利用 JSZip 這個庫提供的 API,我們就可以把目錄下的所有檔案壓縮成 ZIP 檔案,然後再把生成的 ZIP 檔案上傳到伺服器。
4.1 前端程式碼
JSZip 例項上的 file(name, data [,options])
方法,可以把檔案新增到 ZIP 檔案中。基於該方法我們可以封裝了一個 generateZipFile
函式,用於把目錄下的檔案列表壓縮成一個 ZIP 檔案。以下是 generateZipFile
函式的具體實現:
function generateZipFile( zipName, files, options = { type: "blob", compression: "DEFLATE" } ) { return new Promise((resolve, reject) => { const zip = new JSZip(); for (let i = 0; i < files.length; i++) { zip.file(files[i].webkitRelativePath, files[i]); } zip.generateAsync(options).then(function (blob) { zipName = zipName || Date.now() + ".zip"; const zipFile = new File([blob], zipName, { type: "application/zip", }); resolve(zipFile); }); }); }
在建立完generateZipFile
函式之後,我們需要更新一下前面已經介紹過的uploadFile
函式:
async function uploadFile() { let fileList = uploadFileEle.files; if (!fileList.length) return; let webkitRelativePath = fileList[0].webkitRelativePath; let zipFileName = webkitRelativePath.split("/")[0] + ".zip"; let zipFile = await generateZipFile(zipFileName, fileList); upload({ url: "/single", file: zipFile, fileName: zipFileName }); }在以上的
uploadFile
函式中,我們會對返回的 FileList 物件進行處理,即呼叫 generateZipFile
函式來生成 ZIP 檔案。此外,為了在服務端接收壓縮檔案時,能獲取到檔名,我們為 upload
函式增加了一個 fileName
引數,該引數用於呼叫 formData.append
方法時,設定上傳檔案的檔名:
function upload({ url, file, fileName, fieldName = "file" }) { if (!url || !file) return; let formData = new FormData(); formData.append( fieldName, file, fileName ); request.post(url, formData); // 省略上傳進度跟蹤 }
五、拖拽上傳
要實現拖拽上傳的功能,我們需要先了解與拖拽相關的事件。比如 drag
、dragend
、dragenter
、dragover
或 drop
事件等。這裡我們只介紹接下來要用到的拖拽事件:
dragenter
:當拖拽元素或選中的文字到一個可釋放目標時觸發;dragover
:當元素或選中的文字被拖到一個可釋放目標上時觸發(每100毫秒觸發一次);dragleave
:當拖拽元素或選中的文字離開一個可釋放目標時觸發;drop
:當元素或選中的文字在可釋放目標上被釋放時觸發。
基於上面的這些事件,我們就可以提高使用者拖拽的體驗。比如當用戶拖拽的元素進入目標區域時,對目標區域進行高亮顯示。當用戶拖拽的元素離開目標區域時,移除高亮顯示。很明顯當 drop
事件觸發後,拖拽的元素已經放入目標區域了,這時我們就需要獲取對應的資料。
那麼如何獲取拖拽對應的資料呢?這時我們需要使用 DataTransfer 物件,該物件用於儲存拖動並放下過程中的資料。它可以儲存一項或多項資料,這些資料項可以是一種或者多種資料型別。若拖動操作涉及拖動檔案,則我們可以通過 DataTransfer 物件的 files
屬性來獲取檔案列表。
介紹完拖拽上傳相關的知識後,我們來看一下具體如何實現拖拽上傳的功能。
5.1 前端程式碼
html
<div id="dropArea"> <p>拖拽上傳檔案</p> <div id="imagePreview"></div> </div>
css
#dropArea { width: 300px; height: 300px; border: 1px dashed gray; margin-bottom: 20px; } #dropArea p { text-align: center; color: #999; } #dropArea.highlighted { background-color: #ddd; } #imagePreview { max-height: 250px; overflow-y: scroll; } #imagePreview img { width: 100%; display: block; margin: auto; }
js
為了讓大家能夠更好地閱讀拖拽上傳的相關程式碼,我們把程式碼拆成 4 部分來講解:
1、阻止預設拖拽行為
const dropAreaEle = document.querySelector("#dropArea"); const imgPreviewEle = document.querySelector("#imagePreview"); const IMAGE_MIME_REGEX = /^image\/(jpe?g|gif|png)$/i; ["dragenter", "dragover", "dragleave", "drop"].forEach((eventName) => { dropAreaEle.addEventListener(eventName, preventDefaults, false); document.body.addEventListener(eventName, preventDefaults, false); }); function preventDefaults(e) { e.preventDefault(); e.stopPropagation(); }
2、切換目標區域的高亮狀態
["dragenter", "dragover"].forEach((eventName) => { dropAreaEle.addEventListener(eventName, highlight, false); }); ["dragleave", "drop"].forEach((eventName) => { dropAreaEle.addEventListener(eventName, unhighlight, false); }); // 新增高亮樣式 function highlight(e) { dropAreaEle.classList.add("highlighted"); } // 移除高亮樣式 function unhighlight(e) { dropAreaEle.classList.remove("highlighted"); }
3、處理圖片預覽
dropAreaEle.addEventListener("drop", handleDrop, false); function handleDrop(e) { const dt = e.dataTransfer; const files = [...dt.files]; files.forEach((file) => { previewImage(file, imgPreviewEle); }); // 省略檔案上傳程式碼 } function previewImage(file, container) { if (IMAGE_MIME_REGEX.test(file.type)) { const reader = new FileReader(); reader.onload = function (e) { let img = document.createElement("img"); img.src = e.target.result; container.append(img); }; reader.readAsDataURL(file); } }
4、檔案上傳
function handleDrop(e) { const dt = e.dataTransfer; const files = [...dt.files]; // 省略圖片預覽程式碼 files.forEach((file) => { upload({ url: "/single", file, }); }); } const request = axios.create({ baseURL: "http://localhost:3000/upload", timeout: 60000, }); function upload({ url, file, fieldName = "file" }) { let formData = new FormData(); formData.set(fieldName, file); request.post(url, formData, { // 監聽上傳進度 onUploadProgress: function (progressEvent) { const percentCompleted = Math.round( (progressEvent.loaded * 100) / progressEvent.total ); console.log(percentCompleted); }, }); }
拖拽上傳算是一個比較常見的場景,很多成熟的上傳元件都支援該功能。其實除了拖拽上傳外,還可以利用剪貼簿實現複製上傳的功能。
六、剪貼簿上傳
在介紹如何實現剪貼簿上傳的功能前,我們需要了解一下 Clipboard API。Clipboard
介面實現了 Clipboard API,如果使用者授予了相應的許可權,就能提供系統剪貼簿的讀寫訪問。在 Web 應用程式中,Clipboard API 可用於實現剪下、複製和貼上功能。該 API 用於取代通過 document.execCommand API 來實現剪貼簿的操作。
在實際專案中,我們不需要手動建立 Clipboard
物件,而是通過 navigator.clipboard
來獲取 Clipboard
物件:
在獲取Clipboard
物件之後,我們就可以利用該物件提供的 API 來訪問剪貼簿,比如:
navigator.clipboard.readText().then( clipText => document.querySelector(".editor").innerText = clipText );
以上程式碼將 HTML 中含有.editor
類的第一個元素的內容替換為剪貼簿的內容。如果剪貼簿為空,或者不包含任何文字,則元素的內容將被清空。這是因為在剪貼簿為空或者不包含文字時,readText
方法會返回一個空字串。
要實現剪貼簿上傳的功能,可以分為以下 3 個步驟:
- 監聽容器的貼上事件;
- 讀取並解析剪貼簿中的內容;
- 動態構建
FormData
物件並上傳。
瞭解完上述步驟,接下來我們來分析一下具體實現的程式碼。
6.1 前端程式碼
html
<div id="uploadArea"> <p>請先複製圖片後再執行貼上操作</p> </div>
css
#uploadArea { width: 400px; height: 400px; border: 1px dashed gray; display: table-cell; vertical-align: middle; } #uploadArea p { text-align: center; color: #999; } #uploadArea img { max-width: 100%; max-height: 100%; display: block; margin: auto; }
js
在以下程式碼中,我們使用 addEventListener
方法為 uploadArea
容器新增 paste
事件。在對應的事件處理函式中,我們會優先判斷當前瀏覽器是否支援非同步 Clipboard API。如果支援的話,就會通過 navigator.clipboard.read
方法來讀取剪貼簿中的內容。在讀取內容之後,我們會通過正則判斷剪貼簿項中是否包含圖片資源,如果有的話會呼叫 previewImage
方法執行圖片預覽操作並把返回的 blob
物件儲存起來,用於後續的上傳操作。
const IMAGE_MIME_REGEX = /^image\/(jpe?g|gif|png)$/i; const uploadAreaEle = document.querySelector("#uploadArea"); uploadAreaEle.addEventListener("paste", async (e) => { e.preventDefault(); const files = []; if (navigator.clipboard) { let clipboardItems = await navigator.clipboard.read(); for (const clipboardItem of clipboardItems) { for (const type of clipboardItem.types) { if (IMAGE_MIME_REGEX.test(type)) { const blob = await clipboardItem.getType(type); insertImage(blob, uploadAreaEle); files.push(blob); } } } } else { const items = e.clipboardData.items; for (let i = 0; i < items.length; i++) { if (IMAGE_MIME_REGEX.test(items[i].type)) { let file = items[i].getAsFile(); insertImage(file, uploadAreaEle); files.push(file); } } } if (files.length > 0) { confirm("剪貼簿檢測到圖片檔案,是否執行上傳操作?") && upload({ url: "/multiple", files, }); } });
若當前瀏覽器不支援非同步 Clipboard API,則我們會嘗試通過 e.clipboardData.items
來訪問剪貼簿中的內容。需要注意的是,在遍歷剪貼簿內容項的時候,我們是通過 getAsFile
方法來獲取剪貼簿的內容。
前面已經提到,當從剪貼簿解析到圖片資源時,會讓使用者進行預覽,該功能是基於FileReaderAPI 來實現的,對應的程式碼如下所示:
function previewImage(file, container) { const reader = new FileReader(); reader.onload = function (e) { let img = document.createElement("img"); img.src = e.target.result; container.append(img); }; reader.readAsDataURL(file); }
當用戶預覽完成後,如果確認上傳我們就會執行檔案的上傳操作。因為檔案是從剪貼簿中讀取的,所以在上傳前我們會根據檔案的型別,自動為它生成一個檔名,具體是採用時間戳加檔案字尾的形式:
function upload({ url, files, fieldName = "file" }) { let formData = new FormData(); files.forEach((file) => { let fileName = +new Date() + "." + IMAGE_MIME_REGEX.exec(file.type)[1]; formData.append(fieldName, file, fileName); }); request.post(url, formData); }
前面我們已經介紹了檔案上傳的多種不同場景,接下來我們來介紹一個 “特殊” 的場景 ——大檔案上傳。
七、大檔案分塊上傳
相信你可能已經瞭解大檔案上傳的解決方案,在上傳大檔案時,為了提高上傳的效率,我們一般會使用 Blob.slice 方法對大檔案按照指定的大小進行切割,然後通過多執行緒進行分塊上傳,等所有分塊都成功上傳後,再通知服務端進行分塊合併。具體處理方案如下圖所示:
因為在 JavaScript 中如何實現大檔案併發上傳? 這篇文章中,阿寶哥已經詳細介紹了大檔案併發上傳的方案,所以這裡就不展開介紹了。我們只回顧一下大檔案併發上傳的完整流程: