🤔

Reactのお勉強:画像のアップロード

2023/11/18に公開

ファイルアップロード機能を実装しようとしたら、意外と苦戦したのでまとめる
(サーバーにアップロードではないです。ローカルでコンポーネントにアップロードするだけ)

画像をアップロードできるコンポーネントを作ろう!

機能としては

  • 画像のアップロード(今回はjpeg, pngに限定)
  • アップロードした画像のプレビュー
  • プレビュー画像をクリックしたら画像を削除できるようにする
  • 画像をドラッグアンドドロップでアップロードできるようにする

ゴールはこんな感じ

見た目は気にしないでね。TailwindCSSを使用していますが、なんでもいいです。

画像をアップロードする

スタート地点

import React from "react";

export const ImageComponent = () => {
  return <div>ImageComponent</div>;
};

まずは画像を<input type="file">でアップロードできるようにし、かつそのファイルを保持できるようにします(base64Imageで保持します)。
useRefは後で使います。

import React, { useRef, useState } from "react";

export const ImageComponent = () => {
  const [base64Images, setBase64Images] = useState<string[]>([]);

  const handleInputFile = (e: React.ChangeEvent<HTMLInputElement>) => {
    const files = e.target.files;
    if (!files) {
      return;
    }

    // FileListのままだとforEachが使えないので配列に変換する
    const fileArray = Array.from(files);

    fileArray.forEach((file) => {
      // ファイルを読み込むためにFileReaderを利用する
      const reader = new FileReader();
      // ファイルの読み込みが完了したら、画像の配列に加える
      reader.onloadend = () => {
        const result = reader.result;
        if (typeof result !== "string") {
          return;
        }
        setBase64Images((prevImages) => [...prevImages, result]);
      };
      // 画像ファイルをbase64形式で読み込む
      reader.readAsDataURL(file);
    });
  };

  return (
    <div className="relative border-2 border-red-200 bg-red-100 w-[600px] h-[600px] flex flex-col space-y-4">
      <input
        type="file"
        multiple // 画像を複数選択できるようにする
        accept="image/jpeg, image/png"
        onChange={handleInputFile}
      />
    </div>
  );
};

これで画像ファイルを読み込むことができました。

画像のプレビュー

次は保持した画像を表示させようと思います。

export const ImageComponent = () => {
  // ~~~
  return (
    <div className="relative border-2 border-red-200 bg-red-100 w-[600px] h-[600px] flex flex-col space-y-4">
      <input
        type="file"
        multiple // 画像を複数選択できるようにする
        accept="image/jpeg, image/png"
        onChange={handleInputFile}
      />
+      <div className="border-2 border-blue-300 bg-blue-200">
+        <p>画像プレビュー</p>
+        <div className="flex space-x-4 overflow-x-auto py-4">
+          {base64Images.length !== 0 &&
+            base64Images.map((image, idx) => (
+              <div key={idx} className="flex-shrink-0">
+                <img src={image} className="w-32 h-32 " />
+              </div>
+            ))}
+        </div>
+      </div>
    </div>
  );
};

これで表示できるようになったはず。試してみましょう。

はい、変です。なぜか同じ画像が複数回アップロードされます。

同じ画像が複数回アップロードされる、、、どうする?

これは

  1. <input>でファイルを入力する
  2. <input>のvalueにファイルが入力される
  3. onChangeイベントでごちゃごちゃする(handleInputFile)
  4. input.valueはそのまま
  5. もう一度<input>をクリックすると画像を選択する前にもhandleInputFileが呼び出されているので、input.valueに残っているファイルをそのまま使ってアップロードする

という流れみたいで、input.valueにファイルが残ったままなのが問題です。
なのでhandleInputFileの最後にinput.valueの初期化を行いましょう。
どうやってinput.valueを初期化しましょうか?

はい、useRefを使います。

import React, { useRef, useState } from "react";

