# [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)