はじめに
この記事は、トラハック氏(@torahack_)が運営するYoutubeチャンネル『トラハックのエンジニア学習ゼミ【とらゼミ】』の『日本一わかりやすいReact-Redux講座 実践編』の学習備忘録です。
前回講座では、商品情報登録ページおよび登録機能を実装しました。
今回の講座では、さらに商品情報として画像のアップロードを実装していきます。
前回記事はこちら。
要点
- Cloud Storage を利用することで、画像ファイルを保存できる。
- Cloud Storage に保存した画像ファイルと紐づく情報を、他の商品情報と合わせて Cloud Firestore に保存することで、対応する画像を Storage から取り出せる。
#5_Cloud Storageに画像をアップ&プレビュー&削除
Cloud Storageとは?
画像ファイルを保存できる Firebase の機能。
第4回講座で作成した「商品登録ページ」内において「商品画像を登録する」ボタンを作成し、商品画像をアップロードできるようにします。
完成形イメージ
「商品を登録する」の横のアイコンをクリックすると、ダイアログが出てきます。
画像を選択すると、
画面にプレビューが表示されます!
また、画像は複数登録することができます。
画像アップロードの流れ
上記画面での画像アップロードを、アプリ内で実行される処理の流れで説明すると、
- 「商品を登録する」アイコンをクリックし、ダイアログから画像ファイルを選択する。
- 画像ファイルにユニークな
idを付与し、 Cloud Storage にアップロードして保存する。 - アップロードが完了したら、画像ファイルをダウンロードできるURL(
path)を取得する。 -
ProductEdit.jsxの中でimagesという state を配列として定義し、idとpathを格納する。 -
<img>タグを用いてpathの画像を表示する(画像プレビュー) - 「商品情報を保存」クリック時に、商品名(
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に配置する。
先ほどの動作画面に表すとこんな感じ。
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
.
.
.
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()の引数にも追加します。
.
.
.
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 に送信されます)
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)で、クリックした画像以外の画像ファイル情報(idとpath)を取り出しています。
これを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が走り、画像のアップロード処理が実行されます。
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

画像を追加します。実装がうまくいっていれば、複数枚の登録が行えるはずです。
さて「商品情報を登録する」を押す前に、 Cloud Storage を確認してみます。
画像プレビューがうまくいっているのであれば、この時点で Cloud Storage へのアップロードが完了しているはずです。
FirebaseコンソールからStorageに入ります(最初は何らかの初期設定を聞かれますが、基本Yesで進んでOKです)
imagesというフォルダが作られています。これは、uploadImage 内でconst uploadRef = storage.ref('images').child(fileName);のように定義したためです。クリックすると、
先ほどの画像が保存されているのを確認できます!
それでは、画像の削除(deleteImage)を試してみます。画像をクリックすると、
**http://localhost:3000/product/edit**

画像がプレビューから削除されました!コンソール画面を再読み込みすると、
該当画像が Storage から削除されているのが確認できます!
最後に、商品情報を登録します。「商品情報を登録する」ボタンをクリックすると、
ルートにリダイレクトされます。 Firebaseコンソールから、Database を確認します。
新たにimagesというフィールドが増え、idと pathが格納されています。
idは、 Storage における名前(filename)と同じ文字列になっているはずです。また、 pathのURLに実際にブラウザでアクセスすることで、登録した画像が取得できるようになっています。
まとめ
最後に要点を整理すると、
- Cloud Storage を利用することで、画像ファイルを保存できる。
- Cloud Storage に保存した画像ファイルと紐づく情報を、他の商品情報と合わせて Cloud Firestore に保存することで、対応する画像を Storage から取り出せる。
今回は以上です!
このような学習内容を日々呟いていますので、よろしければTwitter(@ddpmntcpbr)のフォローもよろしくお願いします。











