基礎からメモ: Vue.js CH9-p302 ネストされた複雑なページ

ネストされたルート定義をすると、より複雑な画面遷移をすることができる。

これまでの「Product」のコンポーネントは、商品詳細と商品レヴューを表示させるためのベースとし、その中に3つのコンポーネントを定義する。 フォルダ構成としては、viewsの中にHome.vue,ProductList.vue,Product.vueを置き、Productサブディレクトリを作って、その中に次の3つのコンポーネントファイルを置く。

src/views/Product以下に

  • Home.vue : 商品詳細(コード内ではProductHomeという名前で扱う)
  • Review.vue : 商品レヴュー一覧(コード内ではProductReviewという名前で扱う)
  • ReviewDetail.vue : 個別の商品レヴュー詳細(コード内ではProductReviewDetailという名前で扱う)

アプリ全体のベースコンポーネントHomeという名前だが、商品詳細はそれぞれサブディレクトリ名のProductを頭に付けた名前でコード上は扱うという形。

ネストしたルートの定義

まずsrc/router.jsのルート定義。 子コンポーネントを読み込むサブルートの定義には、それらを埋め込みたい特定のルートのchildrenオプションにサブルート定義を書いていく。

[src/router.js]

import Vue from 'vue'
import VueRouter from 'vue-router'

import Home from '@/views/Home.vue'
import ProductList from '@/views/ProductList.vue' // 商品一覧
import Product from '@/views/Product.vue'         // 商品詳細(親ルート)
// Productの子ルートたち
import ProductHome from '@/views/Product/Home.vue'
import ProductReview from '@/views/Product/Review.vue'
import ProductReviewDetail from '@/views/Product/ReviewDetail.vue'


Vue.use(VueRouter)

const router = new VueRouter({
  routes: [
    {
      path: '/',
      component: Home,
    },
    // 商品一覧
    {
      path: '/product',
      component: ProductList,
    },
    // 商品情報
    {
      path: '/product/:id',
      component: Product,
      props: route => ({ 
        id: Number(route.params.id),
       }),
      children: [
        // 商品詳細(デフォルト子ルート)
        {
          name: 'product-home',
          path: '',
          component: ProductHome,
        },
        // 商品レヴュー一覧
        {
          name: 'product-review',
          path: 'review',
          component: ProductReview,
        },
        // 商品レヴュー詳細
        {
          name: 'review-detail',
          path: 'review/:rid',
          component: ProductReviewDetail,
          props: route => ({
            rid: Number(route.params.rid)
          })
        }
      ]
    }
  ]
})

export default router

メインファイル

ルート定義をしたところで、まずはベースとなる主だったファイルから

[src/main.js]

import Vue from 'vue'
import store from '@/store.js'
import router from '@/router.js'
import App from '@/App.vue'

new Vue({
  el: '#app',
  store,   // アプリケーションにstoreを登録
  router,  // アプリケーションにrouterを登録
  render: h => h(App)
})

[src/App.vue]

<template>
  <div id="app">
    <nav>
      <router-link to="/" exact>Home</router-link>
      <router-link to="/product">商品情報</router-link>
    </nav>
    <!-- ここにパスと一致したコンポーネントが埋め込まれる -->
    <router-view/>
  </div>
</template>

<script>
export default {
  name: 'App'
}
</script>

<style>
/* ナビゲーション */
nav {
    display: flex;
    align-items: center;
    background: #222;
}
nav a {
    display: block;
    padding: 0.5em;
    color: #eee;
    line-height: 1em;
    text-decoration: none;
}
/* アクティブなリンク */
.router-link-active {
    background: palevioletred;
}
</style>

ストアとデータの定義

アプリが少し大きくなるとpropsでデータを受け渡していくのは難しくなる。そこでVuexを利用してアプリ全体で共有するデータの状態管理をする。 ここでは商品詳細データ用のストアと、商品レヴュー用のストアを定義し、それらに対応する実際のデータをJSONで定義したファイルとして用意する。

