Nuxt.jsのSPAにvuexでサイドメニューを実装する

はじめに

サイドメニューを実装する機会があり、手っ取り早い方法がvuexだったためvuexを使って実装してみました。

そのやり方を残しておきます。

どんなアプリか

実際の動きをみてもらったほうが早いです。

f:id:kogayushi:20190824233448g:plain

こんな感じでメニューをクリックしたら対応するページに遷移しつつ、そのページのメニューが選択状態になります。

今回もgithubにあげています。

使っているもの

実装時に意識して使っていたもの雑多に並べます

  • nuxt
  • vuetify
    • v-app-bar
    • v-menu
    • v-list
    • v-tabs/v-tab
  • vuex
  • nuxt-link

どんな設計になってるか

概要はこんな感じです。

  1. ページ遷移のタイミングでmiddlewareを使ってroute.nameをvuexに保存する
  2. 現在のページに対応したサブメニューの情報をvuexのgetterから取得する
  3. 取得したサブメニュー情報を元に動的に画面を表示する
  4. グローバルナビはホバーするとサブメニューのリストを出す。
  5. 現在表示されているページに対応するグローバルナビのメニューを選択状態にする
  6. 現在表示されているページに対応するサイドメニューを選択状態にする

(ざっくり)コード解説

設計とはきれいに対応していません。あしからず。

ページ遷移のタイミングでmiddlewareを使ってroute.nameをvuexに保存する

vuexに現在のページを保存するためのstateとmutationを作る

~/store/index.js
export const state = () => ({
  currentPage: 'index'
})

export const mutations = {
  setCurrentPage(state, payload) {
    state.currentPage = payload
  }
}

middlewareを使ってroute.nameを取得して保存する

~/middleware/pageId.js
export default function({ route, store }) {
  store.commit('setCurrentPage', route.name)
}
nuxt.config.js
  router: {
    middleware: 'pageId' // nuxtに教えておかないと動かない
  }

現在のページに対応したサブメニューの情報をvuexのgetterから取得する

各カテゴリのサブメニューの定数を定義する

~/store/index.js
const TOP_SUBMENU = {
  title: 'index',
  menus: [
    {
      pageId: 'awesome',
      name: 'awesome',
      url: '/awesome/menu1'
    },
    {
      pageId: 'wonderful',
      name: 'wonderful',
      url: '/wonderful/menu1'
    }
  ]
}

const AWESOME_SUBMENU = {
  title: 'awesome',
  pageGroup: 'awesome',
  menus: [
    {
      pageId: 'awesome-menu1',
      name: 'awesome-menu1',
      icon: 'phone',
      url: '/awesome/menu1'
    },
    {
      pageId: 'awesome-menu2',
      name: 'awesome-menu2',
      icon: 'email',
      url: '/awesome/menu2'
    },
    {
      pageId: 'awesome-menu3',
      name: 'awesome-menu3',
      url: '/awesome/menu3'
    }
  ]
}

const WONDERFUL_SUBMENU = {
  title: 'wonderful',
  pageGroup: 'wonderful',
  menus: [
    {
      pageId: 'wonderful-menu1',
      name: 'wonderful-menu1',
      icon: 'inbox',
      url: '/wonderful/menu1'
    },
    {
      pageId: 'wonderful-menu2',
      name: 'wonderful-menu2',
      icon: 'move_to_inbox',
      url: '/wonderful/menu2'
    },
    {
      pageId: 'wonderful-menu3',
      name: 'wonderful-menu3',
      icon: 'send',
      url: '/wonderful/menu3'
    }
  ]
}
stateにsubmenuを登録する
export const state = () => ({
  currentPage: 'index',
  submenu: {
    index: TOP_SUBMENU,
    awesome: AWESOME_SUBMENU,
    'awesome-menu1': AWESOME_SUBMENU,
    'awesome-menu2': AWESOME_SUBMENU,
    'awesome-menu3': AWESOME_SUBMENU,
    wonderful: WONDERFUL_SUBMENU,
    'wonderful-menu1': WONDERFUL_SUBMENU,
    'wonderful-menu2': WONDERFUL_SUBMENU,
    'wonderful-menu3': WONDERFUL_SUBMENU
  }
})
pageIdからサブメニューを取得するためのvuexのgetterを定義する
export const getters = {
  submenuOf: (state) => (pageId) => {
    if (state.submenu[pageId]) {
      return state.submenu[pageId]
    }
    return TOP_SUBMENU
  }
}

ちなみにこのgetterにはディスパッチテーブルと言われるデザインパターンを使っています。 これにより、今後カテゴリが増えたとしても、それに対応するサブメニューの定数を宣言し、それをstateに設定すれば対応完了できます。

取得したサブメニュー情報を元に動的に画面を表示する

