React Router v7でファイルアップロード

2025/02/09に公開

ChatGPTと一緒にドキュメントに載っていないファイルアップロードを考え、無事動いたので、共有します。

公式ドキュメント

https://reactrouter.com/how-to/file-uploads
公式ドキュメントにもfileUploadについて書かれたページはありますが、process the upload and return a Fileと記載されており、ドキュメントを見ただけでは、詳細はわかりませんでした。

なお、今後見ていくコードには公式ドキュメントにも記載のあるform-data-parserを使用します。
Githubはこちらです。
https://github.com/mjackson/remix-the-web/tree/main/packages/form-data-parser

全体のコード

まずは、全体のコードを確認します。
以下のコードは、ファイルアップロードを処理し、アップロードされたファイルを保存する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}`);
      }
    }
  }
  // その他のフィールドがある場合は、この部分で処理
  // 省略
}

コードの流れ

  1. uploadHandlerの定義
 const uploadHandler = async (fileUpload: FileUpload) => {
   // some process the upload and return a File
 }
  • uploadHandlerを定義します
  • fileUploadというパラメータにFileUpload型(@mjackson/form-data-parserからインポート)を設定します
  1. 対象のフィールドか判定

    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;して処理をスキップ
  2. 保存先のパスを決定

    const uploadHandler = async (fileUpload: FileUpload) => {
      if (fileUpload.fieldName === "fileDatas") {
    +   const uploadDir = path.resolve("./temp");
    +   const filePath = path.join(uploadDir, fileUpload.name);
      }
      return null;
    }
    
    • ./tempディレクトリにアップロードされたファイルを保存
    • filePathuploadDir内にfileUpload.nameでファイルを保存
    • アップロードされたファイルの名前をつける
  3. ファイルの内容を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()FileAPIのメソッドで、ファイルをArrayBufferに変換
  1. ファイルパスがディレクトリでないかチェック

    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エラー(ファイルが存在しない場合)は無視し、それ以外のエラーはスローする
  2. 保存先ディレクトリを作成(存在しない場合)

    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により、親ディレクトリがない場合も作成)
  3. ファイルをディスクに書き込み

    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()を使い、事前に指定したfilePathbufferの内容を書き込む
  4. 一時ファイルリストに追加

    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配列は、バックエンドに送信するために、一時的に保存したファイルのファイルパスを格納し、あとで削除するファイルを管理するためのリスト
  5. 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タイプを取得
    • 値がない場合(nullundefinedの場合)は、"application/octet-stream"(汎用バイナリデータ)をデフォルトとして設定
  6. 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
  7. フォームデータの取得

    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からデータを取得するときに、定義したuploadHandlerparseFormDataに渡します
    • uplaodHandler内ではファイルサイズなどの指定はしていないため、parseFormData内で行います
      https://zenn.dev/taki/articles/7c317d6612743a
  8. その他処理

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