前回から引き続き、React + Firebaseでミニアプリを作成します。
今回は画像のアップロードを行えるようにします。
React + Firebaseでの画像アップロードに関しては以前記事にしました。Cloud Storageのセットアップに関しては同じですが、React側がReduxを使う関係上少し作り込む必要があります。
React+Firebaseでの画像アップロード
React + Firebaseでの画像アップロードが分かりづらかったのでまとめておきます。 FirebaseのCloud Storageというサービスを利用して画像を保存します。 Co ...
続きを見る
Contents
ソースコードについて
本記事の前提となるコードはこちらから↓
前回のコードからリファクタリングしており、フォルダ構成やコンポーネントが変わっています。
(前回のコードをそのまま使い続けても問題ありません)
ダウンロードした方はsetting.jsのapiKeyとaxiosのbaseURLをご自身のプロジェクト用に書き換えることを忘れずに。
完成コードはこちらから↓
プロジェクトの設定
Firebaseのプロジェクト設定に関しては画像アップロードの記事の通りですので割愛しますが、以下の手順が必要です。
- FirebaseコンソールでCloud Storageを作成
- 権限の変更
- プロジェクトにアプリを追加
- yarn add firebase
- Cloud Storage用にSDKを追加
以下を参考にsrc > firebase > firebase.jsにfirebaseConfigを設定しておいて下さい。
参考↓
React+Firebaseでの画像アップロード
React + Firebaseでの画像アップロードが分かりづらかったのでまとめておきます。 FirebaseのCloud Storageというサービスを利用して画像を保存します。 Co ...
続きを見る
新規作成での画像アップロード
フォーム作成
まずは画像をアップロードするためにフォームを作成します。
components > content > form > SubjectForm.jsにファイルのinputを追加します。
<Button component="label" variant="outlined"> アイキャッチ画像アップロード <input type="file" name="image" ref={register} style={{ display: "none" }} /> </Button>
ポイント
ここではMaterial UIのデザインっぽくするためにButtonでラップしていますが、inputタグだけでも構いません。
react hook formで画像ファイルを扱うのに特別な設定は必要なく、onSubmitも特に変更する必要はありません。
アップロードのロジック
次にロジック部分を作っていきます。
画像アップロードとSubjectデータの作成に関しては同時に行うことは出来ません。
まずは画像をアップロードし、アップロードした画像のURLを取得します。そのURL(と画像名)をSubjectデータに含めDBに保存します。
actions > subject.js内のcreateSubjectを以下のように変更します。
ポイント
画像名は重複を避けるために日付情報をファイル名に付与しています。
export const createSubject = data => async dispatch => { try { const userId = getUserId(); // アップロード処理 const imageName = new Date().toISOString() + data.image[0].name; const imageUrl = await uploadTaskPromise(data.image[0], imageName, userId); const subject = { title: data.title, image: imageUrl, imageName: imageName }; const result = await axios.post(`/${userId}/subject.json`, subject); dispatch(createSubjectSuccess(data, result.data.name, imageUrl)); dispatch(createInitialQuestion(result.data.name)); dispatch(showMessage("新規作成に成功しました。", "success")); } catch (error) { dispatch(apiFailed("作成に失敗しました。もう一度試して下さい。")); } }; const createSubjectSuccess = (data, id, imageUrl) => { return { type: ACTION_TYPES.CREATE_SUBJECT, payload: { id: id, title: data.title, image: imageUrl } }; };
createSubjectSuccessも変更してありますので注意して下さい。
uploadTaskPromise
が実際の画像アップロード処理で、以下のようになります。
import firebase, { storage } from "../firebase/firebase"; async function uploadTaskPromise(image, imageName, userId) { // check => https://stackoverflow.com/questions/53156127/async-await-uploadtask return new Promise(function(resolve, reject) { const uploadTask = storage.ref(`/images/${userId}/${imageName}`).put(image); uploadTask.on( firebase.storage.TaskEvent.STATE_CHANGED, null, err => { console.log("error", err); reject(); }, () => { // 完了後の処理 // 画像表示のため、アップロードした画像のURLを取得 uploadTask.snapshot.ref.getDownloadURL().then(fireBaseUrl => { resolve(fireBaseUrl); }); } ); }); }
アップロードはstorage.ref("保存するパス").put("画像ファイル")
とすることで可能です。
またアップロード処理のuploadTask
に対して.on
でリスナーを追加することができ、3つのコールバック(observer、エラー時の処理、完了後の処理)を渡すことが出来ます。
参考:UploadTask
ここで重要なのは完了後にgetDownloadURL
でアップロードしたファイルのURLを取得している点です。これをSubjectデータ作成時にDBに保存します。
これで画像アップロードが出来るようになっているはずです。試してみましょう。
UIの問題でちゃんと選択できたか分かりづらいものの、アップロード自体はきちんと出来ます。
Firebaseコンソールで確認してみましょう。
このようにアップロードされていることが確認できます。
リスト画面で表示させる
今までリスト画面では固定の画像を表示させていましたが、アップロードした画像を表示させるように修正しましょう。
まずはMediaCardコンポーネントを変更し、渡されたURLの画像を表示させるようにします。
components > content > list > MediaCard.js
import React from "react"; import { makeStyles } from "@material-ui/core/styles"; import Card from "@material-ui/core/Card"; import CardActionArea from "@material-ui/core/CardActionArea"; import CardContent from "@material-ui/core/CardContent"; import CardMedia from "@material-ui/core/CardMedia"; import Typography from "@material-ui/core/Typography"; import Image from "../../../assets/ImageNotFound.png"; const useStyles = makeStyles(theme => ({ root: { width: 400, background: theme.palette.primary.main }, media: { height: 200 } })); const MediaCard = props => { const { title, imageUrl } = props; const classes = useStyles(); return ( <Card className={classes.root}> <CardActionArea> <CardMedia className={classes.media} image={imageUrl ? imageUrl : Image} /> <CardContent> <Typography gutterBottom variant="h5" component="h2" align="center"> {title} </Typography> </CardContent> </CardActionArea> </Card> ); }; export default MediaCard;
Material UIのCardMediaはimageプロパティにURLを渡すことで画像表示が出来ます。
また画像が何らかの理由で見つからない場合のために、ImageNotFound.pngをassetsフォルダに用意しておきましょう。
例えば以下のような画像を配置しておきます。
後はMediaCardにDBに保存しておいたURLを渡します。
components > content > ListView.jsのMediaCard部分を以下のように変更します。
<MediaCard title={item.title} imageUrl={item.image} />
これで画像が表示されます。
このように画像がない場合はNot Foundの画像が、ある場合は保存した画像が表示されます。
プレビュー表示
アップロード機能自体は出来たわけですが、現状ではフォームで画像が選択されているか非常に分かりづらいため、アップロードする画像のプレビューを表示できるようにします。
現状はこんな感じ↓
先程作ったMediaCardがそのままプレビューに流用できます。
SubjectForm.jsを以下のように変更します。
import React, { useState } from "react"; import TextField from "@material-ui/core/TextField"; import Box from "@material-ui/core/Box"; import Grid from "@material-ui/core/Grid"; import Button from "@material-ui/core/Button"; import SubmitButton from "../../utils/SubmitButton"; import MediaCard from "../list/MediaCard"; const SubjectForm = props => { const { handleSubmit, register, onSubmit, title, imageUrl } = props; const [selectedImage, setSelectedImage] = useState(imageUrl); const handleChange = event => { const blobUrl = window.URL.createObjectURL(event.target.files[0]); setSelectedImage(blobUrl); }; return ( <React.Fragment> <form onSubmit={handleSubmit(onSubmit)}> <TextField label="タイトル" variant="outlined" fullWidth inputRef={register} name="title" autoComplete="off" defaultValue={title} /> <Box mt={2}> <Button component="label" variant="outlined"> アイキャッチ画像アップロード <input type="file" name="image" ref={register} style={{ display: "none" }} onChange={handleChange} /> </Button> </Box> <Box textAlign="center" mt={2}> <SubmitButton text="保存" /> </Box> </form> <Grid container justify="center"> <Grid item> <Box mt={3}> <MediaCard title="画像プレビュー" imageUrl={selectedImage} /> </Box> </Grid> </Grid> </React.Fragment> ); }; export default SubjectForm;
補足
SubmitButtonコンポーネントがない場合は完成コードを参照して下さい。(components > utilsにあります)
MediaCardで画像を表示するため、アップロード前に画像のURLを取得する必要があります。
これはwindow.URL.createObjectURL
で可能なようですので、これをuseStateでstateとして持たせ、表示させます。
ポイント
useStateとReduxを併用したくないという方はReduxで管理しても構いません。このFormでしか使わないためuseStateを使用しました。
また、onChangeをinputに追加することで選択された画像が変更されるたびにURLが変更され、その都度画像が切り替わるようになっています。
これで新規作成での画像アップロードは完成です。
注意ポイント
本来は画像が選択されているか確認するバリデーションなども行うべきですが、今回は省略します。
編集での画像アップロード
編集に関しても基本的には新規作成と同じように出来ますが、気をつけなければならないのが画像を更新する場合には過去の画像を削除するという点です。
画像の削除はstorage.ref("画像のパス").delete();
とすることで可能です。
actions > subjects.js
export const updateSubject = ( data, oldImageName, oldImageUrl ) => async dispatch => { try { const userId = getUserId(); let imageUrl = oldImageUrl; // 画像の更新がなければ元のURLをセットする. // data.imageはFileListなので注意 if (data.image.length !== 0) { const imageName = new Date().toISOString() + data.image[0].name; imageUrl = await uploadTaskPromise(data.image[0], imageName, userId); data.image = imageUrl; data.imageName = imageName; await axios.put(`/${userId}/subject/${data.id}.json`, data); await storage.ref(`/images/${userId}/${oldImageName}`).delete(); } else { // 画像を更新しない場合は元のURL, 画像名をセットする data.image = oldImageUrl; data.imageName = oldImageName; await axios.put(`/${userId}/subject/${data.id}.json`, data); } dispatch(updateSubjectSuccess(data, imageUrl)); dispatch(showMessage("更新に成功しました。", "success")); } catch (error) { console.log(error); dispatch(apiFailed("update subject failed")); } }; const updateSubjectSuccess = (data, imageUrl) => { // data.imageはFileListなのでURLをdata.imageにセットする data.image = imageUrl; return { type: ACTION_TYPES.UPDATE_SUBJECT, payload: data }; };
注意ポイント
完成版コードでは画像を更新しない場合の処理が抜けていますのでご注意下さい😰
また、SubjectEditでのonSubmitもupdateSubjectの引数に合わせて変更します。
components > content > form > SubjectEdit.js
const onSubmit = data => { data.id = item.id; dispatch(updateSubject(data, item.imageName, item.image)); history.push("/detail/" + item.id); };
加えて、保存されている画像をあらかじめプレビューに表示させるため、SubjectFormにimageUrlを渡しておきましょう。
<SubjectForm handleSubmit={handleSubmit} register={register} onSubmit={onSubmit} title={item.title} imageUrl={item.image} />
これで編集の実装は完了です。確認してみましょう。
編集画面では以下の通り、画像がプレビューされた状態で表示されています。
画像を更新する場合、しない場合それぞれ正しく更新されるようになっているはずです。
Subjectデータ削除時の画像削除
最後にSubjectデータ削除時の画像削除について対応します。
削除には画像名が必要になるためdeleteSubjectに引数を追加します。削除のロジックは画像更新時の削除ロジックと同じです。
actions > subjects.js
export const deleteSubject = (id, imageName) => async dispatch => { try { ...他の削除処理 await storage.ref(`/images/${userId}/${imageName}`).delete(); dispatch(showMessage("削除に成功しました。", "success"));
またDetailViewでdeleteSubjectをdispatchする際に画像名を渡します。
components > content > DetailView.js
const subjectDelete = id => { dispatch(deleteSubject(id, item.imageName)); history.push("/"); };
これでSubjectデータ削除時に画像も削除されるようになりました。
完成
これで画像アップロードの実装は完了です。
ただし、忘れてはならないのが画像アップロード(Cloud Storage)では認証のチェックがされていないということです。
これまで使ってきたFirebase Auth REST APIのトークン認証の方式だとCloud Storageでの認証と上手く連携がとれません。
このため次回以降はFirebase SDKを利用して認証・CRUDを書き直していきます。
私の知らない上手い方法があるかもしれませんが、いずれにせよSDKを使う方がスマートだと思います
このシリーズも残すところあとわずかです!
それでは。