Vue.js で画像アップロード機能をシンプルに作ってみよう！！

## ご挨拶
サァ Qiita の皆さんごきげんよう！！！

本日記事アップの木曜日といえば、石黒正数の『木曜日のフルット』ですが、アップといえば画像アップロードですね！！！皆さん画像のアップロードしてますか？？？？？？！！！！！！！

Vue.js を使って画像アップロードを実装する機会が何度かあったので、その知見を非常にミニマムにまとめて紹介します！時間がないからといってチープな記事を書く言い訳にはしない！！！書くなら本気の人間は誰のことだ？？？？

オレダァ！！！！！！！！！！！！！！！！！！！！！！！！！！！！

## 下準備

ではまず面倒なのでプロジェクトは vue-cli でサクッと作ってしまいましょう。 vue-cli をインストールしておきましょう。

```bash
$ npm install -g @vue/cli
```

```bash
$ vue create sakura
```

cli で設定どうするか聞かれますが今回はデフォルトでいきましょう。

```bash
Vue CLI v4.1.1
? Please pick a preset:
❯ default (babel, eslint)
  Manually select features
```

プロジェクトが出来たら早速サーバーを起動してみましょう！！！

```bash
$ cd sakura
$ npm run serve
```

<img width="1439" alt="スクリーンショット 2019-12-04 23.10.03.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/102819/2bc44f5b-05ae-3402-8ce3-3a714211167a.png">

お決まりのあれが出ましたね！！！でももうこいつとはおさらばです。容赦なく生成されれたコンポーネントを削除します。

```bash
$ rm src/components/HelloWorld.vue
```

このままだと `HelloWorld.vue` というコンポーネントがないみたいに怒られるので、仮に `Upload.vue` という名前のコンポーネントを作成しておきましょう！！！
それから、親コンポーネントたる `App.vue` も作成した `Upload.vue` を呼び出すようにしておきましょう。
 
```vue:src/components/Upload.vue
<template>
  <div>
    アップロード!!!!!!!!!!
  </div>
</template>

<script>
export default {
  name: 'Upload'
}
</script>
```

```vue:src/App.vue
<template>
  <div id="app">
    <Upload />
  </div>
</template>

<script>
import Upload from './components/Upload.vue'

export default {
  name: 'app',
  components: {
    Upload
  }
}
</script>
```

さてここまででで画面の様子を見てみましょうか。

<img width="1439" alt="スクリーンショット 2019-12-04 23.23.17.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/102819/e7a116e4-4c56-7f87-783c-5ef0241e04bd.png">

渋い！！！！！！
非常に渋いサイトが出来たので、適当に git commit して、本題である画像アップロード機能を実装してみましょう！！


## 親コンポーネントの準備

今回の構想では、 `Upload.vue` コンポーネントで生成した画像情報を base64エンコードして文字列として親コンポーネントである `App.vue` で受け取って、画像として再度表示してみたいと思います！！！！！

ということで、親コンポーネントの実装を先にやっちゃいましょう！！！！GOGO!!

```diff
diff --git a/src/App.vue b/src/App.vue
index ca84756..e7a00be 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -1,6 +1,7 @@
 <template>
   <div id="app">
-    <Upload />
+    <upload v-model="picture" />
+    <img :src="picture" />
   </div>
 </template>

@@ -11,6 +12,11 @@ export default {
   name: 'app',
   components: {
     Upload
+  },
+  data() {
+    return {
+      picture: null
+    }
   }
 }
 </script>
```

ついでにチョットだけスタイルの準備も進めておきましょう。単に気分を上げるためです。

```diff
diff --git a/src/App.vue b/src/App.vue
index e7a00be..577250f 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -20,3 +20,21 @@ export default {
   }
 }
 </script>
+
+<style>
+* {
+  box-sizing: border-box;
+}
+
+html {
+  font-size: 62.5%;
+}
+
+body {
+  color: #2c2d30;
+  font-size: 1.6rem;
+  font-family: "Hiragino Kaku Gothic Pro", "ヒラギノ角ゴ Pro W3", "Meiryo",
+    "メイリオ", "Osaka", "MS PGothic", arial, helvetica, clean, sans-serif;
+  line-height: 1.5;
+}
+</style>
```

これで親コンポーネントで画像を受け取って表示する準備ができましたね！！！オラワクワクすっぞ！！！

