LoginSignup
51
33

More than 3 years have passed since last update.

Rails+refile+jQueryで画像プレビュー機能をざっくり実装してみよう。

Last updated at Posted at 2020-12-13

おはようございます、こんにちは、こんばんわ。
私は株式会社インフラトップに所属しております。
プログラミング学習の『DMM WEBCAMP』でメンターとして働いております。

一昨年は『超初心者のための「Railsがなにをしているか例えてみよう」講座』という記事を書きました。
https://qiita.com/kouichi_s/items/6480586ab7f6356a91c3

去年は不参加でしたが、今年は参加しようと思いました。
私はぶっちゃけ(今も)JavaScriptがかなり苦手です。
しかし、JavaScriptを一切触らずにいるとAPIも全然使えないので、
ちょっとくらい慣れよう、と思って、
かなり前に試しで作っていた機能について書いてみようと思います。

だいぶ参考記事に頼ったハンズオン記事になりますが、どうぞよろしくお願い致します。

実行環境:CentOs(Cloud9)
Rails:5.2.4.3
Ruby:2.5.7

-この記事を読むにあたっての前提条件-

  • RailsのCRUD機能を扱える
  • 既にGemのrefileでの画像アップロード機能を実装した経験がある
  • RailsのjQueryの導入が出来る
  • JavaScriptに興味があるけど、Railsで扱うヒントがなくて困っている

事の起こり

私は数年前、JavaScriptの書籍を呼んでも今一つ理解が進まないな、と悩んでいました。
『比較的実装難易度が低く』
『JavaScriptの理解につながり』
『そこそこ実戦的な内容になる』
という機能を考えていました。
そんな都合のいい機能、そうそうあるわけ…。

ありました。

『画像投稿機能の画像のプレビュー機能』

refileで画像を選択した時、どんな画像を選択したかプレビューさせる機能です。
たまにこの機能を作ってる人に質問されることもあって、
コードを読み解いて答えてるケースもあるけど、
自分では作ったことがないな、と思って調べてみました。

でも、実際RailsとJavaScriptってどう絡めるの?

最初にRailsとJavaScriptとを組み合わせる時に悩んだのがこちらでした。
一見すると、一切噛み合わないように見えます。
初学者用のコードを見ても、
Javascriptで記述をしてalertやpromptで表示させて何かの処理をする、
ということはできますが、それ以外の事はすぐには理解できない状態でした。

しかし理解が進むと、どうってことないことだったと分かるようになります。
Railsが、どんなことをしてアプリケーションを作っているかを正確に理解していれば大丈夫でした。

Railsでは、埋め込みRuby(<%= %>とか<%= %>で記述している部分)が使えます。
この記述方法で、一定の記述をすると、HTMLに変換されるようにできています。
埋め込みRubyを使うことによってHTMLの理解がなくても、
ある程度の記述を覚えておくだけで、簡単に入力フォームなどを機能として実装することが出来ます。
これがなければ、HTMLで記述方法を事細かに覚え、データベースに送る方法を考えなければなりません。
この点を自動化できるのが、フレームワークとしてのRailsの強みです。

つまりRailsの埋め込みRubyは、あくまで最終的にHTMLになっている、ということです。
だからWebアプリケーションを作れるし、ブラウザでアクセスできるようになっています。

ということは、
『HTMLとJavaScriptと絡める方法をそのまま使える』
ということになります。

そうなると…。
『いいねの非同期通信』機能の実装でやっていた方法が使えます。
Railsと絡ませる場合は、HTMLの『id』を使えばいいわけです。
idを使用することで、

  • 「ページ内のどこを変化させる」という、変化を起こす内容を記述できる
  • 「ページ内のどこが変化した」という、変化が起こったところをチェックできる

などの機能が、JavaScriptで作れるようになります。

HTMLの知識の補足ですが、
idはclassのように使いまわさず、
「ページ内に一個しかない要素に名前を付けたい時」に使用します。
これを使うと、他の要素が巻き込まれないで済みます。

JavaScriptの場合はDOMの『document.getElementById』で、
jQueryの場合は『セレクタ』で読めます。

このあたりは少し触ってみて、
コードを読み直さないと感覚的に分かりづらいと思いますので、
実際に作ってみましょう。

画像プレビュー機能については、
『javascript 画像プレビュー』というキーワードでGoogle検索をかけていいページを見つけました。

参考:https://www.softel.co.jp/blogs/tech/archives/5676

