画像プレビュー機能実装(〜 javascript基礎 〜)

前提

・Userはavatarカラムを持ち、プロフィール画像を投稿できる(carriewaveで実装)

条件

・編集画面は/mypage/account/editというパスとする

アバターとユーザー名を変更できるようにする

アバター選択時(ファイル選択時)にプレビューを表示する

・マイページには編集画面だけではなく諸々機能を追加するのでそれを考慮した設計とする(ルーティングやコントローラやレイアウトファイルなどマイページ専用のものを作る)

完成品

Image from Gyazo

画像を選択すると、画面遷移することなく選択した画像が表示される機能(プレビュー機能)。

画像を選択した際のhtmlの挙動(jsが動いてくれている) Image from Gyazo

実装

ルーティング

routes.rb

namespace :mypage do
    resource :account, only: %i[edit update]
end
GET    /mypage/acount/edit    mypage/acounts#edit
PATCH  /mypage/acount      mypage/acounts#update

なぜresourcesではなくresourceなのか
もし、resourcesを使って/mypage/acounts/:id/editのルーティングにしてしまうとURLの直打ちで他のユーザーが勝手に編集画面に飛べてしまうから。

なので/mypage/acounts/:id/editでコントローラーにparams[:id]を送る形にするのではなく、コントローラー側で@user = User.find(current_user.id)current_user.idで指定する形にする。

また、そもそもマイページは各ユーザー1つずつしか持たないからidで分ける必要がない

※ namespace(名前空間)を使うメリットは、名前の重複があるときに衝突を防止できるという点にある。

コントローラー

$ rails g controller mypage/acountsで、mypageフォルダの中にacounts_controller.rbファイルが作られる。

コントローラー作成

class Mypage::AcountsController < ApplicationController

def edit
  @user = User.find(current_user.id)
end

def update
  @user = User.find(current_user_id)
  if @user.update(acount_params)
     redirect_to edit_mypage_acount_path(user), success: 'プロフィールを更新しました'
  else
  flash.now[:danger] = 'プロフィールの更新に失敗しました'
  render :edit
  end
end

private
def acount_params
  params.require(:user).permit(:name, :email, :avatar, :avatar_cache)
end

end

avatar_cacheについては後述する。

view

htmlの記述

views/mypage/accounts/edit.html.slim

<%= form_with model: @user, url: mypage_account_path, method: :patch, local: true do |f| %>

  <%= render 'shared/error_messages', object: f.object %>

  <div class="form-group">
    <%= f.label :avatar %>
    <%= f.file_field :avatar, onchange: 'previewFileWithId(preview)', class: 'form-control', accept: 'image/*' %>
    <%= f.hidden_field :avatar_cache %>
    <%= image_tag @user.avatar.url, class: 'rounded-circle', id: 'preview', size: '100x100' %>
  </div>

  <div class="form-group" >
    <%= f.label :username %>
    <%= f.text_field :username, class: 'form-control' %>
  </div>

  <%= f.submit class: 'btn btn-primary btn-raised' %>

<% end %>

url: mypage_account_pathについて、form_with@userに中身がある場合デフォルトでurl: users/:idmethod: :patchを送る。しかし、今回は独自のルーティングを開拓しているので、それを指定する。

f.objectのobjectは、form_withのオプションで、formのブロック変数に対して使用する事でブロック内でモデルオブジェクトを呼び出す事が出来る。@userで書いてもいいが、f.objectで書けば同じコードを流用する際に変数を変える必要が無くなるというメリットがある。

f.file_fieldは、htmlの<input type="file">
f.htmlタグ名 :カラム名と指定)

f.hidden_fieldは、htmlの<input type="hidden">

image_tagはhtmlの<img src="...">

