# Node.js 大檔案上傳
###### tags: `w3HexSchool` . `node.js`
>最近需要處理一個 1 GB 的檔案上傳,
Google 了很久,才有個方向要如何實作,
在此紀錄作法,以免日後忘記如何處理 :hamster:
## 步驟說明
1. 利用 [File API](https://developer.mozilla.org/zh-TW/docs/Web/API/File) 的 `file.slice` 將大檔案切割成許多 chunk
2. 將 chunk 轉換成 Uint8Array 資料格式並傳給 Express.js
3. 在 Express.js 端接收 Uint8Array 資料
4. 利用 fs.writeFileSync(filePath, byteArray, {flag: 'a'}) , 將接收到的 Uint8Array 資料附加到指定的檔案中
## 細部解說
利用 file.slice( start , end ) 取出 file 中的指定片段
### FileReader 使用說明
FileReader 需要先設定 callback 事件決定各種情況要如何處理
| 事件名稱 | 說明 |
| -------- | -------- |
| onabort | 讀取被中斷時觸發。 |
| onerror | 讀取發生錯誤時觸發。 |
| onload | 讀取完成時觸發。 |
| onloadstart | 讀取開始時觸發。 |
| onloadend | 每一次讀取結束之後觸發(不論成功或失敗),會於 onload 或 onerror 事件處理器之後才執行。 |
| onprogress | progress 事件處理器,於讀取 Blob 內容時觸發。 |
之後利用 fileReader.readAsXXX 開始用特定格式做讀取
| 方法名稱 | 說明 |
| -------- | -------- |
| abort | 中斷目前的讀取,此方法回傳後屬性 readyState 將會是 DONE。 |
| readAsArrayBuffer | 開始讀取指定的 Blob,讀取完成後屬性 result 將以 ArrayBuffer 物件來表示讀入的資料內容。 |
| readAsBinaryString | 開始讀取指定的 Blob,讀取完成後屬性 result 將以字串型式來表示讀入的原始二進位資料(raw binary data)。 |
| readAsDataURL | 開始讀取指定的 Blob,讀取完成後屬性 result 將以 data: URL 格式(base64 編碼)的字串來表示讀入的資料內容。 |
| readAsText | 開始讀取指定的 Blob,讀取完成後屬性 result 將以文字字串型式來表示讀入的資料內容。 |
## 前置準備
上傳檔案用的 upload.html
```htmlmixed=
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>上傳檔案</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.0-beta1/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://unpkg.com/tailwindcss@^2/dist/tailwind.min.css" rel="stylesheet">
<link href="https://fonts.googleapis.com/css?family=Noto+Sans+TC" rel="stylesheet"/>
<link href="https://fonts.googleapis.com/css?family=Andika+New+Basic" rel="stylesheet"/>
<style>
.root {
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
font-weight: 600;
height: 100vh;
font-size: 30px;
}
.folder-img {
max-width: 90vw;
width: 60vh;
cursor: pointer;
margin-top: 0.5rem;
margin-bottom: 1rem;
}
.folder-path {
font-family: 'Andika New Basic', 'Noto Sans TC';
word-wrap: break-word;
}
.btn-wrapper {
display: flex;
justify-content: space-evenly;
}
.progress-wrapper {
border: 1px solid #333333;
border-radius: 20px;
}
.width-constraint {
max-width: 90vw;
width: 60vh;
}
</style>
</head>
<body>
<div class="root"></div>
<script src="https://unpkg.com/vue@3.0.4/dist/vue.global.prod.js"></script>
<script>
const app = Vue.createApp({
template:
`
<div class="root">
<span class="folder-path">{{file ? file.name : '請選擇檔案'}}</span>
<img v-if="mode ==='uploading'" class="folder-img mb-8" src="./uploading-cat.gif" alt="上傳中...">
<img v-else class="folder-img" :src="file ? './folder-full.svg' : './folder.svg'"
alt="資料夾圖片" title="選擇檔案" @click="pickFile">
<input id="upload-file" type="file" class="hidden" @change="handleFiles($event.target.files)">
<div class="btn-wrapper width-constraint" v-if="mode === 'pick'">
<button class="btn btn-primary mx-2 text-2xl w-1/2" @click="pickFile">選擇檔案</button>
<button class="btn btn-success mx-2 text-2xl w-1/2" @click="handleUpload">上傳檔案</button>
</div>
<div class="progress progress-wrapper h-8 width-constraint" v-if="mode === 'uploading'">
<div class="progress-bar progress-bar-animated progress-bar-striped text-xl"
:style="{width:perWidth}"
role="progressbar">
{{percent}} %
</div>
</div>
<div class="alert alert-success block text-center width-constraint" role="alert" style="width: 60vh"
v-if="mode === 'done'">
成功上傳檔案 😁
</div>
</div>`,
data() {
return {
percent: 0,
file: null,
mode: 'pick' // mode : pick . uploading . done
}
},
computed: {
perWidth() {
return this.percent + '%'
},
},
methods: {
pickFile() {
document.getElementById('upload-file').click();
},
handleFiles(files) {
this.file = files[0];
this.mode = 'pick';
},
handleUpload() {
this.mode = 'uploading';
let file = this.file;
let reader = new FileReader();
let CHUNK_SIZE = 10 * 1024; // 10 KB
let processTimes = 0;
reader.onload = function (e) {
let buffer = new Uint8Array(e.target.result);
let start = processTimes * CHUNK_SIZE;
let next = (processTimes + 1) * CHUNK_SIZE;
let end = (next > file.size) ? file.size : next;
console.log(`current start = ${start},end = ${end}`);
// send the buffer to api server
sendTypedArray(buffer, file.name);
}
const nextSeek = () => {
++processTimes;
// get percent of send file progress
const total = file.size;
const next = processTimes * CHUNK_SIZE;
const end = (next > file.size) ? file.size : next;
const percent = Math.floor((end / total) * 100);
if (percent === 100) {
setTimeout(() => {
this.mode = 'done';
this.file = null;
}, 1000)
}
// change to progress bar mode
this.percent = percent;
if (processTimes * CHUNK_SIZE < file.size) seek();
}
function sendTypedArray(typedArray, fileName) {
fetch('http://localhost:3011/file/upload/bytes', {
body: JSON.stringify({bytes: typedArray, fileName}), // must match 'Content-Type' header
cache: 'no-cache', // *default, no-cache, reload, force-cache, only-if-cached
credentials: 'same-origin', // include, same-origin, *omit
headers: {
'user-agent': 'Mozilla/4.0 MDN Example',
'content-type': 'application/json'
},
method: 'POST', // *GET, POST, PUT, DELETE, etc.
mode: 'cors', // no-cors, cors, *same-origin
// redirect: 'follow', // manual, *follow, error
// referrer: 'no-referrer', // *client, no-referrer
})
.then(() => nextSeek())
// .catch(console.error);
}
// 檔案切分好幾塊 , 然後最後把檔案 merge 起來
function seek() {
let start = processTimes * CHUNK_SIZE;
let next = (processTimes + 1) * CHUNK_SIZE;
let end = (next > file.size) ? file.size : next;
// cutting big files to small chunks
let slice = file.slice(start, end);
reader.readAsArrayBuffer(slice);
}
seek();
},
}
});
app.mount('.root');
</script>
</body>
</html>
```
接收檔案用的 file.js
```javascript=
const router = require("express").Router();
const fileUtils = require("../utils/fileUtils");
const fs = require("fs");
router.post("/upload/bytes", (req, res) => {
const { bytes, fileName } = req.body;
const filePath = `../../uploads/${fileName}`;
const byteArray = Uint8Array.from(Object.values(bytes));
fs.writeFileSync(filePath, byteArray, { flag: "a+" });
res.json({ bytes });
});
module.exports = router;
```
### 成果圖
可在 `https://localhonst:3001/static/upload.html` 看到成果
![](https://i.imgur.com/Xh5w0IP.png)
## 成果
:::danger
下方 codesandbox 無法上傳資料 , 前還在解決 😢
:::
<iframe src="https://codesandbox.io/embed/upload-big-file-2nhln?fontsize=14&hidenavigation=1&theme=dark"
style="width:100%; height:500px; border:0; border-radius: 4px; overflow:hidden;"
title="upload-big-file"
allow="accelerometer; ambient-light-sensor; camera; encrypted-media; geolocation; gyroscope; hid; microphone; midi; payment; usb; vr; xr-spatial-tracking"
sandbox="allow-forms allow-modals allow-popups allow-presentation allow-same-origin allow-scripts"
></iframe>
## 參考資料
- [使用FileReader.readAsArrayBuffer()在浏览器中处理大文件](https://joji.me/zh-cn/blog/processing-huge-files-using-filereader-readasarraybuffer-in-web-browser/)
- [MDN File API 說明文件](https://developer.mozilla.org/zh-TW/docs/Web/API/File)