Firebase CloudStorageを使ってブログのサムネイルは画像を設定する【Vue.js】

# Cloud Storage
Firebaseの提供する機能の一つである、CloudStorageを利用します。
`Cloud Storage`は写真や動画など、ユーザーが作成したコンテンツを保管、提供します。
AWSのS3のように使用できます。

## Vue CLIアプリ作成
いつもどおり`Vue CLI`によりVueアプリを作成します。

```
vue create firebase-app
```

どうせ使うので`Router`と`Vuex`も入れておきましょう。

```
? Please pick a preset: 
  default (babel, eslint) 
❯ Manually select features
```

```
? Check the features needed for your project: 
 ◉ Babel
 ◯ TypeScript
 ◯ Progressive Web App (PWA) Support
 ◉ Router
❯◉ Vuex
 ◯ CSS Pre-processors
 ◉ Linter / Formatter
 ◯ Unit Testing
 ◯ E2E Testing
```

## firebase初期化

FireStoreモジュールをnpmでインストールします。

```
npm install firebase
```

`src/plugins/firebase.js`ファイルを作成して初期化コードを作成して、`export defaut`してアプリケーションで`firebase`を利用できるようにします。

```src/plugins/firebase.js
import firebase from 'firebase/app'

if (!firebase.apps.length) {
  firebase.initializeApp(
    {
      apiKey: process.env.VUE_APP_APIKEY,
      authDomain: process.env.VUE_APP_AUTHDOMAIN,
      databaseURL: process.env.VUE_APP_DATABASEURL,
      projectId: process.env.VUE_APP_PROJECTID,
      storageBucket: process.env.VUE_APP_STORAGEBUCKET,
      messagingSenderId: process.env.VUE_APP_MESSAGINGSENDERID,
      appId: process.env.VUE_APP_APPID,
      measurementId: process.env.VUE_APP_MEASUREMENTID,
    }
  )
}

export default firebase
```

APIキーはクライアントアプリケーションで利用される前提なので、そのまま書いても構いませんが、なんとなく`.env.local`から読み込みます。

```.env.local
VUE_APP_APIKEY=MY_API_KEY
VUE_APP_AUTHDOMAIN=MY_AUTHDOMAIN
VUE_APP_DATABASEURL=MY_DATABASEURL
VUE_APP_PROJECTID=MY_PROJECT_ID
VUE_APP_STORAGEBUCKET=MY_STORAGE
VUE_APP_MESSAGINGSENDERID=MY_MESSAGER
VUE_APP_APPID=MY_APPID
VUE_APP_MEASUREMENTID=MY_MEMEASUREMENTID
```

## Storageの設定を初期化する
`storage`をモジュールで初期化して利用します。
`src/plugins/storage.js`に以下のようにかきます。

```src/plugins/storage.js
import firebase from '@/plugins/firebase'

export const storage = firebase.storage()
```

## Cloud Storageを利用する
実際にStorageを利用してみます。
例として、ブログ作成CMSでサムネイルを設定する機能を作成します。

