Closed59

VueRouterのチュートリアルをやる

Daisuke KusatakeDaisuke Kusatake

create-viteViteVue3 プロジェクトを作成する。

※ほんとは create-vue の方が手厚いけど、 VueRouter のチュートリアルに従う。

npm create vite

せめてもの抵抗で TypeScript を選択する。

Daisuke KusatakeDaisuke Kusatake

現在のディレクトリ構造

$ 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
Daisuke KusatakeDaisuke Kusatake

VueRouter を適用させる。

main.ts
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')
Daisuke KusatakeDaisuke Kusatake

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
Daisuke KusatakeDaisuke Kusatake

Home.vue, About.vue を作る。

src/views/Home.vue
<template>
  <div class="home">
    <h1>Home</h1>
  </div>
</template>
src/views/About.vue
<template>
  <div class="about">
    <h1>About</h1>
  </div>
</template>
Daisuke KusatakeDaisuke Kusatake

main.tsHome.vue, About.vueコンポーネントを使う記述を追加する。

main.ts
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')
Daisuke KusatakeDaisuke Kusatake

main.ts で呼び出しているルートコンポーネントの App.vueVueRouter のルーティング用コンポーネントである router-link router-viewを使用する。

App.vue
<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>

参考

https://router.vuejs.org/guide/#router-link

https://router.vuejs.org/guide/#router-view

Daisuke KusatakeDaisuke Kusatake

ここまでの結果

  • Home, About のハイパーリンクでコンポーネントを切り替えることができる。
    • ページ全体を読み込みなおすことなく、ページを部分的に変更することができる。
  • コンポーネントの切り替えに合わせてURLのパスも変更される。

Daisuke KusatakeDaisuke Kusatake

ルーティングに関する記述の外だし

main.tsにまとめていたルーティングに関する記述を専用ファイルにまとめて、そこから呼び出すように変更する。

理由

ルーティングに関する記述はそれだけで膨大なので、あとあと main.ts 内の記述量がとんでもないことになってしまうため。

mkdir src/router
touch src/router/index.ts
main.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')
src/router/index.ts
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
Daisuke KusatakeDaisuke Kusatake

本筋と関係ないけど、プロパティ名とグローバル変数名が一致してたらそのまま変数をプロパティとして採用してくれるのなんで?

const routes = []

{
  routes
}
Daisuke KusatakeDaisuke Kusatake

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'
}

https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Operators/Object_initializer#ecmascript_2015_での新しい表記法

Daisuke KusatakeDaisuke Kusatake

ルーティングで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 不利 有利

https://router.vuejs.org/guide/essentials/history-mode.html

Daisuke KusatakeDaisuke Kusatake

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]があります。

脚注
  1. ルートコンポーネントにプロパティを渡す | Vue Router ↩︎ ↩︎ ↩︎

  2. 【Nuxt.js】Vue Router基礎編:params, queryを使おう - Qiita ↩︎ ↩︎

Daisuke KusatakeDaisuke Kusatake

型アサーションで stringとして扱うのはやりたくないなぁ。

実際問題、配列で来ることもあるから、場合分けして安全に使用したい。

src/views/DestinationShow.vue
<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>
Daisuke KusatakeDaisuke Kusatake

いや、'/destination/:id' のパスでしか来ないから、params.idが複数取得することはありえないね。

src/router/index.ts
const routes = [
  {path: '/destination/:id', component: ()=>import('@/views/DestinationShow.vue')},
]

なら型アサーション使っていいか。

Daisuke KusatakeDaisuke Kusatake

parseInt()に入る文字列が数値解釈できるもの以外の場合がある。そのケアができていないけどいったん目をつむる。

src/views/DestinationShow.vue
<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>
Daisuke KusatakeDaisuke Kusatake

いや、destinationwatch して、destination === undefinedだったら404に飛ばしたりしたらいい気がする。

Daisuke KusatakeDaisuke Kusatake

いや、destinationwatch して、destination === undefinedだったら404に飛ばしたりしたらいい気がする。

いや、例外投げればいいんだ。

src/views/DestinationShow.vue
const destination = computed(()=>{
  const result = sourceData.destinations.find(destination => destination.id === destinationId.value )
  if (result === undefined) {
    throw new Error("invalid destination!")
  }
  return result
})

https://typescriptbook.jp/reference/statements/control-flow-analysis-and-type-guard

Daisuke KusatakeDaisuke Kusatake

destinationundefinedの可能性があるため、型エラーになる。

Array.prototype.find()してるからそりゃそう)

src/views/DestinationShow.vue
<template>
  <h1>{{ destination.value.name }}</h1>
</template>
Daisuke KusatakeDaisuke Kusatake

undefinedで条件分岐すればいいか……

src/views/DestinationShow.vue
<template>
  <h1>{{ destination !== undefined ? destination.name : "ここへ来てはいけない!すぐ戻れ!" }}</h1>
</template>
Daisuke KusatakeDaisuke Kusatake

destination の定義の時点で例外を投げるようにしたのでundefinedのチェックが不要になった。

Daisuke KusatakeDaisuke Kusatake

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.
Daisuke KusatakeDaisuke Kusatake

リダイレクトされているから正しい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]

http://disq.us/p/2t2x19f

Daisuke KusatakeDaisuke Kusatake

つまりこういうこと

- 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()
Daisuke KusatakeDaisuke Kusatake

<script setup>のトップレベルでawaitを使ったら非同期コンポーネントに自動的になるのは理解したけど、非同期コンポーネントは<Suspense>コンポーネントで囲うように言われる。