avatar(カラム名)_cacheについて
file_fieldだけだと、バリデーションエラーになった時にユーザーが選択した画像が白紙になりまた最初から画像を選び直してもらうという不利益が起きる。
これを防止するため、avatar(カラム名)_cachehidden_fieldに入れる。Uploaderはavatar_cacheに画像情報を保存するので、この情報をhidden_fieldに入れれば、バリデーションエラーになった時でもユーザーの選択した画像を表示することができる。

accept属性は、サーバが受け取ることが可能なファイルの種別を指定できる。今回fileの中でも画像fileだけを受け入れたいので、accept="image/*"とする。こうすることで、ユーザーは画像以外のファイルを選択できなくなる。

※ 本来、<%= image_tag @user.avatar.url...の部分は画像が存在する場合(if @user.avatar.present?)はそれを載せ、存在しない場合はno_image.jpegを載せるみたいに条件分岐が必要だが、画像投稿機能にcarrierwaveを使っている場合、carrierwaveのdef default_urlno_image.jpegなどを設定しておけば、画像が存在しない場合にno_image.jpegの表示を自動でやってくれるためif文による条件分岐は必要ない。

onchange: 'previewFileWithId(preview)'について
onchangeとは、フォーム内のエレメント(要素)の内容が変更された時に起こるイベント処理の事

<html>
 <body>
  
  <input type="file" onchange="myfunc(this)">

  <script>
    function myfunc(x) {
          console.log(x);
          //=> <input type="file" onchange="fileChanged(this)">
        }
  </script>

</body>
</html>

 * onchange のハンドラに this を渡すことで、ハンドラ内で直ちに input 要素(<input type="file" onchange="myfunc(this)">)にアクセスできるようになる

javascriptの記述

assets/javascripts/mypage.js

//= require jquery3
//= require popper
//= require rails-ujs
//= require bootstrap-material-design/dist/js/bootstrap-material-design.js

function previewFileWithId(selector) {
    const target = this.event.target;
    const file = target.files[0];
    const reader  = new FileReader();
    reader.onloadend = function () {
        selector.src = reader.result;
    }
    if (file) {
        reader.readAsDataURL(file);
    } else {
        selector.src = "";
    }
}

javascriptの中身について

※ ユーザーが画像選択をするとchangeイベントが発火し、function previewFileWithId()が実行される

※ ここでのthisはwindowオブジェクトを指すため、this.event.targetでinput要素(<input type="file" onchange=...>)を取得できる

※ change イベントが発生したとき、input 要素の files プロパティに、選択されたファイルを表現する File オブジェクトが渡される。input 要素の files プロパティ(要素.files)は、FileList {0: File, 1: File, length: 2}のような形でFileオブジェクト(fileのデータ)を持っているため、要素.files[0]要素.files[1]のような形で各Fileオブジェクトにアクセス可能。

※ ファイルの選択画面を一旦表示して、ファイルを選択しないでキャンセルボタンを押した場合にも change イベントハンドラは呼び出されるが、files プロパティは長さ 0 の配列となる。

※ FileReader オブジェクトを使うと File オブジェクトの中身を読み込むことができる。FileReader オブジェクトのファイル読み込み用のメソッドにFile オブジェクトを渡すことで読み取りを開始する(reader.readAsDataURL(file)でfiles[0]のFileオブジェクトの中身を読み込んでいる)

※ 読み込みが終わったらonloadendイベントが発火し、selector.src = reader.resultが実行される。

※ 読み込み後のFileReaderオブジェクトはこんな感じFileReader {readyState: 2, result: '����\x00....J"Q\x12��D�/��', error: null, onloadstart: null, onprogress: null, …}

reader.resultでfileの中身('����\x00....J"Q\x12��D�/��')にアクセス可能。

※ プレビュー画像を表示したい場所のsrc属性にfileの中身('����\x00....J"Q\x12��D�/��')を入れ込む。

※ もしFileListが空だったら(Fileオブジェクトがなかったら)、src属性には何もいれない(if文)


