【Laravel×Vue.js いいね機能(SPA・Vite)】DB使用・会員登録なしで、localStorageでいいね(お気に入り)機能を実装する

やりたいこと

データベースを使用しない、簡易的ないいね機能を実装(各ブラウザのlocalStorageを使用する)

今回はキャラクター一覧・詳細画面で、いいねを付ける機能をサンプルとして実装します。

※キャラクターは固定表示で、キャラクターの登録編集削除部分は実装せず、いいねの部分だけになります。

Local Storageとは?

ブラウザ(ChromeやSafariなど)にデータを保存できる仕組み

よくECサイトで、会員登録をしていないのにもかかわらず、「最近閲覧した商品」「お気に入り商品」などが表示されているのはlocalStorageを使用している為。

データベースを使用せず、localStorageを使用するメリット

会員登録をする必要が無い

データベースを使用せず、localStorageを使用するデメリット

localStorageを使用してブラウザに保存する為、キャッシュクリアや、別端末、別ブラウザで閲覧されるとお気に入りを共有できない

実装内容詳細

  1. お気に入りの状態によって、表示するアイコンの色を変える
  2. 画面遷移をしても、お気に入りの状態が正しい事(ブラウザバック・一覧へ戻るボタン押下)

バージョン

Laravel:10.3.3

Vue:4.1.0

vue-router:4.1.6

デモ

Vue.js

LaravelでVue.jsを取り入れる方法は、主に以下の2種類の使い方が可能です。

今回は、VueのCLIツールを使用する方法で実装します。

Vite(CLIツール)

ビルドの複雑な処理の一部 (Babel や Webpack の使用など) を実行する、

グローバルにインストールされる npm パッケージ

Node.js、ソースのビルドが必要です。

Vue.jsの記事でよく見るexport defaultは、CLIツールを使用しています。

Laravelのバージョンと、Laravel Mixに注意

CLIツールにもいろいろとありますが、

Laravel 9からは、デフォルトのツールが、Laravel Mix から Vite へ変わりました。

他の記事で検索している時に、Laravel Mixで構成されているものは少々ソースが異なるので、参考にする場合は注意が必要です。

CDN

CDNとは以下のような形で外部から読み込んで使う形の事です。

CLIツールを使用するより簡単に使用することが出来、使いやすいと思います。

<script src="https://cdn.jsdelivr.net/npm/vue@2.6.10/dist/vue.js"></script>

CDNの方が始めるのにはお手軽な感じがしますが、CDNを使用した実装は以下記事にまとめています。

サンプルソース

githubの「localstorage-vue-cli」ブランチにアップしています。

大まかな流れ

  1. Vite(CLIツール)の導入と設定
  2. vue-routerの導入
  3. Laravelのルーティング設定
  4. ベースとなるテンプレートの作成
  5. 表示するデータを用意(今回は固定指定なので、データのファイルを用意)
  6. 一覧画面・詳細画面を用意
  7. お気に入りの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]);
});
詳細ページの404を防ぐ
// idをviewに渡していない場合
Route::any('/', function () {
    return view('layouts/app');
});

web.phpが上記のようにviewにidを渡さず、

vue-router で history を HTML5モード(history: createWebHistory())にしている場合、

詳細ページ(http://127.0.0.1:8000/3 等)でリロードすると、404 NotFoundとなります。

Laravelのweb.phpで、viewに変数$idでアクセスのあったIDを渡すことで、詳細ページでリロードしても404を防ぐことが出来ます。

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>

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です