ストアファイルはsrc/store以下に、データの内容ファイルはsrc/api以下に置く。

商品詳細用ストア

[src/store/product.js]

import products from '@/api/products.js'
// 商品詳細用のVuexモジュール
export default {
  namespaced: true,
  state: {
    detail: {}
  },
  getters: {
    detail: state => state.detail
  },
  mutations: {
    set(state, { detail }) { state.detail = detail },
    clear(state) { state.detail = {} },
  },
  actions: {
    load({ commit }, id) {
      products.asyncFind(id, detail => {
        commit('set', { detail })
      })
    },
    destroy({ commit }) {
      commit('clear')
    }
  }
}

商品詳細用データ

[src/api/products.js]

// 商品リスト
const database = [
  { id: 1, name: '商品A', price: 100, content: '商品A詳細' },
  { id: 2, name: '商品B', price: 200, content: '商品B詳細' },
  { id: 3, name: '商品C', price: 300, content: '商品C詳細' }
]

// インポート先で使用できる関数をオブジェクトとして定義
export default {
  fetch(id) { return database },
  find(id)  { return database.find(el => el.id === id) },
  asyncFind(id, callback) {
    setTimeout(() => {
      callback(database.find(el => el.id === id))
    }, 1000)
  }
}

商品レヴュー用のストア

[src/store/review.js]

import reviews from '@/api/reviews.js'
// 商品レヴュー用のVuexモジュール
export default {
  namespaced: true,
  state: {
    detail: {}
  },
  getters: {
    detail: state => state.detail
  },
  mutations: {
    set(state, { detail }) { state.detail = detail },
    clear(state) { state.detail = {} },
  },
  actions: {
    load({ commit }, id) {
      reviews.asyncFind(id, detail => {
        commit('set', { detail })
      })
    },
    destroy({ commit }) {
      commit('clear')
    }
  }
}

商品レヴュー用のデータ

[src/api/reviews.js]

// レビューリスト
const review_db = [
  { id: 1, name: '商品Aレビュー', 
   reviewlist: [
      { rid: 'a1', reviewer: 'Aさん', star: 5, content: '便利です。' },
      { rid: 'a2', reviewer: 'Bさん', star: 4, content: 'まあまあ。' },
      { rid: 'a3', reviewer: 'Cさん', star: 2, content: '返品です。' }
    ] 
  },
  { id: 2, name: '商品Bレビュー',
    reviewlist:[
      { rid: 'b1', reviewer: 'Dさん', star: 3, content: 'イマイチ。' },
      { rid: 'b2', reviewer: 'Eさん', star: 4, content: '値段はやや高いですが、メーカーの努力を感じました。' },
      { rid: 'b3', reviewer: 'Fさん', star: 1, content: 'もう買わない。' }
    ] 
  },
  { id: 3, name: '商品Cレビュー',
    reviewlist:[
      { rid: 'c1', reviewer: 'Gさん', star: 5, content: 'すばらしい商品です。また買おうと思います。' },
      { rid: 'c2', reviewer: 'Hさん', star: 3, content: '値段相応かな。' },
      { rid: 'c3', reviewer: 'Iさん', star: 4, content: '商品は良かったけど、梱包最悪なので星一つマイナス。' }
    ] 
  }
]

// インポート先で使用できる関数をオブジェクトとして定義
export default {
  fetch(id) { 
    return review_db 
  },
  find(id)  { 
    return review_db.find(el => el.id === id) 
  },
  asyncFind(id, callback) {
    setTimeout(() => {
      callback(review_db.find(el => el.id === id))
    }, 1000)
  }
}

コンポーネントの定義

商品詳細リスト

App.vueのテンプレートの商品詳細リンクから読まれるコンポーネント。商品一覧を表示する。パス/productに対応する。

