vue.jsを使ってシングルページアプリケーション(SPA)を作るためにはHTMLのtable要素に表示されたセルの中の値の更新を別ページに移動することなく更新したいものです。

ページを移動することなく更新する方法にはモーダルを利用する方法もあります。

Vue.jsの入門者の方にもわかるようにvue.jsを利用したシンプルな方法で更新可能なセルの設定方法を説明しています。セルを更新するvue.js側のファイルはcdnを使用するためVueのプロジェクトの作成など特別な環境は必要がありませんが、テーブルデータを取得するために外部リソースを準備する必要があります。

バックエンドサーバについて

vue.jsを使ってテーブルを描写する際、通常はテーブルのデータはデータベースや外部サービス上に保存されているため外部リソースからデータを取得する必要があります。

本文書では外部リソースであるバックエンドサーバにLaravel8を利用しています。vue.jsからのデータ取得/更新/削除処理を受けつけることができればバックエンドがLaravelである必要はありません。Laravelを利用していない/利用しない場合は、この章をスキップして次の章に進んでください。

バックエンドにLaravelを利用していますが、フロントエンドの画面描写にはLaravelを利用していません。

テーブルについて

Laravel上にusersテーブルを作成します。usersテーブルはユーザ名とEmailの2つの列で含まれています。Laravelのマイグレーションファイルの構成をそのまま利用します。

簡易的にデータベースを作成できるのでsqliteデータベースを利用します。Laravelのプロジェクトフォルダの下にあるdatabaseフォルダにdatabase.sqliteファイルを作成します。


 % touch database/database.sqlite

sqliteデータベースへ接続するために.envファイルの環境変数DB_CONNECTIONをmysqlからsqliteに変更します。その他のDB_*がついている環境変数を削除します。

テーブルを作成するためにphp artisan migrateコマンドを実行します。


 % php artisan migrate
Migration table created successfully.
Migrating: 2014_10_12_000000_create_users_table
Migrated:  2014_10_12_000000_create_users_table (3.55ms)
Migrating: 2014_10_12_100000_create_password_resets_table
Migrated:  2014_10_12_100000_create_password_resets_table (1.70ms)
Migrating: 2019_08_19_000000_create_failed_jobs_table
Migrated:  2019_08_19_000000_create_failed_jobs_table (1.64ms)

テーブルにダミーデータを挿入します。LaravelのSeeding機能を利用して一括でダミーを挿入しますがデータがデフォルトでは英語なので日本語に変更するためにconfig¥appのfaker_localeをja_JPに変更します。


'faker_locale' => 'ja_JP',

database¥seedersフォルダにあるDatabaseSeeder.phpファイルを開いてコメントを削除してください。


class DatabaseSeeder extends Seeder
{
    public function run()
    {
        \App\Models\User::factory(10)->create();
    }
}

factoryメソッドの10という値を変更することで作成するダミーデータの数を変更することができます。

Seedingを行う設定が完了したら下記のコマンドを実行してください。10件のダミーデータがusersテーブルに挿入されます。


 % php artisan db:seed

本当にデータが挿入されているかはtinkerを利用することができます。php artisan tinkerを実行後に$user = App\Models\User::all();を実行すると10件分のデータが表示されます。

ルーティングについて

vue.jsからaxiosを使用してユーザ情報の取得、更新、削除を行います。そのためにapi.webファイルに以下のルーティングを追加しています。


Route::get('users',function(){
	return App\Models\User::all();
});

Route::delete('users/{id}',function($id){

	$user = App\Models\User::find($id);

	$user->delete();

	return response()->json([
        'success' => 'user deleted successfully!'
    ]);

});

Route::patch('users/{id}',function($id,Request $request){

	$user = App\Models\::find($id);

	$user->update($request->all());

	return $user;

});

index.htmlファイルの作成

利用するvue.js、axios、bootstrapはすべてcdnを経由して読み込みます。

scriptタグに記述するコード

HTMLに表示させるテーブルの内容はusersテーブルに保存されたデータを利用して行います。usersテーブルはname, email列を持っています。

テーブルのデータはaxiosを通してvue.jsのライフサイクルフックのmountedで取得します。UserTableという名前のコンポーネントを利用して、v-forでtr要素を作成します。

