Laravel,Vue.jsで画像複数枚投稿(新規,編集)

2021.01.18

見出し画像

今回はlaravelとvue.jsを使って画像複数枚投稿を実装したので、そのことを自分への備忘録として書いていきます。

更新処理に苦労しましたwなんせ参考になる記事が少なかった。(探し方が下手くそなのかも)

プレビュー表示や編集機能、更新処理についても全て書きました。(削除についてはほとんど触れません)

目次

  1. ざっくりの全体的な流れ
  2. モデル、マイグレーションファイルの設定
  3. ルーティングの設定
  4. ビューの設定。まずはcreate。
  5. コントローラーの記述。create
  6. ビューの記述(vue.js)
  7. コントローラーの設定。update。
  8. つまづいたところ
  9. まとめ

ざっくりの全体的な流れ

1, まずはモデルとマイグレーションファイルを作っていく。

2, ルーティングの設定。記述はシンプル

3, ビューの設定。まずはcreateのみ。

4, コントローラーの設定。まずはcreateから。

5, コントローラーの設定。update。

6, ビューの設定。updateの方。

ざっくりこんな流れで実装したイメージです。

モデル、マイグレーションファイルの設定

モデルとマイグレーションファイルを作る。今回はmeとimageのモデルを作っていきます。meの部分は適宜変更してください。

-mをつけて同時にマイグレーションファイルも作成しています。

php artisan make:model Image -m

マイグレーションファイルはimageの方だけ載っけます。srcに保存するのは画像が保存されているファイルまでのパスを保存するとイメージしてください。

public function up()
   {
       Schema::create('images', function (Blueprint $table) {
           $table->bigIncrements('id');
           $table->unsignedBigInteger('me_id');
           $table->string('src');
           $table->timestamps();

           $table->foreign('me_id')->references('id')->on('mes')->onDelete('cascade');
       });
   }

ルーティングの設定

Route::get('/me/new', 'MeController@new')->name('me_new');
Route::post('/me/create', 'MeController@create')->name('me_create');
Route::get('/me/{me}/edit', 'MeController@edit')->name('me_edit');
Route::post('/me/{me}/update', 'MeController@update')->name('me_update');

ビューの設定。まずはcreate。

まずはコードを。

<div id="image">
 <form enctype="multipart/form-data" @submit.prevent="send">
   @csrf
     
   <p v-if="errors.length">
     <b>入力内容に不備があります</b>
     <ul>
       <li v-for="error in errors">@{{ error }}</li>
     </ul>
   </p>
   <div class="form-group">
     <div>
       <h2>Select an image</h2>
       <label for="select_image">写真を選択</label>
       <input type="file" @change="onFileChange" id="select_image" hidden>
     </div>
     <div v-if="images">
         <ol>
           <li v-for="(image, index) in images">
             <h5>@{{image.name}}</h5>
             <img :src="image.thumbnail" style="width: 100px; height: 100px;" />
             <input type="hidden" v-model="image.name">
             <div>
               <label for="edit_image">Edit image</label>
               <input type="file" @change="edit(image, index, $event)" id="edit_image" hidden>
             </div>
             <div @click="remove(index)">Remove image</div>
           </li>
         </ol>
     </div>
     <div class="form-group">
      <label for="exampleFormControlTextarea1">メモ</label>
      <textarea name="content" v-model="content" class="form-control" id="exampleFormControlTextarea1" rows="3">{{old('content')}}</textarea>
    </div>
   </div>
   <button type="submit">作成する</button>
 </form>