export const ImageComponent = () => {
  const [base64Images, setBase64Images] = useState<string[]>([]);
+ const inputRef = useRef<HTMLInputElement>(null);

  const handleInputFile = (e: React.ChangeEvent<HTMLInputElement>) => {
    const files = e.target.files;
    if (!files) {
      return;
    }

    // FileListのままだとforEachが使えないので配列に変換する
    const fileArray = Array.from(files);

    fileArray.forEach((file) => {
      // ファイルを読み込むためにFileReaderを利用する
      const reader = new FileReader();
      // ファイルの読み込みが完了したら、画像の配列に加える
      reader.onloadend = () => {
        const result = reader.result;
        if (typeof result !== "string") {
          return;
        }
        setBase64Images((prevImages) => [...prevImages, result]);
      };
      // 画像ファイルをbase64形式で読み込む
      reader.readAsDataURL(file);
    });
+   // inputの値をリセットする
+   if (inputRef.current) {
+     inputRef.current.value = "";
+   }
  };

  return (
    <div className="relative border-2 border-red-200 bg-red-100 w-[600px] h-[600px] flex flex-col space-y-4">
      <input
        type="file"
        multiple // 画像を複数選択できるようにする
        accept="image/jpeg, image/png"
        onChange={handleInputFile}
+       ref={inputRef}
      />
      <div className="border-2 border-blue-300 bg-blue-200">
        <p>画像プレビュー</p>
        <div className="flex space-x-4 overflow-x-auto py-4">
          {base64Images.length !== 0 &&
            base64Images.map((image, idx) => (
              <div key={idx} className="flex-shrink-0">
                <img src={image} className="w-32 h-32 " />
              </div>
            ))}
        </div>
      </div>
    </div>
  );
};

これで複数回アップロードされる心配はなくなりました。

次の問題は、画像をアップロードするときの順番があっていない、ということです。

複数の画像をアップロードしたときに順番通りにならない、、、どうする?

これはFileReader.onloadendは非同期で行われるため、ファイルの読み込み完了のタイミングがまちまちになってしまうからです。
どうやってファイルの順番を読み込み完了の順番ではなく、選択した順番に保存されるようにしましょうか?

これは完了した順にbase64Imagesにセットするのではなく、事前に一時配列を準備しておき、読み込み完了したファイルを一時ファイルに保存し、全ての画像の読み込みが完了したタイミングでセットするようにしましょう。
なのでhandleInputFileを次のように変更します。

  const handleInputFile = (e: React.ChangeEvent<HTMLInputElement>) => {
    const files = e.target.files;
    if (!files) {
      return;
    }

    // FileListのままだとforEachが使えないので配列に変換する
    const fileArray = Array.from(files);

+    // 読み込み結果を保管するための一時配列
+    const loadImages = new Array(fileArray.length);
+    let loadCount = 0; // 読み込み済みのファイル数

    fileArray.forEach((file, index) => {
      // ファイルを読み込むためにFileReaderを利用する
      const reader = new FileReader();
      reader.onloadend = () => {
        const result = reader.result;
        if (typeof result !== "string") {
          return;
        }
-	setBase64Images((prevImages) => [...prevImages, result]);

+        // 読み込み結果を一時配列に入れる
+        loadImages[index] = result;
+        loadCount++; // 読み込み済みカウンタを増やす

+        // 全てのファイルが読み込まれたかチェック
+        if (loadCount === fileArray.length) {
+          setBase64Images((prevImages) => [...prevImages, ...loadImages]);
+        }
      };
      // 画像ファイルをbase64形式で読み込む
      reader.readAsDataURL(file);
    });
    // inputの値をリセットする
    if (inputRef.current) {
      inputRef.current.value = "";
    }
  };

はい、これで選択した順番に画像が表示されるようになりました!

プレビュー画像をクリックしたら削除する

これは簡単ですね。
クリックした画像のindex番目の要素をbase64Imagesからfilterで除外するだけです。

export const ImageComponent = () => {
  const [base64Images, setBase64Images] = useState<string[]>([]);
  const inputRef = useRef<HTMLInputElement>(null);

  const handleInputFile = (e: React.ChangeEvent<HTMLInputElement>) => {
    // ~~~
  };

+  const handleImageClick = (index: number) => {
+    setBase64Images((prev) => prev.filter((_, idx) => idx !== index));
+  };

  return (
    <div className="relative border-2 border-red-200 bg-red-100 w-[600px] h-[600px] flex flex-col space-y-4">
      <input
        type="file"
        multiple // 画像を複数選択できるようにする
        accept="image/jpeg, image/png"
        onChange={handleInputFile}
        ref={inputRef}
      />
      <div className="border-2 border-blue-300 bg-blue-200">
        <p>画像プレビュー</p>
        <div className="flex space-x-4 overflow-x-auto py-4">
          {base64Images.length !== 0 &&
            base64Images.map((image, idx) => (
              <div key={idx} className="flex-shrink-0">
                <img
                  src={image}
                  className="w-32 h-32"
+                  onClick={() => handleImageClick(idx)}
                />
              </div>
            ))}
        </div>
      </div>
    </div>
  );
};