現在のページを取得するためにvuexのgetterを定義する

export const getters = {
  currentPage(state) {
    return state.currentPage
  },
  submenuOf: (state) => (pageId) => {
    if (state.submenu[pageId]) {
      return state.submenu[pageId]
    }
    return TOP_SUBMENU
  }
}

サイドメニューコンポーネントを作る

<template>
  <v-card class="mx-auto" max-width="300" tile>
    <v-list>
      <v-subheader>{{ title }}</v-subheader>
      <v-list-item v-for="menu in menus" :key="menu.pageId" :to="menu.url" nuxt>
        <v-list-item-icon>
          <v-icon v-text="menu.icon"></v-icon>
        </v-list-item-icon>
        <v-list-item-content>
          <v-list-item-title v-text="menu.name"></v-list-item-title>
        </v-list-item-content>
      </v-list-item>
    </v-list>
  </v-card>
</template>

<script>
export default {
  computed: {
    title() {
      return this.$store.getters.submenuOf(this.$store.getters.currentPage)
        .title
    },
    menus() {
      return this.$store.getters.submenuOf(this.$store.getters.currentPage)
        .menus
    }
  }
}
</script>

vuexから値を取得し、その値に応じてメニューが書き換わるようになってます。

読めばわかると信じて解説はしません。雰囲気で感じ取ってください。

f:id:kogayushi:20190824233534j:plain

ヘッダー(グローバルメニュー)コンポーネントを作る

<template>
  <v-app-bar color="#6A76AB" dark :scroll-target="scrollTarget">
    <v-toolbar-title>
      Side Menu Example with Nuxt.js and Vuex
    </v-toolbar-title>
    <template v-slot:extension>
      <v-tabs align-with-title background-color="transparent">
        <v-tab
          v-for="menu in grobalMenus"
          :key="menu.pageId"
          :to="menu.url"
          :class="{ 'v-tab--active': isSamePageGroup(menu.pageId) }"
          nuxt
          v-on="on"
        >
          <v-menu open-on-hover offset-y>
            <template v-slot:activator="{ on }">
              <span text v-on="on">
                {{ menu.name }}
                <v-icon right>arrow_drop_down</v-icon>
              </span>
            </template>
            <v-list class="grey lighten-3">
              <v-list-item
                v-for="submenu in menus(menu.pageId)"
                :key="submenu.pageId"
                :to="submenu.url"
                nuxt
              >
                {{ submenu.name }}
              </v-list-item>
            </v-list>
          </v-menu>
        </v-tab>
      </v-tabs>
    </template>
  </v-app-bar>
</template>
<script>
export default {
  props: {
    scrollTarget: {
      type: String,
      required: true
    }
  },
  computed: {
    grobalMenus() {
      return this.$store.getters.submenuOf('index').menus
    },
    menus() {
      return (pageId) => {
        return this.$store.getters.submenuOf(pageId).menus
      }
    },
    isSamePageGroup() {
      return function(pageId) {
        return this.$store.getters.isSamePageGroup(pageId)
      }
    }
  }
}
</script>
  • ホバーするとサブメニューが表示されるようにしてあります。
  • サブメニューはvuexから取得しています。
  • 本当はcomputedで定義している内容はpropsで渡したほうが良いと思いますが、今回の趣旨ではないのでサボっています。

読めばわかると信じて詳しい解説はしません。雰囲気で(ry

作成したコンポーネントを利用するようにdefault.vueを修正する

<template>
  <v-app>
    <v-content>
      <v-card class="overflow-hidden" height="100%">
        <the-header scroll-target="#scrolling-techniques-4" />
        <v-sheet id="scrolling-techniques-4">
          <v-container grid-list-md text-xs-center>
            <v-layout row wrap>
              <v-flex xs3>
                <side-menu />
              </v-flex>
              <v-flex xs>
                <v-card tile height="100%">
                  <nuxt />
                </v-card>
              </v-flex>
            </v-layout>
          </v-container>
        </v-sheet>
      </v-card>
    </v-content>
    <v-footer fixed app>
      <span>&copy; 2019</span>
    </v-footer>
  </v-app>
</template>

<script>
import TheHeader from '~/components/TheHeader'
import SideMenu from '~/components/SideMenu'

export default {
  components: {
    TheHeader,
    SideMenu
  }
}
</script>

ヘッダーとサイドメニューを配置しているだけです。

まとめ

このような感じで、vuexを使って現在表示しているページに対応するサブメニューを表示してみました。

割と簡単に実装出来たし拡張もしていけるようにしたつもりですが、いまいちこれで本当に良いのか自信がありません。

もっと良い実装方法をご存知の人は是非教えて下さい。

参考にしたサイト

https://qiita.com/natsumi527/items/189d8e122bf2ee02f099