使用Multer處理檔案上傳
- 使用 multer
- 安裝:
> npm i multer
- 說明可參考 multer 的npmjs 主頁 multer - npm
- 建立 tmp_uploads 做為檔案上傳的暫存資料夾(名稱可自訂)
- 建立 public/img 做為存放圖檔的資料夾(名稱可自訂)
Express使用body-parser來處理post的data query (application/x-www-urlencoded),但是body-parser無法處理 multipart bodies (multipart/form-data) (即圖片上傳),在body-parser NPM主頁中,他們建議可用以下套件來做 multipart bodies:
在這裡,我們使用 multer 來做圖片上傳。
multer使用範例
const multer = require('multer');
const upload = multer({ dest: 'tmp_uploads/' }); // 設定上傳暫存目錄
const fs = require('fs'); // 處理檔案的核心套件
app.get('/try-upload', (req, res) => {
res.render('try-upload');
});
app.post('/try-upload', upload.single('avatar'), (req, res) => {
console.log(req.file); // 查看裡面的屬性
if (req.file && req.file.originalname) {
// 判斷是否為圖檔
if (/\.(jpg|jpeg|png|gif)$/i.test(req.file.originalname)) {
// 將檔案搬至公開的資料夾
fs.rename(req.file.path, './public/img/' + req.file.originalname, error => { });
} else {
fs.unlink(req.file.path, error => { }); // 刪除暫存檔
}
}
res.render('try-upload', {
result: true,
name: req.body.name,
avatar: '/img/' + req.file.originalname
});
});
先把index.js
加上以下程式碼:
設定
var express = require('express');
var multer = require('multer');
const upload = multer({ dest: 'tmp_uploads/' }); // 設定上傳暫存目錄
const fs = require('fs'); // 處理檔案的核心套件
路由
app.post('/try-upload', upload.single('avatar'), (req, res) => {
console.log(req.body);
console.log(req.file);
res.send('ok');
});
屆時我們先用console觀看資料內容。
使用postman做發送測試:
傳送後,可在postman的body看到ok字樣。
然後可以在vscode的cmd介面看到以下內容:
其中,以下內容取自 req.body
{ name: 'Ben', age: '20' }
以下內容取自 req.file
{
fieldname: 'avatar',
originalname: '360.png',
encoding: '7bit',
mimetype: 'image/png',
destination: 'tmp_uploads/',
filename: '7b6c8a83cdb22b3751238316edcfee98',
path: 'tmp_uploads\\7b6c8a83cdb22b3751238316edcfee98',
size: 235371
}
修改程式,把index.js
加上以下程式:
app.post('/try-upload', upload.single('avatar'), (req, res) => {
console.log(req.body);
console.log(req.file);
if (req.file && req.file.originalname) {
switch (req.file.mimetype) {
case 'image/png':
case 'image/jpeg':
fs.rename(req.file.path, './public/img/'+req.file.originalname, error=>{});
break;
default:
fs.unlink(req.file.path, error=>{});
break;
}
}
res.send('ok');
});
須注意,multer對於jpg或jpeg,其收到的minetype都是imge/jpeg,所以在判斷時不要寫imge/jpg。
另外也請注意義上程式碼的switch寫法,對於滿足image/png或image/jpeg的檔案,都會執行:
fs.rename(req.file.path, './public/img/'+req.file.originalname, error=>{});
然後使用postman測試。可得到成功上傳檔案的結果。
但是如果重複上傳相同的檔名,後面的檔案會蓋掉前面的檔案。所以最好把上傳的檔案加上亂數編碼。
mimetype參考:
接下來要製作表單來上傳圖檔
首先在index.js加上以下路由
app.get('/try-upload', function (req, res) {
res.render('try-upload');
});
新增 views/try-upload.ejs
end, send, render, json四個只能挑一個。
做出來的效果如下:
另一種上傳方式
/public/try-upload2.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="bootstrap/css/bootstrap.css">
<title>Document</title>
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-light bg-light">
<a class="navbar-brand" href="#">Navbar</a>
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarSupportedContent"
aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarSupportedContent">
<ul class="navbar-nav mr-auto">
<li class="nav-item active">
<a class="nav-link" href="#">Home <span class="sr-only">(current)</span></a>
</li>
<li class="nav-item">
<a class="nav-link" href="#">Link</a>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="navbarDropdown" role="button"
data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
Dropdown
</a>
<div class="dropdown-menu" aria-labelledby="navbarDropdown">
<a class="dropdown-item" href="#">Action</a>
<a class="dropdown-item" href="#">Another action</a>
<div class="dropdown-divider"></div>
<a class="dropdown-item" href="#">Something else here</a>
</div>
</li>
<li class="nav-item">
<a class="nav-link disabled" href="#" tabindex="-1" aria-disabled="true">Disabled</a>
</li>
</ul>
<form class="form-inline my-2 my-lg-0">
<input class="form-control mr-sm-2" type="search" placeholder="Search" aria-label="Search">
<button class="btn btn-outline-success my-2 my-sm-0" type="submit">Search</button>
</form>
</div>
</nav>
<button onclick="$('#avatar').click()" class="btn btn-primary">Upload Image</button>
<div class="file-upload">
<form name="form1" style='display: none;'>
<div class="form-group">
<input type="file" class="form-control" id="avatar" name="avatar">
</div>
</form>
</div>
<img src="" alt="" id="myimg">
<script src="./js/jquery-3.5.1.js"></script>
<script src="./bootstrap/js/bootstrap.bundle.js"></script>
<script>
const avatar = $('#avatar');
avatar.on('change', function (event) {
const fd = new FormData(document.form1)
fetch('/try-upload2', {
method: 'POST',
body: fd
})
.then(r => r.json())
.then(obj => {
$('#myimg').attr('src', '/img-uploads/' + obj.filename)
})
});
</script>
</body>
</html>
/src/upload-module.js
var multer = require('multer');
var { v4: uuidv4 } = require('uuid');
// console.log(uuidv4());
const extMap = {
'image/jpeg': '.jpg',
'image/png': '.png',
'image/gif': '.gif',
}
const storage = multer.diskStorage({
destination: (req, file, cb) => {
cb(null, __dirname + '/../public/img-uploads')
},
filename: (req, file, cb) => {
let ext = extMap[file.mimetype];
if (ext) {
cb(null, uuidv4() + ext);
} else {
cb(new Error('not allowed'));
}
// let filename= uuidv4()+ext;
// cb(null, uuidv4() + ext);
}
});
const fileFilter = (req, file, cb) => {
cb(null, !!extMap[file.mimetype]);
};
const upload = multer({ storage, fileFilter });
module.exports = upload;
/src/index.js
// 1. 引入 express
var express = require('express');
// var multer = require('multer');
// const upload = multer({ dest: 'tmp_uploads/' }); // 設定上傳暫存目錄
const fs = require('fs'); // 處理檔案的核心套件
const upload = require(__dirname+'/upload-module');
// 2. 建立 web server 物件
var app = express();
// 註冊樣版引擎
app.set('view engine', 'ejs');
// 設定views路徑 (選擇性設定)
// app.set('views', __dirname + '/../views');
// top-level middleware
app.use(express.urlencoded({ extended: false }));
app.use(express.json());
// 3. 路由
app.get('/', function (req, res) {
res.render('main', { name: 'Benjamin', pageTitle: '小凱的網站' });
});
app.get('/sales-json', function (req, res) {
const sales = require(__dirname + '/../data/sales.json'); //變數名稱為sales
res.render('sales-json', { sales }) //這樣寫法表示數名名稱跟變數名稱一樣都是sales
});
app.get('/try-qs', function (req, res) {
res.json(req.query);
});
app.get('/try-post-form', function (req, res) {
res.render('try-post-form', { pageTitle: '測試表單' });
});
// const urlencodedPasser = express.urlencoded({extened:false});
// 使用http的post方法
// app.post('/try-post-form', urlencodedPasser, function (req, res) {
// req.body.haha='aabbb';
// res.json(req.body);
// });
app.post('/try-post-form', (req, res) => {
res.locals.pageTitle = '測試表單-posted'
res.render('try-post-form', req.body)
});
app.post('/try-json-post', (req, res) => {
req.body.haha = 'labview360';
req.body.contentType = req.get('Content-Type');
res.json(req.body);
});
app.get('/pending', function (req, res) {
// res.send('Hello World!');
});
app.get('/ok', function (req, res) {
res.send('ok!');
});
app.get('/try-upload', function (req, res) {
res.render('try-upload');
});
/*
app.post('/try-upload', upload.single('avatar'), (req, res) => {
console.log(req.body);
console.log(req.file);
const output={
success:false,
uploadedImg:'',
nickname:'',
errorMsg:''
}
output.nickname = req.body.nickname || '';
if (req.file && req.file.originalname) {
switch (req.file.mimetype) {
case 'image/png':
case 'image/jpeg':
fs.rename(req.file.path, './public/img/'+req.file.originalname, error=>{
if (!error){
output.success = true;
output.uploadedImg = '/img/'+req.file.originalname;
}
res.render('try-upload', output);
});
break;
default:
fs.unlink(req.file.path, error=>{
output.errorMsg='檔案類型錯誤';
res.render('try-upload', output);
});
break;
}
}
});
*/
app.post('/try-upload2', upload.single('avatar'), (req, res) => {
res.json({
filename: req.file.filename,
body: req.body
});
// console.log(req.file);
// res.send('/try-upload2 ok')
});
app.use(express.static('public'));
app.use((req, res) => {
res.type('text/html');
res.status(404);
res.send("<h2>404 - 找不到網頁</h2>");
});
// 4. Server 偵聽
app.listen(3000, function () {
console.log('啟動 server 偵聽埠號 3000');
});
啟動server:nodemon
使用filereader
在上傳檔案到Server前,先預覽圖片
上述做法,沒有在client端以javascript篩選檔案。接下來要實作在上傳之前,先在client端檢查檔案的附檔名、尺寸等是否符合規定,再做上傳。
可以先在google搜尋關鍵字:「js image preview」,以及「filereader
」。
可以參考文件說明:FileReader - Web APIs | MDN
filereader有以下Method:
使用readAsDataURL()
讀取客戶端的檔案,它會把檔案轉換成base64的字串。可參考這裡:https://developer.mozilla.org/zh-TW/docs/Web/API/FileReader/readAsDataURL
範例如下:
function previewFile() {
const preview = document.querySelector('img');
const file = document.querySelector('input[type=file]').files[0];
const reader = new FileReader(); // 設定事件處理器
reader.addEventListener("load", function () {
// convert image file to base64 string
preview.src = reader.result;
}, false);
if (file) {
reader.readAsDataURL(file); // 指頂讀取格式,讀取完畢會觸發load事件,把read的內容傳給image的src標籤,就可以呈現圖片出來
}
}
/public/try-upload-preview.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="bootstrap/css/bootstrap.css">
<title>Document</title>
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-light bg-light">
<a class="navbar-brand" href="#">Navbar</a>
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarSupportedContent"
aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarSupportedContent">
<ul class="navbar-nav mr-auto">
<li class="nav-item active">
<a class="nav-link" href="#">Home <span class="sr-only">(current)</span></a>
</li>
<li class="nav-item">
<a class="nav-link" href="#">Link</a>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="navbarDropdown" role="button"
data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
Dropdown
</a>
<div class="dropdown-menu" aria-labelledby="navbarDropdown">
<a class="dropdown-item" href="#">Action</a>
<a class="dropdown-item" href="#">Another action</a>
<div class="dropdown-divider"></div>
<a class="dropdown-item" href="#">Something else here</a>
</div>
</li>
<li class="nav-item">
<a class="nav-link disabled" href="#" tabindex="-1" aria-disabled="true">Disabled</a>
</li>
</ul>
<form class="form-inline my-2 my-lg-0">
<input class="form-control mr-sm-2" type="search" placeholder="Search" aria-label="Search">
<button class="btn btn-outline-success my-2 my-sm-0" type="submit">Search</button>
</form>
</div>
</nav>
<button onclick="$('#avatar').click()" class="btn btn-primary">Select Image</button>
<div class="file-upload">
<form name="form1" style='display: none;'>
<div class="form-group">
<input type="file" class="form-control" id="avatar" name="avatar">
</div>
</form>
</div>
<img src="" alt="" id="myimg">
<script src="./js/jquery-3.5.1.js"></script>
<script src="./bootstrap/js/bootstrap.bundle.js"></script>
<script>
const avatar = $('#avatar');
const myimg = $('#myimg'); // 取得參照
avatar.on('change', function (event) {
const reader = new FileReader();
reader.addEventListener("load", function () {
// convert image file to base64 string
myimg.attr('src', reader.result);
});
reader.readAsDataURL(avatar[0].files[0])
});
</script>
</body>
</html>
若觀察Elements
可以注意到,圖片的地方,已經讀取為base64格式,而不是對應到網路路徑
可以在<input>
標籤裡面加上accept
屬性,規定檔案的mimetype
須注意要使用mimetype,不是使用副檔名。
<input type="file" class="form-control" id="avatar" name="avatar" accept="image/jpeg">
<-- 只接受JPEG檔案 -->
<input type="file" class="form-control" id="avatar" name="avatar" accept="image/*">
<-- 所有圖片格式都可以 -->
可以在前端先使用accept
屬性,讓用戶較方便選到正確的檔案。
用戶的圖檔使用filereader
讀取為base64後,再把資料用javascript塞到隱藏表單,再做上傳即可。