[初学者の備忘録]ReactとDjango Rest Frameworkの画像アップロード実装まで(プレビュー機能付き)


はじめに

現在ReactDjango REST frameworkを用いて成果物を制作しています。
画像アップロード機能を実装するまでに、多少苦労をしたので、他の学習者の方がより簡単に実装できるように一連の流れをまとめておきたいと思って執筆させていただいております。

画像アップロード機能は詰まることが多いと聞いたことがあるので、この記事が少しでもお役に立てれば幸いです。

参考

参考にした記事は以下のものです。
バックエンドのdjango側の画像アップロード機能実装のためと、フロントエンドの画像プレピュー機能実装のために参照をしました。

はじめてのDjango (7) 画像データの管理やページへの表示,アップロードの方法などについて知ろう
Reactで超簡単な画像ビューアを作る - FileReader

バックエンド

まず、バックエンドから簡潔に説明していきたいと思います。

画像を扱うための、Pillowというパッケージを仮想環境にダウンロードをしなければいけません。
なので、まずはじめに仮想環境をアクティベートした後に以下のコードを打ってダウンロードしてください。

pip install pillow

models.py

続いて、画像を扱うモデルを作成していきます。今回は私が実際に使ったモデルを用いて説明を進めていきます。
画像を扱うためには、ImageFieldを設定しなければいけません。

models.py

class Item_Image(models.Model):
    image = models.ImageField(upload_to="images/")
    item = models.ForeignKey(
        Give_Item, on_delete=models.CASCADE, related_name="item_image")

    def __str__(self):
        return self.item.parent_item.name

    class Meta:
        db_table = "item_images"

upload_toというのは、settings.pyで設定したMEDIA_ROOTからの相対パスを示しています。画像はデータベースで直接保存されるわけではなく、この指定したディレクトリにアップロードされているというのが実際のロジックです。

特にモデルでは書くことはないので、このままsettings.pyの説明に移ります。

settings.py

追記する内容は以下の通りです。

BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
MEDIA_ROOT = os.path.join(BASE_DIR, 'media')
MEDIA_URL = '/media/'

__file__は実行中のファイル(ここではsettings.py)を参照していて、os.path.dirnameは簡単に言うと一個上のディレクトリを参照するので、BASE_DIRはプロジェクトやアプリを全て格納しているフォルダ名を参照していることになります。
これによって、mediaディレクトリのパスを作成することができました。

続いて、プロジェクトの方のurls.pyに移ります。

urls.py

記述はとても簡単で、一種のおまじないみたいなものです。
importを忘れないようにしてください。

urls.py
from django.conf import settings # New
from django.contrib.staticfiles.urls import static # New
from django.contrib.staticfiles.urls import staticfiles_urlpatterns # New


urlpatterns += staticfiles_urlpatterns() # New
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) # New

urlpatternsに関しては、他のエンドポイントを書いた後の下に書いて大丈夫です。具体的には以下のようになります。

urls.py
urlpatterns = [
    path("api/", include("app.urls")),
    path('admin/', admin.site.urls),
    url('rest-auth/', include('rest_auth.urls')),
    url('rest-auth/registration/', include('rest_auth.registration.urls'))
]

# これで準備完了です
urlpatterns += staticfiles_urlpatterns()
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

あとは、Reactを使うならDjango REST frameworkも必要となるのserializers.py, views.pyも設定しておきましょう。

serializers.py, views.py

ここは特に画像アップロードで加筆することはありません。
models.pyを作成しているなら、ModelSerializer,ModelViewSetを使えば楽にAPI実装ができます。

serialize.py

class Item_ImageSerializer(serializers.ModelSerializer):

    class Meta:
        model = Item_Image
        fields = "__all__"
views.py

class CommentViewSet(viewsets.ModelViewSet):
    queryset = Comment.objects.all()
    permission_classes = [
        permissions.AllowAny
    ]
    serializer_class = CommentSerializer

これにてバックエンドの設定は完了です。
フロントエンドに移ります。

フロントエンド

inputフォームの作成

画像をアップロードしてもらうことになるので、inputを作成する必要があります。
ここで大切なのはformタグを使用することです。
buttononSubmitに関数を代入した方がe.preventDefault()を書かなくて楽だと思われるかもしれませんが、画像アップロードの際に必要になるので、必ずformタグで囲ってあげてください。

ちなみに、今回は複数投稿での実装となります。

form.jsx

<div className="imageForm">
            <form onSubmit={this.handleSubmit}>

             //
                   省略
             //              

              <label>商品画像</label>
          //
                // 複数アップロードする際は、multipleをつける必要があります
                //
               <input type="file" multiple onChange={this.handleImageSelect} />
                //
                // 下記はプレビュー機能のためのコードなので後で説明を加えます
                //
                {this.state.imgUrls.length === 0
                  ? null
                  : this.state.imgUrls.map((img, idx) => {
                   return <img key={idx} src={img}></img>;
                  })}

            <form/>

プレビュー機能の実装

先にコードを書きます。