こちらのページはjQueryのセレクタと、Javascriptのコードを組み合わせて使っていました。
では、実際にどうやってRailsの『画像アップロード機能』と絡めるか、
上記記事を参考に作ってみましょう。

まず、Postモデルを作り、名前用のnameカラムと、画像投稿用のimageカラムを設定します。

ターミナル
$ rails g scaffold Post name:string image:string
$ rails db:migrate

試しに作るだけなので、scaffoldを使っても大丈夫です。
モデル作るついでにまとめてviewもcontrollerも自動で作ってくれます。

次にrefileとjQueryの導入ですが、
こちらは導入の経験がある前提で進めますので、
各自で普段参考にしている方法・記事をそのまま利用して下さい。

そして導入が成功したら、通常通りPostモデルにattachmentを設定します。

app/models/post.rb

class Post < ApplicationRecord
    attachment :image
end

投稿フォームの方は、scaffoldで作ってあるものを消し、以下のように作ります。

app/views/posts/new.html.erb
<%= form_with(model: @post,url: posts_path,method: :post, local: true) do |f| %>
  <% if @post.errors.any? %>
    <div id="error_explanation">
      <h2><%= pluralize(post.errors.count, "error") %> prohibited this post from being saved:</h2>

      <ul>
      <% @post.errors.full_messages.each do |message| %>
        <li><%= message %></li>
      <% end %>
      </ul>
    </div>
  <% end %>

  <div class="field">
    <%= f.label :name %>
    <%= f.text_field :name %>
  </div>

  <div class="field">
    商品画像:<%= f.attachment_field :image %>
  </div>

  <div class="actions">
    <%= f.submit %>
  </div>
<% end %>

postモデルのformを作っている場合、
idはpost_カラム名で付くようになっているので、これをセレクタで指定します。
一応、間違えてないかサーバーを起動し、Googlechormeの検証ツールを使ってidを確認してみます。

スクリーンショット 2020-12-13 000621.png

このように、検証ツールを使えば、idが特定できます。
idはオプションで独自の名前を付けることも出来ますが、必要なければこのままでOKです。

画像アップロードのinputのidはpost_imageになっているので、これをセレクタに書きます。
そしてJavaScript&jQueryのコードを書いてみましょう。
通常はapp/assets/javascripts/application.jsに書き込みますが、
今回は分かりやすくするため、ちょっと行儀が悪いですがposts/new.html.erbの最下段に記述します。
その少し上に、画像プレビュー用のimgタグを準備、idはpreviewに設定しました。

app/views/posts/new.html.erb
//ここでプレビュー用のimgタグを準備。プレビューしてない時は出現しない。
<img id="preview" style="width:40%;">

<script>
  $('#post_image').on('change', function (e) {
    var reader = new FileReader();
    reader.onload = function (e) {
        $("#preview").attr('src', e.target.result);
    }
    reader.readAsDataURL(e.target.files[0]);
});
</script>

それをプレビュー用のimgタグに同期させるような形にすればいいので、
Javascript側もidを"preview"に設定しておきましょう。
refileを使っているとファイル選択は参考記事の通りinputになりますので、大丈夫そうです。

こうして…。

スクリーンショット 2020-12-13 011624.png

できた!

スクリーンショット 2020-12-13 011601.png

最終的なコードは以下の通りです。

app/views/posts/new.html.erb
<%= form_with(model: @post, local: true) do |f| %>
  <% if @post.errors.any? %>
    <div id="error_explanation">
      <h2><%= pluralize(post.errors.count, "error") %> prohibited this post from being saved:</h2>

      <ul>
      <% @post.errors.full_messages.each do |message| %>
        <li><%= message %></li>
      <% end %>
      </ul>
    </div>
  <% end %>

  <div class="field">
    <%= f.label :name %>
    <%= f.text_field :name %>
  </div>

  <div class="field">
    商品画像:<%= f.attachment_field :image %>
  </div>

  <div class="actions">
    <%= f.submit %>
  </div>
<% end %>

//ここでプレビュー用のimgタグを準備。プレビューしてない時は出現しない。
<img id="preview" style="width:40%;">

<script>
  $('#post_image').on('change', function (e) {
    var reader = new FileReader();
    reader.onload = function (e) {
        $("#preview").attr('src', e.target.result);
    }
    reader.readAsDataURL(e.target.files[0]);
});
</script>

ざっくりとですがコードの内容を解説しますと、