## 子コンポーネントの準備

続いて `Upload.vue` の最低限の実装を行っていきましょう。

```diff
diff --git a/src/components/Upload.vue b/src/components/Upload.vue
index 77a5175..659d3b5 100644
--- a/src/components/Upload.vue
+++ b/src/components/Upload.vue
@@ -1,11 +1,120 @@
 <template>
   <div>
-    アップロード!!!!!!!!!!
+    <label v-if="!value" class="upload-content-space user-photo default">
+      <input ref="file" class="file-button" type="file" @change="upload" />
+      アップロードする
+    </label>
+
+    <div v-if="value" class="uploaded">
+      <label class="upload-content-space user-photo">
+        <input ref="file" class="file-button" type="file" @change="upload" />
+        <img class="user-photo-image" :src="value" />
+      </label>
+    </div>
   </div>
 </template>

 <script>
 export default {
-  name: 'Upload'
+  name: 'Upload',
+  props: {
+    value: {
+      type: String,
+      default: null
+    }
+  },
+  data() {
+    return {
+      file: null
+    }
+  },
+  methods: {
+    async upload(event) {
+      const files = event.target.files || event.dataTransfer.files
+      const file = files[0]
+
+      if (this.checkFile(file)) {
+        const picture = await this.getBase64(file)
+        this.$emit('input', picture)
+      }
+    },
+    getBase64(file) {
+      return new Promise((resolve, reject) => {
+        const reader = new FileReader()
+        reader.readAsDataURL(file)
+        reader.onload = () => resolve(reader.result)
+        reader.onerror = error => reject(error)
+      })
+    },
+    checkFile(file) {
+      let result = true
+      const SIZE_LIMIT = 5000000 // 5MB
+      // キャンセルしたら処理中断
+      if (!file) {
+        result = false
+      }
+      // jpeg か png 関連ファイル以外は受付けない
+      if (file.type !== 'image/jpeg' && file.type !== 'image/png') {
+        result = false
+      }
+      // 上限サイズより大きければ受付けない
+      if (file.size > SIZE_LIMIT) {
+        result = false
+      }
+      return result
+    }
+  }
 }
 </script>
+
+<style scoped>
+.user-photo {
+  cursor: pointer;
+  outline: none;
+}
+
+.user-photo.default {
+  align-items: center;
+  background-color: #0074fb;
+  border: 1px solid #0051b0;
+  border-radius: 2px;
+  box-sizing: border-box;
+  display: inline-flex;
+  font-weight: 600;
+  justify-content: center;
+  letter-spacing: 0.3px;
+  color: #fff;
+  height: 4rem;
+  padding: 0 1.6rem;
+  max-width: 177px;
+}
+
+.user-photo.default:hover {
+  background-color: #4c9dfc;
+}
+
+.user-photo.default:active {
+  background-color: #0051b0;
+}
+
+.user-photo-image {
+  max-width: 85px;
+  display: block;
+}
+
+.user-photo-image:hover {
+  opacity: 0.8;
+}
+
+.file-button {
+  display: none;
+}
+
+.uploaded {
+  align-items: center;
+  display: flex;
+}
+</style>
```

とりあえずここでは最低限の以下のことができるようにしてみました。これ半分完成だろ・・・

- 画像のローカルマシンからの読み込み
- 画像のサムネイル表示
- 画像のbase64エンコード
- 親コンポーネントにエンコード済み画像データをバインド

では順を追って説明しましょう。

アップロードボタンを押すと change event を拾って `upload` method が呼ばれます。

```js
    async upload(event) {
      const files = event.target.files || event.dataTransfer.files
      const file = files[0]

      if (this.checkFile(file)) {
        const picture = await this.getBase64(file)
        this.$emit('input', picture)
      }
    },
```

ここでは event 情報から取得した file に関する情報を取得した後、 `checkFile` method に該当 file データを渡して、ファイル形式などが仕様的に問題がないかどうかをチェックしています。

```js
    checkFile(file) {
      let result = true
      const SIZE_LIMIT = 5000000 // 5MB
      // ローカルマシンからの読み込みをキャンセルしたら処理中断
      if (!file) {
        result = false
      }
      // jpeg か png 関連ファイル以外は受付けない
      if (file.type !== 'image/jpeg' && file.type !== 'image/png') {
        result = false
      }
      // 上限サイズより大きければ受付けない
      if (file.size > SIZE_LIMIT) {
        result = false
      }
      return result
    }
```