参考
JavaScript でファイルを読み込む方法
ブラウザで画像ファイルを選択した時にプレビュー表示する方法
FileReaderオブジェクト
JavaScriptでFile APIを利用する方法を現役エンジニアが解説【初心者向け】

javascriptの読み込みについて

※ 普段assets/javascripts/以下のjsファイルがviewで読み込まれるのは、各viewがlayouts/application.html.slimのyieldに組み込まれるのと同時に、layouts/application.html.slimに記載してあるjavascript_include_tag 'application'によって、application.js(マニフェストファイル)を読み込んでいるから。application.jsとはアセットパイプラインによって連結された結果のファイルで、他のjsを結合する指示をアセットパイプラインに与えるために//= requireが記述してある。CSSも同様。

※ jsファイルを連結するためにアセットパイプラインがファイルを探しにいくのだが、そのとき探しにいくディレクトリはapp/assets/*lib/assets/*vender/assets/*がデフォルトで設定されている。

※ 他のディレクトリにも探しに行って欲しい場合はconfig/initializers/assets.rbにパスの設定を追加する。

config/initializers/assets.rb

Rails.application.config.assets.paths << Rails.root.join('node_modules')

 * 上の例だと、node_modules/ディレクトリにもアセットパイプラインがファイルを探しにいってくれるようになる

※ 今回、application.jsに//= require mypage.jsを書いてassets/javascripts/mypage.jsを読み込ませるのが通常のやり方

※ でも今回は、mypageを特別なページにしたい

そこでlayouts/application.html.slimのmypage版を独自に作成する

layouts/mypage.html.slim

<doctype html>
<html>
  <head>
    <meta content=("text/html; charset=UTF-8") http-equiv="Content-Type" />
    <meta[name="viewport" content="width=device-width, initial-scale=1.0"]>
    <title>マイページ | InstaCloneApp</title>
    <%= csrf_meta_tags %>
    <%= csp_meta_tag %>
    <%= stylesheet_link_tag 'mypage', media: 'all' %>
    <%= javascript_include_tag 'mypage' %>
  </head>
  <body>
    <%= render 'shared/header' %>
    <%= render 'shared/flash_messages' %>
・
・
・
<%= yield %>

※ マイページでは、application.jsではなくmypage.jsを読み込みたいので<%= javascript_include_tag 'mypage' %>と書く。

※ mypage.jsとmypage.scssをプリコンパイルできるようにconfig/initializers/assets.rbの設定に追記しておく。

config/initializers/assets.rb

Rails.application.config.assets.precompile += %w[mypage.js mypage.css]

※ まだこのままではlayouts/mypage.html.slim<%= yield %>にmypage/accounts/edit.html.slimは入ってくれない。

なのでlayouts/mypage.html.slimを読み込むようにコントローラーで操作する

mypage/base_controller.rb 

class Mypage::BaseController < ApplicationController
  before_action :require_login
  layout 'mypage'
end
mypage/accounts_controller.rb

class Mypage::AccountsController < Mypage::BaseController
・
・(editアクション)
・

layoutメソッドを指定した場合、layouts/application.html.slimではなく、layouts/指定したファイル名が読み込まれる。

※ これでlayouts/mypage.html.slimmypageの世界のapplication.html.slimになった

※ よって、views/mypage/accounts/edit.html.slimレンダリングされる際にはlayouts/mypage.html.slim<%= yield %>に組み込まれ、mypage.jsが適用される

※ これからマイページにいろいろ機能を追加するので、コントローラーは基底コントローラーを作って継承させた方が良い。なのでMypage::AccountsControllerMypage::BaseControllerApplicationControllerの関係にしている。

注意

モデルでvalidationをかけている場合、新規でインスタンスを作成する(create)場合と既存のインスタンスを更新(update)する場合とでvalidationをかけるか否かをif文で条件分岐しないといけない。そうしないと、プロフィール更新時にvalidationに引っかかり『パスワード入力してください』とエラーが出る。