$('#post_image').on('change', function (e) {

は、jQueryのセレクタでidがpost_imageになってるところを探し、
中身が変更されたら(changeというイベントが発生したら)、function(e)を実行、という意味です。
Javascriptになれていない人はunction(e)という記述を見て、少し疑問に思うと思います。
function 関数名(){...処理内容}
少し勉強したての段階だと、上記の形で関数を準備するはず、と思って、
関数名がついてない記述で、何をしているか分かりづらいと思います。

これは無名関数と呼ばれるものです。
無名関数は即時関数とも呼ばれ、文字通りその場で実行される関数です。
とりあえず「何度も呼び出さないけど、すぐに使う処理をまとめた関数」程度に思ってください。

つまり、上記の通り『changeというイベントが発生したら』即実行してほしい内容を書いています。

ちなみにeはイベントハンドラー(イベントが発生した時に動く処理)にデータを渡す変数です。
このfunctionの処理を行う対象になったidのpost_imageのデータを変数eに渡す、ということをしています。

ここまでの内容をまとめると、

『post_imageというidを持つ場所に変化があったら、
 即時関数であるfunction(e)を実行します。
 eは、post_imageのデータが入っている状態です』

と言うことになります。
続くコードの解説をします。

    var reader = new FileReader();
    reader.onload = function (e) {
        $("#preview").attr('src', e.target.result);
    }
    return reader.readAsDataURL(e.target.files[0]);
});

こちらでは変数reader関数を定義し、FileReaderを定義してファイルを読めるようにします。
そしてreader.onloadでファイルの読み込みが可能になったら、再び別の無名関数の内容を実行するようにします。
ここでもeを使っているので、idがpost_imageになっている場所からデータを引き出しています。
$("#preview").attr('src', e.target.result);
で、プレビュー用に準備しておいたidがpreviewになっているところに、画像データの取得結果を反映させるよう設定します。
idがpreviewになっている場所を探し、imgタグはsrcの指定で画像を読み込むようになっているので、
srcの部分を、e、つまりidがpost_imageになっている場所のデータを反映させるために、

e.target.result

という記述で、データをとれるように設定しています。
こうすることで、
『imgタグであるidがpreviewになっている場所を探し、画像表示のためのsrcを書き換える』
という作業が完了します。

最後に、

reader.readAsDataURL(e.target.files[0]);

で、読み込まれた内容を実際に読み込むように設定されています。
しかし、ここまでのソースコードを見ると疑問点が浮かびます。

    reader.onload = function (e) {
        $("#preview").attr('src', e.target.result);
    }

という部分で、
「あれ?この記述ですでにidがpreviewになってる場所を書き換えてなかったっけ?」
と思うかもしれませんが、これはonloadがファイルの読み込みが発生したら動く記述なので、
読み込みが発生していない時は動きません。

return reader.readAsDataURL(e.target.files[0]);

という記述で『ファイルの読み込み』と『ファイルを参照するためのURL生成』を行っています。
files[0]は、ファイルを配列(複数)で受け取るようになっているので、
一番最初のファイルを取得するように設定されています。
つまりこの記述がない限り、

    reader.onload = function (e) {
        $("#preview").attr('src', e.target.result);
    }

こちらの記述は動かなくなっている、という状態になっています。

ざっくりとですが、こんな構造になっています。

こんな感じで、コードを書いたらサクッと表示できました。
よし…。

画像プレビュー機能、完。

・・・・・。

いやいやいやいやいやいや。

そんなんでいいわけないでしょ。
参考記事をRailsで使えるようにしただけじゃないですか。
これで終わりって、これじゃ練習にもなってない…。
誰かに教えるにしても、そのまま参考記事を伝えるだけでいいし…。

と、試していた当時、自分自身にダメ出しをしたところで、
せめて画像の複数投稿に対応してみよう、と考えてみました。

複数画像のプレビューに挑もう。

refileで複数投稿に対応するやり方はいくつかありますが、今回はこの記事を参考にしてみました。

カラム名、モデル名などは参考記事に準拠しています。
Productモデル(親)とProductImageモデル(子)でアソシエーションを組み、
PostImageモデルのimageカラムに画像を持たせる形にしています。

とりあえず、

  • 五枚まで同時投稿可能
  • 五枚以上の投稿は禁止、エラーを出させる

という条件をざっくり決めて、書き始めてみました。

そして書きあがったのが、こんなコードでした。

