目次
やりたいこと
データベースを使用しない、簡易的ないいね機能を実装(各ブラウザのlocalStorageを使用する)
今回はキャラクター一覧・詳細画面で、いいねを付ける機能をサンプルとして実装します。
※キャラクターは固定表示で、キャラクターの登録編集削除部分は実装せず、いいねの部分だけになります。
Local Storageとは?
ブラウザ(ChromeやSafariなど)にデータを保存できる仕組み
よくECサイトで、会員登録をしていないのにもかかわらず、「最近閲覧した商品」「お気に入り商品」などが表示されているのはlocalStorageを使用している為。
データベースを使用せず、localStorageを使用するメリット
会員登録をする必要が無い
データベースを使用せず、localStorageを使用するデメリット
localStorageを使用してブラウザに保存する為、キャッシュクリアや、別端末、別ブラウザで閲覧されるとお気に入りを共有できない
- お気に入りの状態によって、表示するアイコンの色を変える
- 画面遷移をしても、お気に入りの状態が正しい事(ブラウザバック・一覧へ戻るボタン押下)
Laravel:10.3.3
Vue:4.1.0
vue-router:4.1.6
デモ
Vue.js
LaravelでVue.jsを取り入れる方法は、主に以下の2種類の使い方が可能です。
今回は、VueのCLIツールを使用する方法で実装します。
ビルドの複雑な処理の一部 (Babel や Webpack の使用など) を実行する、
グローバルにインストールされる npm パッケージ
Node.js、ソースのビルドが必要です。
Vue.jsの記事でよく見るexport default
は、CLIツールを使用しています。
CDNとは以下のような形で外部から読み込んで使う形の事です。
CLIツールを使用するより簡単に使用することが出来、使いやすいと思います。
<script src="https://cdn.jsdelivr.net/npm/vue@2.6.10/dist/vue.js"></script>
CDNの方が始めるのにはお手軽な感じがしますが、CDNを使用した実装は以下記事にまとめています。
サンプルソース
githubの「localstorage-vue-cli」ブランチにアップしています。
大まかな流れ
- Vite(CLIツール)の導入と設定
- vue-routerの導入
- Laravelのルーティング設定
- ベースとなるテンプレートの作成
- 表示するデータを用意(今回は固定指定なので、データのファイルを用意)
- 一覧画面・詳細画面を用意
- お気に入りのvueコンポーネントを作る
1)Vite(CLIツール)の導入と設定
※CLIツールを使用する為に、Node.jsのインストールが必要になります。
導入・設定は以下の参考サイトを参考にしました。
フォルダ構成や紹介されていないコンポーネントはgitのソースをご確認ください。
2)vue-routerの導入
導入・設定は以下の参考サイトを参考にしました。
最終的なrouterの設定
import { createRouter, createWebHistory } from 'vue-router';
import List from '../page/List.vue'
import Detail from '../page/Detail.vue'
import NotFound from "../page/NotFound.vue";
const routes = [
{
path: '/',
name: 'List',
component: List
},
{
path: '/:id',
name: 'Detail',
component: Detail,
props: true
},
{
path: '/:pathMatch(.*)',
name: 'NotFound',
component: NotFound
}
];
const router = createRouter({
history: createWebHistory(),
routes,
});
export default router;
3)Laravelのルーティング設定
どんなアクセスがあってもベースのレイアウトを返すという内容です。
Route::any('/{id?}', function ($id = null) {
return view('layouts/app', ['id' => $id]);
});
4)ベースとなるテンプレートの作成
web.phpで設定したviewファイル
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex,nofollow">
<title>{{ config('app.name') }}</title>
<!-- Fonts -->
<link href="https://use.fontawesome.com/releases/v5.6.1/css/all.css" rel="stylesheet">
<!-- Styles -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC" crossorigin="anonymous">
@vite(['resources/css/app.css', 'resources/js/app.js'])
</head>
<body>
<div id="app"></div>
<!-- Scripts -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/js/bootstrap.bundle.min.js" integrity="sha384-MrcW6ZMFYlzcLA8Nl+NtUVF0sA7MsXsP1UyJoMp4YLEuNSfAP+JcXn/tWtIaxVXM" crossorigin="anonymous"></script>
</body>
</html>
app.jsの設定
import './bootstrap';
import { createApp } from "vue";
import App from "./App.vue";
import router from "./router";
const app = createApp(App);
app.use(router);
app.mount("#app");
app.jsで設定した、mountするApp.vueの内容
<template>
<div>
<Header></Header>
<router-view></router-view>
</div>
</template>
<script>
import Header from "./components/Header.vue";
export default {
name: "App.vue",
components: {Header},
}
</script>
<style scoped>
</style>
5)表示するデータを用意(今回は固定指定なので、データのファイルを用意)
// 表示するデータ
[
{ "id": 1, "name" : "ひよこ隊長", "image" : "hiyoko.png", "description" : "夏が大好きなひよこ隊長。<br>夏はよくフェスに行き、夏を堪能している。" },
{ "id": 2, "name" : "フクロウ先生", "image" : "fukurou-sensei.png", "description" : "森の学校のフクロウ先生。<br>最近物忘れすることを本人は気にしている。" },
{ "id": 3, "name" : "パンダ氏", "image" : "panda.png", "description" : "パンダ財閥の次男。<br>蝶ネクタイがトレードマーク。" },
{ "id": 4, "name" : "おしゃべりオウム", "image" : "oumu.png", "description" : "おしゃべり大好きオウム君。<br>おしゃべりしすぎて、フクロウ先生に怒られる事が多い。" }
]
6)一覧画面・詳細画面を用意
作成したお気に入りコンポーネントを、一覧画面・詳細画面で<like></like>
タグで読み込んでいます。
お気に入りコンポーネントのvueの処理中に、idを使用するので:id="item.id"
でidを渡しています。
一覧画面
// 一覧画面
<template>
<div class="page bg-dots" id="pageList">
<PageHeader></PageHeader>
<div class="container mt-5">
<div class="row">
<div v-for="item in items" :key="item.id" class="col-12 col-md-6 mb-3">
<div @click="pushPage(item)" class="card characterCard p-4">
<!-- いいねアイコン -->
<Like :id="item.id" @click="noPushPage($event)"></Like>
<div class="d-flex mt-2">
<!-- 画像 -->
<div>
<img :src="imagePath(item.image)" :alt="item.name">
</div>
<!-- 説明 -->
<div class="ps-3">
<h2>{{ item.name }}</h2>
<p v-html="item.description" class="mt-2 text-sm text-muted"></p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import axios from 'axios';
import Like from "../components/Like.vue";
import PageHeader from "../components/PageHeader.vue";
import {characterImagePath} from "../const";
import router from "../router";
export default {
name: "List",
components: {Like, PageHeader},
data() {
return {
items: [],
}
},
beforeMount() {
this.getItems();
},
methods: {
getItems() {
axios.get('data/data.json')
.then(response => (this.items = response.data));
},
imagePath(image) {
return characterImagePath + image;
},
pushPage(item) {
router.push({
name: 'Detail',
params: {
id: item.id
}
});
},
noPushPage($event) {
$event.stopPropagation()
}
}
}
</script>
<style scoped>
#pageList .characterCard {
position: relative;
}
#pageList .characterCard img {
max-width: 100px;
}
#pageList .characterCard::after {
content: "";
position: absolute;
right: 30px;
top: 50%;
width: 40px;
height: 8px;
border-bottom: 1px solid var(--main);
border-right: 1px solid var(--main);
transform: skew(45deg);
transition: all .5s;
}
#pageList .characterCard:hover::after {
right: 20px;
}
#pageList .characterCard:hover {
cursor: pointer;
}
</style>
詳細画面
// 詳細画面
<template>
<div class="page bg-dots" id="pageDetail">
<PageHeader></PageHeader>
<div class="container">
<div v-if="isExistItem" class="mt-5 characterDetail">
<!-- いいねアイコン -->
<Like :id="item.id"></Like>
<!-- 画像 -->
<div class="text-center">
<img :src="imagePath(item.image)" :alt="item.name">
</div>
<!-- 説明 -->
<div class="card mt-3 p-4">
<h2>{{ item.name }}</h2>
<p v-html="item.description" class="mt-2 text-sm text-muted"></p>
</div>
</div>
<!-- 該当するキャラクター無し -->
<div v-else class="mt-5 text-center">
<p>{{ message }}</p>
</div>
<a @click="pushPage()" class="backBtn">一覧に戻る</a>
</div>
</div>
</template>
<script>
import axios from "axios";
import {characterImagePath} from "../const";
import {messages} from "../const";
import router from "../router";
import Like from "../components/Like.vue";
import PageHeader from "../components/PageHeader.vue";
export default {
name: "Detail",
props: [ 'id' ],
components: {Like, PageHeader},
data() {
return {
item: {},
message: '',
}
},
beforeMount() {
this.getItem();
},
computed: {
isExistItem: function () {
return Object.keys(this.item).length;
}
},
methods: {
getItem() {
let items = [];
try {
axios.get('data/data.json')
.then(response => {
items = response.data;
if (!items.length) {
this.message = messages.canNotGet;
return;
}
items.forEach(item => {
if (String(item.id) === String(this.id)) {
this.item = item;
}
});
});
} catch (err) {
this.message = messages.canNotGet;
} finally {
items = [];
}
},
imagePath(image) {
return characterImagePath + image;
},
pushPage() {
router.push({
name: 'List',
});
}
}
}
</script>
<style scoped>
#pageDetail .characterDetail img {
max-width: 250px;
}
#pageDetail .backBtn:hover {
cursor: pointer;
}
</style>
5)お気に入りのvueコンポーネントを作る
フォルダ構成や紹介されていないコンポーネントはgitのソースをご確認ください。
お気に入りコンポーネント(Vue.js)
jsLikeButton
クラスの要素を確認してもらえれば、中身を設定していないことが分かりますが、
これは要素のクラス名によってcssで表示するアイコン(未いいね、いいね済)を変更しています。
<template>
<div class="jsLikeButton" :class="getClassName" @click="setLike($event)">
<!-- いいねアイコンはcssで設定。クラス名によって表示を変更 -->
</div>
</template>
<script>
export default {
name: "Like.vue",
props: ['id'],
data: function() {
return {
likes: [],
className: 'unLike'
}
},
created: function () {
// お気に入りを取得して変数に保持
this.likes = this.getLikes();
this.setClassName();
},
computed: {
getClassName: function () {
return this.className;
}
},
methods: {
getLikes() {
let values = [];
let localStorageValues = JSON.parse(localStorage.getItem('likes'));
if (localStorageValues) {
values = localStorageValues;
}
return values;
},
isLiked() {
let indexPosition = this.likedIndexPosition();
// 含まれている
if (this.likes && -1 < indexPosition) {
return true;
}
// 含まれていない
return false;
},
likedIndexPosition() {
// 配列の何番目のインデックスに含まれているか(配列中にマッチする値が含まれなければ-1を返す)
return this.likes.indexOf(this.id);
},
setClassName() {
// 既にお気に入りか
if (this.isLiked()) {
this.className = 'liked';
} else {
this.className = 'unLike';
}
},
setLike(event) {
if (event) {
event.preventDefault();
}
// 一覧の他要素で変更されていることを考慮し、likesを再取得
// (もしくはemit等を使用してList・Detailから取得)
this.likes = this.getLikes();
// localStorageが存在している
if (this.likes) {
// 既にお気に入り
if (this.isLiked()) {
let indexPosition = this.likedIndexPosition();
// お気に入りから該当idを除去
this.likes.splice(indexPosition, 1);
} else {
// お気に入り追加
this.likes.splice(0, 0, this.id);
}
} else {
// localStorageが存在していない場合
// お気に入り追加
this.likes.splice(0, 0, this.id);
}
// 新しい値をlocalStorageに設定
localStorage.setItem('likes', JSON.stringify(this.likes));
// 表示の更新
this.setClassName();
},
}
}
</script>
<style>
.jsLikeButton {
width: 50px;
height: 50px;
}
.jsLikeButton.unLike {
background-image: url('data:image/.....');
}
.jsLikeButton.liked {
background-image: url('data:image/.....');
}
.jsLikeButton img {
width: 100%;
}
.jsLikeButton:hover {
cursor: pointer;
}
</style>