</div>
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
<script src="https://code.jquery.com/jquery-3.5.1.js"></script>
<script src="https://cdn.jsdelivr.net/npm/vue@2.6.11"></script>
<script>
 new Vue({
           el: '#image',
           data() {
             return {
               images: [],
               content: "",
               errors: []
             }
           },
           methods: {
             onFileChange(e) {
               let files = e.target.files
               // 0は今取得したものを指している
               this.createImage(files[0])
             },
             createImage(file) {
               // FileReaderでinputのfileが所有する中身を読み取ることができる。
               // resultは読み込んだ後に、中身のデータを取得してインスタンス化。詳しくは下記リンク。
               // https://hakuhin.jp/js/file_reader.html
               const reader = new FileReader();
               // 読み込みが成功した際に走るイベント。eには読み込んだ値が入っている。
               reader.onload = (e) => {
                   // プレビュー表示用
                   file.thumbnail = e.target.result;
                   this.images.push(file)
               };
               // base64形式にエンコードされたURLに変換
               reader.readAsDataURL(file);
             },
             edit(img, i, e) {
               // 新しく入力された写真を取得する
               let edit_image = e.target.files[0]

               // 新しく入力された写真をプレビュー表示させるための処理
               const reader = new FileReader();
               reader.onload = (e) => {
                   // プレビュー表示用
                   edit_image.thumbnail = e.target.result;
                   // 配列の置き換えも出来る。詳しくは https://ginpen.com/2018/12/07/array-splice-to-insert-replace/
                   this.images.splice(i, 1, edit_image);
               };
               // base64形式にエンコードされたURLに変換
               reader.readAsDataURL(edit_image);
             },
             remove(i) {
               this.images.splice(i, 1);
             },
             send(e){

               this.errors = []

               if(!this.content){
                 this.errors.push("メモを入力してください");
               }

               if(this.images.length === 0){
                 this.errors.push("写真を選択してください");
               } else if(this.images.length > 4){
                 this.errors.push("写真は4枚以内で選択してください");
               }

               e.preventDefault();
               

               // エラーが無かったら送信する
               if(this.errors.length === 0) {
                 console.log('kiteru')
                 images = this.images

                 let list = [];
                 images.forEach(function(val) {
                   list.push(val) 
                 })
                 
                 dataform = new FormData();
                 dataform.append('content', this.content);

                 // 以下の形にしないと配列で送れなくなってしまう
                 list.forEach(function(img) {
                   dataform.append('images[]', img);
                 })
                 axios.post('http://127.0.0.1:8000/me/create', dataform).then(res => {
                   // vue-routerを使わない場合
                   window.location.href = "http://127.0.0.1:8000/post/index"
                 }).catch(function(error){
                   console.log(error)
                 })
               }
             }
           },
         })
</script>

複数枚の写真をdataformに配列の形で入れてaxiosでlaravel側に送っている。(バリデーションに引っかからなければ)

上記は、写真ファイルが追加された時にその値を、dataの中にあるimages: []に値を入れてfilereaderを使ってプレビュー表示している。

追加された写真にはeditとremoveボタンが付いていて順番を変えずに中身のデータだけ変えるようになっている。

コントローラーの記述。create

まずはコードを。

public function create(CreateMe $request)
   {
       $user = Auth::user();
       $me = new Me();

       $me->user_id = $user->id;
       $me->content = $request->content;

       $image_list = [];
       if($request->has('images')) {
           foreach($request->images as $image) {
               $img = new Image();
               $path = $image->store('public');
               $read_temp_path = str_replace('public/', 'storage/', $path);
               $img->src = $read_temp_path;
               array_push($image_list, $img);
           }
       }

       $me->save();
       $me->images()->saveMany($image_list);
       
       return redirect()->route('post_index');

   }

流れとしてはリクエストの中から欲しい値を受け取って、meが保存された後にimageを保存している。

$request->imagesの中身は写真本体の情報が配列の形で構成されたもの。

$image->store(‘public’);を実行する時に大事なのは、写真ファイルの情報でないと保存できないということ。だからファイル名だけ送ってきてもstoreは出来ない。(現状の理解だと)

ただし、ファイル本体をデータベースに保存するのであればstoreをする必要はないので問題ない。が、ここにも記載されている通り写真本体をデータベースに保存するのはあまりイケメンなやり方じゃないらしい。

ビューで表示する際はassetから写真のsrcを呼び出すと表示される。ここに付いては後ほど解説する。