app/views/products/new.html.erb
<%= form_with model: @product,url: products_path,method: :post,local: true do |f| %>
  <div class="field">
    商品名:<%= f.text_field :name %>
  </div>
  <div class="field">
    商品画像:<%= f.attachment_field :product_images_images, multiple: true %>
  </div>
  <%= f.submit %>
<% end %>
  <img id="preview_0" style="width:15%;">
  <img id="preview_1" style="width:15%;">
  <img id="preview_2" style="width:15%;">
  <img id="preview_3" style="width:15%;">
  <img id="preview_4" style="width:15%;">
<script>
$('#product_product_images_images').on('change', function (e) {

    if(e.target.files.length > 5){
       // 五枚以上の画像を選択していた場合、選択したファイルをリセット。
      alert('一度に投稿できるのは五枚までです。');
      $('#product_product_images_images').val = "";

      // 画像のプレビューが残っている場合は、
      // リセットしないと『まだ選択できている』と勘違いを誘発するので初期化。
      for( var i = 0; i < 5; i++) {
        $('#preview_[i]').attr('src', "");
      }

    }else{
      var reader = new Array(5);

      // 画像選択を二回した時、一回目より数が少なかったりすると画面上に残るので初期化
      for( var i = 0; i < 5; i++) {
        $('#preview_[i]').attr('src', "");
      }

      for(var i = 0; i < e.target.files.length; i++) {
          $('#post_image_[i]').on('change', function (e) {
              var reader = new FileReader();
              reader.onload = function (e) {
                  $('#preview_[i]').attr('src', e.target.result);
              }
              reader.readAsDataURL(e.target.files[i]);
          });
        }
      }
});
</script>

プレビュー用のimgタグを、

<img id="preview_0" style="width:15%;">

と書かれたものを五つ準備して、for文とe.target.files.lengthで、
ファイルを読み込んだ個数ぶんの回数ループさせるように作ってみました。

しかし動きません。
うーん…コードの中身は決して間違ってないような気がするんですが。
しかも、エラーが出ません。
一個一個、確実につぶしていくしかなさそうです。
調査した結果、このコードには、いくつかの問題がありました。

問題1:JavaScriptでは文字列にループの変数を入れる時は「テンプレートリテラル」が必要

実は、ループ用のi変数を読み込む手順が間違っていました。
$('#post_image[i]')
この書き方では、変数を読み込んでくれません。
考えてみれば、文字列の中にfor文の中に配列の番号として変数iを入れても、単なる文字列になります。
Rubyでいう「式展開」「変数展開」にあたる書き方が必要では、と思い当たりました。
そのまま「javascript 変数展開」というキーワードでGoogle検索をしてみると、
『テンプレートリテラル (テンプレート文字列)』という単語が出てきました。

