【Node.js × Express × TypeScript × React】multerを使用してファイルをアップロ
はじめに
本記事は前科の記事に引き続いて、MERNスタックアプリケーションで、multer
を用いて、ファイルアップロード機能を実装するという内容の記事です。
今回は、フロントエンドから、バックエンドで作成したファイルアップロード機能を含むAPIを呼び出し、画面からファイルをアップロードしDB保存する機能を実装します。
(前回とはことなるツイート投稿APIでの実装になります)
※ 前回はユーザー更新APIだったので、気になる方や理解を深めたい方は前回の記事を以下から参照ください
【Node.js × Express × TypeScript × React】multerを使用してファイルをアップロードし表示する方法
実装
まずは、APIの実装から見ていきます。
APIの実装
:::note info
multerの導入
:::
まず以下のコマンドを実行し、ライブラリをインストールします。
npm install multer
npm install @types/multer
:::note info
multerの設定
:::
ライブラリをインストールしたいら、以下のように設定を行います。
import multer from "multer";
import fs from "fs";
import path from "path";
// ファイルの保存先とファイル名を指定
const storage = multer.diskStorage({
destination: (req, file, cb) => {
// 画像がuploadされるパス
const uploadPath = path.resolve(__dirname, "../uploads");
// uploadPathにディレクトリが存在するかどうかを確認
if (!fs.existsSync(uploadPath)) {
// uploadPathにディレクトリが存在しない場合、ディレクトリを作成
fs.mkdirSync(uploadPath, { recursive: true });
}
cb(null, uploadPath);
},
// アップロードされるファイル名を作成
filename: (req, file, cb) => {
const uniqueSuffix = Math.random().toString(26).substring(4, 10);
cb(null, `${Date.now()}-${uniqueSuffix}-${file.originalname}`);
},
});
const upload = multer({
storage,
fileFilter: (req, file, cb) => {
// ファイルのMIMEタイプが以下のいずれかの場合のみファイルアップロードを許可
if (
["video/mp4", "image/png", "image/jpeg", "audio/mpeg"].includes(
file.mimetype
)
) {
cb(null, true);
return;
}
cb(new TypeError("Invalid File Type"));
},
});
export default upload;
これで何をしているのかというのは、コメントに書いてある通りで、ファイルの保存先とファイル名を指定し、アップロード時のフィルタリングを行うようにしています。
(この状態では、まだアップロード処理は走りません)
ビジネスロジック
:::note info
エンドポイントの設定
:::
以下、APIのエンドポイントの設定です。
import express from "express";
import { create } from "../services/tweetService";
import verifyToken from "../middleware/tokenHandler";
import { validContentLength } from "../services/validation/tweetValid";
import { printErrors } from "../services/validation/validation";
import upload from "../middleware/multerHandler";
const router = express.Router();
// ツイート新規登録APIの呼び出し
router.post(
"/create",
validContentLength,
printErrors,
upload.array("tweetImage", 4),
verifyToken,
(req: express.Request, res: express.Response) => {
create(req, res);
}
);
export default router;
ここで、注目していただきたいのは、upload.array()
の部分です。
ここで、tweetImage
というフィールドのファイルを4ファイルアップロードするようにしています。
このupload
はmulterの設定のuploadです。
tweetImageというのは、Tweetスキーマのプロパティで、型はStringArray型を採用しています。
:::note info
メインロジック
:::
以下が、メインの処理になります。
import express from "express";
import Tweet from "../models/Tweet";
export const create = async (req: express.Request, res: express.Response) => {
try {
if (!req.user?.id) {
return res.status(401).json({
errors: [
{
param: "userId",
msg: "無効なリクエストです",
},
],
});
}
const tweetImage = req.files
? (req.files as Express.Multer.File[]).map((file) => file.filename)
: [];
const tweet = await Tweet.create({
userId: req.user?.id,
content: req.body.content,
tweetImage: tweetImage,
});
return res.status(201).json(tweet);
} catch (err) {
console.log(err);
return res.status(500).json(err);
}
};
ここで重要な点はtweetImage
という変数です。
ここで、リクエストで取得したファイルからファイル名を取得し、mapで新しい配列として変数化しています。
これをTweetスキーマのcreateに渡せば、ファイルをアップロードすることが可能となります。
以上がAPI側の実装です。
フロントエンドの実装
フロントエンドではaxios
を使用して、APIと通信していきます。
::: note info
axiosの導入
:::
以下のコマンドを実行してaxiosを導入します。
npm install axios
:::note info
axiosのインスタンスを作成
:::
以下が、そのコードです。
import axios from "axios";
const BASE_URL = process.env.REACT_APP_BASE_URL as string;
const getToken = () => localStorage.getItem("token");
const createInstance = (config = {}) => {
const instance = axios.create({
baseURL: BASE_URL,
...config,
});
instance.interceptors.request.use(async (conf: any) => {
const token = await getToken();
return {
...conf,
headers: {
...conf.headers,
authorization: `Bearer ${token}`,
},
};
});
instance.interceptors.response.use(
(response) => {
return response;
},
(err) => {
throw err.response;
}
);
return instance;
};
export const axiosClient = createInstance();
export const axiosClientFormData = createInstance({
headers: { "Content-Type": "multipart/form-data" },
});
createInstane
でインスタンスを作成するのですが、config
などは定型的な記述方法ですので、あまり気にしなくも良い部分です。
やっていることとしては、tokenをローカルストレージから取得して、ヘッダーのauthorization
に設定しているということと、レスポンス返却の設定です。
重要なのが、exportしている部分で、multer
を使用しているAPIはaxiosClientFormData
を使用しないといけません。
これは、multer
がmultipart/form-data
というフォームの型でデータをやり取りするため、axiosの設定でも、それを明示的に使用する旨を記述する必要があります。
:::note info
APIエンドポイントの設定
:::
次に使用するAPIををフロントで使用するため、フロント側でもエンドポイントの設定を行います。
import { axiosClientFormData } from "./axiosClient";
const tweetApi = {
create: (formData: FormData) =>
axiosClientFormData.post("tweet/create", formData, {
headers: {
"Content-Type": "multipart/form-data",
},
}),
};
export default tweetApi;
ここでもheaders
にmultipart/form-data
を指定しています。
axiosの設定と符号しています。
コンポーネントでAPIを呼び出し
以下が、APIを呼び出しているコンポーネントの記述です。
import { Typography, Box, TextField, Avatar, IconButton } from "@mui/material";
import noAvatar from "../../assets/images/noAvatar.png";
import ImageIcon from "@mui/icons-material/Image";
import EmojiEmotionsIcon from "@mui/icons-material/EmojiEmotions";
import React, { useState } from "react";
import { LoadingButton } from "@mui/lab";
import CloseIcon from "@mui/icons-material/Close";
import tweetApi from "../../api/tweetApi";
type TweetBoxPropsType = {
title: string | undefined;
rows: number | undefined;
};
const TweetBox = ({ title, rows }: TweetBoxPropsType) => {
const [loading, setLoading] = useState<boolean>(false);
const [tweet, setTweet] = useState<string>("");
const [tweetErrMsg, setTweetErrMsg] = useState<string>("");
const [images, setImages] = useState<File[]>([]);
const [imagePreviews, setImagePreviews] = useState<string[]>([]);
const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) {
const filesArray = Array.from(e.target.files);
setImages([...images, ...filesArray]);
const filePreviews = filesArray.map((file) => URL.createObjectURL(file));
setImagePreviews([...imagePreviews, ...filePreviews]);
}
};
const handleImageRemove = (imagePreview: string) => {
// 引数と同じimagePreviewsのindexを取得
const imageIndex = imagePreviews.indexOf(imagePreview);
// imagePreviewが存在する場合
if (imageIndex > -1) {
const updatedImagePreviews = [...imagePreviews];
// imagePreviewsから該当のimagePreviewを削除
updatedImagePreviews.splice(imageIndex, 1);
setImagePreviews(updatedImagePreviews);
const updatedImages = [...images];
// imagesから該当のimageを削除
updatedImages.splice(imageIndex, 1);
setImages(updatedImages);
}
};
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setLoading(true);
// バリデーション
let err = false;
// 改行と空白を除去した文字列を作成
const trimmedTweet = tweet.replace(/(\r\n|\n|\r|\s)/g, "");
if (!trimmedTweet) {
err = true;
setTweetErrMsg("ツイートを入力してください");
} else if (tweet.length > 140) {
err = true;
setTweetErrMsg("ツイートは140文字以内で入力してください");
}
if (images.length > 4) {
err = true;
setTweetErrMsg("画像は4枚まで選択可能です");
}
if (err) return setLoading(false);
const formData = new FormData();
formData.append("content", tweet);
images.forEach((image) => {
formData.append("tweetImage", image);
});
// ツイート登録API呼出
try {
await tweetApi.create(formData);
setLoading(false);
console.log("ツイート登録に成功しました");
setImagePreviews([]);
setImages([]);
setTweet("");
} catch (err: any) {
const errors = err.data.errors;
console.log(errors);
setLoading(false);
}
};
return (
<Box>
<Typography variant="h5">{title}</Typography>
<Box
component="form"
encType="multipart/form-data"
noValidate
onSubmit={handleSubmit}
sx={{ display: "flex", mr: "10px", maxWidth: 500 }}
>
<Avatar src={noAvatar} alt="noAvatar" sx={{ mt: "20px" }} />
<Box sx={{ display: "flex", flexDirection: "column", flexGrow: 1 }}>
<TextField
fullWidth
variant="standard"
id="tweet"
name="tweet"
value={tweet}
rows={rows}
label="What's happening?"
placeholder="What's happening?"
margin="normal"
multiline
onChange={(e) => setTweet(e.target.value)}
error={tweetErrMsg !== ""}
helperText={tweetErrMsg}
inputProps={{ maxLength: 140 }}
/>
{/* 画像プレビュー */}
<Box sx={{ display: "flex", flexWrap: "wrap", position: "relative" }}>
{imagePreviews.map((imagePreview, index) => (
<Box
key={index}
sx={{
position: "relative",
width: "100px",
height: "100px",
margin: "5px",
}}
>
<IconButton
sx={{
position: "absolute",
left: -10,
top: -5,
background: "#6f7070",
color: "white",
":hover": {
background: "#6f7070",
opacity: 0.7,
},
}}
onClick={() => handleImageRemove(imagePreview)}
>
<CloseIcon />
</IconButton>
<img
src={imagePreview}
alt={`tweet_image_${index}`}
width="100"
height="100"
style={{ objectFit: "cover" }}
/>
</Box>
))}
</Box>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<Box sx={{ display: "flex" }}>
<IconButton
component="label"
htmlFor="tweetImage"
disabled={images.length === 4}
sx={{
color: "#1DA1F2",
":hover": {
cursor: "pointer",
background: "#c2dff0",
borderRadius: "50%",
},
}}
>
<input
type="file"
id="tweetImage"
name="tweetImage"
accept="video/mp4 image/png image/jpeg audio/mpeg"
multiple
style={{ display: "none" }}
onChange={handleImageChange}
/>
<ImageIcon />
</IconButton>
<IconButton
sx={{
color: "#1DA1F2",
":hover": {
cursor: "pointer",
background: "#c2dff0",
borderRadius: "50%",
},
}}
>
<EmojiEmotionsIcon />
</IconButton>
</Box>
<LoadingButton
type="submit"
loading={loading}
sx={{
padding: "5px 20px",
borderRadius: "40px",
textTransform: "none",
background: "#1DA1F2",
color: "#fff",
":hover": { background: "#1da0f29c" },
}}
>
Tweet
</LoadingButton>
</Box>
</Box>
</Box>
</Box>
);
};
export default TweetBox;
handleImageChange
とhandleImageRemove
は画像プレビューに関する機能なので、無視してもらって構いません。
重要なのが、handleSubmit
の処理と処理を行っているformの部品です。
const formData = new FormData();
formData.append("content", tweet);
images.forEach((image) => {
formData.append("tweetImage", image);
});
<Box
component="form"
encType="multipart/form-data"
noValidate
onSubmit={handleSubmit}
sx={{ display: "flex", mr: "10px", maxWidth: 500 }}
>
上記で特に重要な箇所を抜粋しました。
multipart/form-data
はformDataとして渡してあげる必要があるため、formDataにappendするようにして、APIのreqに渡してあげるデータを設定します。
さらに、form部品でもencType
でmultipart/form-data
とし符号するようにしています。
これで、DBにデータを登録することができます。
:::note info
画像を表示
:::
画像を表示する場合、multerにもエンドポイントを作成する必要があります。
import express from "express";
const app = express();
app.use("/uploads", express.static(path.join(__dirname, "./uploads/")));
このようにすることで、http://localhost:ポート番号/uploads/uploadsフォルダに保存しているファイル名
でファイルにアクセすることができます。
私の場合、APIでファイル名のみ保存しているので、以下のように、srcを設定することで画像を表示することができます。
<img src={`http://localhost:4000/uploads/${tweet.tweetImage[0]}`}/>
このようにすることで一連の画面からのファイルアップロード〜表示を実現することができます。
おわりに
MERNスタックの画面からのファイルアップロードとAPIの実装の記事がなく、かなり手こずりました。(特にaxiosの設定)
どなたかの参考になれば幸いです。
参考文献
【Node.js × Express × TypeScript × React】multerを使用してファイルをアップロードし表示する方法
Discussion