# [JavaScript] 前端上傳檔案整理 ###### tags: `前端筆記` `Web API` ## 原生的 HTML `<input type="file" />` 即可實現上傳檔案 其配合的 event 為 `change`。 ### 該如何取得上傳的檔案? 1. `<input type="file" />` 的 `change` 事件可以取得上傳的檔案資訊 2. `event.target.files` 內可以找到其資訊 ```javascript= uploader.addEventListener('change', (event) => { console.log(event.target.files); }); ``` ![](https://hackmd.io/_uploads/S1h0ZO9Ii.png) ### 該如何刪除上傳的檔案? `<input type="file" />` 就是一般的 `<input />`,所以就直接 `input.value = ''`,手動更改 `<input type="file" />` 的值,這樣子下次上傳檔案才可以正確地觸發 `change`。 ### 什麼是 `File`? 簡單來說,`File` 繼承了 `Blob`,所以 `File` 繼承了 `Blob` 擁有的 properties 及 methods。`Blob` 為 Binary Large Object 的縮寫,表示的是二進位檔案的資料內容,透過 Blob,JavaScript 才能讀寫二進位資料的檔案。 日後再詳細研究,目前先把 `File` 理解成上傳檔案的物件,且可以讀取及使用 `Blob` 的 properties 及 methods。 ## 打 API 給後端資料時需要 `FormData` 攜帶資料 > 關鍵字:`FormData`, `.append(key, value)`, `headers: { "Content-Type": "multipart/form-data" }` `FormData()` 可以帶檔案給後端,也可以在同個請求之中發送其他欄位的值給後端。`FormData()` 的使用方式很簡單,直接透過 `new` 建立一個 `FormData()` 的 instance 即可。建立完 instance 就可以使用其內建方法(`.append(key, value)`)的方式把資料塞進 instance 中。 > 要注意,`FormData(key, value)` 能接受的 `value` 只有 `Blob`(包含 subclasses `File`)與 `string` 而已,如果 `value` 為其他型別的話就會被強制改為 `string`。 ```javascript= const formData = new FormData(); formData.append('key', null); console.log(typeof formData.get('key')); // string ``` ### 那該如何讓後端知道這邊的欄位是沒有值的? [Angular 9: formData.append('key', null) actually appends 'null' string](https://stackoverflow.com/questions/62303002/angular-9-formdata-appendkey-null-actually-appends-null-string) 1. 試試看能不能帶空字串 `formData.append('key', '')` 2. 如果不行的話該欄位就不要傳過去 ### `multipart/form-data` > multipart/form-data 最大的用處在於使用者可以把複數個資料格式一次傳送(一個請求)出去,主要用在 HTML 的表單裡頭,或是在實作檔案上傳功能時使用到。 > *.ref: [一起理解 HTML 當中的 Form Data](https://blog.kalan.dev/2021-03-13-html-form-data#form-data-%E8%AB%8B%E6%B1%82%E8%A7%A3%E6%9E%90)* 所以當開發者使用 `multipart/form-data`,就是和其他常見的 `application/json`(代表請求的內容是 json)不同,開發者可以在同一個請求中塞入不同的資料格式。 ### multipart document format 使用 `multipart/form-data` 發出 `POST` 請求後,所傳送的資料會被轉成 multipart document format 格式。前面已經說明 `FormData()` 是以一組 property(key, value)為單位儲存資料,因此每一組單位都會有自己的 Content-Disposition 及 Content-Type,並且透過 boundary(a string starting with a double dash)區分各組單位: ```javascript= // ref. https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types Content-Type: multipart/form-data; boundary=aBoundaryString (other headers associated with the multipart document as a whole) // 所以這邊就是一組 --aBoundaryString // Content-Disposition 用來描述檔案的格式 Content-Disposition: form-data; name="myFile"; filename="img.jpg" // Content-Type 用來描述檔案的類型 Content-Type: image/jpeg // 第二組 (data) --aBoundaryString Content-Disposition: form-data; name="myField" // 第三組 (data) --aBoundaryString (more subparts) --aBoundaryString-- ``` ```htmlembedded= <!-- ref. https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types --> <!-- 送出去的表格 --> <form action="http://localhost:8000/" method="post" enctype="multipart/form-data"> <label>Name: <input name="myTextField" value="Test" /></label> <label><input type="checkbox" name="myCheckBox" /> Check</label> <label> Upload file: <input type="file" name="myFile" value="test.txt"/> </label> <button>Send the file</button> </form> ``` 如果送出的資料是媒體或者其他檔案格式的話,將會以 binary 顯示(所以看起來會像是一堆亂碼,這個是正常的!) ```javascript= // ref. https://blog.kalan.dev/posts/2021-03-13-html-form-data Content-Disposition: form-data; name="file"; filename="image.png" Content-Type: image/png PNG IHDR¤@¬ ÃiCCPICC ProfileHTSÙϽétBoô*%ôÐ{³@B!!ØPGp,¨2 cd,(¶A±a :l¨¼ <ÂÌ{ë½·Þ¿ÖY÷»;ûì½ÏYçÜµÏ (省略) ``` > 要注意的是,不是前端傳檔案後端就可以直接收了,還是必須要經過解析後後端才可以拿到檔案內容喔。 ### 發送 `FormData` 的請求時,記得要更換標頭 `headers: {'Content-Type': 'multipart/form-data'}` ```javascript= const form = new FormData() form.append('name', value) const processSendDataRequest = () => { fetch('someUrl', { method: 'POST', headers: { // 傳 FormData 給後端時記得要更換標頭(標頭用來告知後端,我現在是傳什麼類型的檔案過去) 'Content-Type': 'multipart/form-data' }, body: form, }) .then((res) => res.json()) .then((data) => console.log(data)) .catch((error) => conosle.log(error)) } ``` 但如果使用 axios 套件處理 http 請求,那就不需要特別更換標頭,因為 axios 會根據傳送的 body 自動幫開發者處理 > To send the data as a multipart/formdata you need to pass a formData instance as a payload. Setting the Content-Type header is not required as Axios guesses it based on the payload type. ```javascript= // ref. https://github.com/axios/axios#using-multipartform-data-format const formData = new FormData(); formData.append('foo', 'bar'); axios.post('https://httpbin.org/post', formData); ``` ## 範例:透過 API 上傳圖片至後端 ```javascript= const processUploadHandler = async (formData) => { try { await fetch('url', { method: 'POST', headers: { 'Content-Type': 'multipart/form-data' }, body: formData }) } catch(error) { // 錯誤處理 ... } finally { // 請求最後要做什麼(不管成功與否)... } } const getFormData = (file) => { const form = new FormData(); // FormData 就與 sessionStorage.setItem('key', 'value') 相同,為 {key: value} 的組合 form.append('uploadedFile', file) // 也可用 set(),但如果 key 已經存在,使用 set() 會覆寫值,用 append() 則是再該 key 新增值(不會覆寫) // form.set('uploadedFile', file) return form; }; // Step: 1: 上傳檔案後觸發 event 事件 uploader.addEventListener('change', (event) => { // Step 2: 建立 FormData const file = event.target.files[0] // Step 3: 觸發執行 API 的函式 processUploadHandler(getFormData(file)) }); ``` ## 上傳圖片前預覽圖片 古早的方式是請後端開一個 API,前端用 `POST` 傳圖片給後端,再等待後端回傳圖片網址,前端在使用該網址顯示其上傳媒體。但這樣子其實效率很差,因為必須要等 `POST` 上傳成功才可以看到圖片是什麼。但是現在可以透過 `FileReader` 或者 `createObjectURL`,讓瀏覽器自己生成顯示媒體的網址。 ### `URL.createObjectURL()` #### 語法: `objectURL = URL.createObjectURL(blob);` 叫用時帶入 `Blob`(或者其 subclass `File`)就會得到該 `Blob` 的網址,其形式為 `blob:<origin>/<uuid>`,範例: `blob:http://127.0.0.1:8080/2ee69220-5480-4931-be5c-300d7a80a97c` 得到的網址可以直接當作 `<img />` `src` 的網址。 但要注意的是,這個建立的網址會存在瀏覽器的記憶體內直到應用程式結束,所以如果不需要的話記得要手動刪除 `URL.revokeObjectURL()` ```javascript= const url = window.URL.createObjectURL(blob); // 手動刪除釋放記憶體 window.URL.revokeObjectURL(url) ``` #### 範例 ```javascript= const fileUploader = document.querySelector('input'); const previewer = document.querySelector('img'); fileUploader.addEventListener('change', event => { const [file] = event.target.files; const url = window.URL.createObjectURL(file); previewer.setAttribute('src', url); }); ``` ### `FileReader()` > 關鍵字:`new FileReader()`, `load event`, `.readAsDataURL()` 透過 `FileReader` 也可以讀取使用者透過 `<input type="file">` 上傳的檔案。 #### 範例 ```javascript= const fileUploader = document.querySelector('input'); const previewer = document.querySelector('img'); fileUploader.addEventListener('change', event => { const [file] = event.target.files; const reader = new FileReader(); // 建立 load 事件,待 Blob / File 轉換成功就會叫用 reader.addEventListener('load', event => { // result 為轉換完成的網址 const { result } = event.target; previewer.setAttribute('src', result); }); if (file) { // 讀取 Blob / File 變成 DataURL 的方法,當讀取完畢就會觸發 load 事件 reader.readAsDataURL(file); } }); ``` ### 實作檔案上傳預覽 需求: 1. 不可以上傳超過 100 MB 的媒體檔案 2. 限制單筆上傳 3. 要可以刪除重新傳一次 ```javascript= const fileUploader = document.querySelector('input'); const imgPreviewer = document.querySelector('img'); const videoPreviewer = document.querySelector('video'); const removeBtn = document.querySelector('button'); /** 洗掉兩個 src */ const removeHandler = () => { videoPreviewer.classList.add('hide'); imgPreviewer.classList.add('hide'); videoPreviewer.removeAttribute('src'); imgPreviewer.removeAttribute('src'); fileUploader.value = ''; }; /** 將 input 預設的 bytes 轉為 MB */ const bytesToMegaBytes = (bytes, digits) => { return digits ? (bytes / (1024 * 1024)).toFixed(digits) : bytes / (1024 * 1024); }; /** 檢查是否為 100 MB 以下 */ const isSizeOk = size => { return size <= 100; }; /** 用副檔名檢查上傳檔案的格式 */ const isFileExtensionOk = fileName => { // xxx.jpeg 會檢查副檔名 const fileNameReg = /\.(jpe?g|png|gif|mp4)$/i; return fileNameReg.test(fileName); }; const checkMediaIsOk = ({ size, fileName }) => { if (!isSizeOk(Number(size))) { return { isFileValid: false, errorMessage: '超過 100 MB 的上限' }; } if (!isFileExtensionOk(fileName)) { return { isFileValid: false, errorMessage: '檔案類型錯誤' }; } return { isFileValid: true, errorMessage: null }; }; /** 依照不同的檔案類型顯示不同的元素(並把另一個隱藏)*/ const getStrategy = type => { const strategies = { img: src => { imgPreviewer.classList.remove('hide'); videoPreviewer.classList.add('hide'); imgPreviewer.setAttribute('src', src); }, video: src => { videoPreviewer.classList.remove('hide'); imgPreviewer.classList.add('hide'); videoPreviewer.setAttribute('src', src); }, }; return strategies[type]; }; const getFileExtension = name => { const imgFileReg = /\.(jpe?g|png|gif)$/i; return imgFileReg.test(name) ? 'img' : 'video'; }; const previewHandler = file => { // 建立 FileReader const reader = new FileReader(); // File 讀取完畢就會觸發 load 事件 reader.addEventListener('load', event => { const fileExtension = getFileExtension(file.name); const { result } = event.target; getStrategy(fileExtension)(result); }); if (file) { // 把 file 變成 DataURL,轉換完畢就會叫用 load 事件 reader.readAsDataURL(file); } }; /** STEP 1: 上傳檔案觸發 change */ fileUploader.addEventListener('change', event => { const [file] = event.target.files; const { size, name } = file; // 將 bytes 轉成 MegaBytes const mediaMegaBytes = bytesToMegaBytes(size, 2); /** STEP 2: 檢查上傳的檔案是否符合規定 */ const { isFileValid, errorMessage } = checkMediaIsOk({ size: mediaMegaBytes, fileName: name }); // STEP 3: 失敗,印出錯誤及重新洗掉 input if (!isFileValid) { console.error(errorMessage); removeHandler(); } else { // STEP 3: 成功,將 file 轉換成 URL previewHandler(file); } }); removeBtn.addEventListener('click', removeHandler); ``` [完整練習範例 - 上傳媒體預覽](https://codepen.io/lun0223/pen/vYrrNqv) ## 參考資料 1. [[JS] 透過 JavaScript 處理檔案上傳(AJAX Upload byte / JSON / formData File)](https://pjchender.blogspot.com/2019/01/js-javascript-input-file-upload-file.html) 2. [JavaScript中的Blob你知道多少](https://zhuanlan.zhihu.com/p/500199997) 3. [Preview an image before it is uploaded](https://stackoverflow.com/questions/4459379/preview-an-image-before-it-is-uploaded) 4. [HTML5 File and FileList Objects](https://www.youtube.com/watch?v=7fybEXre70o&t=0s) 5. [一起理解 HTML 當中的 Form Data](https://blog.kalan.dev/posts/2021-03-13-html-form-data)