form.jsx

  readImageUrl = () => {
    const files = Array.from(this.state.info.images);
    Promise.all(
      files.map((file) => {
       //
       //   3
       //
        return new Promise((resolve, reject) => {
          const reader = new FileReader();
          reader.addEventListener('load', (event) => {
            resolve(event.target.result);
          });
          reader.addEventListener('error', reject);
          reader.readAsDataURL(file);
        });
      })
    )
      .then((images) => {
        this.setState({ imgUrls: images });
      })
      .catch((err) => console.log(err));
  };


  handleImageSelect = async (e) => {
  // 
  //   1
  //
    await this.setState({
      info: { ...this.state.info, images: [...this.state.info.images, ...e.target.files] },
    });
    this.setState({
      message: {...this.state.message, images: this.validator("images", this.state.info.images )}
    })
    //
    //  2
    //
    this.readImageUrl();
  };

順番に説明をしていきます。
私のコードのValidationの実装方法に関連して、少し記述がごちゃごちゃになっております。申し訳ございません。

1.state内に選択されたファイルを格納する
一番はじめに行うことは極めて単純です。選択されたファイルをSubmitするためにstateに入れ込むだけです。私の場合、後のValidationの都合でasycn/awaitで全ファイルが代入されるまで待っていますが、この非同期処理への対応は必須ではないのでお任せします。(後にわかったことですがsetStateasync/awaitに付けても特に効果はないようです。)

重要なことは、選択されたファイルをスプレッド構文を用いて、配列にまとめて代入するということです。

2. ファイルをインラインで埋め込むdata:URLに変更する
(この表現が正しいかはわかりませんが)inputから得られるFileBlobを継承しているため中のデータに直接アクセスすることはできません。Fileに格納されたデータにアクセスするための一つの方法がFileを`data:URL"として読み込むことであるので、この関数を使って配列内のFile達を変換しているということになります。

3. FileReaderを使って画像のURLを取得しstateに入れる
このプレビュー機能の実装方法において、肝となるのはFileReaderというオブジェクトです。今回は複数投稿での実装ということで、inputから得たfilesarrayをmapしています。書かれてる順番が前後しますが、Promiseの中で行われているのは、まずreadAsDataURLメソッドを使って選択されたファイルを読み込むことです。名の通り読み込まれたファイルは上述のdata:URLに変換されます。そして、読み込みが終わったと同時に発火するのが、その上のloadイベントです。ちなみにこれはaddEventListenerを使う必要はなく、onloadというプロパティを使ってより簡潔に記述することもできます。書かれてる順がややこしくさせますが、このloadイベントは読み込みが完了され他あと、resultとして読み込まれたファイルを返してくれます。今回はプレビューとして画像を描画したいので、stateに入れます。

{
  this.state.imgUrls.length === 0
    ? null
    : this.state.imgUrls.map((img, idx) => {
        return <img key={idx} src={img} alc="アップロード写真" height="150px"></img>;
       })
}

今回は複数投稿なので、先ほど変換されたdata:URLが入ったファイルをmapします。後は、imgタグのsrcに受け取ったURLを入れるだけです。

画像アップロードの実装

this.state.info.images.map((image) => {
      let data = new FormData();
      data.append('image', image);
      data.append('item', giveItem_id);

      axios
        .post(this.props.axiosUrl + 'image/', data, authHeader)
        .then((res) => console.log(res.data))
        .catch((err) => console.log(err));
    });

画像アップロードに関連するコードだけ抜粋して書いていますが、説明に大きく影響はないのでそのまま使用させていただきます。

React, Django Rest Frameworkにおいて画像をアップロードする肝となるのはFormDataです。他のCharField,IntegerFieldモデルでは可能なaxiosdata部分に値を入れてPOSTリクエストを送ってもエラーが返ってきます。ImageFieldFileFieldを継承しているからか理由は定かではありませんが、少なくとも画像アップロードにはFormDataオブジェクトとしてリクエストを送らないとモデルを作成できないのは間違い無いと思います。

FormDataを使うことさえわかれば実装は至極単純です。.append(name, value)を用いてFormData()にリクエストを送りたい値を入れるだけです。

私の場合、複数投稿された画像一枚ごとにモデルを新規作成したかったのでinputから得たfileを格納した配列をmapしています。

豆知識となりますが、FormDataにちゃんと値が入っているか確認したい場合は以下の方法を使えば可能です(上記のコードからlet data = new FormData()として代入されている前提です)。

console.log(...data)

まとめ

上記が私が実装した方法です。

  1. inputから得られるfileBlob形式である
  2. Blob形式のファイルをdata:URL等に変換するのにFileReader()が有効である
  3. フロントからバックエンドへのImageFieldを持つモデルを作る場合、FormDataとして送信しなければならない

以上がまとめです。
他にもより良い実装方法があると思いますが、あくまでも一つの方法として参考にしていただければ幸いです。

アドバイスや間違っている点含めコメントをいただければとても嬉しいです。
拙く読み辛い文章だったとは思いますが最後までご覧いただき誠にありがとうございました。