Rails6 API + Vue.js3 + TypeScript で画像アップロード機能を実装してみた

こんにちは!kossyです!




さて、今回はRails6 API + Vue.js3 + TypeScript で画像アップロード機能の実装手順をブログに残してみたいと思います。



環境

Ruby 2.6.6
Rails 6.0.3.4
Docker for Mac
Vue 3.0.5
Vue CLI 4.5.9
TypeScript 3.9.7




なお、既にRailsAPIモードでのApp作成やVue.js Appの作成は済んでいるものとし、拡張子制限等の考慮はしないものとします。


API

今回はActiveStorageを使って画像アップロード機能を実現させたいと思います。(導入は済んでいるものとします)

任意のmodel(今回はPostModelとします)に画像を保存するフィールドを定義します。

class Post < ApplicationRecord
  belongs_to :user

  has_one_attached :icatch
end

任意のcontroller(例としてPostsControllerとします)に画像を保存する処理を追加します。

class PostsController < ApplicationController
  before_action :set_post, only: [:show, :update]

  def show
  end

  def create
    @post = Post.new(post_params)

    if @post.save
      render :show, status: :created
    else
      render json: @post.errors, status: :unprocessable_entity
    end
  end

  private
    def set_post
      @post = Post.find(params[:id])
    end

    def post_params
      params.permit(:title, :body, :icatch).merge(user: current_user)
    end

Vue側からicatch keyに画像が格納されて送信されてくる想定をしています。

保存された画像をBase64エンコードしたものをクライアントに送るメソッドも定義します。

class Post < ApplicationRecord
  belongs_to :user

  has_one_attached :icatch

  def encoded_icatch
    "data:image/png;base64,#{Base64.encode64(icatch.download)}" if icatch.attached?
  end
end

このメソッドをviewから呼び出します。

# app/views/posts/show.json.jbuiler

json.partial! "posts/post", post: @post
# app/views/posts/_post.json.jbuilder

json.extract! post, :id, :title, :body, :user_id, :created_at
json.encoded_icatch post.encoded_icatch

最後にroutingの定義をします。

Rails.application.routes.draw do
  scope format: 'json' do
    resources :users do
      resources :posts, only: [:show, :create]
    end
  end
end


これでAPI側の準備は完了です。


Vue側

Vue側はAPIとの通信にAxiosを用いることとします。

まずはAPI側にPostリクエストをするコードを記述します。

// src/api/client.ts

import axios from 'axios'

export default axios.create({
  baseURL: process.env.VUE_APP_API_BASE
})



// .env.development
VUE_APP_API_BASE=http://localhost:3000



// src/api/post.ts

export const createPost = async (id: integer, formData: any) => {
  return await Client.post(
    'users/:id/posts', formData,
    {
      headers: {
        'Content-Type': 'multipart/form-data'
      }
    }
  )
    .then((response) => {
      return response.data
    })
}

このコードをcomponentから呼び出すようにします。今回はviews/NewPost.vueから呼び出すこととします。(tailwind cssを使っています)

<template>
  <div class="flex items-center h-screen w-full bg-teal-lighter">
    <div class="w-full bg-white rounded shadow-lg p-8 m-4">
      <h1 class="block w-full text-center text-grey-darkest mb-6">New Post</h1>
      <div class="flex flex-col mb-4">
        <label class="mb-2 font-bold text-lg text-grey-darkest" for="title">Title</label>
        <input v-model='title' class="border py-2 px-3 text-grey-darkest" type="text" name="title" id="title">
      </div>
      <div class="flex flex-col mb-4">
        <label class="mb-2  font-bold text-lg text-grey-darkest" for="body">Body</label>
        <textarea v-model='body' class="border py-2 px-3 text-grey-darkest" name="body" id="body"></textarea>
      </div>
      <div class="flex flex-col mb-4">
        <label class="mb-2 font-bold text-lg text-grey-darkest" for="icatch">iCatch</label>
        <input @change="setIcatch($event)" accept="image/png,image/jpeg" class="border py-2 px-3 text-grey-darkest" type="file">
      </div>
      <button @click='handleCreatePost()' class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 uppercase text-lg mx-auto rounded" type="submit">Create Post</button>
    </div>
  </div>
</template>

<script lang="ts">
import { createPost } from '@/api/post'
import { defineComponent, reactive, toRefs } from 'vue'

export default defineComponent({
  name: 'NewPost',
  setup () {
    const postData = reactive({
      title: '',
      body: ''
    })

    const formData = new FormData()

    const setIcatch = (e: Event) => {
      e.preventDefault()
      if (e.target instanceof HTMLInputElement && e.target.files) {
        formData.append('icatch', e.target.files[0])
      }
    }

    return {
      ...toRefs(postData),
      setIcatch,
      handleCreatePost: async () => {
        formData.append('title', postData.title)
        formData.append('body', postData.body)
        await createPost(formData)
          .then((data) => {
            console.log(data) // indexページやshowページに遷移するといい
          })
      }
    }
  }
})
</script>

<style scoped>

</style>

Vue.js 3系から導入されたreactive関数を使ってプロパティをリアクティブにし、template内の変更を受け取れるようにしています。

画像の添付はformDataオブジェクトを使って実現しています。

ネットに落ちている情報だと、

<input @change="setIcatch()" accept="image/png,image/jpeg" class="border py-2 px-3 text-grey-darkest" type="file">

changeの箇所に$eventを記載しない例が散見されましたが、私の環境だと$eventを引数として与えないとうまくイベントを検知してくれませんでした。

テンプレートの表示は概ねこのようになるかと。

f:id:kossy-web-engineer:20210307114909p:plain



APIとの通信に成功すると以下の返り値を得られます。

{
	"id": 9,
	"title": "Vue + RailsのSPA",
	"body": "画像添付のテスト",
	"user_id": 1,
	"created_at": "2021-03-07T11:51:01.604+09:00",
	"encoded_icatch": "data:image/png;base64,iVBORw0KGgo..."
}

後はこの返り値を表示すればOKです。




勉強になりました。