![スクリーンショット 20200419 22.44.31.png](https://firebasestorage.googleapis.com/v0/b/app-blog-1ef41.appspot.com/o/articles%2Fa51lPE293EOY2F6iaXjM%2Fe30d4d855cf5553bed1264930d7a12b0.png?alt=media&token=7ff157a1-10d0-4184-98c8-9f494e30329c)

機能として、新しい画像を新たにアップロードしてサムネイルを設定するか、ブログ内で使用した画像をサムネイルに設定できるようにします。

## 画像をアップロードする
まずは、画像をアップロードしてサムネイルを設定する機能を作成します。

`<template>`は次のようになっています。

```vue

<template>
    <v-dialog v-model="dialog" scrollable max-width="600px">
      <template v-slot:activator="{ on }">
        <v-btn color="primary" class="ma-5" v-on="on">
          <v-icon>fas fa-image</v-icon>
          サムネイルの設定
        </v-btn>
      </template>
      <v-card>
        <v-card-title>サムネイルの設定</v-card-title>
        <v-divider></v-divider>
        <v-progress-linear
          v-model="fileLoading"
          stream
        ></v-progress-linear>
        <v-card-text style="height: 600px;">
          <v-card-subtitle>現在のサムネイル</v-card-subtitle>
          <v-row>
            <v-col cols=4>
              <v-img
                :src="thumbnail"
                alr="サムネイル"
                width=200
                height=200
              ></v-img>
            </v-col>
            <v-col cols=8>
              <v-file-input accept="image/*" label="画像をアップロードして設定する。" @change="onFileUpload"></v-file-input>
              <v-btn color="error" @click="deleteThumbnail">サムネイルを削除する</v-btn>
            </v-col>
          </v-row>
```

注目すべき点は、`<v-file-input>`によってファイルがアップロードされたら、`onFileUpload`イベントが発火するところです。
(`<v-file-input>`は`<input type="file">`を提供する`Vuetify`のコンポーネントです。)

`onFileUpload`イベントを見ていきます。

```js
onFileUpload(file) {
    const fileType = this.getFileType(file)
      if (!fileType) {
        this['flash/setFlash']({
          message: 'ファイルタイプが不正です。',
          type: 'error'
        })
      }
      const storageRef = storage.ref(`articles/${this.article.id}/thumbnail.${fileType}`)
      const uploadTask = storageRef.put(file)
      uploadTask.on('state_changed', 
          snapshot => {
            const percentage = (snapshot.bytesTransferred / snapshot.totalBytes) * 100
            this.fileLoading = percentage
          },
          err => {
            console.log(err)
            this['flash/setFlash']({
              message: 'ファイルのアップロードに失敗しました。',
              type: 'error'
            })
          },
          () => {
            uploadTask.snapshot.ref.getDownloadURL().then(downloadURL => {
              this.fileLoading = 0
              this.thumbnail = downloadURL
            })
          }
        )
    },
```

`@change`イベントは[file API](https://developer.mozilla.org/ja/docs/Web/API/File)を受け取ります。
`getFileType`と`this['flash/setFlash']`は独自で作成したメソッドです。
ファイルの拡張子を取得する機能と、フラッシュメッセージ機能を提供します。

### ファイルの参照を作成
ファイルを`Cloud Storage`にアップロードするには、まずファイル名を含むファイルの完全パスへの参照を作成します。

ファイルの参照を作成するために、`storage.ref()`メソッドを利用します。

```js
const storageRef = storage.ref(`articles/${this.article.id}/thumbnail.${fileType}`)
```

### ファイルをアップロード
参照を作成したら、`storageRef.put()`メソッドでファイルをアップロードします。

`put()`は先程取得した[file API](https://developer.mozilla.org/ja/docs/Web/API/File)や[Blob API](https://developer.mozilla.org/ja/docs/Web/API/Blob)経由でファイルを取得し、`Cloud Storage`にアップロードします。

```js
const uploadTask = storageRef.put(file)
```

### ファイルのアップロード状況を監視する
`Cloud Storage`でファイルをアップロードしている最中には、アップロード状況を監視することができます。

```js
uploadTask.on('state_changed', 
          snapshot => {
            const percentage = (snapshot.bytesTransferred / snapshot.totalBytes) * 100
            this.fileLoading = percentage
          },
          err => {
            console.log(err)
            this['flash/setFlash']({
              message: 'ファイルのアップロードに失敗しました。',
              type: 'error'
            })
          },
          () => {
            uploadTask.snapshot.ref.getDownloadURL().then(downloadURL => {
              this.fileLoading = 0
              this.thumbnail = downloadURL
            })
          }
        )
```

進行率を取得するには、転送済みのバイト数(`snapshot.bytesTransferred`)をアップロード予定のバイト数(`snapshot.totalBytes`)の合計で割って100をかけて算出します。

2つ目の関数では、エラー処理を担当することになります。

最終的にアップロードが完了した場合には、3つ目の関数が呼び出されます。
アップロードが完了したら`getDownloadURL`メソッドから、画像へアクセスするためのURLを取得できます。

このURLを新たにサムネイルプロパティに設定したとき、`watch`イベントが発火します。

```js

watch: {
    thumbnail (newval) {
      this.$emit('onThubnailChanged', newval)
    }
  }
```

`watch`はデータの変更を監視します。
`thumbnail`プロパティが変更したとき、それを親コンポーネントに通知することで、親コンポーネントにサムネイルの変更の永続化を担ってもらいます。（おそらく、`FireStore`に保存することになるでしょう)

## ブログ内で使用した画像をサムネイルに設定できるようにする。
こんどは、ブログ内で使用した画像を取得して設定するよういします。

次のような`<template>`です。

```html
          <v-card-subtitle>記事内の画像から設定する。</v-card-subtitle>
          <v-row>
            <v-col cols=12>
              <v-card>
                <.div v-if="loading">
                  画像データの取得中...
                  <v-progress-circular indeterminate color="red"></v-progress-circular>
                </div>
                <div>
                  この記事に画像は使われていません。
                </div>
                <v-container fluid v-else>
                  <v-row>
                    <v-col
                      v-for="(image, index) in images"
                      :key="index"
                      :index="index"
                      class="d-flex child-flex"
                      cols="4"
                    >
                      <v-card flat tile class="d-flex">
                        <v-img
                          :id="index"
                          :src="image"
                          aspect-ratio="1"
                          :class="{ selected: isSelected(index) }"
                          @click="onClick"
                        >
                          <template v-slot:placeholder>
                            <v-row
                              class="fill-height ma-0"
                              align="center"
                              justify="center"
                            >
                              <v-progress-circular indeterminate color="grey lighten-5"></v-progress-circular>
                            </v-row>
                          </template>
                        </v-img>
                      </v-card>
                    </v-col>
                  </v-row>
                </v-container>
              </v-card>
            </v-col>
          </v-row>
```

`images`配列を`v-for`でループして、使用した画像一覧を表示します。
`images`配列に画像を渡しましょう。

### 画像のリストをダウンロードする

はじめに、`Cloud Storage`のフォルダの構成は次のようになっています。
記事内で使用されたファイル一覧は以下のパスで取得できます。

```
articles/articleId/files
```

スクリプト部分は次のようになっています。

```js
import { storage } from '@/plugins/storage'

export default {
  name: 'thumbnail-setting-dialog',
  props: {
    article: {
      type: Object,
      required: true
    },
  },
  data() {
    return {
      thumbnail: this.article.thumbnail,
      loading: true,
      error: false,
      dialog: false,
      images: [],
      selectedImage: '',
      fileLoading: 0,
    }
  },
  async created() {
    try {
      const storageRef = await storage.ref(`articles/${this.article.id}`)
      const res = await storageRef.listAll()
      res.items.forEach(itemRef => {
        itemRef.getDownloadURL().then(url => {
          this.images.push(url)
        })
      })
    } catch(e) {
      this.error = true
      console.log(e)
    } finally {
      this.loading = false
    }
  },
```

ファイルを取得する機能は、`created`メソッドで発動します。`created`メソッドはVue.jsのライフサイクルの一つです。インスタンスが生成されたときにフックされるため、APIの通信など一番初めに処理を実行したいときに呼び出します。

## ストレージの参照を作成する
まずはじめに、ファイルへアクセスするための参照を作成します。Cloud Storageは、普段使い慣れているファイルシステムと同じように、階層構造になっています。
`storage.ref()`メソッドを使用して、現在の記事の参照まで移動しましょう。
そのために、次のように参照を作成しました。前提条件のファイル構成を参考にしてください。

```js
storage.ref(`articles/${this.article.id}`)
```

`this.article.id`は`props`で親から受け取ったデータになります。予想通り、現在編集している記事のIDが入っているので、これで正しく参照を作成することができます。

# ディレクトリのファイル一覧を取得する

参照を作成できたので、ディレクトリの中に存在するすべてのファイルを取得しましょう。
`listAll()`メソッドで取得することができます。

```js
const res = await storageRef.listAll()
```

取得に成功したファイル一覧は、`forEach`を利用して処理します。
個々のファイルの情報からは、ファイルをアップロードしたときと同じように`getDownloadURL()`メソッドから画像のURLを取得することができます。

取得したURLは`data`プロパティの`images`配列に格納しておきます。

```js
res.items.forEach(itemRef => {
   itemRef.getDownloadURL().then(url => {
      this.images.push(url)
    })
  })
```

## 取得した画像一覧を表示する
ここまでの処理の中でエラーがなければ、記事に使用されている画像のURLの一覧が取得できたはずです。
このままではなにも表示されないので、画像を表示するHTML部分を実装しましょう。
全体像は次のようになっています。

```html
<v-row>
  <v-col cols=12>
    <v-card>
      <.div v-if="loading">
        画像データの取得中...
        <v-progress-circular indeterminate color="red"></v-progress-circular>
      </div>
      <.div v-else-if="images.length === 0">
        この記事に画像は使われていません。
      </div>
      <v-container fluid v-else>
        <v-row>
          <v-col
            v-for="(image, index) in images"
            :key="index"
            :index="index"
            class="d-flex child-flex"
            cols="4"
          >
            <v-card flat tile class="d-flex">
              <v-img
                :id="index"
                :src="image"
                aspect-ratio="1"
                :class="{ selected: isSelected(index) }"
                @click="onClick"
              >
                <template v-slot:placeholder>
                  <v-row
                    class="fill-height ma-0"
                    align="center"
                    justify="center"
                  >
                    <v-progress-circular indeterminate color="grey lighten-5"></v-progress-circular>
                  </v-row>
                </template>
              </v-img>
            </v-card>
          </v-col>
        </v-row>
      </v-container>
    </v-card>
  </v-col>
</v-row>
```

一番はじめの`v-if`ディレクティブにより、ローディング中にはプログレスサークルが表示されます。
もし、一つも画像が記事内で使用されていないのであれば、その旨を表示してあげるのが丁寧でしょう。

```html
<.div v-if="loading">
  画像データの取得中...
  <v-progress-circular indeterminate color="red"></v-progress-circular>
</div>
<.div v-else-if="images.length === 0">
    この記事に画像は使われていません。
</div>
```

画像が表示できるのなら、`v-for`ディレクティブによりすべての画像を表示させましょう。
その際に、`Vuetify`のグリッドシステムを利用します。
`v-container`の中は、12個のカラムに分割されるので、`v-col`の`cols`に4を指定すれば、4 + 4 + 4ということで、画像が3つづつに分割されます。

```html
<v-container fluid v-else>
  <v-row>
    <v-col
      v-for="(image, index) in images"
      :key="index"
      :index="index"
      class="d-flex child-flex"
      cols="4"
    >
      <v-card flat tile class="d-flex">
        <v-img
          :id="index"
          :src="image"
          aspect-ratio="1"
          :class="{ selected: isSelected(index) }"
          @click="onClick"
        >
          <template v-slot:placeholder>
            <v-row
              class="fill-height ma-0"
              align="center"
              justify="center"
            >
              <v-progress-circular indeterminate color="grey lighten-5"></v-progress-circular>
            </v-row>
          </template>
        </v-img>
      </v-card>
    </v-col>
  </v-row>
</v-container>
````

グリッドシステムによる画像の配置は[公式ドキュメント](https://vuetifyjs.com/ja/components/images/#grid)を参考にしました。

画像URLを`v-img`の`src`プロップスに渡せば画像を表示してくれます。
`v-img`コンポーネントは優れもので、特に気にせずとも画像を遅延読み込みしてくれます。

## 選択された画像をサムネイルに設定する
画像一覧が表示されたので、画像が選択されたらサムネイルに設定されるようにしましょう。
`v-img`に`@click`イベントハンドラを用いてクリックイベントを購読しましょう。

```js
onClick(e) {
  this.selectedImage = +e.target.parentElement.id
  this.thumbnail = this.images[e.target.parentElement.id]
},
```

`v-img`には配列のインデックスをIDとして付与しているので、イベント引数から取得します。(`v-img`コンポーネントを利用している都合上、画像自体にIDが不要されていないので、その親要素から取得しています。)

`e.terget.parantElement.id`の前についている奇妙な`+`記号は、文字列を数値へ変換するためのものです。
算術演算子の単項プラスはオペランドを評価する際に数値へ変換するので、値を数値へ変換する際に利用されます。
[算術演算子 - JavaScript | MDN](https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Operators/Arithmetic_Operators#Unary_plus)

後はインデックスから配列の中の選択された画像のURLを取得することができるので、これを`thumbnail`に設定すればOKです。

## 選択された画像を強調する
最後に、画像一覧から画像が選択された場合わかりやすいように黄色い枠で囲むようにしてみましょう。こういう感じです。



![スクリーンショット 20200426 23.34.47.png](https://firebasestorage.googleapis.com/v0/b/app-blog-1ef41.appspot.com/o/articles%2FZHJPsdDMYIOUOfS0w6p6%2Fd48587ee834c66fe23d968b10dfe0d60.png?alt=media&token=750f0ee0-2355-48b6-b004-375cfabcbcec)

![スクリーンショット 20200426 23.35.54.png](https://firebasestorage.googleapis.com/v0/b/app-blog-1ef41.appspot.com/o/articles%2FZHJPsdDMYIOUOfS0w6p6%2F0b894196b2526f215db2c96d2a921ec9.png?alt=media&token=4f411d57-fff0-46a5-b4da-398a99875cc0)

### ボーダースタイルを定義する
画像を枠で囲むには、`border`スタイルを適用します。次のようなCSSを書きます。

```css
<style scoped>
.selected {
  border: medium solid #FFEB3B
}
</style>
```

このスタイルを、現在選択されている画像に適用すればいいわけです。

そのための機能がVue.jsには用意されています。[クラスとスタイルのバインディング](https://jp.vuejs.org/v2/guide/class-and-style.html)。

`:class`(`v-bind:class`)の省略構文にオブジェクトを渡すと、クラスを動的に切り替えることができます。今回の例をもう一度上げましょう。

```html
<v-img
    :id="index"
    :src="image"
    aspect-ratio="1"
    :class="{ selected: isSelected(index) }"
    @click="onClick"
>
```

オブジェクトのキーには適用させたいクラス名を渡し、プロパティデータには真偽値を渡します。つまり、`isSelected(index)`が`true`を返した場合には、`selected`クラスが適用され先程定義されたスタイルが適用されます。

`isSelected`は算出プロパティとして定義されています。

```js
computed: {
   isSelected() {
     return index => this.selectedImage === index
  },
},
```

算出プロパティで引数を受け取るために、関数をreturnしています。
`this.selectedImage`には画像がクリックされたときにクリックされた画像の`index`が入っているのでした。

算出プロパティは依存する値(今回は`this.selectedImage`)が変更されるたびに新たな値を返すので、他の画像がクリックされるたび返す値が変わることになります。

これで、選択された画像にスタイルを適用させて強調させることができました。
この例で見たとおり、クラスバインディングは算出プロパティと組み合わせることで強力な効果を発揮します。

# おわりに
`CloudStorage`を利用して、画像をアップロード、ダウンロードする方法を紹介しました。
かなりシンプルに利用することができるので、ぜひ使ってみてください。