vue.jsのライフサイクルフックの一つであるmountedフックを利用しているので、JavaScriptを実行するページが開かれるとaxiosを使ってバックエンドからusersテーブルの内容を取得します。

index.htmlファイルを作成して、scriptタグに以下を記述します。コンポーネントuser-tableを追加しています。


<script>
Vue.component('user-table',{
  template : `<tr><td>{{ user.id }}</td>
                 <td>{{ user.name }}</td>
                 <td>{{ user.email }}</td>
                 <td><button class="btn btn-danger">削除</button></td>
                 </tr>`,
props : ['user','index']
});


var app = new Vue({
  el: '#app',
  data: {
    users: []
  },
  mounted: function(){
    axios.get('http://127.0.0.1:8000/api/users').then(response => this.users = response.data);
  }
})
</script> 

HTML側に記述するコード

HTMLは下記のように記述します。scriptタグでvue.jsとaxios、linkタグでbootstrapを読み込みます。

bootstrapは必須ではなくテーブルに装飾を行うために使用します。

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>Vue.js</title>

<script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css">
</head>
<body>
<div id="app">
    <table class="table table-bordered">
        <thead  class="thead-dark">
            <tr>
                <th>ID</th>
                <th>ユーザ名</th>
                <th>E-mail</th>
                <th>削除</th>
            </tr>
        </thead>
        <tbody>
          <tr is="user-table" v-for="(user,index) in users" v-bind:user="user" v-bind:key="user.id" v-bind:index="index"></tr>
        </tbody>
    </table>
</div>
<script>
//上記のVueのコードを記述
</script> 

ここまでの記述では特別な処理は行わずv-forを使ってaxiosから取得したusersデータを展開して、テーブルとして表示しているだけです。

ブラウザで確認すると下記のように表示されます。削除ボタンが付いていますが、何も設定されていないのでクリックしても変化はありません。

ユーザ一覧
ユーザ一覧

コンポーネントの記述について

HTML側ではコンポーネントを以下のように記述しています。


<tr is="user-table" v-for="(user,index) in users" v-bind:user="user" v-bind:key="user.id" v-bind:index="index"></tr>
is属性はvue.jsの公式マニュアルのComponents BasicsのDOM Template Parsing Caveatsを参考に行なっています。

コンポーネントは<user-table></user-table>タグで記述すると表示が正常に行われないので注意してください。


<user-table v-for="(user,index) in users" v-bind:user="user" v-bind:key="user.id" v-bind:index="index"></user-table>

下記のように崩れたテーブルとして描写されます。

崩れたテーブルの描写
崩れたテーブルの描写

テーブルの列の削除

子コンポーネントuser-tableの設定

最初に削除の機能を追加します。

user-tableコンポーネントのbutton要素にv-on:clickイベントと追加し、クリックを行うと行の番号であるindexとuserのidをdeleteRowメソッドに渡します。


template : `<tr><td>{{ user.id }}</td>
             <td>{{ user.name }}</td>
             <td>{{ user.email }}</td>
             <td><button class="btn btn-danger" v-on:click="deleteRow(user.id,index)">削除</button></td>
             </tr>`,

deleteRowメソッド内ではaxiosによりその行のdelete処理を行い、$emitメソッドを使って親コンポーネントに行が削除されたことを伝えます。


