1. 程式人生 > 其它 >檔案上傳,8種場景

檔案上傳,8種場景

------------恢復內容開始------------

  • 單檔案上傳:利用 input 元素的 accept 屬性限制上傳檔案的型別、利用 JS 檢測檔案的型別及使用 Koa 實現單檔案上傳的功能;

  • 多檔案上傳:利用 input 元素的 multiple 屬性支援選擇多檔案及使用 Koa 實現多檔案上傳的功能;

  • 目錄上傳:利用 input 元素上的 webkitdirectory 屬性支援目錄上傳的功能及使用 Koa 實現目錄上傳並按檔案目錄結構存放的功能;

  • 壓縮目錄上傳:在目錄上傳的基礎上,利用 JSZip 實現壓縮目錄上傳的功能;

  • 拖拽上傳:利用拖拽事件和

    DataTransfer 物件實現拖拽上傳的功能;

  • 剪貼簿上傳:利用剪貼簿事件和 Clipboard API 實現剪貼簿上傳的功能;

  • 大檔案分塊上傳:利用 Blob.sliceSparkMD5 和第三方庫 async-pool 實現大檔案併發上傳的功能;

  • 服務端上傳:利用第三方庫 form-data 實現服務端檔案流式上傳的功能。


一、單檔案上傳

對於單檔案上傳的場景來說,最常見的是圖片上傳的場景,所以我們就以圖片上傳為例,先來介紹單檔案上傳的基本流程。

1.1 前端程式碼

html

在以下程式碼中,我們通過 input 元素的 accept 屬性限制了上傳檔案的型別。這裡使用 image/*

限制只能選擇圖片檔案,當然你也可以設定特定的型別,比如 image/pngimage/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); // 省略上傳進度跟蹤
}

五、拖拽上傳

要實現拖拽上傳的功能,我們需要先了解與拖拽相關的事件。比如 dragdragenddragenterdragoverdrop 事件等。這裡我們只介紹接下來要用到的拖拽事件:

  • 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 中如何實現大檔案併發上傳? 這篇文章中,阿寶哥已經詳細介紹了大檔案併發上傳的方案,所以這裡就不展開介紹了。我們只回顧一下大檔案併發上傳的完整流程: