4
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

【備忘録】日本一わかりやすいReact-Redux講座 実践編 #5 「Cloud Storageに画像をアップ&プレビュー&削除」

Last updated at Posted at 2020-07-18

はじめに

この記事は、トラハック氏(@torahack_)が運営するYoutubeチャンネル『トラハックのエンジニア学習ゼミ【とらゼミ】』の『日本一わかりやすいReact-Redux講座 実践編』の学習備忘録です。

前回講座では、商品情報登録ページおよび登録機能を実装しました。

今回の講座では、さらに商品情報として画像のアップロードを実装していきます。

前回記事はこちら

要点

  • Cloud Storage を利用することで、画像ファイルを保存できる。
  • Cloud Storage に保存した画像ファイルと紐づく情報を、他の商品情報と合わせて Cloud Firestore に保存することで、対応する画像を Storage から取り出せる。

#5_Cloud Storageに画像をアップ&プレビュー&削除

Cloud Storageとは?

画像ファイルを保存できる Firebase の機能。

第4回講座で作成した「商品登録ページ」内において「商品画像を登録する」ボタンを作成し、商品画像をアップロードできるようにします。

完成形イメージ

image.png

「商品を登録する」の横のアイコンをクリックすると、ダイアログが出てきます。

image.png

画像を選択すると、

image.png

画面にプレビューが表示されます!

また、画像は複数登録することができます。

image.png

画像アップロードの流れ

上記画面での画像アップロードを、アプリ内で実行される処理の流れで説明すると、

  1. 「商品を登録する」アイコンをクリックし、ダイアログから画像ファイルを選択する。
  2. 画像ファイルにユニークな id を付与し、 Cloud Storage にアップロードして保存する。
  3. アップロードが完了したら、画像ファイルをダウンロードできるURL(path)を取得する。
  4. ProductEdit.jsxの中でimagesという state を配列として定義し、idpathを格納する。
  5. <img>タグを用いてpathの画像を表示する(画像プレビュー)
  6. 「商品情報を保存」クリック時に、商品名(name)や商品説明(description)などと合わせてimagesもCloud firestoreに送信・保存する。

重要な点は、

  • 画像ファイル本体は Cloud Storage に保存する
  • 画像ファイルに紐づくidおよびpathは、他の商品情報と一緒に Cloud Firestore に保存する

です。

画像ファイルを Storage に保存したとしても、適切な場面で取り出すことができなければ意味がありません。

商品名や商品説明のような「商品情報」の一つとして、これら画像に紐づく情報を保存しておくことで、Storage 内の適切な画像を取り出せるようになります。

この流れをイメージしておくと、以降の具体的な実装が理解しやすいかと思います。

画像用コンポーネントの作成

さて、上記流れを実現する記述を全てProductEdit.jsx内にしてもよいのですが、記述がかなり多くなりますので、別のコンポーネントに分けて定義します。

今回は、画像用コンポーネントとして、以下二つを作成します。

1. src/components/Products/imageArea.jsx ¥

  • 画像プレビューおよび画像登録ボタンなどを含んだコンポーネント。ProductEdit.jsxに配置する。

2. src/components/Products/imagePreview.jsx

  • 画像プレビュー部分のみを担当するコンポーネント。imageArea.jsxに配置する。

先ほどの動作画面に表すとこんな感じ。

image.png

imageArea.jsximagePreview.jsxを分けているのは、今後別の場所でimagePreview.jsxのみを使うことが想定しているためです(例えば商品閲覧ページなど)。

以上を踏まえ、下記4ファイルを新規作成・修正します。

1. src/templates/ProductEdit.jsx
2. src/reducks/products/operations.js
3. src/components/Products/ImageArea.jsx
4. src/components/Products/ImagePreview.jsx
src/templates/ProductEdit.jsx
.
.
.
import ImageArea from "../components/Products/imageArea";   // 追記

const ProductEdit = () => {
.
.
.

  const [name, setName] = useState(""),
        [description, setDescription] = useState(""),
        [category, setCategory] = useState(""),
        [gender, setGender] = useState(""),
        [images, setImages] = useState([]),   // 追記
        [price, setPrice] = useState("");

.
.
.

  return (
    <section>
      <h2 className="u-text__headline u-text-center">商品の登録・編集</h2>
      <div className="c-section-container">
        <ImageArea images={images} setImages={setImages} />   // 追記
.
.
.
      </div>
.
.
.
      <div className="center">
        <PrimaryButton
          label={"商品情報を保存"}
          onClick={() => dispatch(saveProduct(name, description, category, gender, images, price))} // 追記
        />
      </div>
    </section>
  )
}

export default ProductEdit

useState()を用いて、imagesという state を定義しています。imagesには、画像ファイルに紐づくidpathが入ることになります(そのためimagesの初期値は空配列[]にしてあります)

それらの値を格納する処理はImageArea.jsxで行うため、imagesおよびsetImagesを props として渡しています。

また、imagesは他の state と合わせて Cloud firestore に保存したいため、saveProduct()の引数にも追加します。

src/reducks/products/operations.js
.
.
.

export const saveProduct = (name,description,category,gender,images,price) => {
  return async (dispatch) => {
    const timestamp = FirebaseTimestamp.now()

    const data = {
      category: category,
      description: description,
      gender: gender,
      images: images,    // 追記
      name: name,
      price: parseInt(price, 10),
      updated_at: timestamp
    }
.
.
.
  }
}

先ほどsaveProduct()の引数としてimagesを追加したので、こちらでも追記をします。(ここのdataオブジェクトが、Cloud firestore に送信されます)

src/components/Products/imageArea.jsx
import React, {useCallback} from "react";
import IconButton from "@material-ui/core/IconButton";
import AddPhotoAlternateIcon from '@material-ui/icons/AddPhotoAlternate';
import { makeStyles } from "@material-ui/core";
import {storage} from "../../firebase/index";
import ImagePreview from "./imagePreview"

const useStyles = makeStyles({
  icon: {
    height: 48,
    width: 48
  }
})

const ImageArea = (props) => {
  const classes = useStyles();

  const deleteImage = useCallback( async (id) => {
    const ret = window.confirm("この画像を削除しますか?");
    if (!ret) {
      return false
    } else {
      const newImages = props.images.filter(image => image.id !== id)
      props.setImages(newImages);
      return storage.ref("image").child(id).delete()
    }
  }, [props.images])

  const uploadImage = useCallback((event) => {
    const file = event.target.files;
    let blob = new Blob(file, {type: "image/jpeg"});

    const S="abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
    const N=16;
    const fileName = Array.from(crypto.getRandomValues(new Uint32Array(N))).map((n) => S[n%S.length]).join('')

    const uploadRef = storage.ref('images').child(fileName);
    const uploadTask = uploadRef.put(blob);

    uploadTask.then(() => {
      uploadTask.snapshot.ref.getDownloadURL().then((downloadURL) => {
        const newImage = {id: fileName, path: downloadURL};
        props.setImages((prevState => [...prevState, newImage]))
      });
    })
  }, [props.setImages])

  return (
    <div>
      <div className="p-grid__list-images">
        {props.images.length > 0 && (
          props.images.map(image => <ImagePreview delete={deleteImage} id={image.id} path={image.path} key={image.id} />)
        )}
      </div>
      <div className="u-text-right">
        <span>商品画像を登録する</span>
        <IconButton className={classes.icon}>
          <label>
            <AddPhotoAlternateIcon />
            <input  className="u-display-none" type="file" id="image"
                    onChange={(event) => uploadImage(event)}/>
          </label>
        </IconButton>
      </div>
    </div>
  )
}

export default ImageArea

今回のメインはここ。部分に分けて解説します。

uploadImage関数

画像のアップロードは、uploadImage関数を定義することで実装しています。

 const uploadImage = useCallback((event) => {
    const file = event.target.files;
    let blob = new Blob(file, {type: "image/jpeg"});

    const S="abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
    const N=16;
    const fileName = Array.from(crypto.getRandomValues(new Uint32Array(N))).map((n) => S[n%S.length]).join('')

    const uploadRef = storage.ref('images').child(fileName);
    const uploadTask = uploadRef.put(blob);

    uploadTask.then(() => {
      uploadTask.snapshot.ref.getDownloadURL().then((downloadURL) => {
        const newImage = {id: fileName, path: downloadURL};
        props.setImages((prevState => [...prevState, newImage]))
      });
    })
  }, [props.setImages])

eventは、この関数が埋め込まれる<input>タグから送られてくるもの。file = event.target.files;とすることで、fileには、登録したい画像データそのものが入っているイメージ。

しかし、このfileはそのまま Cloud Storage にアップロードすることができません。事前処理としては、let blob = ...で、画像データを Blobオブジェクトに変換しています。(BlobとはBinary Large OBjectの略で、一般的なバイナリデータをJavascriptで扱えるようにするものです。)

