Node.js自學筆記 (5/12)

使用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對於jpgjpeg,其收到的minetype都是imge/jpeg,所以在判斷時不要寫imge/jpg

另外也請注意義上程式碼的switch寫法,對於滿足image/pngimage/jpeg的檔案,都會執行:

fs.rename(req.file.path, './public/img/'+req.file.originalname, error=>{});

然後使用postman測試。可得到成功上傳檔案的結果。

但是如果重複上傳相同的檔名,後面的檔案會蓋掉前面的檔案。所以最好把上傳的檔案加上亂數編碼。

mimetype參考:

  1. 常见 MIME 类型列表 - HTTP | MDN
  2. MIME 類別 (IANA 媒體類別) - HTTP | MDN

接下來要製作表單來上傳圖檔

首先在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

2020-11-07_19-21-50

使用filereader在上傳檔案到Server前,先預覽圖片

上述做法,沒有在client端以javascript篩選檔案。接下來要實作在上傳之前,先在client端檢查檔案的附檔名、尺寸等是否符合規定,再做上傳。

可以先在google搜尋關鍵字:「js image preview」,以及「filereader」。

可以參考文件說明:FileReader - Web APIs | MDN

filereader有以下Method:

  1. abort()
  2. readAsArrayBuffer()
  3. readAsBinaryString()
  4. readAsDataURL()
  5. readAsText()

使用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>

2020-11-07_19-56-10

若觀察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塞到隱藏表單,再做上傳即可。