[src/views/ProductList.vue]

<template>
  <div class="product-list">
    <h1>商品一覧</h1>
    <ul>
      <li v-for="{ id, name } in list" :key="id">
        <router-link :to="`/product/${id}`">{{ name }}</router-link>
      </li>
    </ul>
  </div>
</template>

<script>
import products from '@/api/products.js'
export default {
  computed: {
    list: () => products.fetch()
  }
}
</script>

個別の商品詳細のホーム

このコンポーネントをベースとして、実際の個別商品の詳細とレヴューを読み込む。この商品詳細用ベースコンポーネントは、商品リストから/product/:idのパスで読み込まれてくるので、その時点でどの商品のページかが:idのパラメータで決定している。そこで、この商品詳細ベースをロードした際に、個別の商品詳細とその商品のレヴューリストがストアに書き込まれ、ページを去る際には逆にストアが初期化される仕組みを作っている。

具体的にはURLのidパラメータを監視してハンドラを動作させる次の部分

watch: {
    id: {
      handler() {
        this.$store.dispatch('product/load', this.id)
        this.$store.dispatch('review/load', this.id)
      }, immediate: true // 初期読み込み時にも呼び出す
    }
  },
  beforeDestroy() {
    // 親ルートを移動する時は商品詳細データを破棄
    this.$store.dispatch('product/destroy')
    this.$store.dispatch('review/destroy')
  }

[src/views/Product.vue]

<template>
  <div class="product">
    <h1>{{ detail.name }}</h1>
    <nav class="nav">
      <router-link :to="{ name: 'product-home' }" exact>商品詳細</router-link>
      <router-link :to="{ name: 'product-review' }">レヴュー</router-link>
    </nav>
    <!-- ここに子ルートを埋め込む -->
    <router-view />
  </div>
</template>

<script>
import { mapGetters } from 'vuex'
export default {
  props: { id: Number },
  computed: {
    ...mapGetters('product', ['detail']),
    ...mapGetters('review', ['detail'])
  },
  watch: {
    id: {
      handler() {
        this.$store.dispatch('product/load', this.id)
        this.$store.dispatch('review/load', this.id)
      }, immediate: true // 初期読み込み時にも呼び出す
    }
  },
  beforeDestroy() {
    // 親ルートを移動する時は商品詳細データを破棄
    this.$store.dispatch('product/destroy')
    this.$store.dispatch('review/destroy')
  }
}
</script>

<style>
.product-table, .product-table * {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}
.product-table {
  display: flex;
  flex-wrap: wrap;
  margin: 20px;
  width: 230px;
  background-color: #FFF;
  border: 1px solid #ddd;
}
.product-table dt {
  width: 40%;
  padding: 8px 10px;
  background-color: #f2f2f2;
  border-bottom: 1px solid #ddd;
  font-weight: bold;
}
.product-table dd {
  width: 60%;
  padding: 8px 0 8px 22px;
  border-bottom: 1px solid #E4E4E4;
}
</style>

商品詳細データの表

個別商品の詳細を表形式で表示する。Product.vueのテンプレートの中に埋め込まれる。このコンポーネントが読み込まれる時点で、ProductHomeコンポーネントで商品詳細ストアのデータが読み込まれているので、ここではゲッターでそのデータを読みテーブル表示する。

[src/views/Product/Home.vue]

<template>
  <div class="product" v-if="item" key="id">
    <h1>商品情報</h1>
    <dl class="product-table">
      <dt>商品名</dt><dd>{{ item.name }}</dd>
      <dt>価格</dt><dd>{{ item.price }}</dd>
      <dt>商品説明</dt><dd>{{ item.content }}</dd>
    </dl>
  </div>
  <div v-else key="loading">商品情報を読み込んでいます....</div>
</template>

