⚡
React Router v7でファイルアップロード
ChatGPTと一緒にドキュメントに載っていないファイルアップロードを考え、無事動いたので、共有します。
公式ドキュメント
process the upload and return a File
と記載されており、ドキュメントを見ただけでは、詳細はわかりませんでした。
なお、今後見ていくコードには公式ドキュメントにも記載のあるform-data-parser
を使用します。
Githubはこちらです。
全体のコード
まずは、全体のコードを確認します。
以下のコードは、ファイルアップロードを処理し、アップロードされたファイルを保存するuploadHandler
関数です。
TypeScriptで書かれており、FileUpload
型のオブジェクトを受け取ります。
uploadHandler
import { parseFormData, type FileUpload } from "@mjackson/form-data-parser";
export async function action({ request, params }: Route.ActionArgs) {
// データ送信用の配列
const postFormData = new FormData();
const temporaryFiles: string[] = [];
const uploadHandler = async (fileUpload: FileUpload) => {
console.log(fileUpload);
if (fileUpload.fieldName === "fileDatas") {
// ファイルを保存するディレクトリを指定
const uploadDir = path.resolve("./temp");
// アップロードされたファイルの保存場所を決定
const filePath = path.join(uploadDir, fileUpload.name);
// ファイルの内容をバッファとして取得
const buffer = Buffer.from(await fileUpload.arrayBuffer());
// ファイルをディスクに書き込む
try {
const status = await fs.lstat(filePath);
if (status.isDirectory()) {
console.log("File path is a directory. Skipping file upload.");
return null;
}
} catch (err: any) {
if (err.code !== "ENOENT") {
throw err;
}
}
// 保存先ディレクトリが存在しない場合は作成
await fs.mkdir(uploadDir, { recursive: true });
// ファイルをディスクに書き込む
await fs.writeFile(filePath, buffer);
// 一時ファイルを格納
temporaryFiles.push(filePath);
// MIMEタイプを取得(必要であれば独自に設定)
const mimeType = fileUpload.type || "application/octet-stream";
// Fileオブジェクトを作成して返す
const file = new File([buffer], fileUpload.name, { type: mimeType });
return file;
}
return null; // 他のフィールドは処理しない
};
// 送信用データの取得
const formData = await parseFormData(
request,
uploadHandler, // file upload用
{ maxFileSize: 1024 * 1024 * 10 } // max file size:10MB
);
// file upload
const uploadFiles = formData.getAll("fileDatas") as File[];
const formDataUpload = new FormData();
uploadFiles.forEach((file) => {
formDataUpload.append("files", file);
});
try {
const fileData = await addFiles(formDataUpload);
if (!fileData) {
throw new Error("Failed to submit file data.");
}
postFormData.append("fileDatas", JSON.stringify(fileData.file_info));
} catch (error: unknown) {
console.error("Error detail:", error);
} finally {
// 一時ファイル削除
for (const tempFilePath of temporaryFiles) {
try {
await fs.unlink(tempFilePath); // ファイル削除
console.log(`Temporary file deleted: ${tempFilePath}`);
} catch (deleteError) {
console.warn(`Failed to delete tempolary file: ${tempFilePath}`);
}
}
}
// その他のフィールドがある場合は、この部分で処理
// 省略
}
コードの流れ
- uploadHandlerの定義
const uploadHandler = async (fileUpload: FileUpload) => {
// some process the upload and return a File
}
- uploadHandlerを定義します
- fileUploadというパラメータにFileUpload型(
@mjackson/form-data-parser
からインポート)を設定します
-
対象のフィールドか判定
const uploadHandler = async (fileUpload: FileUpload) => { + if (fileUpload.fieldName === "fileDatas") { // some process the upload and return a File + } + return null; }
- `<input type="file" name="fileDatas" />からデータを取得します
-
fieldName
(=name属性)が"fileDatas"
の場合のみ処理します - 他のname属性であれば
return null;
して処理をスキップ
-
保存先のパスを決定
const uploadHandler = async (fileUpload: FileUpload) => { if (fileUpload.fieldName === "fileDatas") { + const uploadDir = path.resolve("./temp"); + const filePath = path.join(uploadDir, fileUpload.name); } return null; }
-
./temp
ディレクトリにアップロードされたファイルを保存 -
filePath
はuploadDir
内にfileUpload.name
でファイルを保存 - アップロードされたファイルの名前をつける
-
-
ファイルの内容を
Buffer
に変換const uploadHandler = async (fileUpload: FileUpload) => { if (fileUpload.fieldName === "fileDatas") { const uploadDir = path.resolve("./temp"); const filePath = path.join(uploadDir, fileUpload.name); + const buffer = Buffer.from(await fileUpload.arrayBuffer()); } return null; }
-
fileUpload.arrayBuffer()
を呼び出し、ファイルのデータをBuffer
に変換 -
arrayBuffer()
はFile
APIのメソッドで、ファイルをArrayBuffer
に変換
-
-
ファイルパスがディレクトリでないかチェック
const uploadHandler = async (fileUpload: FileUpload) => { if (fileUpload.fieldName === "fileDatas") { const uploadDir = path.resolve("./temp"); const filePath = path.join(uploadDir, fileUpload.name); const buffer = Buffer.from(await fileUpload.arrayBuffer()); + try { + const status = await fs.lstat(filePath); + if (status.isDirectory()) { + console.log("File path is a directory. Skipping file upload."); + return null; + } + } catch (err: any) { + if (err.code !== "ENOENT") { + throw err; + } + } } return null; }
-
fs.lstat(filePath)
でファイルの状態を取得 -
filePath
がディレクトリなら、エラーメッセージを出力して処理を中止 -
ENOENT
エラー(ファイルが存在しない場合)は無視し、それ以外のエラーはスローする
-
-
保存先ディレクトリを作成(存在しない場合)
const uploadHandler = async (fileUpload: FileUpload) => { if (fileUpload.fieldName === "fileDatas") { const uploadDir = path.resolve("./temp"); const filePath = path.join(uploadDir, fileUpload.name); const buffer = Buffer.from(await fileUpload.arrayBuffer()); try { const status = await fs.lstat(filePath); if (status.isDirectory()) { console.log("File path is a directory. Skipping file upload."); return null; } } catch (err: any) { if (err.code !== "ENOENT") { throw err; } } + await fs.mkdir(uploadDir, { recursive: true }); } return null; }
-
fs.mkdir()
を使ってuploadDir
を作成(recursive: true
により、親ディレクトリがない場合も作成)
-
-
ファイルをディスクに書き込み
const uploadHandler = async (fileUpload: FileUpload) => { if (fileUpload.fieldName === "fileDatas") { const uploadDir = path.resolve("./temp"); const filePath = path.join(uploadDir, fileUpload.name); const buffer = Buffer.from(await fileUpload.arrayBuffer()); try { const status = await fs.lstat(filePath); if (status.isDirectory()) { console.log("File path is a directory. Skipping file upload."); return null; } } catch (err: any) { if (err.code !== "ENOENT") { throw err; } } await fs.mkdir(uploadDir, { recursive: true }); + await fs.writeFile(filePath, buffer); } return null; }
-
fs.writeFile()
を使い、事前に指定したfilePath
にbuffer
の内容を書き込む
-
-
一時ファイルリストに追加
const uploadHandler = async (fileUpload: FileUpload) => { if (fileUpload.fieldName === "fileDatas") { const uploadDir = path.resolve("./temp"); const filePath = path.join(uploadDir, fileUpload.name); const buffer = Buffer.from(await fileUpload.arrayBuffer()); try { const status = await fs.lstat(filePath); if (status.isDirectory()) { console.log("File path is a directory. Skipping file upload."); return null; } } catch (err: any) { if (err.code !== "ENOENT") { throw err; } } await fs.mkdir(uploadDir, { recursive: true }); await fs.writeFile(filePath, buffer); + temporaryFiles.push(filePath); } return null; }
-
temporaryFiles
配列(グローバル変数)にfilePath
を追加 -
temporaryFiles
配列は、バックエンドに送信するために、一時的に保存したファイルのファイルパスを格納し、あとで削除するファイルを管理するためのリスト
-
-
MIMEタイプを取得
const uploadHandler = async (fileUpload: FileUpload) => { if (fileUpload.fieldName === "fileDatas") { const uploadDir = path.resolve("./temp"); const filePath = path.join(uploadDir, fileUpload.name); const buffer = Buffer.from(await fileUpload.arrayBuffer()); try { const status = await fs.lstat(filePath); if (status.isDirectory()) { console.log("File path is a directory. Skipping file upload."); return null; } } catch (err: any) { if (err.code !== "ENOENT") { throw err; } } await fs.mkdir(uploadDir, { recursive: true }); await fs.writeFile(filePath, buffer); temporaryFiles.push(filePath); + const mimeType = fileUpload.type || "application/octet-stream"; } return null; }
-
fileUpload.type
でMIMEタイプを取得 - 値がない場合(
null
やundefined
の場合)は、"application/octet-stream"
(汎用バイナリデータ)をデフォルトとして設定
-
-
File
オブジェクトを作成const uploadHandler = async (fileUpload: FileUpload) => { if (fileUpload.fieldName === "fileDatas") { const uploadDir = path.resolve("./temp"); const filePath = path.join(uploadDir, fileUpload.name); const buffer = Buffer.from(await fileUpload.arrayBuffer()); try { const status = await fs.lstat(filePath); if (status.isDirectory()) { console.log("File path is a directory. Skipping file upload."); return null; } } catch (err: any) { if (err.code !== "ENOENT") { throw err; } } await fs.mkdir(uploadDir, { recursive: true }); await fs.writeFile(filePath, buffer); temporaryFiles.push(filePath); const mimeType = fileUpload.type || "application/octet-stream"; + const file = new File([buffer], fileUpload.name, { type: mimeType }); + return file; } return null; }
-
buffer
からFile
オブジェクトを作成し、fileUpload.name
をファイル名前に設定 -
type
(MIMEタイプ)も設定してreturn
-
-
フォームデータの取得
export async function action({ request, params }: Route.ActionArgs) { const postFormData = new FormData(); const temporaryFiles: string[] = []; const uploadHandler = async (fileUpload: FileUpload) => { if (fileUpload.fieldName === "fileDatas") { const uploadDir = path.resolve("./temp"); const filePath = path.join(uploadDir, fileUpload.name); const buffer = Buffer.from(await fileUpload.arrayBuffer()); try { const status = await fs.lstat(filePath); if (status.isDirectory()) { console.log("File path is a directory. Skipping file upload."); return null; } } catch (err: any) { if (err.code !== "ENOENT") { throw err; } } await fs.mkdir(uploadDir, { recursive: true }); await fs.writeFile(filePath, buffer); temporaryFiles.push(filePath); const mimeType = fileUpload.type || "application/octet-stream"; const file = new File([buffer], fileUpload.name, { type: mimeType }); return file; } return null; } + const formData = await parseFormData( + request, + uploadHandler, // file upload用 + { maxFileSize: 1024 * 1024 * 10 } // max file size:10MB + ); }
- ファイルアップロードを伴わないデータの取得は、
const formData = await request.formData();
を使用しますが、ファイルアップロードを行う場合は、@mjackson/form-data-parser
からインポートしたparseFormData
を使用します - 実際に
formData
からデータを取得するときに、定義したuploadHandler
をparseFormData
に渡します -
uplaodHandler
内ではファイルサイズなどの指定はしていないため、parseFormData
内で行います
https://zenn.dev/taki/articles/7c317d6612743a
- ファイルアップロードを伴わないデータの取得は、
-
その他処理
import { parseFormData, type FileUpload } from "@mjackson/form-data-parser";
export async function action({ request, params }: Route.ActionArgs) {
// データ送信用の配列
const postFormData = new FormData();
const temporaryFiles: string[] = [];
const uploadHandler = async (fileUpload: FileUpload) => {
console.log(fileUpload);
if (fileUpload.fieldName === "fileDatas") {
// ファイルを保存するディレクトリを指定
const uploadDir = path.resolve("./temp");
// アップロードされたファイルの保存場所を決定
const filePath = path.join(uploadDir, fileUpload.name);
// ファイルの内容をバッファとして取得
const buffer = Buffer.from(await fileUpload.arrayBuffer());
// ファイルをディスクに書き込む
try {
const status = await fs.lstat(filePath);
if (status.isDirectory()) {
console.log("File path is a directory. Skipping file upload.");
return null;
}
} catch (err: any) {
if (err.code !== "ENOENT") {
throw err;
}
}
// 保存先ディレクトリが存在しない場合は作成
await fs.mkdir(uploadDir, { recursive: true });
// ファイルをディスクに書き込む
await fs.writeFile(filePath, buffer);
// 一時ファイルを格納
temporaryFiles.push(filePath);
// MIMEタイプを取得(必要であれば独自に設定)
const mimeType = fileUpload.type || "application/octet-stream";
// Fileオブジェクトを作成して返す
const file = new File([buffer], fileUpload.name, { type: mimeType });
return file;
}
return null; // 他のフィールドは処理しない
};
// 送信用データの取得
const formData = await parseFormData(
request,
uploadHandler, // file upload用
{ maxFileSize: 1024 * 1024 * 10 } // max file size:10MB
);
// file upload
const uploadFiles = formData.getAll("fileDatas") as File[];
const formDataUpload = new FormData();
uploadFiles.forEach((file) => {
formDataUpload.append("files", file);
});
+ try {
+ const fileData = await addFiles(formDataUpload);
+ if (!fileData) {
+ throw new Error("Failed to submit file data.");
+ }
+ postFormData.append("fileDatas", JSON.stringify(fileData.file_info));
+ } catch (error: unknown) {
+ console.error("Error detail:", error);
+ } finally {
+ // 一時ファイル削除
+ for (const tempFilePath of temporaryFiles) {
+ try {
+ await fs.unlink(tempFilePath); // ファイル削除
+ console.log(`Temporary file deleted: ${tempFilePath}`);
+ } catch (deleteError) {
+ console.warn(`Failed to delete tempolary file: ${tempFilePath}`);
+ }
+ }
+ }
// その他のフィールドがある場合は、この部分で処理
// 省略
}
- 追加した部分は、実際にフォームからファイルデータを受け取るコードです
- このとき、フロントエンド側のコードではinputに
multiple
を設定し、複数ファイルを一括でアップロードできるようにしています - 取得した複数のファイルを
addFiles
という関数で一度バックエンドにアップロード処理し、返されたファイル名とファイルパスを取得してjson形式でfileDatas(送信するフィールド)に追加しています - その後、バックエンドに送信するために保存していた
./temp
ディレクトリのファイルを削除しています
参考コード
addFiles
export async function addFiles(formDataUpload: FormData) {
try {
const res = await fetch(`${process.env.API_ROOT_URL}/todo/upload_files`, {
method: "POST",
headers: {
accept: "application/json",
},
body: formDataUpload,
});
if (!res.ok) {
throw new Error(`File upload failed: ${res.status} ${res.statusText}`);
}
return res.json();
} catch (err: unknown) {
throw new Error(`${err}`);
}
}
upload_files(FastAPIのコード)
@router.post("/upload_files")
def upload_files(files: list[UploadFile] = File(...)):
upload_dir = "uploads/files/"
file_info = []
try:
for file in files:
file_path = f"{upload_dir}{uuid.uuid4()}_{file.filename}"
with open(file_path, "wb") as file_object:
shutil.copyfileobj(file.file, file_object)
# filename と file_path を辞書形式で格納
file_info.append({
"file_name": file.filename,
"file_path": file_path
})
# 辞書形式でレスポンス
return JSONResponse(content={"file_info": file_info})
except Exception as e:
print(f"Error during file processing: {e}")
return JSONResponse(
content={"error": "画像ファイルのアップロードに失敗しました。{e}"},
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR
)
チェックポイント
✅ ファイルが"fileDatas"
のフィールドであるかチェック
✅ アップロードされたファイルのバリデーション(存在チェック、ディレクトリチェック)
✅ ファイルをBuffer
に変換し、保存
✅ アップロードされたファイルをFile
オブジェクトとして返す
✅ 一時ファイルリストに追加して後で管理できるようにする
まとめ
このuploadHandler
はファイルを受け取り、ローカルの./temp
に保存し、Fileオブジェクトとして返す関数として定義しています。
今後、React Routerを使用する方は参考にしてみて下さい。
Discussion