バージョン
Laravel : 5.8
Vue.js : 2.5
Vuetify : 1.5
作る画面
サーバーサイドにLaravel、フロントエンドにVue.js、デザインにVuetifyを使用して検索つきのページネーションを作ります。
データは何でもいいので、本を検索することにしました。
このような画面が出来上がります。
booksテーブルはtitle(タイトル)とpublishing_year(発行年)だけのシンプルなテーブルです。
データはLaravelのFakerとSeederで500件用意しました。
Vuetifyとは
簡単に言うとCSSを使わずに、独自のHTMLタグでデザインを完成させてしまえる優れものです。
その他にもVue.jsで使えるマテリアルデザインコンポーネントはQuasarやBootstrapVueもあるみたいですが、何となくVuetifyが良さげな雰囲気を出しているので採用しました。
https://vuetifyjs.com/ja/
インストール
まずはVue.jsとVuetifyを使う上で必要なライブラリをインストールします。
前述の通りVuetifyは1系を使います。
npm install vue-router vuex vuetify@1 css-loader material-design-icons-iconfont vuex-persistedstate
ディレクトリ構成
主なファイルの配置です。
LaravelとVue.jsの一般的な構造なので特に問題ないと思います。
├── app
│ ├── Http
│ │ └── Controller
│ │ └── BookController.php
│ └── Book.php
├── resources
│ └── js
│ ├── components
│ │ ├── Book.vue
│ │ ├── BookList.vue
│ │ └── SearchArea.vue
│ ├── app.js
│ ├── bootstrap.js
│ ├── router.js
│ ├── store.js
│ ├── util.js
│ └── App.vue
└── routes
├── api.php
└── web.php
余談ですがresources/js/componentsにBook.vueとBookList.vueがあるのが腑に落ちない方はAtomic Designがピッタリです。
https://uxdaystokyo.com/articles/glossary/atomic-design/
ルーティング
ここからはコードをどんどん載せていきます。
<?php
Route::get('/{any?}', function () {
return view('index');
})->where('any', '.+');
画面の変化はJavaScriptで行うことになるので、どのURLでもindex.blade.php
を呼びます。
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>vuetify pagination</title>
<script src="{{ mix('js/app.js') }}" defer></script>
</head>
<body>
<div id="app"></div>
</body>
</html>
Laravel Mixでコンパイルしたapp.jsを読み込みます。
VuetifyがあるのでCSSは使いません。
const mix = require("laravel-mix");
mix.js("resources/js/app.js", "public/js")
.version();
webpack.mix.jsはこれだけです。
ここでもCSSは不要です。
<?php
Route::get('/books', 'BookController@index')->name('books');
apiのルーティングは本を検索するためのものです。
JavaScript
import "./bootstrap";
import Vue from "vue";
import Vuetify from "vuetify";
Vue.use(Vuetify);
import "vuetify/dist/vuetify.min.css";
import "material-design-icons-iconfont/dist/material-design-icons.css";
import router from "./router";
import store from "./store";
import App from "./App.vue";
new Vue({
el: "#app",
router,
store,
components: { App },
template: "<App />"
});
app.jsにVuetifyを使用するための記載をします。
import Vue from "vue";
import VueRouter from "vue-router";
import BookList from "./components/BookList.vue";
Vue.use(VueRouter);
const routes = [
{
path: "/",
component: BookList
}
];
const router = new VueRouter({
mode: "history",
routes
});
export default router;
router.jsでルートパスにアクセスしたらBookList.vueが呼ばれるように設定します。
import Vue from "vue";
import Vuex from "vuex";
import createPersistedState from "vuex-persistedstate";
Vue.use(Vuex);
const store = new Vuex.Store({
plugins: [createPersistedState()]
});
export default store;
store.jsのvuex-persistedstateはブラウザをリロードしてもVuexのストアを保持してくれるもので、ログイン認証の永続化でよく使われるみたいです。
今回は特に使いませんが便利なので載せときましょう。
import { getCookieValue } from "./util";
window.axios = require("axios");
// Ajaxリクエストであることを示すヘッダーを付与する
window.axios.defaults.headers.common["X-Requested-With"] = "XMLHttpRequest";
window.axios.interceptors.request.use(config => {
// クッキーからトークンを取り出してヘッダーに添付する
config.headers["X-XSRF-TOKEN"] = getCookieValue("XSRF-TOKEN");
return config;
});
window.axios.interceptors.response.use(
response => response,
error => error.response || error
);
/**
* クッキーの値を取得する
* @param {String} searchKey 検索するキー
* @returns {String} キーに対応する値
*/
export function getCookieValue(searchKey) {
if (typeof searchKey === "undefined") {
return "";
}
let val = "";
document.cookie.split(";").forEach(cookie => {
const [key, value] = cookie.split("=");
if (key === searchKey) {
return (val = value);
}
});
return val;
}
bootstrap.jsはとutil.jsはCSRF対策のためのトークンをクッキーから取り出して、リクエストに含める処理です。
こちらのサイトを参考にさせてもらっています。
https://www.hypertextcandy.com/vue-laravel-tutorial-authentication-part-3/
素晴らしいサイトでかなりお世話になりました!
Vue
<template>
<v-app>
<v-content tag="div">
<v-container fluid>
<RouterView />
</v-container>
</v-content>
</v-app>
</template>
やっとVuetifyのタグが登場しました。
<v-app>
で囲む必要があります。
公式を見れば便利なタグがたくさんあるので、こちらを参考に。
https://vuetifyjs.com/ja/components/paginations
<template>
<div>
<search-area @search="searchBooks($event)"></search-area>
<v-layout wrap>
<v-flex sm4 pa-2>{{from}}〜{{to}}件 / {{total}}件</v-flex>
<v-flex sm8>
<v-layout justify-end>
<v-pagination v-model="page" :length="length" :total-visible="10"></v-pagination>
</v-layout>
</v-flex>
</v-layout>
<v-layout wrap>
<v-flex sm6 pa-2 v-for="(book, key, index) in books" :key="index">
<book :title="book.title" :publishingYear="book.publishing_year"></book>
</v-flex>
</v-layout>
</div>
</template>
<script>
// Book.vueとSearchArea.vue(検索エリア)を読み込みます。
import Book from "../components/Book.vue";
import SearchArea from "../components/SearchArea.vue";
export default {
data() {
return {
books: [], // 一覧データ
page: 1, // 表示中のページ(v-paginationにバインド)
length: 0, // ページネーションのリンクの数(v-paginationのprops)
urlParams: "",// 検索パラメータ
from: "", // 一覧の開始
to: "", // 一覧の終了
total: "" // 件数
};
},
methods: {
// 検索ボタンをクリックしたら呼ばれる
async searchBooks(params) {
// 検索パラメータをURLに付与してapiを叩く
this.urlParams = params;
let url = "/api/books?page=" + this.page + "&" + this.urlParams;
const response = await axios.get(url);
// 戻り値をデータに代入すれば表示が変わってくれます
let books = response.data.data;
this.books = books;
this.length = response.data.last_page;
this.from = response.data.from;
this.to = response.data.to;
this.total = response.data.total;
}
},
mounted() {
// 初期表示
this.searchBooks(this.urlParams);
},
watch: {
// ページネーションのリンクをクリックするとpageが変わる。
// pageを監視して、変更されたらsearchBooksを実行
page: function(newPage) {
this.searchBooks(this.urlParams);
}
},
components: {
Book,
SearchArea
}
};
</script>
BookList.Vueが今回の肝になるファイルで、ページネーションの処理はここで扱います。
pageというVue.jsで保持しているデータがトリガーになっていて、pageが変更されたら本を検索するapiが発火して、booksというデータが変更され、画面の表示が変わるという流れです。
通常のマルチページアプリケーションではページネーションのリンクのURLに直接遷移することが多いので、一番の違いはここではないでしょうか。
<template>
<v-card class="indigo lighten-5">
<v-card-title class="pa-1">タイトル:{{title}}</v-card-title>
<v-card-text class="pa-1">発行年:{{publishingYear}}</v-card-text>
</v-card>
</template>
<script>
export default {
props: {
title: {
type: String,
required: true
},
publishingYear: {
type: Number,
required: true
}
}
};
</script>
Book.vueはpropsにタイトルの発行年があるだけで簡単です。
<v-card>
のclassは色をつけるためのもで、Vuetifyが提供してくれます。
たくさんあるので、これだけあれば困ることはないでしょう。
https://vuetifyjs.com/ja/styles/colors
<template>
<div>
<v-layout wrap>
<v-flex sm4 pa-2>
<v-text-field v-model="searchForm.title" label="タイトル"></v-text-field>
</v-flex>
<v-flex sm4 pa-2>
<v-text-field v-model="searchForm.publishing_year" label="発行年"></v-text-field>
</v-flex>
<v-flex pa-2>
<v-btn @click="clickHandler">検索</v-btn>
</v-flex>
</v-layout>
</div>
</template>
<script>
const querystring = require("querystring");
export default {
data() {
return {
searchForm: {
title: "",
publishing_year: ""
}
};
},
methods: {
clickHandler() {
let params = querystring.encode(this.searchForm);
this.$emit("search", params);
}
}
};
</script>
イベント名をsearch、引数をparams(検索パラメータ)にして検索ボタンにクリックイベントを定義しています。
paramsは検索項目に入力されている値をquerystringで文字列に変換してます。
title=A&publishing_year=2018
みたいな文字列になります。
PHP
Laravel側で本を検索する処理を作ります。
<?php
namespace App;
use Illuminate\Database\Eloquent\Model;
class Book extends Model
{
}
Modelはあれば良いので中身は空です。
<?php
namespace App\Http\Controllers;
use App\Book;
use Illuminate\Http\Request;
class BookController extends Controller
{
public function index(Request $request)
{
$per_page = 5; // 1ページあたりの件数
$input = $request->all();
$books = Book::select('id', 'title', 'publishing_year');
if (!empty($input['publishing_year'])) {
$books = $books->where('publishing_year', $input['publishing_year']);
}
if (!empty($input['title'])) {
$books = $books->where('title', 'LIKE', "%{$input['title']}%");
}
$books = $books->paginate($per_page);
return response()->json($books);
}
}
検索してます。それだけですw
一応これで完成です!めでたし!!