ActiveStorageで複数枚画像を投稿(保存)する方法についての情報はたくさん出てきますが、正直どれも僕が思い描いていた画像アップロード機能のイメージとは違うかなぁと感じております。
例えば、僕が思い描いている複数枚画像アップロード機能のイメージは以下の通り。
- 「ファイル選択」ボタンで追加したファイルのプレビューが表示される
- 「ファイル選択」ボタンでファイルを追加する度にプレビュー表示に追加されていく
- プレビュー表示から任意のファイルを削除できる
- バリデーションエラー時でも選択したファイルが保持される
- 編集時も上記と同様の動作を行う
こんな感じの画像アップローダーをActiveStorage + JavaScriptで実装したい…
そこで試行錯誤の末、なんとかイメージ通りの複数枚画像アップロード機能を実装することができました。
ゆえに今回は、ActiveStorage + JavaScript(Stimulus)を用いて実装した複数枚画像アップロード機能について、その実装の流れを覚書としてまとめました。
なお、単数枚の画像アップロード(プロフィール画像の登録、更新)の実装手順については以下の記事をご覧ください。
開発環境
- Ruby 3.1.2
- Ruby on Rails 7.0.4
- tailwindcss 3.1.8
- M1 Macbook Air 2020
- mac OS Monterey (ver. 12.4)
- ターミナル bash (Rosetta 2 使用)
出来上がりイメージ
イメージとしては、一般的なWebアプリケーションでよく見かけるようなアップロード方法かと思います。
まず、「ファイル選択」ボタンで追加したファイルをプレビュー表示させます。
(「ファイル選択」ボタンで複数回に分けてファイルを追加した場合も、累積的にプレビューが表示されます)
今回の実装では、file_fieldにファイルをアタッチして送信する方法ではなく、「ファイル選択」ボタンでファイルを選択すると同時にDBにアップロードさせる方法(Ajax)を取りました。
というのも、file_fieldでは「ファイル選択」ボタンを押すたびに前回選択したファイルの内容が上書きされてしまいますし、バリデーションエラー時もfile_fieldの値がリセットされてしまうからです。(少なくともユーザーフレンドリーな仕様とは言えませんね)
また、以下のように「削除」ボタンで任意のファイルをプレビューから削除できるようにもしました。
(プレビューから削除されたファイルは投稿に反映されませんが、DB内には残り続けます。対処法は当記事後半にて)
また、編集時はDBに保存されている画像ファイルをプレビューで表示状態にし、あとは新規投稿時と同じようにファイルの追加、削除ができるようにしています。
バリデーションエラー時は選択状態のファイル(のプレビュー)を保持できるようにしています。
編集時のバリデーションエラーでも同様に、選択状態のファイルを保持できるようにしています↓
以上、こんな感じの画像アップローダーを実装していきます。
前提条件
- Ruby on Rails 7.0.4 で実装
- JavaScriptフレームワークはStimulusを使用
- TailwindCSS導入済み
- Postモデル作成済み
今回の実装において、JavaScriptフレームワークはRails7.0.4に標準搭載されているStimulusを使用しています。
Stimulusについて詳しく知りたい方は公式マニュアルを参考にしてみてください。
一応、過去記事にStimulusを用いて実装した機能もあるので、こちらも参考にしていただければと。
また、CSSはTailwindCSSを使用しています。
複数枚画像のアップロード実装の流れ
それでは、ActiveStorageによる複数枚画像アップロード(Ajaxで非同期アップロード)の実装を進めていきます。
ActiveStorageを追加する
以下のコマンドでActiveStorageをインストールします。
$ rails active_storage:install
$ rails db:migrate
マイグレーションを実行すると以下3つのテーブルが作成されます。
テーブル名 | 内容 |
---|---|
active_storage_blobs | アップロードしたファイル(画像)を保存するテーブル(blob型) |
active_storage_attachments | アップロードしたファイルとActive Recordを紐付けるための中間テーブル |
active_storage_variant_records | アップロードしたファイルのVariantに関する情報を保存するテーブル |
【blobとは】
DBのデータ型の一つで、画像や音声、圧縮ファイルなどのデータを保存するためのもの。
【variantとは】
Railsのblob型のデータに対して使えるメソッドの一つで、画像を呼び出したときに、ビューの中で画像サイズを変換することができる。
モデルの編集
作成済みのPostモデルを以下のように編集します。
class Post < ApplicationRecord
has_many_attached :images # ←複数枚画像の場合。1枚のみの場合は has_one_attached :image
validates :title, presence: true
end
ビュー(View)の作成
画像を投稿・編集、および表示するためのビューを作成します。
- 新規投稿、編集用ビュー(_form.html.erbなど)
- 表示用ビュー(index.html.erbなど)
まずは新規投稿、編集用のビュー_form.html.erb
のサンプルから。
<%= form_with(model: @post, local: true, data: { controller: "images"}) do |f| %>
<%= render 'layouts/errors', obj: @post %>
<div class="mb-3">
<%= f.label :title, "タイトル", class: "font-semibold block p-1" %>
<%= f.text_field :title, autofocus: true, class: "bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" %>
</div>
<div class="mb-3">
<%= f.label :images, "画像", class: "font-semibold block p-1" %>
<%= f.file_field :images, multiple: true, accept: "image/jpeg,image/gif,image/png", data: { images_target: "select", action: "change->images#selectImages" } %>
</div>
<div data-images-target="preview">
<% if @post.images.attached? %>
<% @post.images.each do |image| %>
<div class="image-box inline-flex mx-1 mb-5" data-controller="images" data-images-target= "image_box">
<div class="text-center">
<%= image_tag image, width: "100" %>
<a class="link cursor-pointer" data-action="click->images#deleteImage">削除</a>
<%= f.hidden_field :images, name: "post[images][]", style: "display: none", value: "#{image.blob.id}" %>
</div>
</div>
<% end %>
<% end %>
</div>
<div class="mb-3">
<%= f.submit "投稿する", class: "btn btn-sky" %>
</div>
<% end %>
今回、_form.html.erb
にはJSフレームワークのStimulusを用いるため、要素中に以下のdata属性
を設定しています。
- data-controller
- data-{controller}-target
- data-action
data属性の設定方法はこちらの記事を参考にしてみてください。
続いては表示用のビューindex.html.erb
のサンプルです。
<h1 class="text-3xl mb-3">投稿一覧</h1>
<div class="mb-3">
<%= link_to '新規投稿', new_post_path, class: "btn btn-sky" %>
</div>
<%= render @posts %> // パーシャルを表示(以下参照)
<div class="my-8">
<div id="post-<%= post.id %>" class="mb-3">
<p class="font-bold">タイトル:<%= post.title %></p>
<% if post.images.attached? %>
<% post.images.each do |image| %>
<div class="mb-2 inline-flex">
<%= image_tag(image, width:100, class: "") %>
</div>
<% end %>
<% end %>
</div>
<%= link_to "編集", edit_post_path(post), class: "btn btn-outline-gray" %>
<%= link_to "削除", post, data: { turbo_method: :delete ,turbo_confirm: "削除してよろしいですか?" }, class: "btn btn-outline-gray" %>
</div>
コントローラーの編集
postsコントローラーを以下のように記述します。
class PostsController < ApplicationController
before_action :set_post, only: [:show, :edit, :update, :destroy]
def index
@posts = Post.all
end
def show
end
def new
@post = Post.new
end
def edit
end
def create
@post = Post.new(post_params)
if @post.save
redirect_to posts_path, notice: "投稿しました"
else
render "new", status: :unprocessable_entity
end
end
def update
if @post.update(post_params)
redirect_to posts_path, notice: "更新しました"
else
render "edit", status: :unprocessable_entity
end
end
def destroy
@post.destroy
redirect_to posts_path, notice: "削除しました", status: :see_other
end
# 画像アップロード用のアクション
def upload_image
@image_blob = create_blob(params[:image])
render json: @image_blob
end
private
def set_post
@post = Post.find(params[:id])
end
# 選択状態の画像をパラメータにマージ(Postモデルとの紐付け)
def post_params
params.require(:post).permit(:title).merge(images: uploaded_images)
end
# アップロード済み画像の検索
def uploaded_images
params[:post][:images].drop(1).map{|id| ActiveStorage::Blob.find(id)} if params[:post][:images]
end
# blobデータの作成
def create_blob(file)
ActiveStorage::Blob.create_and_upload!(
io: file.open,
filename: file.original_filename,
content_type: file.content_type
)
end
end
ここでは、file_fieldで選択した画像をアップロードするためのアクションupload_image
を追加しています。
アップロードは、file_fieldで選択した画像をJS側で読み込んでupload_imageアクションに送信し、コントローラー側で受け取った画像ファイルからblobデータを生成しDB上に保存する、という流れで行います。
Postの投稿時には、merge(images: upload_images)で選択状態の画像をパラメータにマージすることでPostモデルとの紐付けを行います。
こうすることで、バリデーションエラー時も選択した画像が保持されたままになります。
ルーティングの設定
画像アップロードのアクション用のルーティング設定を行います。
post "posts/upload_image", to: "posts#upload_image"
Stimulusのコントローラー追加
以下のコマンドを実行してStimulusのコントローラーを追加します。
$ rails g stimulus コントローラー名
$ rails g stimulus images
Stimulusのコントローラーはapp/javascript/controllers
ディレクトリ以下に自動生成されます。
Stimulusのコントローラー編集
自動生成されたapp/javascript/controllers/images_controller.js
を開き、以下のように編集します。
import { Controller } from "@hotwired/stimulus"
// Connects to data-controller="images"
export default class extends Controller {
/* ①静的プロパティを定義(data-{controller}-target で指定したターゲット名) */
static targets = ["select", "preview", "image_box"]
/* ②画像選択時の処理 */
selectImages(){
const files = this.selectTargets[0].files // file_fieldで取得した画像ファイル
for(const file of files){
this.uploadImage(file) // 選択した画像ファイルのアップロード
}
this.selectTarget.value = "" // 選択ファイルのリセット
}
/* ③画像アップロード */
uploadImage(file){
const csrfToken = document.getElementsByName('csrf-token')[0].content // CSRFトークンを取得
const formData = new FormData()
formData.append("image", file) // formDataオブジェクトに画像ファイルをセット
const options = {
method: 'POST',
headers: {
'X-CSRF-Token': csrfToken
},
body: formData
}
/* fetchで画像ファイルをPostコントローラー(upload_imageアクション)に送信 */
fetch("/posts/upload_image", options)
.then(response => response.json())
.then(data => { // Postコントローラーからのレスポンス(blobデータ)
this.previewImage(file, data.id) // 画像プレビューアクションにblobデータのidを受け渡す
})
.catch((error) => {
console.error(error)
})
}
/* ④画像プレビュー */
previewImage(file, id){
const preview = this.previewTarget // プレビュー表示用の<div>要素
const fileReader = new FileReader()
const setAttr = (element, obj)=>{ // 属性設定用の関数
Object.keys(obj).forEach((key)=>{
element.setAttribute(key, obj[key])
})
}
fileReader.readAsDataURL(file) // ファイルをData URIとして読み込む
fileReader.onload = (function () { // ファイル読み込み時の処理
const img = new Image()
const imgBox = document.createElement("div")
const imgInnerBox = document.createElement("div")
const deleteBtn = document.createElement("a")
const hiddenField = document.createElement("input")
const imgBoxAttr = { // imgBoxに設定する属性
"class" : "image-box inline-flex mx-1 mb-5",
"data-controller" : "images",
"data-images-target" : "image_box",
}
const imgInnerBoxAttr = { // imgInnerBoxに設定する属性
"class" : "text-center"
}
const deleteBtnAttr = { // deleteBtnに設定する属性
"class" : "link cursor-pointer",
"data-action" : "click->images#deleteImage"
}
const hiddenFieldAttr = { // hiddenFieldに設定する属性
"name" : "post[images][]",
"style" : "none",
"type" : "hidden",
"value" : id, // 受け取ったidをセット
}
setAttr(imgBox, imgBoxAttr)
setAttr(imgInnerBox, imgInnerBoxAttr)
setAttr(deleteBtn, deleteBtnAttr)
setAttr(hiddenField, hiddenFieldAttr)
deleteBtn.textContent = "削除"
imgBox.appendChild(imgInnerBox)
imgInnerBox.appendChild(img)
imgInnerBox.appendChild(deleteBtn)
imgInnerBox.appendChild(hiddenField)
img.src = this.result
img.width = 100;
preview.appendChild(imgBox) // プレビュー表示用の<div>要素の中にimgBox(プレビュー画像の要素)を入れる
})
}
/* ⑤プレビュー画像の削除 */
deleteImage(){
this.image_boxTarget.remove()
}
}
長いので番号分けして順番に説明していきます。
①静的プロパティを定義
/* ①静的プロパティを定義(data-{controller}-target で指定したターゲット名) */
static targets = ["select", "preview", "image_box"]
まず最初に、HTMLのdata-{controller}-target
で指定したターゲット名をstatic targets
に定義しておきます。
こうすることで、例えばdata-images-target="select"
と指定した要素を取得したい場合、JS側に以下のコードを記述することで要素を取得することができます。
this.selectTarget
// <input multiple="multiple" accept="image/jpeg,image/gif,image/png" data-images-target="select" data-action="change->images#selectImages" type="file" name="post[images][]" id="post_images">
また、以下のように複数形にした場合は要素を配列として取得することができます。
this.selectTargets
// ▼ [input#post_images]
// ▶︎ 0: input#post_images (以下略)
// length: 1
// ▶︎ [[Prototype]]: Array(0) (以下略)
②画像選択時の処理
/* ②画像選択時の処理 */
selectImages(){
const files = this.selectTargets[0].files // file_fieldで取得した画像ファイル
for(const file of files){
this.uploadImage(file) // 選択した画像ファイルのアップロード
}
this.selectTarget.value = "" // 選択ファイルのリセット
}
file_fieldの「ファイル選択」ボタンでファイル選択時に行う処理を記述しています。
ここでは、this.selectTargets[0].files
で取得した画像ファイル(単数、もしくは複数)を、画像アップロード用のアクションthis.uploadImage(file)
に受け渡しています。
今回は画像を選択した時点でDBに画像がアップロードされるため、file_fieldの値はリセットしておきます。
(リセットしておかないとfile_fieldに残ったファイルがsubmit時に送信され、すでにアップロードした画像と重複して保存されてしまうため)
③画像アップロード
/* ③画像アップロード */
uploadImage(file){
const csrfToken = document.getElementsByName('csrf-token')[0].content // CSRFトークンを取得
const formData = new FormData()
formData.append("image", file) // formDataオブジェクトに画像ファイルをセット
const options = {
method: 'POST',
headers: {
'X-CSRF-Token': csrfToken
},
body: formData
}
/* fetchで画像ファイルをPostコントローラー(upload_imageアクション)に送信 */
fetch("/posts/upload_image", options)
.then(response => response.json())
.then(data => { // Postコントローラーからのレスポンス(blobデータ)
this.previewImage(file, data.id) // 画像プレビューアクションにblobデータのidを受け渡す
})
.catch((error) => {
console.error(error)
})
}
file_fieldで選択した画像をアップロードするためのアクションです。
引数fileで受け取った画像ファイルを、formData
オブジェクトにセットしてfetch
メソッドでPostコントローラー(upload_imageアクション)に送信しています。
(RailsアプリケーションでAjaxによるPOST送信を行う場合は、送信時にcsrfTokenトークンを添付する必要があります)
Postコントローラー側では受け取った画像ファイルを元にblobデータ(@image_blob)を作成し、render json: @image_blob
でJSONデータとして返しています。
・・・
# 画像アップロード用のアクション
def upload_image
@image_blob = create_blob(params[:image])
render json: @image_blob
end
private
・・・
# blobデータの作成
def create_blob(file)
ActiveStorage::Blob.create_and_upload!(
io: file.open,
filename: file.original_filename,
content_type: file.content_type
)
end
そして、fetchメソッドのレスポンスでblobデータ(@image_blob)を受け取り、blobデータのidを画像プレビュー用アクションthis.previewImage(file, data.id)
に受け渡しています。
fetch("/posts/upload_image", options)
.then(response => response.json())
.then(data => { // Postコントローラーからのレスポンス(blobデータ)
this.previewImage(file, data.id) // 画像プレビューアクション実行時にblobデータのidを受け渡す
})
④画像プレビュー
/* ④画像プレビュー */
previewImage(file, id){
const preview = this.previewTarget // プレビュー表示用の<div>要素
const fileReader = new FileReader()
const setAttr = (element, obj)=>{ // 属性設定用の関数
Object.keys(obj).forEach((key)=>{
element.setAttribute(key, obj[key])
})
}
fileReader.readAsDataURL(file) // ファイルをData URIとして読み込む
fileReader.onload = (function () { // ファイル読み込み時の処理
const img = new Image()
const imgBox = document.createElement("div")
const imgInnerBox = document.createElement("div")
const deleteBtn = document.createElement("a")
const hiddenField = document.createElement("input")
const imgBoxAttr = { // imgBoxに設定する属性
"class" : "image-box inline-flex mx-1 mb-5",
"data-controller" : "images",
"data-images-target" : "image_box",
}
const imgInnerBoxAttr = { // imgInnerBoxに設定する属性
"class" : "text-center"
}
const deleteBtnAttr = { // deleteBtnに設定する属性
"class" : "link cursor-pointer",
"data-action" : "click->images#deleteImage"
}
const hiddenFieldAttr = { // hiddenFieldに設定する属性
"name" : "post[images][]",
"style" : "none",
"type" : "hidden",
"value" : id, // 受け取ったidをセット
}
setAttr(imgBox, imgBoxAttr)
setAttr(imgInnerBox, imgInnerBoxAttr)
setAttr(deleteBtn, deleteBtnAttr)
setAttr(hiddenField, hiddenFieldAttr)
deleteBtn.textContent = "削除"
imgBox.appendChild(imgInnerBox)
imgInnerBox.appendChild(img)
imgInnerBox.appendChild(deleteBtn)
imgInnerBox.appendChild(hiddenField)
img.src = this.result
img.width = 100;
preview.appendChild(imgBox) // プレビュー表示用の<div>要素の中にimgBox(プレビュー画像の要素)を入れる
})
}
file_fieldで選択した画像をプレビュー表示するためのアクションです。
ここでは、以下のような流れで処理を行っています。
- uploadImage()アクションで受け取ったfileとidをセット
- fileReaderオブジェクトを作成し、fileReader.readAsDataURL(file)で受け取ったファイルを読み込む
- ファイル読み込み時にHTML上に画像プレビュー表示用の要素を追加する
- 読み込みが完了したらプレビューとして表示される
なお、previewImage(file, id)
で受け取ったblobデータのidは、_form.html.erb
上に生成した隠しフィールドに埋め込み、フォーム送信時にパラメータ(params[:post][:images])に付与して送信しています。
こうすることで、Postコントローラーでモデルと画像データを紐付けすることができます。
(バリデーションエラー時には選択した画像を保持、再表示するようになる)
⑤プレビュー画像の削除
/* ⑤プレビュー画像の削除 */
deleteImage(){
this.image_boxTarget.remove()
}
こちらは任意のプレビュー画像を削除するアクションです。
今回はimageBox要素ごと(隠しフィールドのblobデータのid含む)削除することで対応しています。
以上、これで複数枚画像のアップロード機能の実装は一通り終わりました。
画像アップロード枚数、およびファイルサイズの上限を設定する(バリデーション)
このままだと無限に画像をアップロードできてしまうので、1投稿あたりにアップロードできる画像の枚数およびファイルサイズに制限を設けたいと思います。
まずは出来上がりイメージを見てみましょう。
こちらは画像アップロード枚数の上限(最大10枚)を設けた場合の動作です↓
そして、こちらはファイルサイズの上限(1枚あたり2MBまで)を設けた場合の動作です↓
記述するコードですが、まず_form.html.erb
にエラーメッセージ表示用の要素を追加します↓
<%= form_with(model: @post, local: true, data: { controller: "images"}) do |f| %>
・・・
<div class="mb-3">
<%= f.label :images, "画像", class: "font-semibold block p-1" %>
<%= f.file_field :images, multiple: true, accept: "image/jpeg,image/gif,image/png", data: { images_target: "select", action: "change->images#selectImages" } %>
/******************** ↓追加 **************************/
<p data-images-target="error" class="text-red-600"></p>
/****************************************************/
</div>
<div data-images-target="preview">
<% if @post.images.attached? %>
<% @post.images.each do |image| %>
<div class="image-box inline-flex mx-1 mb-5" data-controller="images" data-images-target= "image_box">
<div class="text-center">
<%= image_tag image, width: "100" %>
<a class="link cursor-pointer" data-action="click->images#deleteImage">削除</a>
<%= f.hidden_field :images, name: "post[images][]", style: "display: none", value: "#{image.blob.id}" %>
</div>
</div>
<% end %>
<% end %>
</div>
<div class="mb-3">
<%= f.submit "投稿する", class: "btn btn-sky" %>
</div>
<% end %>
続いて、app/javascript/controllers/images_controller.js
を以下のように編集します。
import { Controller } from "@hotwired/stimulus"
// Connects to data-controller="images"
export default class extends Controller {
static targets = ["select", "preview", "image_box", "error"] // ← "error" を追加
・・・
/********************** 追加↓ *************************/
imageSizeOver(file){ // アップロードする画像ファイルサイズの上限(2MB)を超えたかどうか判定
const fileSize = (file.size)/1000 // ファイルサイズ(KB)
if(fileSize > 2000){
return true // ファイルサイズが2MBを超えた場合はtrueを返す
}else{
return false
}
}
/*******************************************************/
/* 画像選択時の処理(以下のように編集↓) */
selectImages(){
this.errorTarget.textContent = ""
const uploadedFilesCount = this.previewTarget.querySelectorAll(".image-box").length // すでにアップロードされた画像の枚数
const files = this.selectTargets[0].files // 選択した画像の枚数(これからアップロードする画像)
if(files.length + uploadedFilesCount > 10){
this.errorTarget.textContent = "画像アップロード上限は最大10枚です。"
}else{
for(const file of files){
if(this.imageSizeOver(file)){
this.errorTarget.textContent = "ファイルサイズの上限(1枚あたり2MB)を超えている画像はアップロードできません。"
}else{
this.uploadImage(file) // ファイルのアップロード
}
}
}
this.selectTarget.value = "" // 選択ファイルのリセット
}
・・・
これで完成です。
画像アップロード枚数、およびファイルサイズの上限はお好きなように変更してください。
【追記】複数枚画像をドラッグ&ドロップで表示、アップロードする方法
今回実装したコードを元に、画像をドラッグ&ドロップで表示およびアップロードする機能を追加しました。
詳しくは下記記事をご覧ください。
DBにアップロード後、モデルに紐付いていないファイルを定期的に削除する方法(自動化対応)
今回実装した複数枚画像アップロード機能は、「ファイル選択」ボタンでファイルを選択した瞬間にDBにアップロードされる(かつプレビュー表示される)ようになっています。
ここでもし、投稿フォームで選択状態のファイル(プレビュー表示されているファイル)を削除した場合、削除されたファイルは投稿に反映されませんが、DB内には残り続けてしまいます。
DB内に残り続けているファイルはモデルと紐付いていないためビュー上に表示されることはありませんが、データベースのリソースを無駄に使うことになるため、このようなファイルは定期的にDB上から削除しておくのがベターです。
そこで、便利なのが以下のコードです。
ActiveStorage::Blob.unattached.find_each(&:purge)
このコードを実行すれば、モデルに紐ついていないファイルをまとめて削除することができます。
上記コードはRailsコンソール上から実行することができますが、手動で削除しなければならないのは少々面倒です。
そこで、Rails標準搭載のRakeタスク機能を駆使して、定期的にDB内の不要なファイルを清掃してくれるようにします(自動化)。
詳しいやり方は下記記事を参考にしてみてください↓
コメント