VueRouterのチュートリアルをやる
映像教材
Vue3 ルーティングについての説明
公式ルーター VueRouter
create-vite
で Vite の Vue3 プロジェクトを作成する。
※ほんとは create-vue の方が手厚いけど、 VueRouter のチュートリアルに従う。
npm create vite
せめてもの抵抗で TypeScript を選択する。
現在のディレクトリ構造
$ tree -I node_modules
.
├── README.md
├── index.html
├── package-lock.json
├── package.json
├── public
│ └── vite.svg
├── src
│ ├── App.vue
│ ├── assets
│ │ └── vue.svg
│ ├── components
│ │ └── HelloWorld.vue
│ ├── main.ts
│ ├── style.css
│ └── vite-env.d.ts
├── tsconfig.json
├── tsconfig.node.json
└── vite.config.ts
VueRouter
を適用させる。
import { createApp } from 'vue'
+ import { createRouter, createWebHistory } from 'vue-router'
import './style.css'
import App from './App.vue'
+ const router = createRouter({
+ history: createWebHistory(),
+ routes: [],
+ })
- createApp(App).mount('#app')
+ createApp(App)
+ .use(router)
+ .mount('#app')
VueRouter で切り替えるコンポーネントを作成する。
mkdir src/views
touch src/views/About.vue src/views/About.vue
$ tree -I node_modules
.
├── README.md
├── index.html
├── package-lock.json
├── package.json
├── public
│ └── vite.svg
├── src
│ ├── App.vue
│ ├── assets
│ │ └── vue.svg
│ ├── components
│ │ └── HelloWorld.vue
│ ├── main.ts
│ ├── style.css
+ │ ├── views
+ │ │ ├── About.vue
+ │ │ └── Home.vue
│ └── vite-env.d.ts
├── tsconfig.json
├── tsconfig.node.json
└── vite.config.ts
Home.vue
, About.vue
を作る。
<template>
<div class="home">
<h1>Home</h1>
</div>
</template>
<template>
<div class="about">
<h1>About</h1>
</div>
</template>
main.ts
にHome.vue
, About.vue
コンポーネントを使う記述を追加する。
import { createApp } from 'vue'
import { createRouter, createWebHistory } from 'vue-router'
import './style.css'
import App from './App.vue'
+ import Home from '@/views/Home.vue'
+ import About from '@/views/About.vue'
const router = createRouter({
history: createWebHistory(),
- routes: [],
+ routes: [
+ {path: '/', name: 'Home', component: Home},
+ {path: '/about', name: 'About', component: About},
+ ],
})
createApp(App)
.use(router)
.mount('#app')
main.ts
で呼び出しているルートコンポーネントの App.vue
に VueRouter のルーティング用コンポーネントである router-link
router-view
を使用する。
<template>
<div>
<a href="https://vitejs.dev" target="_blank">
<img src="/vite.svg" class="logo" alt="Vite logo" />
</a>
<a href="https://vuejs.org/" target="_blank">
<img src="./assets/vue.svg" class="logo vue" alt="Vue logo" />
</a>
</div>
+ <router-link to="/">Home</router-link> |
+ <router-link to="/about">About</router-link> |
+ <router-view></router-view>
</template>
参考
ここまでの結果
-
Home, About のハイパーリンクでコンポーネントを切り替えることができる。
- ページ全体を読み込みなおすことなく、ページを部分的に変更することができる。
- コンポーネントの切り替えに合わせてURLのパスも変更される。
ルーティングに関する記述の外だし
main.ts
にまとめていたルーティングに関する記述を専用ファイルにまとめて、そこから呼び出すように変更する。
理由
ルーティングに関する記述はそれだけで膨大なので、あとあと main.ts
内の記述量がとんでもないことになってしまうため。
mkdir src/router
touch src/router/index.ts
import { createApp } from 'vue'
- import { createRouter, createWebHistory } from 'vue-router'
+ import router from '@/router/index'
import './style.css'
import App from './App.vue'
import Home from '@/views/Home.vue'
import About from '@/views/About.vue'
- const router = createRouter({
- history: createWebHistory(),
- routes: [
- {path: '/', name: 'Home', component: Home},
- {path: '/about', name: 'About', component: About},
- ],
- })
createApp(App)
.use(router)
.mount('#app')
import { createRouter, createWebHistory } from 'vue-router'
import Home from '@/views/Home.vue'
import About from '@/views/About.vue'
const routes = [
{path: '/', name: 'Home', component: Home},
{path: '/about', name: 'About', component: About},
]
const router = createRouter({
history: createWebHistory(),
routes
})
export default router
本筋と関係ないけど、プロパティ名とグローバル変数名が一致してたらそのまま変数をプロパティとして採用してくれるのなんで?
const routes = []
{
routes
}
ECMAScript2015で採用された略記法 を利用できるから。
引用した説明のように、 オブジェクトのプロパティ名と値が一致していればプロパティ名を省略できる 。
const hoge = [1,2,3]
const hogeObject1 = {hoge: hoge}
const hogeObject2 = {hoge}
// 両方とも以下の結果が得られる。
// {hoge: [1,2,3]}
したがって、後の処理で生成する オブジェクトのプロパティ名で変数名を宣言 することによって、 オブジェクトのプロパティ に利用するときに 省略記法を採用できる 。
ECMAScript 2015 での新しい表記法
これらの表記をサポートするための互換性の表を参照してください。非サポート環境では、これらの表記は、構文エラーにつながります。
// 略記プロパティ名 (ES2015) let a = 'foo', b = 42, c = {}; let o = {a, b, c} // 略記メソッド名 (ES2015) let o = { property(parameters) {} } // 計算プロパティ名 (ES2015) let prop = 'foo'; let o = { [prop]: 'hey', ['b' + 'ar']: 'there' }
ルーティングでHistoryAPIを利用する2つの方法
- Hash Mode
- HTML5 Mode
HTML5 Mode | Hash Mode | 補足 | |
---|---|---|---|
historyオプションに使う 関数 | createWebHistory() | createWebHashHistory(https://developer.mozilla.org/ja/docs/Web/API/History_API) | |
URL | {hostname}{path} | {hostname}/#{path} | pathの例: /home |
History API | 使用する | 使用しない | |
サーバーとの通信 | 発生する | 発生しない | HTML5 ModeでSPAを実現するにはサーバー側でエントリーポイントを単一にするルーティング設定が必要。 ふつうのホスティングサービスであればSPA用の設定が提供されているので心配不要(らしい) |
SEO | 不利 | 有利 |
Param
<script setup lang="ts">
import { computed } from 'vue';
import { useRoute } from 'vue-router';
const route = useRoute()
const destinationId = computed(()=>{
return route.params.id
})
</script>
<template>
<h2>Hello Destination {{ destinationId }}</h2>
</template>
route.params.id
の型が string | string[]
らしく、型チェックを通らない。
Bingちゃんに聞いた route.params.id
がstring[]になるケース
VueRouterでroute.params.idがstring配列になるケースは、以下のような場合が考えられます。
- ルートパスに動的セグメントを複数指定した場合[1]。例えば、
/user/:id/post/:id
のように同じ名前の動的セグメントを使った場合、route.params.idは['user-id', 'post-id']という配列になります。- ルートパスに正規表現を使ってマッチさせた場合[2]。例えば、
/user/:id(\\d+)
のように数字だけを受け付ける動的セグメントを使った場合、route.params.idはマッチした数字の配列になります。- ルートパスにワイルドカード(*)を使ってマッチさせた場合[2:1]。例えば、
/user/*
のように任意の文字列を受け付ける動的セグメントを使った場合、route.params.idはマッチした文字列の配列になります。これらのケースでは、ルートコンポーネントにプロパティとして渡す際に注意が必要です。propsオプションで関数を使ってプロパティを定義する方法[1:1]やラッパーコンポーネントを使用する方法[1:2]があります。
型アサーションで stringとして扱うのはやりたくないなぁ。
実際問題、配列で来ることもあるから、場合分けして安全に使用したい。
<script setup lang="ts">
import { computed } from 'vue';
import { useRoute } from 'vue-router';
import sourceData from '@/data.json'
const route = useRoute()
const destinationId = computed(()=>{
return parseInt(route.params.id as string)
})
const destination = computed(()=>{
return sourceData.destinations.find(destination => destination.id === destinationId.value )
})
</script>
<template>
<h2>Hello Destination {{ destinationId }}</h2>
</template>
いや、'/destination/:id'
のパスでしか来ないから、params.idが複数取得することはありえないね。
const routes = [
{path: '/destination/:id', component: ()=>import('@/views/DestinationShow.vue')},
]
なら型アサーション使っていいか。
parseInt()
に入る文字列が数値解釈できるもの以外の場合がある。そのケアができていないけどいったん目をつむる。
<script setup lang="ts">
import { computed } from 'vue';
import { useRoute } from 'vue-router';
import sourceData from '@/data.json'
const route = useRoute()
const destinationId = computed(()=>{
// parseIntできない場合のケアが出来ていない。
// const result = route.params.id.test(/^\d+$/) とかでよさそう。
// result: false ならば、 404とかに飛ばす必要がありそう。
return parseInt(route.params.id as string)
})
const destination = computed(()=>{
return sourceData.destinations.find(destination => destination.id === destinationId.value )
})
</script>
<template>
<h2>Hello Destination {{ destinationId }}</h2>
</template>
いや、destination
を watch
して、destination === undefined
だったら404に飛ばしたりしたらいい気がする。
watchEffect()
is 何?
値を返さない computed
みたいな感じ?
いや、
destination
をwatch
して、destination === undefined
だったら404に飛ばしたりしたらいい気がする。
いや、例外投げればいいんだ。
const destination = computed(()=>{
const result = sourceData.destinations.find(destination => destination.id === destinationId.value )
if (result === undefined) {
throw new Error("invalid destination!")
}
return result
})
destination
がundefined
の可能性があるため、型エラーになる。
(Array.prototype.find()
してるからそりゃそう)
<template>
<h1>{{ destination.value.name }}</h1>
</template>
undefined
で条件分岐すればいいか……
<template>
<h1>{{ destination !== undefined ? destination.name : "ここへ来てはいけない!すぐ戻れ!" }}</h1>
</template>
destination
の定義の時点で例外を投げるようにしたのでundefined
のチェックが不要になった。
Composition API: ライフサイクルフック
Composition API だと ライフサイクルフック関数に created はなくなったみたい。
setup()
関数がcreated()
にあたる。
<script setup>
を利用しているならば、トップレベルが setup()
にあたる。
Response型 is 何?
APIのレスポンスに型付け
json()
メソッドで Responseオブジェクト のBodyテキストをJSONオブジェクトに変換するPromiseを取得する。
const response = await fetch()
const data: Destination = await response.json()
CORSエラーでJSONをもらえない……
:5173/destination/1/brazil:1 Access to fetch at 'https://travel-dummy-api.netlify.app/brazil' from origin 'http://localhost:5173' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: Redirect is not allowed for a preflight request.
リダイレクトされているから正しいCORSヘッダーを遅れていないのが原因らしい。
コメント欄に解消法を書いてくれてる人がいた。
The problem is the redirect from https://travel-dummy-api.netlify.app/brazil to https://travel-dummy-api.netlify.app/brazil.json, it doesn't send the correct CORS header.
Adding .json to the url solves the CORS problem for me:
const response = await fetch(https://travel-dummy-api.ne...{this.$route.params.slug}.json
)[^1]
つまりこういうこと
- const response = await fetch(`https://travel-dummy-api.netlify.app/${route.params.slug}`)
+ const response = await fetch(`https://travel-dummy-api.netlify.app/${route.params.slug}.json`)
const data: Destination = await response.json()
<script setup>
のトップレベルでawait
を使ったら非同期コンポーネントに自動的になるのは理解したけど、非同期コンポーネントは<Suspense>
コンポーネントで囲うように言われる。
でも<Suspense>
は実験的な機能でstableじゃないらしい。どうしよ。
なんやかんやあってこうなった。
<script setup lang="ts">
import { ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import { Destination } from '@/classes/destination';
const route = useRoute()
let destination = ref<Destination>()
const initData = async () => {
const response = await fetch(`https://travel-dummy-api.netlify.app/${route.params.slug}.json`)
const data: Destination = await response.json()
destination.value = data
}
watch(
()=>route.params,
initData,
{ immediate: true }
)
</script>
<template>
<section v-if="destination" class="destination">
<h1>{{ destination.name }}</h1>
<div class="destination-details">
<img :src="`/images/${destination.image}`" :alt="destination.name">
<p>{{ destination.description }}</p>
</div>
</section>
</template>
参考
reactive()
は引数を与えないといけないので、初期値がundefined
で非同期通信で得られた値を後から代入する使用法には不適。(要追加調査)
ref()
は引数がなければundefined
を許容する仕様らしい。
もし、型引数を指定して初期値を省略した場合には、型は
undefined
を含む union 型になります:
setup
関数のトップレベルでawait
を使うと 非同期コンポーネント になる。
非同期コンポーネントの扱いがよくわかっていないので 初期値 undefined
の ref()
関数オブジェクトでリアクティブにしつつ、別の非同期関数内でそれに代入する形式に逃げました(要追加調査)
このあたり
let destination = ref<Destination>()
const initData = async () => {
const response = await fetch(`https://travel-dummy-api.netlify.app/${route.params.slug}.json`)
const data: Destination = await response.json()
destination.value = data
}
watcher
で route.params
の変更を検知して initData
を実行。 immediate
オプションを使って、コンポーネントの初回呼び出しにも対応。
watch(
()=>route.params,
initData,
{ immediate: true }
)
watch は、デフォルトでは、遅延して実行されます: 監視対象の値が変更するまでコールバックは実行されません。しかし、同様のコールバックのロジックを先に実行したい場合もあります。- たとえば、初期値のデータを読み込み、関連する状態が変更されるたび、再びデータを読み込みたいときです。
immediate: true オプションを渡すと、ウォッチャーのコールバックを強制的に即時実行させられます:
なぜ監視対象を ()=>route.params
として関数で指定しないといけないのかは分かってない(要追加調査)
Bing Search
watchは特定のデータや算出プロパティの状態の変化を監視して、変化があったときに登録した処理を実行するプロパティです[1]。watchオプションに監視するデータの名前と変化した時に呼び出されるハンドラ関数を指定します[1:1] [2]。
上記の例では、route.paramsは算出プロパティであり、ルートパラメータが変更されるたびに再評価されます[2:1]。そのため、watchでroute.paramsを直接指定すると、ルートパラメータが変更されるたびにinitData関数が実行されます。
しかし、watchで無名関数をsourceに指定すると、無名関数の戻り値が監視対象となります[2:2]。その場合、無名関数の中でroute.paramsを参照している必要があります。そうしないと、無名関数は常に同じ値(undefined)を返すため、watchは変化を検知できません。
つまり、watchで無名関数をsourceに指定する理由は、
- ルートパラメータが変更されるたびにinitData関数を実行したい
- 無名関数の中でroute.paramsを参照しないとwatchは変化を検知できない
ということです。
-
【Vue.js 】 watch データの変更を監視 - Qiita https://qiita.com/smkhkc/items/d5e1bc5580a62d060516 ↩︎ ↩︎
-
Watchers | Vue.js https://vuejs.org/guide/essentials/watchers.html ↩︎ ↩︎ ↩︎
公式ドキュメントのサンプルコードにも書いてあった。
以下のようなリアクティブのオブジェクトのプロパティを監視できないことに注意してください:
const obj = reactive({ count: 0 }) // これは、watch() に数値を渡しているので動作しません。 watch(obj.count, (count) => { console.log(`count is: ${count}`) })
代わりに、getter を使います:
// 代わりに、getter を使います: watch( () => obj.count, (count) => { console.log(`count is: ${count}`) } )
Chrome拡張機能のVue Devtoolsでコンポーネントについて詳細を見てみたら、RouterView
コンポーネントの params
に id
, slug
が渡されているよう。
Symbol(router view location)
{
"fullPath": "/destination/1/brazil",
"hash": "",
"query": {},
"name": "destination.show",
"path": "/destination/1/brazil",
"params": { "id": "1", "slug": "brazil" },
"matched": [
{
"path": "/destination/:id/:slug",
"name": "destination.show",
"meta": {},
"props": { "default": true },
"children": [],
"instances": { "default": {} },
"leaveGuards": [],
"updateGuards": [],
"enterCallbacks": {},
"components": {
"default": {
"_custom": {
"type": "component-definition",
"display": "DestinationShow",
"tooltip": "Component definition",
"file": "/vue-school-travel-app/src/views/DestinationShow.vue"
}
}
}
}
],
"meta": {},
"href": "/destination/1/brazil"
}
たぶん、:id
, :slug
のようにプロパティを渡しているから?
const routes = [
{
path: '/destination/:id/:slug',
name: 'destination.show',
component: ()=>import('@/views/DestinationShow.vue'),
props: true,
},
]
警告が出る。
<section class="experiences">
<h2>Top Experiences in {{ destination.name }}</h2>
<router-link
v-for="experience in destination.experiences"
:key="experience.slug"
:to="{name: 'experience.show', params: {experienceSlug: experience.slug}}"
+ v-bind="$attrs"
>
<ExperienceCard
:experience="experience"
></ExperienceCard>
</router-link>
<router-view />
</section>
使っていないpropsを渡しているから。
かつ
ルートにコンポーネントが複数あり、v-bind="$attrs"
してないから。
使ってないpropsを渡しているところ
const routes = [
{path: '/', name: 'Home', component: Home},
{
path: '/destination/:id/:slug',
name: 'destination.show',
component: ()=>import('@/views/DestinationShow.vue'),
props: (route: {params: {id: string, slug?: string}}) => ({...route.params, id: parseInt(route.params.id)}),
children: [
{
path: ':experienceSlug',
name: 'experience.show',
component: ()=>import('@/views/ExperienceShow.vue'),
// 👇ここ
props: (route: {params: {id: string, slug?: string}}) => ({...route.params, id: parseInt(route.params.id)})
}
]
},
]
あわせて読む
存在しないページでは404表示をするを実現するために想定していないパスのパターンにマッチしたら専用コンポーネントを呼び出す
公式サンプル
const routes = [ // will match everything and put it under `$route.params.pathMatch` { path: '/:pathMatch(.*)*', name: 'NotFound', component: NotFound }, // will match anything starting with `/user-` and put it under `$route.params.afterUser` { path: '/user-:afterUser(.*)', component: UserGeneric }, ]
https://router.vuejs.org/guide/essentials/dynamic-matching.html#catch-all-404-not-found-route
?
複数のroutesのパターンマッチしたパスはどれが採用されるの?
VueRouter4では説明が見つからなかったけど、V3ではその説明があった。おそらく同じ挙動でしょう。
しばしば同じ URL で複数のルートがマッチすることがあります。そのようなケースではマッチングの優先度はルートの定義された順番によって決定されます。先に定義されたルートほど優先度が高くなります。
ついでに
/:pathMatch(.*)*
全マッチさせようと思ったら/:pathMatch(.*)
だけでいいのでは?と思ったけどその意味について
/${pattern}
こういう記述の場合、pattern
はセグメント内にのみマッチする。
/${pattern}*
*
はセグメントを含めてすべてのパスにマッチする。
/:pathMatch(.*)*
よって、この記述だとすべてのパスにマッチする。
グローバルオブジェクトのwindow
オブジェクトにプロパティを追加しようとすると型エラーになる。任意のプロパティ追加を許容していないから。(逆に任意のプロパティを追加可能にする設定あったっけ?)
interface Window {
myProp: number
}
参考
GitHubPages に Deploy できるんだけど、画像のパスが通ってない。
これだと通らない。
<script setup>
</script>
<template>
<img src="/images/hoge.jpg">
</template>
こうするといいらしい。
<script setup>
import hogeImgUrl = from "./images/hoge.jpg"
</script>
<template>
<img src="/images/hoge.jpg">
</template>
ただ、動的に変わるパスだった場合、うまくいかない。
こういうやつ。
<script setup>
const dynamicImgUrl = await import (`./images/${dynamic}.jpg`)
</script>
Dynamic Import
glob import と同様に、 Vite は変数を使った動的インポートをサポートしています。
const module = await import(`./dir/${file}.js`)
いけるって書いてるけどなぁ
いくつかの制限を設けているらしい。
今回でいえば、これに引っかかった。
Imports must end with a file extension
そしてPromiseでラップされていて<img :src="">
に入らない。
複数必要な場合はglobで取るのがよさそう。
課題
RouterLink
の to
プロパティにstring
と RouterLinkのto専用の型(忘れた)
を併用させたいが、 string
に限定しないとうまくいかなかった箇所があるので直す