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

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

またコンポーネントの設計にはAtomicDesignを採用します。
AtomicDesignについて知りたい方は「[Vue.jsでAtomic Designを実践する - Qiita](https://qiita.com/hareku/items/04705fcf92cc80ec4095)」を参考にしてください。

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

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

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()メソッドが呼ばれます。

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

### resize()メソッド

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

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

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

reader.readAsDataURL(file)
```

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

```js
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要素を作成しています。

```js
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については次節で説明します。

```js
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の引数については以下を参考にしてください。

- [drawImage() メソッド - Canvasリファレンス - HTML5.JP](http://www.html5.jp/canvas/ref/method/drawImage.html)
- [drawImage(image, sx, sy, sw, sh, dx, dy, dw, dh)－Canvasリファレンス](http://www.htmq.com/canvas/drawImage_s.shtml)

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

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

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

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

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

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

### アップロード処理

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

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

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

  await uploadFunction(formData)

  this.sendingForm = false
},
```

## まとめ

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

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