Vue.component('user-table', {
//略
 props: ['user', 'index'],
 methods: {
     deleteRow : function(id,index){
         axios.delete('http://127.0.0.1:8000/api/users/' + id)
         .then((response) => {
             this.$emit('from-child',index)
         }).catch((error) =>{
             console.log(error)
         })
     },
 },
//略

親側のdelete処理

親側ではUserTableコンポーネントから送られてくるイベントfrom-childを受け取ってdeleteRowを実行します。このdeleteRowメソッドにより、描写されているテーブル上の削除ボタンを押された行が画面上から削除されます。

UserTableコンポーネントと親側のdeleteRowは名前は一緒ですが関連は全くありません。

<tr is="user-table" v-for="(user,index) in users" v-bind:user="user" v-bind:key="user.id" v-bind:index="index" v-on:from-child="deleteRow"></tr>

親側のdeleteRowで行の削除を行います。削除機能の作業は完了です。


methods: {
	deleteRow : function(index){
    this.$delete(this.users,index)
	}
},
this.$delete(this.user,index)はthis.users.splice(index,1)と同じ意味で、this.usersのindex番目の要素の削除を行います。

テーブルのtd要素の更新

ここからの処理はuser-tableコンポーネントの中身を更新することで行います。

tdをクリックするとinput要素に変える

td要素をダブルクリックするとinput要素に変更する機能を追加します。

input要素の表示・非表示はv-ifディレクティブを使用します。v-ifディレクティブを切り替えるための値として、ユーザ名用にisEditName、email用にisEditEmailという2つのデータプロパティを追加し、初期値はfalseとします。


data: function(){
    return {
      isEditName : false,
      isEditEmail : false
    }
}

v-ifとisEditNameを組み合わせるとisEditNameがfalseの場合は通常の表示、isEditNameがtrueの場合は、input要素が表示されるようにします。


<td v-if="!isEditName">{{ user.name }}</td>
<td v-else><input type="text" class="form-control" v-bind:value="user.name"></td>

v-ifによってinput要素の非表示・表示を行うためには、isEditNameをfalseからtrueに切り替える仕組みを入れる必要があります。そのために利用するのがdoubleclickイベントです。文字列が入ったtd要素をダブルクリックするとisEditNameがfalseからtrueになりinput要素が表示されます。


<td v-if="!isEditName" v-on:dblclick="isEditName = true">{{ user.name }}</td>
<td v-else><input type="text" class="form-control" v-bind:value="user.name"></td>

先頭のユーザ名をダブルクリックするとinput要素に変わります。

ダブルクリックでinput要素表示
ダブルクリックでinput要素表示

input要素を元の状態に戻す

td要素をダブルクリックをするとinput要素に変わりました。input要素の中身を更新した後にはinput要素から元の状態に戻す必要があります。元の状態に戻すためblurイベントを利用します。input要素にblurイベントを入れることでダブルクリックでinput要素にしたあと、一度input要素にカーソルを合わせて外すとisEditNameがfalseになります。isEditNameがfalseになるのでv-ifディレクティブによりinput要素から元の状態に戻ります。


<td v-if="!isEditName" v-on:dblclick="isEditName = true">{{ user.name }}</td>
<td v-else><input type="text" class="form-control" v-bind:value="user.name" v-on:blur="isEditName=false" ></td>

input要素の更新を反映させる

input要素の切り替えができるようになったので、最後は更新した内容をデータベースに反映させるための処理が必要になります。先程設定したblurイベントを利用しますが、今回は新規のメソッドを追加します。

メソッドの名前はupdateCellとして、変更を行ったuser.idとinput要素の中に入力されている値を取得するために$eventを渡します。


<td v-if="!isEditName" v-on:dblclick="isEditName = true">{{ user.name }}</td>
<td v-else><input type="text" class="form-control" v-bind:value="user.name" v-on:blur="updateName($event, user.id)" ></td>

axiosでサーバに対して変更のpatchリクエストを送ります。リクエストが成功した時のみisEditNameの値をfalseにし変更した値を親コンポーネントに渡すためemitを設定します。

サーバ側の処理はここでは説明しませんが、それぞれの環境に合わせた処理を追加してください。

updateName : function(event, id){ 
    axios.patch('http://127.0.0.1:8000/api/users/' + id, {id : id, name: event.target.name})
    .then((response) => {
        this.isEditName = false
        this.$emit('update-cell', response.data);
     }).catch((error) =>{
        console.log(error)
      })
},

親コンポーネントではupdate-cellイベントを受け取れる設定を行い、受け取ったらupdateCellメソッドを実行します。


<tr
  is="user-table"
  v-for="(user,index) in users"
  v-bind:user="user"
  v-bind:key="user.id"
  v-bind:index="index"
  v-on:from-child="deleteRow"
  v-on:update-cell="updateCell"
></tr>

findメソッドを見つけて更新されたユーザと同じidを持つユーザ情報を取得して更新を行なっています。


updateCell(updateUser) {
  const user = this.users.find((user) => {
    return user.id === updateUser.id;
  });
  Object.assign(user, updateUser);
},

通常ではサーバ側との通信の障害やバグまたValidationによってサーバ側での更新が行われない場合も考えられます。成功した場合には成功メッセージ、失敗した場合にはエラーメッセージを表示する機能をつける必要があります。

実際に更新を行って反映されるか確認しましょう。ユーザIDの1のユーザの名前を山岸から山本に変更します。変更後input要素からカーソルを外すとサーバへの更新が行われます。

セルの更新を行う
セルの更新を行う

axiosを使って戻される値で更新されたかどうか判断する必要がありますが、ブラウザを再読み込みして更新データが表示されれば問題なくデータ更新は反映されています。

emailにも同じ設定

E-mail列の情報も書き換えができるように下記のように設定を行います。


<td v-else><input type="text" class="form-control" v-bind:value="user.email" v-on:blur="updateEmail($event,user.id)"></td>
<td><button class="btn btn-danger" v-on:click="deleteRow(user.id,index)">削除</button></td>

email用のupdateEmailも追加します。


updateEmail: function (event, id) {
  axios
    .patch('http://127.0.0.1:8000/api/users/' + id, {
      id: id,
      email: event.target.value,
    })
    .then((response) => {
      this.isEditEmail = false;
      this.$emit('update-cell', response.data);
    })
    .catch((error) => {
      console.log(error);
    });
},

これでnameとemailのセルをダブルクリックすると変更ができるようになりました。

フォーカス設定

nameとemailを更新することができるようになりましたが、名前をダブルクリックしてinput要素にフォーカスしなければemailをダブルクリックした場合に一度に二つのinput要素が表示されます。

ダブルクリックする時に自動でinput要素にフォーカスさせるためにrefを利用します。nameのinput要素にref=”name”、emailのinput要素にref=”email”を設定してください。


<input type="text" class="form-control" v-bind:value="user.name" v-on:blur="updateName($event,user.id)" ref="name">
<input type="text" class="form-control" v-bind:value="user.email" v-on:blur="updateEmail($event,user.id) ref="email">

ダブルクリックした後にフォーカスできるようにダブルクリックイベントの値をinputNameメソッドに変更します。


<td v-if="!isEditName" v-on:dblclick="inputName" >{{ user.name }}</td>

inputNameメソッドを追加してrefを使って要素に直接アクセスして要素にフォーカスさせます。


inputName: function () {
  this.isEditName = true;
  this.$nextTick(() => {
    this.$refs.name.focus();
  });
},

nextTickを利用せずそのまま記述するとisEditNameをtrueに設定していますがまだinput要素がブラウザ上に表示されていないためフォーカスすることはできません。nextTickでinput要素が描写された後にフォーカスを行なっています。

emailの方も同様にinputEmailを設定してください。


inputEmail: function () {
  this.isEditEmail = true;
  this.$nextTick(() => {
    this.$refs.email.focus();
  });
},

ダブルクリックすると自動でフォーカスが行われるので2つのinput要素が一度に表示されることはなくなります。

今回記述したコード

下記が今回作成したindex.htmlファイルの全体コードです。


<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <title>Vue.js</title>

    <script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
    <script src="https://unpkg.com/axios/dist/axios.min.js"></script>
    <link
      rel="stylesheet"
      href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css"
    />
  </head>
  <body>
    <div id="app">
      <table class="table table-bordered">
        <thead class="thead-dark">
          <tr>
            <th>ID</th>
            <th>ユーザ名</th>
            <th>E-mail</th>
            <th>削除</th>
          </tr>
        </thead>
        <tbody>
          <tr
            is="user-table"
            v-for="(user,index) in users"
            v-bind:user="user"
            v-bind:key="user.id"
            v-bind:index="index"
            v-on:from-child="deleteRow"
            v-on:update-cell="updateCell"
          ></tr>
        </tbody>
      </table>
    </div>
    <script>
      Vue.component('user-table', {
        template: `<tr><td>{{ user.id }}</td>
                    <td v-if="!isEditName" v-on:dblclick="inputName" >{{ user.name }}</td>
                    <td v-else><input type="text" class="form-control" v-bind:value="user.name" v-on:blur="updateName($event,user.id)" ref="name"></td>
                    <td v-if="!isEditEmail" v-on:dblclick="inputEmail">{{ user.email }}</td>
                    <td v-else><input type="text" class="form-control" v-bind:value="user.email" v-on:blur="updateEmail($event,user.id)" ref="email"></td>
                   <td><button class="btn btn-danger" v-on:click="deleteRow(user.id,index)">削除</button></td>
                   </tr>`,
        props: ['user', 'index'],
        data() {
          return {
            isEditName: false,
            isEditEmail: false,
          };
        },
        methods: {
          inputName: function () {
            this.isEditName = true;
            this.$nextTick(() => {
              this.$refs.name.focus();
            });
          },
          inputEmail: function () {
            this.isEditEmail = true;
            this.$nextTick(() => {
              this.$refs.email.focus();
            });
          },
          updateName: function (event, id) {
            axios
              .patch('http://127.0.0.1:8000/api/users/' + id, {
                id: id,
                name: event.target.value,
              })
              .then((response) => {
                this.isEditName = false;
                this.$emit('update-cell', response.data);
              })
              .catch((error) => {
                console.log(error);
              });
          },
          updateEmail: function (event, id) {
            axios
              .patch('http://127.0.0.1:8000/api/users/' + id, {
                id: id,
                email: event.target.value,
              })
              .then((response) => {
                this.isEditEmail = false;
                this.$emit('update-cell', response.data);
              })
              .catch((error) => {
                console.log(error);
              });
          },
          deleteRow: function (id, index) {
            axios
              .delete('http://127.0.0.1:8000/api/users/' + id)
              .then((response) => {
                this.$emit('from-child', index);
              })
              .catch((error) => {
                console.log(error);
              });
          },
        },
      });

      var app = new Vue({
        el: '#app',
        data: {
          users: [],
        },
        methods: {
          updateCell(updateUser) {
            const user = this.users.find((user) => {
              return user.id === updateUser.id;
            });
            Object.assign(user, updateUser);
          },
          deleteRow: function (index) {
            this.$delete(this.users, index);
          },
        },
        mounted: function () {
          axios
            .get('http://127.0.0.1:8000/api/users')
            .then((response) => (this.users = response.data));
        },
      });
    </script>
  </body>
</html>

【付録】CORSの設定について

今回はLaravelの外部にファイルを作成し、そのファイルからaxiosを使ってLaravelにリクエストを送ります。そのため、CORS(Cross Origin Resource Sharing)の設定を行なっていないと以下のエラーが表示され、外部のファイルからLaravelにアクセスすることができません。

Access to XMLHttpRequest at ‘http://127.0.0.1:8000/api/users’ from origin ‘http://localhost:8080’ has been blocked by CORS policy: No ‘Access-Control-Allow-Origin’ header is present on the requested resource.

ミドルウェアのCorsを作成します。


 $ php artisan make:middleware Cors
Middleware created successfully.

実行するとapp¥Http¥MiddlwareにCors.phpファイルが作成されます。$next($request)に3つの->header()を追加してください。


namespace App\Http\Middleware;

use Closure;

class Cors
{
  
    public function handle($request, Closure $next)
    {
        return $next($request)
        ->header('Access-Control-Allow-Origin','*')
        ->header('Access-Control-Allow-Headers','*')
        ->header('Access-Control-Allow-Methods','*');
    }
}

app¥Http¥Kernel.phpに作成したCorsミドルウェアを追加します。


protected $middleware = [
    \App\Http\Middleware\TrustProxies::class,
    \App\Http\Middleware\CheckForMaintenanceMode::class,
    \Illuminate\Foundation\Http\Middleware\ValidatePostSize::class,
    \App\Http\Middleware\TrimStrings::class,
    \Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull::class,
    \App\Http\Middleware\Cors::class, //追加
];

ここまでの準備が整ったら、php artisan serverでLaravelの開発サーバを起動します。


$ php artisan serve
Laravel development server started: <http://127.0.0.1:8000>
axiosではhttp://127.0.0.1:8000に向けてリクエストを送ります。