ドラッグアンドドロップで画像をアップロード

onDropイベントハンドラーがあるのでこれも簡単・・・?

export const ImageComponent = () => {

  // ~~~

+  const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
+    console.log("drop");
+  };

  return (
    <div className="relative border-2 border-red-200 bg-red-100 w-[600px] h-[600px] flex flex-col space-y-4">
      <input
        type="file"
        multiple // 画像を複数選択できるようにする
        accept="image/jpeg, image/png"
        onChange={handleInputFile}
        ref={inputRef}
      />
+      <div className="p-4 bg-gray-200">
+        <div
+          onDrop={handleDrop}
+          className="flex justify-center items-center bg-gray-300 w-[200px] h-[100px] rounded-md border-dashed border-2 border-gray-500"
+        >
+          ドラッグ&ドロップ
+        </div>
+      </div>
      <div className="border-2 border-blue-300 bg-blue-200">
        <p>画像プレビュー</p>
        <div className="flex space-x-4 overflow-x-auto py-4">
          {base64Images.length !== 0 &&
            base64Images.map((image, idx) => (
              <div key={idx} className="flex-shrink-0">
                <img
                  src={image}
                  className="w-32 h-32"
                  onClick={() => handleImageClick(idx)}
                />
              </div>
            ))}
        </div>
      </div>
    </div>
  );
};


ドラッグ&ドロップするスペースを作ってみました。
しかしこれに画像をドロップすると別タブで画像を開いてしまいます(Chromeの場合)、画像をコントロールできません。

画像をドラッグ&ドロップできない・・・?

これはブラウザが画像を受け取った時のデフォルトの挙動が先に起こってしまうためです。
ブラウザのデフォルトの挙動をキャンセルして、handleDropを呼び出したい、さてどうしましょう?

答えは簡単です。e.preventDefault()で、デフォルトの挙動をキャンセルしてしまいましょう。

export const ImageComponent = () => {
  // ~~~
  
+ const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
+   e.preventDefault();
+ };

  const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
+   e.preventDefault();
    console.log("run");
  };
  
  return (
  
        <div className="p-4 bg-gray-200">
        <div
          onDrop={handleDrop}
+         onDragOver={handleDragOver}
          className="flex justify-center items-center bg-gray-300 w-[200px] h-[100px] rounded-md border-dashed border-2 border-gray-500"
        >
          ドラッグ&ドロップ
        </div>
      </div>

はい、これでドラッグ&ドロップした時にファイルを受け取れるようになりました。

ではhandleDropを実装していきます。中身はhandleInputFileと似たようなものです。

  const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
    e.preventDefault();

    // ドラッグ&ドロップされたファイルを取得
    const files = e.dataTransfer.files
    if (files.length === 0) {
      return
    }
    // 今回は画像は1枚だけと仮定。複数の場合はhandleInputFileと同じように実装する
    const file = files[0]
    // png, jpeg以外のファイルなら何もしない
    if (file.type !== "image/png" && file.type !== "image/jpeg") {
      return
    }
    const reader = new FileReader()
    reader.onloadend = () => {
      const data = reader.result;
      if (typeof data !== "string") {
        return;
      }
      setBase64Images((prevImages) => [...prevImages, data]);
    };
    reader.readAsDataURL(file);
  };

これでOKです。実際に画像ファイルをドラッグ&ドロップしてみてください。
プレビューに表示されるようになりました。

これで完成、でもいいんですが、あとちょっとだけ作業します。

画像ファイルをドラッグ&ドロップする時に、背景色が変わるようにしたい・・・

画像をドラッグ&ドロップする時に要素の背景色を変えようと思います。どうしましょうか?
パッと思いつくのはhoverですが、hoverだとファイルがない時でも背景色が変わってしまいます。

なのでドラッグ&ドロップしている状態を管理する値を新たに作ってしまいましょう

export const ImageComponent = () => {
+ const [dragOver, setDragOver] = useState<boolean>(false);
  // ~~~
  const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
    e.preventDefault();
+   setDragOver(true);
  };

+ const handleDragLeave = (e: React.DragEvent<HTMLDivElement>) => {
+   setDragOver(false);
+ };

  const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
    e.preventDefault();