fileName = Array.from....は、アップロードする画像ファイルにユニークなidを付与するために、idとして使用する16文字のランダムな文字列を生成しています。

このfileNameを用いてuploadRef...,uploadTask...で、Blobオブジェクトにした画像ファイルを、ユニークな文字列をidとして付与しながら Cloud Storage にアップロードしています。

このタイミングで、画像ファイルが Cloud Storage に保存されます。

アップロードが無事完了したら、uploadTask.snapshot.ref.getDownloadURL()で、アップロードした画像ファイルのdownloadURLを取得し、idとあわせてnewImageに格納します。

このnewImagesを、親コンポーネントから渡ってきたprops.setImages()に入れることで、ProductEdit.jsx内で定義したimages state が更新されます。

prevStateは、「更新前のstateに入っていた値」を表すワードで、[...prevState, newImage]と書くことで、newImagesで images state を完全に書き換えるのではなく「既存の値はそのまま新規に値を追加をする」という処理が行えます。

これにより、登録画像を2枚目、3枚目と複数枚の画像ファイルの情報をimagesに入れることができるようになります。

そして、imagesの値が更新された状態で、ProductEdit.jsx内の「商品情報を登録する」ボタンをクリックすることで、operatins.jsで定義した saveProductが実行され、imagesを含めた state の値が Cloud Firesotre に保存されます

繰り返しになりますが、

  • 画像ファイル本体は Cloud Storage に保存する
  • 画像ファイルに紐づくidおよびpathは、他の商品情報と一緒に Cloud Firestore に保存する

を同時に実現するために、これら処理が並列して進んでいることを意識すると、コードの意味が理解しやすいと思います。

最後に、uploadImage関数はuseCallbackで定義されており、第2引数に[props.setImages]が設定されています。

これにより、uploadImage関数は[props.setImages]が変更されるとき(=画像ファイルが登録されたとき)のみ、再生成されるようになります、

通常のコールバック関数として定義すると、コンポーネントの再レンダーのたびに関数が再生成されることになってしまい、処理として無駄が出てしまいます。

これ無しでも問題なく動作しますが、アプリのパフォーマンス向上のため、という位置付けです。

useCallbackについては、『日本一わかりやすいReact入門【実践編】#11...useCallbackでパフォーマンスを向上させよう』でも一度解説がなされています。

deleteImage関数

  const deleteImage = useCallback( async (id) => {
    const ret = window.confirm("この画像を削除しますか?");
    if (!ret) {
      return false
    } else {
      const newImages = props.images.filter(image => image.id !== id)
      props.setImages(newImages);
      return storage.ref("images").child(id).delete()
    }
  }, [props.images])

一度登録した画像を、削除するための関数です。

この関数は、子コンポーネントにあたるImagePreview.jsxに渡し、「プレビュー画像をクリックすると、該当画像を削除する」という操作を実現します。

newImages = props.images.filter(image => image.id !== id)で、クリックした画像以外の画像ファイル情報(idpath)を取り出しています。

これをprops.setImages()に渡すことで、React側の images state から、該当画像の情報を削除しています。

加えて、storage.ref("images").child(id).delete()とすることで、該当画像を Cloud Storage から削除し、完全に画像登録をする前の状態に戻します。

最後に、この関数も uploadImage と同様に useCallback で定義しています。

uploadImage のときは単にパフォーマンスの向上が目的でしたが、deleteImage は子コンポーネントに渡す関数のため、useCallbackでの定義が必須になります

レンダー部

.
.
.
return (
    <div>
      <div className="p-grid__list-images">
        {props.images.length > 0 && (
          props.images.map(image => <ImagePreview delete={deleteImage} id={image.id} path={image.path} key={image.id} />)
        )}
      </div>
      <div className="u-text-right">
        <span>商品画像を登録する</span>
        <IconButton className={classes.icon}>
          <label>
            <AddPhotoAlternateIcon />
            <input  className="u-display-none" type="file" id="image"
                    onChange={(event) => uploadImage(event)}/>
          </label>
        </IconButton>
      </div>
    </div>
  )
.
.
.

この画像プレビュー表示の部分が、ちょっとややこしいです。

        {props.images.length > 0 && (
          props.images.map(image => <ImagePreview delete={deleteImage} id={image.id} path={image.path} key={image.id} />)
        )}

