Web備忘録

雑多に書いています

Vueで画像アップロード + プレビュー機能付きフォームを作りました。(Base64エンコード利用)

タイトルの通り、Vueで簡単な画像アップロードページを作ったので、JSFiddleで公開してみます。

今回はBase64エンコードを使って、画像を文字列情報として扱う方法を採用しています。

バックエンドとのやり取りをJSONベースで行っている場合は、この方法がシンプルで扱いやすいと思います。

VueImageForm - JSFiddle - Code Playground

挙動については上のリンクに飛んで触っていただければすぐにわかると思います。簡易的な画像アップロード機能と、画像送信前のプレビュー機能のついたフォームです。

ほしい方は勝手に使っていただいて大丈夫です。編集も自由です。変な所あったら勝手に直しちゃって下さい。(そしてこっそり教えて下さい。)

「なんでBase64エンコード使ったん?」という件については、一番最後の余談で書いたので、Vue興味ない方はそこまで飛ばして下さい!!

一応、それぞれのコードの解説を書いておきます。

HTML側

<div id="app">
  <p id="error" v-show="error">{{ error }}</p>
  <label>
    <p>クリックで画像を変更できます。</p>
    <img :src="avatar" alt="Avatar" class="image">
    <div>
    <input
           type="file"
           id="avatar_name"
           accept="image/jpeg, image/png"
           @change="onImageChange"
           />
    </div>
  </label>
  <button @click="upload()">アップロード</button>
  <p>{{ message }}</p>
</div>

labelタグについて

これでimgタグとinputタグを囲めば、inputタグのクリック可能範囲を広げ、画像クリックに判定が付きます。

imgタグについて

:src="avatar"で、Vue側がもつdataの「avatar」をバインドしてきています。Vue側のavatarの値が変更されれば、src属性の値は自動で更新されます。

avatarのデータ型はStringで、画像をBase64エンコードしたものを渡しています。Base64形式でsrc属性に値を渡せば、特別な処理を書かずとも画像を表示できます。

Base64エンコードとは?

データを文字列として表す変換方式です。メールの添付等でよく使われるようです。

inputタグについて

@change="onImageChange"は、inputでファイルが変更されるたびに、onImageChangeメソッドが実行されるという意味です。

ここは「v-model="avatar"」でも動きそうに見えますが、inputのtype="file"はvalueをもつことが出来ないので、v-modelでavatarをセットしても、その値をinputタグが保持しておくことができずにエラーが出ます。

JavaScript(Vue)側

new Vue({
  el: "#app",
  name: 'ImageUploder',
  data () {
    return {
      avatar: '',
      message: '',
      error: ''
    }
  },
  methods: {
    setError (error, text) {
      this.error = (error.response && error.response.data && error.response.data.error) || text
    },
    getBase64 (file) {
      return new Promise((resolve, reject) => {
        const reader = new FileReader()
        reader.readAsDataURL(file)
        reader.onload = () => resolve(reader.result)
        reader.onerror = error => reject(error)
      })
    },
    onImageChange (e) {
      const images = e.target.files || e.dataTransfer.files
      this.getBase64(images[0])
        .then(image => this.avatar = image)
        .catch(error => this.setError(error, '画像のアップロードに失敗しました。'))
    },
    upload () {
        if (this.avatar) {
        /* postで画像を送る処理をここに書く */
        this.message = 'アップロードしました' 
        this.error = ''
      } else {
        this.error = '画像がありません'
      }
    }
  }
})

各メソッドについて、簡単に解説します。

getBase64メソッドについて

getBase64 (file) {
  /* Promiseにアロー関数を渡して非同期処理を書く。*/
  return new Promise((resolve, reject) => {
    
    /* 組み込みオブジェクトを使い、Base64エンコードされた画像データを取得  */
    const reader = new FileReader()
    reader.readAsDataURL(file)

    /* 成功時と失敗時の処理。resolveの引数に結果を渡して、のちの.then(result => ...)で受け取る  */
    reader.onload = () => resolve(reader.result)
    reader.onerror = error => reject(error)
  })
}

onImageChangeメソッドについて

inputでファイルが入力されると着火されるメソッドです。イベントオブジェクトからファイルを貰い、そのもらったファイルをBase64エンコードしたものを、avatarにセットしています。これでavatarの値が変更されるのですが、そのとき、その変更を検出したimgタグがsrc属性に新たなavatarの値を当てはめます。すると、選択したファイルの画像が表示されます。これがプレビュー機能に当たる部分です。

uploadメソッド

バックエンドにBase64エンコードされた画像を送る処理を書くところです。今回は詳しい処理については割愛していますが、axiosでバックエンド側のAPIにpostまたはpatchする処理を書きましょう。

