Reactで画像をアップロードする

以前作成した鳥画像判定AIをweb上から利用できるようにするため、Reactで画像をアップロードするプログラムを実装しました。

Reactバージョン

React:18.2.0
・react-scripts:5.0.1

出来上がり画面

フォルダ構成

基本的に編集するのはpublicフォルダとsrcフォルダ内のファイルのみです。

$ tree ./
./
├── build //ビルドされたコードが格納されている
├── node_modules //installしたモジュールデータが自動で格納される。
├── package-lock.json
├── package.json //インストールしたパッケージ一覧
├── public
│   └── index.html #最初に読み込まれるHTMLページ
└── src
    ├── components #管理しやすいようにReact要素を部品化する
    │   ├── SelectFile.js #ファイル読み込み用のコンポーネント
    │   └── SendFile.js #ファイル送信用のコンポーネント
    ├── context
    │   └── FileContext.js #画像データ操作用ファイル
    ├── index.js #大元となるjsファイル
    └── pages #各ページごとにファイルを分ける(今回はTopPageのみ)
        └── TopPage.js #トップページ用のReactファイル

主要ファイルは以下の流れで処理を実行します。

index.html

ブラウザが最初に読み込むファイル。bodyに記載した「<div id="root"></div>」に埋め込む情報を、後述のindex.jsで定義します。

<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <!-- 以下2行のlinkタグはMaterial UIに関する記載なので、動かすだけなら省略してOKです -->
    <link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap"/>
    <link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons"/>
    <title>Dataflake App</title>
  </head>
  <body>
    <div id="root"></div>
  </body>
</html>

index.js

先ほどのindex.htmlのroot要素に、Reactの「TopPage」要素を当てはめます。

import React from 'react';
import { createRoot } from 'react-dom/client';
import TopPage from './pages/TopPage.js'

const container = document.getElementById('root');//index.htmlのid=root要素を取得
const root = createRoot(container);//並行処理の機能を使うための設定。React18からの新機能?
root.render(<TopPage />);//root要素にTopPage.jsを埋め込む

TopPage.js

実際にブラウザに表示させるHTMLコードをこちらで定義します。ファイル選択、ファイル送信に関する細かいコーディングはSelectFileとSendFileの2つのコンポーネントに記載します。

またコンポーネント間でデータのやりとりが発生するため、その管理のためにFileContextコンポーネントを読み込みます。

//画像ファイルの操作に関するコンポーネントを読み込む
import { FileProvider } from '../context/FileContext';
//コンポーネントの読み込み
import SelectFile from '../components/SelectFile'
import SendFile from '../components/SendFile'

const TopPage = () => {
    return (
        <div style={{margin: '20px'}}>
            <h2>可愛い鳥とかっこいい鳥を識別するAI</h2>
            <FileProvider>
                <SelectFile/>
                <SendFile/>
            </FileProvider>
        </div>
    );
}
export default TopPage;

FileContext.js

今回のwebページでは「SelectFile」コンポーネントで選択したファイルデータを「SendFile」コンポーネントで利用する必要があります。したがってコンポーネント間でデータをやりとりするためのReact機能であるContextを活用します。

FileContext.jsにContextの初期設定や操作関数をまとめて記載することで、TopPage.jsやSelectFile.js、SendFile.jsでのContext関連のコーディングの手間を省きます。

※useContextを使ったデータの受け渡しについてはこちらの記事が参考になります。

import { createContext, useState, useContext } from 'react';
//Contextオブジェクトの作成
const FileContext = createContext();
//他のコンポーネントでファイルデータを操作するための関数をexport
export function useFileContext() {
  return useContext(FileContext);
}
//TopPage.jsで利用するFileProviderを定義&export
export function FileProvider({ children }) {
  //const [状態変数, 状態を変更するための関数] = useState(状態の初期値);
  const [fileInfo, setFile] = useState({object: '', base64data: ''});
  const value = {
    fileInfo,
    setFile,
  };
  return (
    <FileContext.Provider value={value}>{children}</FileContext.Provider>
  );
}

SelectFile.js

このコンポーネントではローカルファイルを読み込む動作を担います。「import Button from '@mui/material/Button’;」でmaterial UIの要素を読み込んでいますが、見栄えに関する部分なのでmaterial UIを導入していない場合はなくても構いません。(その場合は「<Button></Button>」タグが使えないので「<button></button>」に変更してください。)

fileInfo変数にはファイル名やサイズなどを取得するための「object」キーとアップロード用のbase64形式データを格納する「base64data」キーの2つの要素を入れています。

//画像ファイルの操作に関するコンポーネントを読み込む
import { useFileContext } from '../context/FileContext';
//Material UIのButtonをインポート
import Button from '@mui/material/Button';

