以 React + Express 為範例探討 Web 應用中的檔案上傳機制
最近在工作遇到了需要處理上傳檔案的需求,借此機會完整地學習 Web 前後端處理檔案上傳的機制。本篇筆記以一個簡單範例輔助學習,該範例提供使用者在畫面中上傳任意圖片格式的單一圖片檔案,並儲存於伺服器端的特定資料夾中。使用者可以透過拖放的方式將檔案上傳至網站上,亦可以用點擊的方式打開資料總管,選擇要上傳的檔案。
本篇筆記專注於探討 React 專案中檔案上傳的各種機制與資料結構。本文不會深入討論元件的樣式問題。本筆記提供的範例程式碼將專注於功能實現。
範例

Example Code: React
- 使用 Vite 在 frontend 資料夾建立一個新的 React 專案
yarn create vite frontend --template react-ts
cd frontend
yarn
- 安裝必要套件
yarn add @mui/material @emotion/react @emotion/styled
yarn add axios react-dropzone
- frontend/src/App.tsx
import React, { useState } from "react";
import { useDropzone } from "react-dropzone";
import axios from "axios";
import LinearProgress from "@mui/material/LinearProgress";
import { Box, Typography, Paper, Button, Snackbar } from "@mui/material";
const App: React.FC = () => {
const [file, setFile] = useState<File | null>(null);
const [fileInfo, setFileInfo] = useState<{
name: string;
size: number;
type: string;
preview: string;
} | null>(null);
const [uploadProgress, setUploadProgress] = useState<number>(0);
const [openSnackbar, setOpenSnackbar] = useState<boolean>(false);
const simulateUploadProgress = () => {
setUploadProgress(0);
const interval = setInterval(() => {
setUploadProgress((oldProgress) => {
if (oldProgress >= 100) {
clearInterval(interval);
return 100;
}
return oldProgress ? oldProgress * 2 : oldProgress + 10; // 每次增加 10%,可根據需求調整
});
}, 100); // 每 100 毫秒更新一次進度
};
const handleFileChange = (acceptedFiles: File[]) => {
console.log(acceptedFiles);
const selectedFile = acceptedFiles[0];
setFile(selectedFile);
setFileInfo({
name: selectedFile.name,
size: selectedFile.size,
type: selectedFile.type,
preview: URL.createObjectURL(selectedFile),
});
simulateUploadProgress(); // 模擬上傳進度
};
const uploadFile = () => {
if (!file) {
alert("請先選擇一個檔案。");
return;
}
const formData = new FormData();
formData.append("file", file);
console.log("file", file);
console.log("formData", formData);
axios
.post("http://localhost:5000/upload", formData)
.then((response) => {
console.log(response.data);
setOpenSnackbar(true); // 顯示上傳成功的通知
})
.catch((error) => {
console.error(error);
});
};
const handleCloseSnackbar = () => {
setOpenSnackbar(false);
};
const { getRootProps, getInputProps } = useDropzone({
// accept: { "image/jpeg": [], "image/png": [] },
accept: { "image/*": [] },
onDrop: handleFileChange,
onDragEnter: () => {
setUploadProgress(0);
},
onFileDialogOpen: () => {
setUploadProgress(0);
},
});
console.log("getRootProps", getRootProps());
console.log("getInputProps", getInputProps());
return (
<Box
sx={{
display: "flex",
flexDirection: "column",
alignItems: "center",
paddingTop: "20px",
}}
>
<Paper elevation={3} sx={{ width: "50%", padding: "20px" }}>
<Box
{...getRootProps()}
sx={{ border: "2px dashed #ccc", p: 3, mb: 3 }}
>
<input {...getInputProps()} />
<Typography sx={{ textAlign: "center" }}>
將檔案拖曳到這裡,或點擊選擇檔案
</Typography>
</Box>
<LinearProgress
variant='determinate'
value={uploadProgress}
sx={{ marginTop: "10px" }}
/>
{uploadProgress === 100 && fileInfo && (
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "center",
paddingTop: "20px",
}}
>
<img
src={fileInfo.preview}
alt='Preview'
style={{
maxWidth: "100px",
maxHeight: "100px",
marginRight: "10px",
}}
/>
<Typography sx={{ flexGrow: 1 }}>{fileInfo.name}</Typography>
<Typography sx={{ mx: 1 }}>{fileInfo.type}</Typography>
<Typography sx={{ mx: 1 }}>
{(fileInfo.size / 1024).toFixed(2)} KB
</Typography>
</Box>
)}
<Button
variant='contained'
onClick={uploadFile}
sx={{ width: "100%", marginTop: "10px" }}
>
上傳檔案
</Button>
</Paper>
<Snackbar
open={openSnackbar}
autoHideDuration={6000}
onClose={handleCloseSnackbar}
message='上傳成功!'
/>
</Box>
);
};
export default App;
Example Code: Express
- 在 backend 資料夾建立一個新的 Express 專案
mkdir backend
cd backend
yarn init -y
- 安裝必要套件
yarn add express multer
yarn add @types/node @types/express @types/multer ts-node-dev typescript -D
- 在 backend 目錄中,創建 tsconfig.json:
{
"compilerOptions": {
"target": "esnext",
"module": "commonjs",
"moduleResolution": "node",
"outDir": "./dist",
"esModuleInterop": true,
"strict": true
},
"include": ["src/**/*"]
}
- 打開 backend/package.json 文件,並在其中添加如下的 scripts 部分:
"scripts": {
"start": "ts-node-dev src/index.ts",
"build": "tsc",
"serve": "node dist/index.js"
}
- backend/src/index.ts
import express from "express";
import multer from "multer";
import cors from "cors";
import path from "path";
const app = express();
const port = 5000;
app.use(cors());
const storage = multer.diskStorage({
destination: (req, file, cb) => {
cb(null, "uploads/");
},
filename: (req, file, cb) => {
// 先轉換檔案名編碼
const correctedName = Buffer.from(file.originalname, "latin1").toString(
"utf8"
);
const ext = path.extname(correctedName);
const baseName = path.basename(correctedName, ext);
cb(null, `${baseName}-${Date.now()}${ext}`);
},
});
const upload = multer({ storage });
app.post("/upload", upload.single("file"), (req, res) => {
if (req.file) {
console.log(`Received file: ${req.file.path}`);
res.json({ message: "檔案上傳成功", path: req.file.path });
} else {
res.status(400).json({ message: "檔案上傳失敗" });
}
});
app.listen(port, () => {
console.log(`Server is running at http://localhost:${port}`);
});
前端(React)上傳檔案機制
HTML 元素 <input type="file">
在 Web 應用中,檔案上傳功能的實現通常依賴於 <input type="file"> 這個 HTML 元素。當使用這種類型的輸入時,瀏覽器會提供一個按鈕,用戶點擊後會打開標準的系統文件選擇對話框,用戶可以從本地電腦中選擇一個或多個文件。
- 基本用法
基本的文件輸入元素看起來像這樣:
<input type="file" id="myfile" name="myfile">
這個元素會在網頁上創建一個按鈕,用戶點擊後可以選擇文件。
- 接受特定類型的文件
<input> 標籤的 accept 屬性允許我們限制用戶可以選擇的文件類型。例如,如果只希望用戶上傳圖片,可以這樣設定:
<input type="file" id="imagefile" name="imagefile" accept="image/*">
- 多文件選擇
如果希望允許用戶一次選擇多個文件,可以添加 multiple 屬性:
<input type="file" id="multiplefiles" name="files" accept="image/*" multiple>
檔案資料類型:File, FileList
當用戶通過 <input type="file"> 選擇了文件後,這些文件將會作為 DOM 元素的一部分存儲在其 files 屬性中。我們在前端可以寫一個事件處理函數綁定於當用戶選擇了新的檔案時觸發的 onChange 事件,這樣我們就可以從事件目標(event.target)中獲取檔案資料進行進一步的操作。
- FileList
當使用者選擇檔案後,事件的 target.files 屬性是一個 FileList 物件。FileList 是一個類似陣列的物件,包含了所有選擇的檔案。每個檔案都被封裝為一個 File 物件。
- File
File 物件包含在 FileList 中,提供了檔案的基本訊息,它繼承自 Blob,並添加了檔案相關的屬性,像是:
- name:檔案的名稱(包括擴展名)。
- size:檔案的大小,以 byte 為單位。
- type:檔 案的 MIME 類型,例如 "image/jpeg"。
- lastModified:檔案最後修改的時間戳,以毫秒為單位。這個數值表示自 1970 年 1 月 1 日午夜 (00:00 UTC) 至文件最後修改時間的毫秒數。
- lastModifiedDate:檔案最後修改的日期,為一個 Date 物件。這個屬性是對 lastModified 毫秒時間戳的一個更直觀的日期表示,便於讀取和顯示。
使用 react-dropzone 以 Drag & Drop 方式上傳檔案
比起傳統的點擊上傳按鈕,現在的 Web 應用程式通常允許用戶直接從他們的檔案管理器中把文件拖到網頁的指定區域,本文章提供的範例中使用了 react-dropzone 套件來實現這一功能。 react-dropzone 封裝了很多細節和事件處理,讓開發者可以輕鬆地實現拖放功能,它提供了一個簡單的 useDropzone API ,讓開發者可以輕鬆的設定並管理拖放區域所需的所有屬性和方法。
詳細API 使用方法參閱 @react-dropzone
以下簡單介紹 useDropzone 的幾個常用的參數定義與回傳
- 選項
- accept:指定接受的檔案類型。
- onDrop:當檔案被拖放到區域時的回呼,參數為接受和拒絕的檔案。
- multiple:是否允許選擇多個檔案。
- minSize、maxSize:設定檔案的最小和最大大小。
- disabled:停用拖放功能。
- maxFiles:限制用戶一次性能夠上傳的最大文件數量
- noClick:當設置為 true 時,拖放區域將不會響應點擊事件,這意味著用戶不能通過點擊來打開文件選擇對話框。
- noDrag:當設置為 true 時,禁用拖放功能。這意味著用戶不能將文件拖到指定區域來上傳,但仍可以通過點擊來觸發文件選擇。
- 回傳
- getRootProps():返回應用於拖放區域的 props,包括 onClick, onDrop 等事件處理器等
- getInputProps():返回應用於 <input type="file"> 的 props,如
{accept: 'image/*', multiple: true, type: 'file', style: {display: 'none'}, onChange: ƒ, …}。 - acceptedFiles:一個包含所有被接受檔案的陣列。
- fileRejections:一個包含所有被拒絕檔案的陣列,每個對象包含檔案和錯誤資訊。
- open():一個函數,可以用來打開檔案選擇器,通常用於自訂點選行為時。