ターミナルで php artisan storage:link を実行するのを忘れないように。
<img src="{{asset($image->src)}}">

ビューの記述(vue.js)

編集でのビュー。ほとんど同じだが若干違うのでコード載っけます。

<div id="image" v-cloak>
 <form enctype="multipart/form-data" @submit.prevent="send">
   @csrf
     
   <p v-if="errors.length">
     <b>入力内容に不備があります</b>
     <ul>
       <li v-for="error in errors">@{{ error }}</li>
     </ul>
   </p>
   <div class="form-group">
     <label for="exampleFormControlInput1">お知らせメール送信日</label>
     <input type="date" name="send_day" v-model="send_day">
   </div>
   <div class="form-group">
     <div>
       <h2>Select an image</h2>
       <label for="select_image">写真を選択</label>
       <input type="file" @change="onFileChange" id="select_image" hidden>
     </div>
     <div v-if="images">
       <ol>
         <div>
           <li v-for="(image, index) in images">
             <h5>@{{image.name}}</h5>
             <div v-if="!image.thumbnail">
               <img :src="getImgUrl(image)" style="width: 100px; height: 100px;" alt="">
             </div>
             <div v-else>
               <img :src="image.thumbnail" style="width: 100px; height: 100px;" />
               <input type="hidden" v-model="image.name">
             </div>
             <div>
               <label :for="['edit_img_' + index]">Edit image</label>
               <input type="file" @change="edit(image, index, $event)" :id="['edit_img_' + index]">
             </div>
             <div @click="remove(image, index, $event)">Remove image</div>
           </li>
         </div>
       </ol>
     </div>
   </div>
   <div class="form-group">
     <label for="exampleFormControlTextarea1">メモ</label>
     <textarea name="content" v-model="content" class="form-control" id="exampleFormControlTextarea1" rows="3"></textarea>
   </div>
   <button type="submit">更新する</button>
 </form>
</div>
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
<script src="https://code.jquery.com/jquery-3.5.1.js"></script>
<script src="https://cdn.jsdelivr.net/npm/vue@2.6.11"></script>
<script>
 new Vue({
           el: '#image',
           data() {
             return {
               me_id: "",
               images: [],
               send_day: "",
               content: "",
               box: [],
               remove_image_list: [],
               errors: []
             }
           },
           mounted() {
             this.images = @json($edit_me->images);
             this.send_day = @json($edit_me->send_day);
             this.content = @json($edit_me->content);
             this.me_id = @json($edit_me->id);
           },
           methods: {
             getImgUrl(img) {
               // すでに存在している写真を表示させる
               let path = ["http://127.0.0.1:8000/", img.src];
               let path_link = path.join("");
               return path_link
             },
             onFileChange(e) {
               let files = e.target.files
               this.createImage(files[0])
             },
             createImage(file) {

               // boxに追加
               let test = {
                 file_name: file.name,
                 edit_id: null
               }
               this.box.push(test)

               // FileReaderでinputのfileが所有する中身を読み取ることができる。
               // resultは読み込んだ後に、中身のデータを取得してインスタンス化。詳しくは下記リンク。
               // https://hakuhin.jp/js/file_reader.html
               const reader = new FileReader();
               // 読み込みが成功した際に走るイベント。eには読み込んだ値が入っている。
               reader.onload = (e) => {
                   // プレビュー表示用
                   file.thumbnail = e.target.result;
                   this.images.push(file)
               };
               // base64形式にエンコードされたURLに変換
               reader.readAsDataURL(file);
             },
             edit(img, i, e) {
               // 新しく入力された写真を取得する
               let edit_image = e.target.files[0]

               // boxに追加
               let test = {
                 file_name: edit_image.name,
                 edit_id: img.id
               }
               this.box.push(test)

               // 新しく入力された写真をプレビュー表示させるための処理
               const reader = new FileReader();
               reader.onload = (e) => {
                   // プレビュー表示用
                   edit_image.thumbnail = e.target.result;
                   // 配列の置き換えも出来る。詳しくは https://ginpen.com/2018/12/07/array-splice-to-insert-replace/
                   this.images.splice(i, 1, edit_image);
               };
               // base64形式にエンコードされたURLに変換
               reader.readAsDataURL(edit_image);
             },
             remove(img, i, e) {
               // 削除するリストに追加
               this.remove_image_list.push(img.id)
               this.images.splice(i, 1);
             },
             send(e){

               this.errors = []

               if(!this.send_day){
                 this.errors.push("送信日を入力してください");
               }

               if(!this.content){
                 this.errors.push("メモを入力してください");
               }

               if(this.images.length === 0){
                 this.errors.push("写真を選択してください");
               } else if(this.images.length > 4){
                 this.errors.push("写真は4枚以内で選択してください");
               }

               e.preventDefault();
               

               // エラーが無かったら送信する
               if(this.errors.length === 0) {
                 dataform = new FormData();
                 dataform.append('send_day', this.send_day);
                 dataform.append('content', this.content);
                 dataform.append('remove_image_list', this.remove_image_list);

                 this.box.forEach(function(ele, i) {
                   let koko = JSON.stringify(ele)
                   dataform.append('box[]', koko);
                 })

                 // 以下の形にしないと配列で送れなくなってしまう
                 this.images.forEach(function(img) {
                   if(img.thumbnail) {
                     dataform.append('images[]', img);
                   } else {
                     return false;
                   }
                 })

                 // 現在編集中のmeのidを取得
                 let id = this.me_id
                 // 編集まで飛ぶパスを作成
                 var array = ["http://127.0.0.1:8000/me/", id, "/update"];
                 // パスをjoinで結合
                 let path = array.join('')
                 axios.post(path, dataform).then(res => {
                   console.log('back')
                   // vue-routerを使わない場合
                   window.location.href = "http://127.0.0.1:8000/post/index"
                 }).catch(function(error){
                   console.log(error)
                 })
               }
             }
           },
         })