const SelectFile = () => {
    //ファイル情報を読み込み
    const { fileInfo, setFile } = useFileContext();
    //ファイルが選択された際のアクションを定義
    const onFileInputChange = (event) => {
        //ファイル情報を読み込む
        setFile((fileInfo)=>({ ...fileInfo, object: event.target.files[0] }));
        const reader = new FileReader()
        // ファイルを読み込み終わったタイミングで実行するイベントハンドラー
        reader.onload = (e) => {
            //base64形式の画像データをfileInfoに格納
            setFile((fileInfo)=>({ ...fileInfo, base64data: e.target.result }));
        }
        // ファイルを読み込む
        // 読み込まれたファイルはデータURL形式で受け取れる(上記onload参照)
        reader.readAsDataURL(event.target.files[0])
	};
    return (
        <div>
            <div style={{marginBottom: '10px'}}>画像を選択してください</div>
            <Button variant="contained" component="label">
                ファイルを選択
                <input type='file' hidden accept="image/*" onChange={onFileInputChange}/>
            </Button>
            <div style={{margin: '5px'}}>{fileInfo && fileInfo.object.name}</div>
            <img width="200" src={fileInfo.base64data}/>
        </div>
    );
};

export default SelectFile;

SendFile.js

SelectFile.jsで取得したbase64形式の画像データをサーバへ送信します。アップロードデータを格納するfileInfo変数の他に、判定結果を格納するposts変数とエラーメッセージを表示させるためのerror変数を利用しています。

JSX部分は

  • 判定ボタン
  • 判定結果の表示エリア
  • エラーメッセージの表示エリア

で構成されており、サーバから正しく結果が返ってきた場合には判定結果を表示させ、エラーが返ってきた場合はエラーメッセージを表示させるような構造にしています。

import React from 'react'
import { useState } from 'react'
import { useFileContext } from '../context/FileContext';
import axios from 'axios'
import Button from '@mui/material/Button';

const SendFile = () =>  {
  //判定結果を格納するための変数
  const [posts, setPosts] = useState({bool:false, results:'', eval:''});
  //エラー処理用の変数
  const [error, setError] = useState({bool:false, message:'判定に失敗しました。もう一度試してみてください'});
  //識別中にボタン名を「判定中...」に変更するための変数
  const [buttonName, setButtonName] = useState('判定する');
  const { fileInfo, setFile } = useFileContext();
  //readAsDataURL関数で取得したデータには冒頭に「data:image/<拡張子>;base64,」という余分な情報が含まれているためreplaceで削除します
  const data = {'file': fileInfo.base64data.replace(/data:.*\/.*;base64,/, '')}
  //判定ボタンが押されたときの動作を定義
  const buttonSend = () => {
    //処理中であることが分かるように、判定ボタンの名前を変更
    setButtonName('判定中...');
    //判定結果を格納する変数を初期化(判定ボタンが押されるたびに結果をクリアする)
    setPosts({bool:false, results:'', eval:''});
    //エラー処理変数を初期化(判定ボタンが押されるたびにエラーメッセージをクリアする)
    setError((error)=>({ ...error, bool:false }));
    //axiosでデータをアップロード(URLや設定などは目的に応じて変更)
    axios
      .post('https://****', data, { headers: { "Content-type": "application/json" }})
      .then(response => {
        //posts変数に結果を格納
        setPosts({bool:true, results:response.data.results, eval:response.data.eval});
        //判定ボタンの名前を「判定する」に戻す
        setButtonName('判定する');
      })                               //成功した場合、postsを更新する(then)
      .catch((err) => {
        console.log('通信に失敗しました');
        console.log(err)
        //判定ボタンの名前を「判定する」に戻す
        setButtonName('判定する');
        //エラーメッセージをブラウザへ表示させる
        setError((error)=>({ ...error, bool:true }));
      });
  }
  return (
    <React.Fragment>
        <Button style={{marginTop: '20px'}} variant="contained" onClick={buttonSend}>{buttonName}</Button>
        {/* 判定結果を正しく受け取った場合のみ下記を表示する */}
        { posts.bool &&
          <div>
            <h3>判定結果:</h3>
            <div>この画像は
            {/* resultsが0の場合は「可愛い」、1の場合は「かっこいい」判定である。それぞれで表示コメントを場合分けする */}
            {posts.results == 0
                ? <span style={{fontSize: '1.3em'}}> 可愛い鳥 </span>
                : <span style={{fontSize: '1.3em'}}> かっこいい鳥 </span>
            }
            と判定されました!</div>
            <br></br>
            <div>可愛い度:{Math.round(posts.eval.kawaii*100) / 100}</div>
            <div>かっこいい度:{Math.round(posts.eval.kakkoii*100) / 100}</div>
          </div>
        }
        {/* post送信時にエラーが起こった場合のみ下記を表示する */}
        { error.bool &&
          <div style={{marginTop: '15px', color:'red'}}>{error.message}</div>
        }
    </React.Fragment>
  );
}

export default SendFile;

動作確認

最終的に以上のソースコードをビルドして実行すると、以下のような動作を実現することができます。

最後に

データを送信して結果を受け取る最低限のコードを実装しましたが、コンポーネント間のデータの受け渡しやエラー処理などを盛り込んだためコード量としては少し多めになりました。

ただサーバとのデータ送受信はWebアプリにおける基本的な動作のため、画像処理以外でも色々と使えると思います。(githubにソースコードをアップしているのでよかったら利用してみてください。)

それではまた。