+   setDragOver(false);
    // ~~~
  }
  
  return (
  
        <div className="p-4 bg-gray-200">
        <div
          onDrop={handleDrop}
          onDragOver={handleDragOver}
          onDragLeave={handleDragLeave}
-         className="flex justify-center items-center bg-gray-300 w-[200px] h-[100px] rounded-md border-dashed border-2 border-gray-500"
+         className={`flex justify-center items-center w-[200px] h-[100px] rounded-md border-dashed border-2 border-gray-500 ${
+           dragOver ? "bg-white" : "bg-gray-300"
          }`}
        >
          ドラッグ&ドロップ
        </div>
      </div>

これで完成です。

完成形

import React, { useRef, useState } from "react";

export const ImageComponent = () => {
  const [base64Images, setBase64Images] = useState<string[]>([]);
  const [dragOver, setDragOver] = useState<boolean>(false);
  const inputRef = useRef<HTMLInputElement>(null);

  const handleInputFile = (e: React.ChangeEvent<HTMLInputElement>) => {
    const files = e.target.files;
    if (!files) {
      return;
    }

    // FileListのままだとforEachが使えないので配列に変換する
    const fileArray = Array.from(files);

    // 読み込み結果を補完するための一時配列
    const loadImages = new Array(fileArray.length);
    let loadCount = 0; // 読み込み済みのファイル数

    fileArray.forEach((file, index) => {
      // ファイルを読み込むためにFileReaderを利用する
      const reader = new FileReader();
      reader.onloadend = () => {
        const result = reader.result;
        if (typeof result !== "string") {
          return;
        }
        // 読み込み結果を一時配列に入れる
        loadImages[index] = result;
        loadCount++; // 読み込み済みカウンタを増やす

        // 全てのファイルが読み込まれたかチェック
        if (loadCount === fileArray.length) {
          setBase64Images((prevImages) => [...prevImages, ...loadImages]);
        }
      };
      // 画像ファイルをbase64形式で読み込む
      reader.readAsDataURL(file);
    });
    // inputの値をリセットする
    if (inputRef.current) {
      inputRef.current.value = "";
    }
  };

  const handleImageClick = (index: number) => {
    setBase64Images((prev) => prev.filter((_, idx) => idx !== index));
  };

  const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
    e.preventDefault();
    setDragOver(true);
  };

  const handleDragLeave = (e: React.DragEvent<HTMLDivElement>) => {
    setDragOver(false);
  };

  const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
    e.preventDefault();
    setDragOver(false);

    // ドラッグ&ドロップされたファイルを取得
    const files = e.dataTransfer.files;
    if (files.length === 0) {
      return;
    }
    // 今回は画像は1枚だけと仮定。複数の場合はhandleInputFileと同じように実装する
    const file = files[0];
    // png, jpeg以外のファイルなら何もしない
    if (file.type !== "image/png" && file.type !== "image/jpeg") {
      return;
    }
    const reader = new FileReader();
    reader.onloadend = () => {
      const data = reader.result;
      if (typeof data !== "string") {
        return;
      }
      setBase64Images((prevImages) => [...prevImages, data]);
    };
    reader.readAsDataURL(file);
  };

  return (
    <div className="relative border-2 border-red-200 bg-red-100 w-[600px] h-[600px] flex flex-col space-y-4">
      <input
        type="file"
        multiple // 画像を複数選択できるようにする
        accept="image/jpeg, image/png"
        onChange={handleInputFile}
        ref={inputRef}
      />
      <div className="p-4 bg-gray-200">
        <div
          onDrop={handleDrop}
          onDragOver={handleDragOver}
          onDragLeave={handleDragLeave}
          className={`flex justify-center items-center w-[200px] h-[100px] rounded-md border-dashed border-2 border-gray-500 ${
            dragOver ? "bg-white" : "bg-gray-300"
          }`}
        >
          ドラッグ&ドロップ
        </div>
      </div>
      <div className="border-2 border-blue-300 bg-blue-200">
        <p>画像プレビュー</p>
        <div className="flex space-x-4 overflow-x-auto py-4">
          {base64Images.length !== 0 &&
            base64Images.map((image, idx) => (
              <div key={idx} className="flex-shrink-0">
                <img
                  src={image}
                  className="w-32 h-32"
                  onClick={() => handleImageClick(idx)}
                />
              </div>
            ))}
        </div>
      </div>
    </div>
  );
};

終わり

これで終わりです。
やってみると結構苦戦しました。

  • inputの要素をリセットするのにuseRefを思いつかない
  • 表示される画像の順番が選択した順番と違う理由がわからない
  • 画像をドラッグ&ドロップしても別タブに表示されて思った通りにいかない

などなど。

でも楽しかったです。
ありがとうございました。

Discussion