はじめに
この記事は、トラハック氏(@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)のフォローもよろしくお願いします。