😀

【Node.js × Express × TypeScript × React】multerを使用してファイルをアップロ

2023/05/09に公開

はじめに

本記事は前科の記事に引き続いて、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の設定
:::
ライブラリをインストールしたいら、以下のように設定を行います。

multerHandler.ts
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のエンドポイントの設定です。

tweet.ts
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
メインロジック
:::
以下が、メインの処理になります。

tweetService.ts
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のインスタンスを作成
:::
以下が、そのコードです。

axiosClient.ts
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を使用しないといけません。

これは、multermultipart/form-dataというフォームの型でデータをやり取りするため、axiosの設定でも、それを明示的に使用する旨を記述する必要があります。

:::note info
APIエンドポイントの設定
:::
次に使用するAPIををフロントで使用するため、フロント側でもエンドポイントの設定を行います。

tweetApi.ts
import { axiosClientFormData } from "./axiosClient";

const tweetApi = {
	create: (formData: FormData) =>
		axiosClientFormData.post("tweet/create", formData, {
			headers: {
				"Content-Type": "multipart/form-data",
			},
		}),
};

export default tweetApi;

ここでもheadersmultipart/form-dataを指定しています。
axiosの設定と符号しています。

コンポーネントでAPIを呼び出し

以下が、APIを呼び出しているコンポーネントの記述です。

TweetBox.tsx
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;

handleImageChangehandleImageRemoveは画像プレビューに関する機能なので、無視してもらって構いません。

重要なのが、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部品でもencTypemultipart/form-dataとし符号するようにしています。

これで、DBにデータを登録することができます。

:::note info
画像を表示
:::
画像を表示する場合、multerにもエンドポイントを作成する必要があります。

index.ts
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