</script>

大きく違うところはmounted()を使用してlaravelからbladeに渡されている変数たちをvueでも使えるようにしている。

もう1つは、axiosでlaravelに送る際のパスを作成しているところ。編集時はmeのidが必要になってくるから作っている。

編集画面はeditでもcreateでもboxに格納する様にしている。editが動いた場合はコントローラーで更新する値を取得出来る様にidを付与している。ここが1番苦労しましたw

コントローラーの設定。update。

public function edit($me) 
   {
       $user = Auth::user();
       $edit_me = Me::findOrFail($me);
       if($user->id === $edit_me->user_id){
           return view('me.edit', ['edit_me' => $edit_me]);
       }
   }

   public function update(CreateMe $request, $me)
   {
       $user = Auth::user();
       $edit_me = Me::findOrFail($me);

       if($user->id === $edit_me->user_id){
           $edit_me->send_day = $request->send_day;
           $edit_me->content = $request->content;
           $edit_me->update();

           $image_list = [];

           if($request->has('images')) {
               foreach($request->box as $ele) {

                   // json_decodeでjsonの形にデータを変形している
                   $test = json_decode($ele, true);

                   // file名を取得
                   $file_name = $test['file_name'];

                   // 編集されるimageのidを取得
                   $file_id = $test['edit_id'];
                   
                   // 編集されるimageのidを持っている場合。つまり更新処理。
                   if($file_id !== null) {
                       // 送られてきているfileオブジェクトをeachで回す。
                       foreach($request->images as $image) {

                           // file名取得
                           $name_file = $image->getClientOriginalName();

                           // 更新される予定のimageのカラムsrcを変更していく。それが更新される予定の$file_nameと一致しているかを確認
                           if($name_file === $file_name) {

                               // 更新される予定のimageを取得
                               $edit_image = Image::find($file_id);

                               // 新しく入ってきている写真fileで保存処理
                               $path = $image->store('public');
       
                               // srcカラムに保存するパスを作成
                               $read_temp_path = str_replace('public/', 'storage/', $path);
       
                               // 元からある写真のsrcに新しく作成したscrのパスを入れる
                               $edit_image->src = $read_temp_path;
                               array_push($image_list, $edit_image);
                           } else {
                               // 更新処理でない場合は普通に新規作成と同じ流れ
                               $img = new Image();
                               $path = $image->store('public');
                               $read_temp_path = str_replace('public/', 'storage/', $path);
                               $img->src = $read_temp_path;
                               array_push($image_list, $img);
                           }
                       }
                   }
               }
           }

           $list = $request->remove_image_list;
           $ee = explode(',', $list);
           foreach($ee as $id) {
               // 数字に変換している、数字以外だとエラーになるので注意。https://teratail.com/questions/276973
               $i = (int)$id;
               foreach($edit_me->images as $img) {
                   if($img->id === $i) {
                       $img->delete();
                   }
               }
           }

           $edit_me->images()->saveMany($image_list);
           return redirect()->route('post_index');
       }
   }