問題がないと判断された場合、 `getBase64` method に再度該当 file データを渡して `FileReader` のインスタンスメソッドを利用してエンコードを行います。

```js
    getBase64(file) {
      return new Promise((resolve, reject) => {
        const reader = new FileReader()
        reader.readAsDataURL(file)
        reader.onload = () => resolve(reader.result)
        reader.onerror = error => reject(error)
      })
    },
```

このエンコード済み画像情報の値を、親コンポーネントである `App.vue` に `this.$emit('input', picture)` として返します。

※ なお、ここのイベント名が `input` なのは、 `v-model` が `v-bind:value` と `v-on:input` のシンタックスシュガーだからです。 そういえば props として `value` を受け取っていましたね。

![a.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/102819/cf69286a-0044-3d16-39fc-02f81927ec9a.gif)


小さい方が `Upload.vue` コンポーネントでサイズをスタイル制御している方、大きいほうが親コンポーネント `App.vue` に渡された情報を読み込んでいる方です。成功しましたね！！

では仕上げに削除機能とエラーメッセージ表示機能をつけましょう！！

```diff
diff --git a/src/components/Upload.vue b/src/components/Upload.vue
index 36dcddb..59b0f4d 100644
--- a/src/components/Upload.vue
+++ b/src/components/Upload.vue
@@ -10,7 +10,17 @@
         <input ref="file" class="file-button" type="file" @change="upload" />
         <img class="user-photo-image" :src="value" />
       </label>
+
+      <button type="button" class="delete-button" @click="deleteImage">
+        削除する
+      </button>
     </div>
+
+    <ul v-if="fileErrorMessages.length > 0" class="error-messages">
+      <li v-for="(message, index) in fileErrorMessages" :key="index">
+        {{ message }}
+      </li>
+    </ul>
   </div>
 </template>

@@ -25,7 +35,8 @@ export default {
   },
   data() {
     return {
-      file: null
+      file: null,
+      fileErrorMessages: []
     }
   },
   methods: {
@@ -38,6 +49,10 @@ export default {
         this.$emit('input', picture)
       }
     },
+    deleteImage() {
+      this.$emit('input', null)
+      this.$refs.file = null
+    },
     getBase64(file) {
       return new Promise((resolve, reject) => {
         const reader = new FileReader()
@@ -48,6 +63,7 @@ export default {
     },
     checkFile(file) {
       let result = true
+      this.fileErrorMessages = []
       const SIZE_LIMIT = 5000000 // 5MB
       // キャンセルしたら処理中断
       if (!file) {
@@ -55,10 +71,12 @@ export default {
       }
       // jpeg か png 関連ファイル以外は受付けない
       if (file.type !== 'image/jpeg' && file.type !== 'image/png') {
+        this.fileErrorMessages.push('アップロードできるのは jpeg画像ファイル か png画像ファイルのみです。')
         result = false
       }
       // 上限サイズより大きければ受付けない
       if (file.size > SIZE_LIMIT) {
+        this.fileErrorMessages.push('アップロードできるファイルサイズは5MBまでです。')
         result = false
       }
       return result
@@ -114,4 +132,24 @@ export default {
   align-items: center;
   display: flex;
 }
+
+.delete-button {
+  background-color: #fff;
+  border: none;
+  color: #0074fb;
+  margin-left: 2rem;
+  padding: 0;
+}
+
+.delete-button:hover {
+  text-decoration: underline;
+}
+
+.error-messages {
+  color: #cf0000;
+  list-style: none;
+  margin: 0.4rem 0 0 0;
+  padding: 0 0.2rem;
+  font-size: 1.6rem;
+}
 </style>
```

![b.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/102819/313581eb-ca01-cdfd-ec35-f41515afd32f.gif)

無事、謎の音楽ファイルをアップロードしようとしたら怒られちゃいましたね。削除もできて便利になりました。

## 終わりの言葉

サァ画像プレビューが簡単にできちゃいましたね！！！！！！

本当は画像ファイルを ajax でバックエンドにアップして表示、ダウンロードするところまでちゃんと説明したかったんですが、今回はシンプルに base64 で画面表示する感じですませてみました。

そうそう、フルットといえば、モニカの季節ですね！！！皆さんメリークリスマス！！！！！！！！！！！！！