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

## はじめに
この記事は、トラハック氏([@torahack_](https://twitter.com/torahack_))が運営するYoutubeチャンネル『トラハックのエンジニア学習ゼミ【とらゼミ】』の『[日本一わかりやすいReact-Redux講座 実践編](https://www.youtube.com/watch?v=8fNgXnVTULU)』の学習備忘録です。

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

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

前回記事は[こちら](https://qiita.com/ddpmntcpbr/items/52ac3c863ea6d05e5f38)。

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

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

### Cloud Storageとは？
画像ファイルを保存できる Firebase の機能。

[第4回講座](https://qiita.com/ddpmntcpbr/items/52ac3c863ea6d05e5f38)で作成した「商品登録ページ」内において「商品画像を登録する」ボタンを作成し、商品画像をアップロードできるようにします。

### 完成形イメージ
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/540216/4fdff6c5-b2b4-9c0a-2802-1d6424061794.png)

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

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/540216/9c9105f9-72d0-03a8-0cc3-a90b974d5af6.png)

画像を選択すると、

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/540216/dbb00c4b-6522-c57e-4870-fe501287d6ab.png)

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

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

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/540216/12363b80-2bc7-20d5-6460-ae4fd4d1c0cc.png)


### 画像アップロードの流れ

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

1. 「商品を登録する」アイコンをクリックし、ダイアログから画像ファイルを選択する。
2. 画像ファイルにユニークな ``id`` を付与し、 Cloud Storage にアップロードして保存する。
3. アップロードが完了したら、画像ファイルをダウンロードできるURL(``path``)を取得する。
4. ``ProductEdit.jsx``の中で``images``という state を配列として定義し、``id``と``path``を格納する。
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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/540216/4e816450-f7d3-4717-e1b8-1646dc0c2298.png)

``imageArea.jsx``と``imagePreview.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
```

```react: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``には、画像ファイルに紐づく``id``と``path``が入ることになります(そのため``images``の初期値は空配列``[]``にしてあります)

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

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

```javascript: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 に送信されます)


```react: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``関数を定義することで実装しています。

```react:
 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でパフォーマンスを向上させよう](https://www.youtube.com/watch?v=Ta_0dsZHK_g&t=623s)』でも一度解説がなされています。

#### deleteImage関数

```react:
  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)``で、クリックした画像**以外**の画像ファイル情報(``id``と``path``)を取り出しています。

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

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

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

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

#### レンダー部

```react:
.
.
.
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>
  )
.
.
.
```

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

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

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

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

[こちらのQiita記事](https://qiita.com/Imamotty/items/bc659569239379dded55)を引用すると、


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

<br/>

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

<br/>

>(省略)falseと見ることができる値は、JavaScriptでは以下の７つになります。
>
>・0
>・-0
>・null
>・false
>・NaN
>・undefined
>・空文字列 ("")
>
>よってtrueと見ることができる値は、上記以外のすべての値を指します。


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

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

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

今回のようなケースでの``&&``の使い方は、React公式Docsの[条件付きレンダー](https://ja.reactjs.org/docs/conditional-rendering.html)でも説明されています。

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

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

```react: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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/540216/39170e20-bcfe-e570-4b1e-73942ed68c12.png)

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

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/540216/2d2c05a8-b039-692d-b159-eed84a65cf0e.png)

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

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

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

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/540216/9d7c29e2-33c3-5151-ac16-5608fa22b227.png)

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

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/540216/aafeb67c-3458-2c29-9efc-ff0456bf0084.png)


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

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

``**http://localhost:3000/product/edit**``
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/540216/960362fc-fe48-efc5-c083-17d379628061.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/540216/4d3490bf-e932-dbd4-071b-24285d9a3ca7.png)

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

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/540216/057a2752-4375-a0aa-fcbc-4661465841aa.png)

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

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

**``http://localhost:3000/``**
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/540216/e50905d7-31d9-1c02-3ba9-0c0eb279fa5b.png)

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

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/540216/da9f3d05-c12e-acaf-a0a9-79b1f246bddc.png)

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

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

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

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

今回は以上です！

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