でも<Suspense>は実験的な機能でstableじゃないらしい。どうしよ。

https://ja.vuejs.org/guide/built-ins/suspense.html

Daisuke KusatakeDaisuke Kusatake

なんやかんやあってこうなった。

src/views/DestinationShow.vue
<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>

参考

https://ja.vuejs.org/guide/essentials/reactivity-fundamentals.html
https://ja.vuejs.org/guide/extras/reactivity-in-depth.html
https://ja.vuejs.org/guide/essentials/watchers.html#eager-watchers

Daisuke KusatakeDaisuke Kusatake

reactive()は引数を与えないといけないので、初期値がundefinedで非同期通信で得られた値を後から代入する使用法には不適。(要追加調査

ref()は引数がなければundefinedを許容する仕様らしい。

もし、型引数を指定して初期値を省略した場合には、型は undefined を含む union 型になります:

https://ja.vuejs.org/guide/typescript/composition-api.html#typing-ref

Daisuke KusatakeDaisuke Kusatake

setup 関数のトップレベルでawait を使うと 非同期コンポーネント になる。

非同期コンポーネントの扱いがよくわかっていないので 初期値 undefinedref() 関数オブジェクトでリアクティブにしつつ、別の非同期関数内でそれに代入する形式に逃げました(要追加調査

このあたり

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
}
Daisuke KusatakeDaisuke Kusatake

watcherroute.params の変更を検知して initData を実行。 immediate オプションを使って、コンポーネントの初回呼び出しにも対応。

watch(
  ()=>route.params,
  initData,
  { immediate: true }
)

watch は、デフォルトでは、遅延して実行されます: 監視対象の値が変更するまでコールバックは実行されません。しかし、同様のコールバックのロジックを先に実行したい場合もあります。- たとえば、初期値のデータを読み込み、関連する状態が変更されるたび、再びデータを読み込みたいときです。

immediate: true オプションを渡すと、ウォッチャーのコールバックを強制的に即時実行させられます:

https://ja.vuejs.org/guide/essentials/watchers.html#eager-watchers

なぜ監視対象を ()=>route.params として関数で指定しないといけないのかは分かってない(要追加調査

Daisuke KusatakeDaisuke Kusatake

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は変化を検知できない

ということです。

脚注
  1. 【Vue.js 】 watch データの変更を監視 - Qiita https://qiita.com/smkhkc/items/d5e1bc5580a62d060516 ↩︎ ↩︎

  2. Watchers | Vue.js https://vuejs.org/guide/essentials/watchers.html ↩︎ ↩︎ ↩︎

Daisuke KusatakeDaisuke Kusatake

公式ドキュメントのサンプルコードにも書いてあった。

以下のようなリアクティブのオブジェクトのプロパティを監視できないことに注意してください:

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}`)
  }
)

https://ja.vuejs.org/guide/essentials/watchers.html#watch-source-types

Daisuke KusatakeDaisuke Kusatake

Chrome拡張機能のVue Devtoolsでコンポーネントについて詳細を見てみたら、RouterView コンポーネントの paramsid, 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"
}
Daisuke KusatakeDaisuke Kusatake

たぶん、:id, :slug のようにプロパティを渡しているから?

src/router/index.ts
const routes = [
  {
    path: '/destination/:id/:slug',
    name: 'destination.show',
    component: ()=>import('@/views/DestinationShow.vue'),
    props: true,
  },
]
Daisuke KusatakeDaisuke Kusatake

警告が出る。

https://vuejs.org/guide/components/attrs.html#attribute-inheritance-on-multiple-root-nodes

src/views/DestinationShow.vue
  <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を渡しているところ

src/router/index.ts
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)})
      }
    ]
  },
]
Daisuke KusatakeDaisuke Kusatake

存在しないページでは404表示をするを実現するために想定していないパスのパターンにマッチしたら専用コンポーネントを呼び出す

https://router.vuejs.org/guide/essentials/dynamic-matching.html#catch-all-404-not-found-route

Daisuke KusatakeDaisuke Kusatake

公式サンプル

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のパターンマッチしたパスはどれが採用されるの?

Daisuke KusatakeDaisuke Kusatake

VueRouter4では説明が見つからなかったけど、V3ではその説明があった。おそらく同じ挙動でしょう。

しばしば同じ URL で複数のルートがマッチすることがあります。そのようなケースではマッチングの優先度はルートの定義された順番によって決定されます。先に定義されたルートほど優先度が高くなります。

https://v3.router.vuejs.org/ja/guide/essentials/dynamic-matching.html#マッチングの優先度

Daisuke KusatakeDaisuke Kusatake

ついでに

/:pathMatch(.*)*

全マッチさせようと思ったら/:pathMatch(.*)だけでいいのでは?と思ったけどその意味について

/${pattern}

こういう記述の場合、patternはセグメント内にのみマッチする。

/${pattern}*

* はセグメントを含めてすべてのパスにマッチする。

/:pathMatch(.*)*

よって、この記述だとすべてのパスにマッチする。

Daisuke KusatakeDaisuke Kusatake

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>
Daisuke KusatakeDaisuke Kusatake

ただ、動的に変わるパスだった場合、うまくいかない。

こういうやつ。

<script setup>
const dynamicImgUrl = await import (`./images/${dynamic}.jpg`)
</script>
Daisuke KusatakeDaisuke Kusatake

課題

RouterLinkto プロパティにstringRouterLinkのto専用の型(忘れた) を併用させたいが、 string に限定しないとうまくいかなかった箇所があるので直す

このスクラップは2023/10/22にクローズされました