<script>
import products from '@/api/products.js'
export default {
  data() {
    return { item: null }
  },
  watch: {
    id: {
      handler() {
        setTimeout(() => {
          this.item = this.$store.getters['product/detail']
        }, 1000)
      }, immediate: true // 初期読み込み時にも呼び出す
    }
  }
}
</script>

<style>
.product-table, .product-table * {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}
.product-table {
  display: flex;
  flex-wrap: wrap;
  margin: 20px;
  width: 230px;
  background-color: #FFF;
  border: 1px solid #ddd;
}
.product-table dt {
  width: 40%;
  padding: 8px 10px;
  background-color: #f2f2f2;
  border-bottom: 1px solid #ddd;
  font-weight: bold;
}
.product-table dd {
  width: 60%;
  padding: 8px 0 8px 22px;
  border-bottom: 1px solid #E4E4E4;
}
</style>

商品レヴューのリスト

開いている個別商品のレヴュー一覧を表示。ここでもProductHomeコンポーネントで商品レヴューのリストデータが読み込まれているので、ゲッターでそのデータを読みリスト表示する。

[src/views/Product/Review.vue]

<template>
  <div class="review-list">
    <h1>レビュー一覧</h1>
    <ul>
      <li v-for="{ rid, reviewer } in list" :key="rid">
        <router-link :to="`review/${rid}`">{{ reviewer }}</router-link>
      </li>
    </ul>
  </div>
</template>

<script>
// import reviews from '@/api/reviews.js'
export default {
  data() {
    return { list: null }
  },
  watch: {
    id: {
      handler() {
        setTimeout(() => {
          let rev = this.$store.getters['review/detail']
          this.list = rev.reviewlist
        }, 1000)
      }, immediate: true // 初期読み込み時にも呼び出す
    }
  }
}
</script>

選択された投稿者個別の商品レヴュー

各個別のレヴュー内容を表示。ここは少し工夫が必要で、今度は先にストアに読まれた個別商品のレヴューリストの中から、クリックされた個別のレヴューをreview/:ridridパラメータを元に読み込む。

[src/views/Product/ReviewDetail.vue]

<template>
  <div class="review-detail" v-if="item" key="rid">
    <h1>レビュー情報</h1>
    <dl class="review-table">
      <dt>名前</dt><dd>{{ item.reviewer }}</dd>
      <dt>レビュー内容</dt><dd>{{ item.content }}</dd>
      <dt>スター</dt><dd>{{ item.star }}</dd>
    </dl>
  </div>
  <div v-else key="loading">商品レビューを読み込んでいます....</div>
</template>

<script>
import reviews from '@/api/reviews.js'
export default {
  data() {
    return { item: null }
  },
  watch: {
    rid: {
      handler() {
        setTimeout(() => {
          let rid = this.$route.params.rid
          let list = this.$store.getters['review/detail'].reviewlist
          this.item = list.find(el => el.rid === rid)
        }, 1000)
      }, immediate: true // 初期読み込み時にも呼び出す
    }
  }
}
</script>

<style>
.review-table, .review-table * {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}
.review-table {
  display: flex;
  flex-wrap: wrap;
  margin: 20px;
  width: 350px;
  background-color: #FFF;
  border: 1px solid #ddd;
}
.review-table dt {
  width: 34%;
  padding: 8px 10px;
  background-color: #f2f2f2;
  border-bottom: 1px solid #ddd;
  font-weight: bold;
}
.review-table dd {
  width: 65%;
  padding: 8px 10px 8px 15px;
  border-bottom: 1px solid #E4E4E4;
}
</style>

特に商品レヴューのデータやコンポーネントについては、元の本やサポートページには詳しいコードが無かったので試行錯誤で作ってみた。個別商品のデータはVuexでストアされるまでほんのわずかなタイムラグがあり、個別の商品詳細、レビューリスト、個別レビューでストアを読み込むハンドラに全てsetTimeoutを使った。もっとスマートな書き方があるのかもしれないが一応動作する。