これによると、
1.文字列の中に${変数名}と書くと、変数の中身が文字列の中に追記される。
2.文字列を囲むシングルクォーテーション(')は、グレイヴ・アクセント(`)に変える
という手続きを踏まないと、変数を解釈してくれない、ということでした。
参考:https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Template_literals#%E3%83%96%E3%83%A9%E3%82%A6%E3%82%B6%E5%AE%9F%E8%A3%85%E7%8A%B6%E6%B3%81

 $('#preview_[i]')
 これを、
 ↓
$(`#preview_${i}`)

このように直すと、変数の中身が展開され、
一週目には"#preview_0"として扱われるようになり、無事に反応するようになりました。

問題2:onloadは発火するタイミングが合わない場合がある。

「onload for文」というキーワードでGoogle検索をしてみると、こんな記事に出会いました。
参考:https://qiita.com/gonshi_com/items/615226b5fa355869a01c
どうやらonloadは若干処理が遅いらしく、
使う時は別個に関数として定義して、データを渡す方式を取らないといけないようです。

問題3:そもそもvarを使っており、ES2015(ES6)に準拠していない。

Javascriptは歴史が古い分、そして『何でもできる』と呼ばれる分だけ、
初学者・初心者殺しの文法がたくさん出てきます。
varではなくletを使おう、ということが言われていますが、
つい『動くから』と放置してしまうことがあります。
せめて、該当する変数をちゃんとletに変更する必要があり、
もし動かなくなったらそれに伴って変化したコードをちゃんと(うまく記述できなくても)書かないとまずいです。
上記のテンプレートリテラルはES2015(ES6)の記述方法なので、
ちゃんとこちらも準拠させてみましょう。

最終的なコードはこんな形になりました。

app/views/products/new.html.erb
<%= form_with model: @product,url: products_path,method: :post,local: true do |f| %>
  <div class="field">
    商品名:<%= f.text_field :name %>
  </div>
  <div class="field">
    商品画像:<%= f.attachment_field :product_images_images, multiple: true %>
  </div>
  <%= f.submit %>
<% end %>

<!-- プレビュー用のimgタグを五つ準備。最初は何も表示されない。 -->
<img id="preview_0" style="width:15%;">
<img id="preview_1" style="width:15%;">
<img id="preview_2" style="width:15%;">
<img id="preview_3" style="width:15%;">
<img id="preview_4" style="width:15%;">

<script>
$('#product_product_images_images').on('change', function (e) {

    if(e.target.files.length > 5){

      alert('一度に投稿できるのは五枚までです。');
      // 五枚以上の画像を選択していた場合、選択したファイルをリセット。
      $('#product_product_images_images').val = "";

      // 以前の画像のプレビューが残っていた場合は、
      // まだ画像選択できていると勘違いを誘発するため初期化。
      for( let i = 0; i < 5; i++) {
        $(`#preview_${i}`).attr('src', "");
      }

    }else{
      let reader = new Array(5);

      // 画像選択を二回した時、一回目より数が少なかったりすると画面上に残るので初期化
      for( let i = 0; i < 5; i++) {
        $(`#preview_${i}`).attr('src', "");
      }

      for(let i = 0; i < e.target.files.length; i++) {
        reader[i] = new FileReader();
        reader[i].onload = finisher(i,e); 
        reader[i].readAsDataURL(e.target.files[i]);

        // onloadは別関数で準備しないとfor文内では使用できないので、関数を準備。
        function finisher(i,e){
          return function(e){
          $(`#preview_${i}`).attr('src', e.target.result);
          }
        }
      }
   }
});
</script>

試しに選択してみると…。
スクリーンショット 2020-12-13 195554.png

できました!
スクリーンショット 2020-12-13 202553.png

今回はかなり真似している部分が多いですが、分かりやすいコードにはなったと思います。
このように複数の記事を参考にして、コードを組み合わせる必要がある場合もあります。

しかしそのためには基礎をしっかりやっておき、時には別のプログラミング言語を参考にしたり、
検索ワードの選び方が上手でないといけません。

ちょっと慣れてくるとネットの記事を参考にすればどんな機能でも簡単に作れる気がしてきますが、
こういうちょっとした引っ掛かりがあることがあります。

また、エラー文が出ないことも結構あります。

今回の場合は「文法間違いでセレクタが効いてないだけ」だとエラーが出ないケースがあります。
その場合は、基礎の文法を改めて確認したり、状況をうまく検索できるようになっている必要もあります。
この点に慣れてしまえばいろんなことが出来るようになりますが、進めないのは苦しいですよね。
しかし、大概のことは調べれば解決の糸口がつかめます。

「記事の通りにやったのに…」
「こんなに時間かけたのに…」
「才能ないのかな…」

と思ってしまうこともありますが、成長して振り返ってみると、
案外大したことないところで躓いていることに気付くことも多いです。
ストレスを感じることも多いかと思いますが、地道に取り組んでいきましょう。
今回のような『idを使って、変化を取得したりする』というのに慣れていけば、
GoogleMapAPIなどの扱いも、ある程度分かるようになるので、
苦手意識があった方は、この点に着目しつつ練習すると分かりやすくなると思います。

それでは少し長い記事になってしまいましたが、
ご覧いただきありがとうございました!

~編集後記~

最後に。
この記事を書いてる最中に思ったのが、

『下手なコードを残しておきたくなくてGithubへのプッシュを渋って、
 Cloud9でぽつぽつ作っているのはよくない』

ということです。
今回の内容はだいぶ前に書いたコードで、完成版が出来てる手前、
当時の自分がどこに引っ掛かっていたのかを思い出すのがかなり大変でした。
進化の過程を残しておかないと振り返ることが難しくなるということですね…。
ポートフォリオや、成果として完成したものだけではなく、
成長過程のコードも少しは残したりした方がいいです。
ついでにバックアップもできますし。
本当に。
改めて思いました。

それでは、そろそろ記事を終わりにしたいと思います。
良いプログラミングライフをお過ごし下さい!

51
33
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
51
33