CSS

input {
  display: none;
}

img:hover {
  opacity: 0.7;
}

#error {
  color: red;
}

特筆すべき点はないですが、imgにhoverしたら透明度を上げるようにしてクリック判定をわかりやすくしてます。

コンポーネント・モジュール可する

せっかく作ったフォームなので、コンポーネントにして取扱いを良くしたり、他で使いそうなメソッドはモジュール化した方がいいかもしれません。(設計に関する一番むずかしいところ…)

とりあえず自分は、以下のように親子関係を作ってみました。

親側のフォーム

<label>
  <img class="w-24 h-24 rounded-full mr-4 bg-hover" v-lazy="avatar" alt="Avatar">
  <UserImageUploader
    v-bind="user"
    :params="{ limit: 1000, unit: 'kb', allow: 'jpg,png' }"
    v-model="avatar"
   />
</label>

3行目が子コンポーネントです。(UserImageUploader)。親側で呼び出すときに必要なデータをバインドして子供側で扱えるようにしてます。

今回の場合だと、ユーザー情報、バリデーション情報、画像情報を渡してます。

子供側(コンポーネント名:UserImageUploader)

<template>
  <div>
    <p id="error" v-show="error">{{ error }}</p>
      <div class="m-6">
        <input
          type="file"
          id="avatar_name"
          class="input_image"
          accept="image/jpeg, image/png"
          @change="onImageChange"
        />
      </div>
  </div>
</template>

<script>
import { FileEvaluable } from '@/components/mixins/FileEvaluable'
export default {
  name: 'UserImageUploder',
  mixins: [ FileEvaluable ],
  props: {
    id: Number,
    currentImage: String
  },
  model: {
    // このcurrentImageは親で指定した(v-model="avatar")と同値。
    prop: 'currentImage',
    event: 'change'
  },
  data () {
    return {
      message: '',
      error: ''
    }
  },
  methods: {
    setError (error, text) {
      this.error = (error.response && error.response.data && error.response.data.error) || text
    },
    getBase64 (file) {
      return new Promise((resolve, reject) => {
        const reader = new FileReader()
        reader.readAsDataURL(file)
        reader.onload = () => resolve(reader.result)
        reader.onerror = error => reject(error)
      })
    },
    setImage (currentImage) {
      this.$emit('change', currentImage)
    },
    onImageChange (e) {
      const images = e.target.files || e.dataTransfer.files
      if (this.validation(images[0])) {
        this.getBase64(images[0])
          .then(image => this.setImage(image))
          .catch(error => this.setError(error, '画像のアップロードに失敗しました。'))
      } else {
      }
    },
    validation (file) {
      if (!this.isAllowFileType(file.type)) {
        this.error = this.getErrorMessageType()
        this.canBeUploaded = false
        return false
      }
      if (!this.isAllowFileSize(file.size)) {
        this.error = this.getErrorMessageSize()
        this.canBeUploaded = false
        return false
      }
      this.error = ''
      this.canBeUploaded = true
      return true
    }
  }
}
</script>

<style scoped>
.input_image {
  display: none;
}
</style>

読みづらいですが、propだったりmodelだったりで、親からバインドされた値を受け取ってます。

子供側でmixinsされてるモジュールは、今回の記事とは関係ないバリデーション処理のものですが、わざわざ取り除くのも大変だったのでべた張りしました。

機能としての改善点としては、プレビューの画像サイズが今のままだとユーザー依存なのでそれを変更したり、選択した画像の一部分だけ使うよう編集できる機能についても必要かな、と思いますが、長くなりますので今回はここまでとします。

余談

画像の送信方法について、最初はBase64エンコードを使わず、JSにおけるFormData型の画像を「Content-Type: multipart/form-data」でバックエンドに送る方式をとっていました。ただ、自分がバックエンドで使用しているRailsAPIとのかみ合わせが悪く、リクエストではファイルをそのまま受け取れるものの、レスポンスにおいてはJSONじゃないファイル型を返せるコントローラーを新たに作ったりする必要がある(ActionController::APIを継承したコントローラじゃファイルをrender出来ない。)というデメリットがあり、一度実装してみたものの、取り回しが悪そうなので削除しました。

(こういうの実装する前に気づけるようになりてえ〜)

ちなみに、Railsでは受け取ったエンコード済み画像をデコードしてFileオブジェクトに戻し、それをAWSのS3に保存してます。

その処理もまた後にブログにするかもしれません。(しないかもしれない! 読みたい方がいたら書きます)

おしまい。

続編

fujiten3.hatenablog.com

fujiten3.hatenablog.com