React では、&&を用いることで、JSX要素の表示・非表示を条件分岐するさせることができます。

そもそもJavascriptにおいては、&&||のような論理演算子は、必ずしも真偽値を返すわけではありません

こちらのQiita記事を引用すると、

【expr1 && expr2】
・expr1 を false と見ることができる場合は、expr1 を返します。
・そうでない場合は、expr2 を返します。
・したがって、真偽値と共に使われた場合、 演算対象の両方が true ならば、&& は、true を返し、
 そうでなければ、false を返します。


【expr1 || expr2】
・expr1 を true と見ることができる場合は、expr1 を返します。
・そうでない場合は、expr2 を返します。
・したがって、真偽値と共に使われた場合、 演算対象のどちらかが true ならば、|| は、true を返し、
 両方とも false の場合は、false を返します。


(省略)falseと見ることができる値は、JavaScriptでは以下の7つになります。

・0
・-0
・null
・false
・NaN
・undefined
・空文字列 ("")

よってtrueと見ることができる値は、上記以外のすべての値を指します。

今回のケースにおいては、&&の前半部(props.images.length > 0 )の真偽値によって、後半部のJSX要素の表示・非表示が分岐します。

もし前半部が true 、すなわちimagesの中に値が一つ以上でも含まれているときは、ユーザーが商品画像を登録したときになるので、mapメソッドを用いて全要素を<ImagePreview />コンポーネントに渡しています(同時に、先ほど定義したdeleteImage関数も渡しています。)

反対に、ユーザーが商品画像を登録していない状態では、<ImagePreview />コンポーネントの描画が行われない、ということになります。

今回のようなケースでの&&の使い方は、React公式Docsの条件付きレンダーでも説明されています。

JSX部分後半のうち、<IconButton /><AddPhotoAlternateIcon />は Material-UI が提供するコンポーネントです。これで、写真マークのようなアイコンが表示されます。

このアイコンをクリックすることで、<input>タグの onChange イベントに設置されたuploadImageが走り、画像のアップロード処理が実行されます。

src/components/products/ImagePreview.jsx
import React from "react";

const ImagePreview = (props) => {
 return (
  <div className="p-media__thumb" onClick={() => props.delete(props.id)}>
    <img src={props.path} alt="プレビュー画像" />
  </div>
 )
}

export default ImagePreview

props.images.pathに、登録画像のダウンロードURLが格納されているので、それを用いて<img>タグで画像を表示します。

また、onClickイベントに props.delete(props.id)を設置することで、画像クリック時に対象画像の削除が行われます。

動作確認

http://localhost:3000/product/edit
image.png

画像を追加します。実装がうまくいっていれば、複数枚の登録が行えるはずです。

image.png

さて「商品情報を登録する」を押す前に、 Cloud Storage を確認してみます。

画像プレビューがうまくいっているのであれば、この時点で Cloud Storage へのアップロードが完了しているはずです

FirebaseコンソールからStorageに入ります(最初は何らかの初期設定を聞かれますが、基本Yesで進んでOKです)

image.png

imagesというフォルダが作られています。これは、uploadImage 内でconst uploadRef = storage.ref('images').child(fileName);のように定義したためです。クリックすると、

image.png

先ほどの画像が保存されているのを確認できます!

それでは、画像の削除(deleteImage)を試してみます。画像をクリックすると、

**http://localhost:3000/product/edit**
image.png

image.png

画像がプレビューから削除されました!コンソール画面を再読み込みすると、

image.png

該当画像が Storage から削除されているのが確認できます!

最後に、商品情報を登録します。「商品情報を登録する」ボタンをクリックすると、

http://localhost:3000/
image.png

ルートにリダイレクトされます。 Firebaseコンソールから、Database を確認します。

image.png

新たにimagesというフィールドが増え、idと pathが格納されています。

idは、 Storage における名前(filename)と同じ文字列になっているはずです。また、 pathのURLに実際にブラウザでアクセスすることで、登録した画像が取得できるようになっています。

まとめ

最後に要点を整理すると、

  • Cloud Storage を利用することで、画像ファイルを保存できる。
  • Cloud Storage に保存した画像ファイルと紐づく情報を、他の商品情報と合わせて Cloud Firestore に保存することで、対応する画像を Storage から取り出せる。

今回は以上です!

このような学習内容を日々呟いていますので、よろしければTwitter(@ddpmntcpbr)のフォローもよろしくお願いします。

4
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?