Help us understand the problem. What is going on with this article?

Vue.jsでcanvasを使った画像リサイズとアップロードを実装する

画像アップロードにおいて、ブラウザ側でリサイズしてからアップロードするとサーバー側の負担を減らすことができます。
ということで今回は、Vue.jsでcanvasを使った画像リサイズとアップロード機能の実装を紹介していきます。

またコンポーネントの設計にはAtomicDesignを採用します。
AtomicDesignについて知りたい方は「Vue.jsでAtomic Designを実践する - Qiita」を参考にしてください。

コンポーネント全体の流れ

まずは全体の流れを説明します。

  1. inputコンポーネントを作る
  2. リサイズに関する値をinputコンポーネントに渡す
  3. resizedイベントから画像のBlobを取得する
  4. そのBlobを使ってアップロードする

inputコンポーネントを作る

ResizableImageInputという<input>要素を持ったコンポーネントを作成します。

components/molecules/ResizableImageInput.vue
<template>
  <input
    ref="input"
    type="file"
    accept="image/gif,image/jpeg,image/jpg,image/png"
    :disabled="disabled"
    @change="resize()"
  >
</template>

<script>
import base64ToBlob from 'b64-to-blob' // https://www.npmjs.com/package/b64-to-blob

export default {
  props: {
    drawImageArgs: {
      type: Function,
      required: true
    },

    disabled: {
      type: Boolean,
      default: false
    }
  },

  methods: {
    resize () {
      const file = this.$refs.input.files[0]

      if (!file) {
        return
      }

      const reader = new FileReader()

      reader.onload = (event) => {
        const canvas = document.createElement('canvas')
        const ctx = canvas.getContext('2d')
        const image = new Image()
        image.crossOrigin = 'Anonymous'

        image.onload = () => {
          const drawImageArgs = this.drawImageArgs(image)

          if (drawImageArgs.length === 9) {
            canvas.width = drawImageArgs[7]
            canvas.height = drawImageArgs[8]
          }

          ctx.drawImage(...drawImageArgs)

          const base64 = canvas.toDataURL()
          this.$emit('resized', { base64, blob: base64ToBlob(base64.replace(/^.*,/, '')) })
        }

        image.src = event.target.result
      }

      reader.readAsDataURL(file)
    }
  }
}
</script>

1つずつ解説していきます。

template

template自体は、単純なinput要素です。
ユーザーがinput要素からファイルを選択すればchangeイベントが発生し、resize()メソッドが呼ばれます。

<template>
  <input
    ref="input"
    type="file"
    accept="image/gif,image/jpeg,image/jpg,image/png"
    :disabled="disabled"
    @change="resize()"
  >
</template>

resize()メソッド

const file = this.$refs.input.files[0]

refsからinputのDOMオブジェクトを取得します。

reader.onload = (event) => {
  //
}

reader.readAsDataURL(file)

onloadでファイルを読み込んだ際のイベント処理を登録しておきます。読み込みはreadAsDataURLで行います。

const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
const image = new Image()
image.crossOrigin = 'Anonymous'

image.onload = () => {
  //
}

image.src = event.target.result

reader.onloadの中身です。
createElement('canvas')でcanvas要素を作成し、new Image()でimg要素を作成しています。

const drawImageArgs = this.drawImageArgs(image)

if (drawImageArgs.length === 9) {
  canvas.width = drawImageArgs[7]
  canvas.height = drawImageArgs[8]
}

ctx.drawImage(...drawImageArgs)

image.onloadの中身です。
this.drawImageArgsはpropsで親から渡ってきた関数で、drawImage関数に渡す引数になっています。drawImageについては次節で説明します。

const base64 = canvas.toDataURL()
this.$emit('resized', { base64, blob: base64ToBlob(base64.replace(/^.*,/, '')) })

canvas.toDataURL()で画像のbase64を取得できます。
そしてそのbase64とbase64をblobにしたものを、$emitで親コンポーネントへ伝えます。

サーバーが受け付ける画像フォーマットは基本的にBlobであるためBlobを親コンポーネントへ伝えます。
そしてbase64も伝えることにより、例えば親コンポーネントでプレビュー用として簡単に利用することができます。(base64はimg要素のhrefに入れればそのまま画像が表示できるため)

input要素にリサイズに関する値を渡す

先ほど出てきたdrawImageArgsというpropsですが、それを親コンポーネントから渡す必要があります。

まずは親コンポーネントの全体像を見てみましょう。

components/organisms/ProfileImageUploader.vue
<template>
  <ResizableImageInput
    :disabled="sendingForm"
    :draw-image-args="drawImageArgs"
    @resized="uploadProfileImage"
  />
</template>

<script>
import ResizableImageInput from '~/components/molecules/ResizableImageInput'

export default {
  components: { ResizableImageInput },

  data () {
    return {
      sendingForm: true
    }
  },

  methods: {
    async uploadProfileImage ({ blob }) {
      this.sendingForm = true

      const formData = new FormData()
      formData.append('profile_image', blob)

      await uploadFunction(formData)

      this.sendingForm = false
    },

    drawImageArgs (image) {
      const maxSize = 500
      let sx = 0
      let sy = 0
      let imageWidth = image.width
      let imageHeight = image.height

      if (imageWidth > imageHeight) {
        sx = (imageWidth - imageHeight) / 2
        imageWidth = imageHeight
      }

      if (imageHeight > imageWidth) {
        sy = (imageHeight - imageWidth) / 6
        imageHeight = imageWidth
      }

      const dstWidth = imageWidth > maxSize ? imageWidth * maxSize / imageWidth : imageWidth
      const dstHeight = imageHeight > maxSize ? imageHeight * maxSize / imageHeight: imageHeight

      return [image, sx, sy, imageWidth, imageHeight, 0, 0, dstWidth, dstHeight]
    }
  }
}
</script>

drawImageArgs

drawImageArgsをメインに説明していきます。
今回の例では、「アップロードされた画像を最大500pxで正方形トリミングする」という引数を作成します。

drawImageArgsの引数については以下を参考にしてください。

またinputで選択された画像が正方形ではない場合、以下のようにして対応します。

if (imageWidth > imageHeight) {
  sx = (imageWidth - imageHeight) / 2
  imageWidth = imageHeight
}

これはwidthがheightよりも長い場合、両端を切り落として中央を中心に正方形トリミングするという処理です。
heightの方が長い場合は、widthとheightの差分の1/6の高さを中心としてトリミングしています。これは一般的に画像の焦点(人物の顔など)が画像上の上部に位置することが多い、という曖昧な基準で決めています。(なのでご自由に変更していただいて構いません。)

if (imageHeight > imageWidth) {
  sy = (imageHeight - imageWidth) / 6
  imageHeight = imageWidth
}

そして最後にdrawImageの引数を返しています。

return [image, sx, sy, imageWidth, imageHeight, 0, 0, dstWidth, dstHeight]

アップロード処理

あとはアップロードの処理のみです。
子コンポーネント(ResizableImageInput)からresizedイベントが発行されるので、@resized="uploadProfileImage"で購読し、アップロード処理を行います。

uploadProfileImage ({ blob }) {
  this.sendingForm = true

  const formData = new FormData()
  formData.append('profile_image', blob)

  await uploadFunction(formData)

  this.sendingForm = false
},

まとめ

以上が、Vue.jsによるcanvasを使った画像リサイズとアップロード機能の実装です。
drawImageの引数を親コンポーネントから受け取ることで、非常に再利用がしやすいコンポーネントとなりました。

何か改善点や質問点などがありましたら、コメントよりお気軽にご連絡ください。

Why do not you register as a user and use Qiita more conveniently?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away