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