ここは複雑に見えますがやっていることはそこまでです。送られてきたboxの情報から更新処理なのか、新規作成処理なのかをedit_idの有無で判断。

更新処理の場合はboxの中に入っているedit_idと一致するimageを取得し、srcを更新している。

新規の場合はそのまま作成。

ざっとこんな流れです。

送られてくる情報が文字列なのでjson_decodeしたり、intしたりexplodeしたりしてるのはそーゆーことです。

つまづいたところ

1, 写真を複数枚選択出来る事

これは最初からinput要素が何個も表示されるのを防ぎたかったのでファイルが追加された際の処理をv-forを使ってそれぞれ別のものを表示するという部分。

こちらのサイトを参考に複数枚選択の部分を実装しました。

2, 複数枚保存

複数枚選択出来ても、どうしてかコントローラー側でstoreが出来ずという部分でした。

これはlaravelに送っている写真の情報がファイル名のみになってしまっているのが原因でした。vue側で配列の形かつ写真の情報(オブジェクト)を送ると正しく出来ました。

3, 写真の表示

php artisan storage:linkを実行しているのに写真が表示されずにハマりました。上記のコマンドが実行しているのは、storage/app/public/以下に保存される写真本体をpublic/storageからアクセスできるようにする。というイメージで理解しています。

$path = $image->store(‘public’);ここでどこに写真を保存しているのかなどを確認すると解決できました。

4, spliceが便利

editボタンの実装の際に役立ってくれたメソッドです。簡単にいうともともと配列の中にあった値を、交換したい値と置き換えてくれます。置換です。

このメソッドに出会えたのは大きい発見でした。

5, joinが便利

更新作業の時にvueからupdateに飛ばす際にmeのidが必要な部分で見つけました。パスを配列から作ってしまえたので、指定が楽になりました。

あとはめんどくさがってvue-routerを使わない場合の処理後の動きを下記で実装しました。
window.location.href = “http://127.0.0.1:8000/post/index”

6, fileオブジェクトを送ろうとするとobject Objectになる問題

最後にして1番苦労した点かもですw編集処理で編集と新規が混ざると写真が表示された時に順番が変わってしまうと言う部分でした。

これを防ぐには編集されるimageファイルと編集されるimageのidを一緒に送ってやらないといけないと言うとこでした。

そこで一緒に送ろうとするとずっとobject Objectでした。原因はここでした。fileオブジェクトは他の情報と一緒に送れないみたいです。あとは文字列として送られるのでコントローラーでjson_decodeなどが必要になってくるって感じです。

まとめ

1番苦労したのは、更新処理の部分でした。fileオブジェクトに苦しめられましたがなんとか解決でき動くコードがかけました。

やっぱり自己解決できると楽しいですねw

もしこんな書き方の方が良いんじゃないかとかありましたら教えていただけると助かります。

次は個人開発で釣りが好きなのでそれ系のなんか作れたら良いなと。w

今回はそんな感じで!

以上!