基礎からメモ: 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/:rid
のrid
パラメータを元に読み込む。
[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
を使った。もっとスマートな書き方があるのかもしれないが一応動作する。