Vue Router と Pinia を使った簡単なアプリの作成

JSON Placeholder という JSON 形式のデータを返してくれる無料のサービスを利用して、Vue Router と Pinia を使って投稿やユーザーの情報などを表示する簡単なアプリを作成する方法の覚書です。

関連ページ

作成日:2022年12月14日

{JSON} Placeholder というダミーの投稿やユーザーのデータを返してくれるサービスを利用して、Vue Router と Pinia を使って以下のような簡単なアプリを作成します。

Vue コンポーネントの定義は Composition API を使用して、拡張子が .vue単一ファイルコンポーネントscript setup 構文を使います。

ページ上部のリンクをクリックすると、コンポーネントで作成したビュー(View)を表示します。

Home(/)と About(/about)は単にタイトルが表示されるだけです。

/posts は取得した投稿の一覧ページです。投稿のタイトルをクリックすると個別ページに移動します。

/users は取得したユーザー(投稿の作成者)の一覧ページです。ユーザー名をクリックすると個別ページに移動します。

投稿の個別ページ /posts/id の画面では、投稿のタイトルと本文とユーザー名、コメントを表示します。

ユーザーの個別ページ /users/id の画面では、ユーザー名とそのユーザーが作成した投稿のリストを表示します。Pinia を使ったサンプルでは /users/username でアクセスするようにしています(特に理由はありません)。

投稿を追加したり、編集したりする機能はありません。

最初は Vue Router のみを使って作成し、次に Vue Router と Pinia を使って作成しています。

以下は、Vue Router や Pinia の使い方を個人的に確認するために作成したものです。サンプルは動作しますが、もっと良い方法があるかと思います。おかしなところがあるかもしれませんが悪しからず。

Vue Router を使ったアプリの作成

Vite を使って Vue Router をインストールしたプロジェクトの雛形を作成します。

Vue Router をインストール

npm init vue@latest を実行して、表示されるプロジェクト生成ウィザードで Add Vue Router for Single Page Application development? と聞かれた際に、 Yes を選択して Vue Router を追加します。

% npm init vue@latest return

Vue.js - The Progressive JavaScript Framework

✔ Project name: … vue-blog-project  //プロジェクト名(この名前のディレクトリが作成される)
✔ Add TypeScript? … No / Yes
✔ Add JSX Support? … No / Yes
✔ Add Vue Router for Single Page Application development? … No / Yes //Yes を選択
✔ Add Pinia for state management? … No / Yes
✔ Add Vitest for Unit Testing? … No / Yes
✔ Add an End-to-End Testing Solution? › No
✔ Add ESLint for code quality? … No / Yes

Scaffolding project in /Applications/MAMP/htdocs/vue/vue-blog-project...

Done. Now run:
  // 続いて以下のコマンドを実行
  cd vue-blog-project
  npm install
  npm run dev

続いて上記コマンドのレスポンスの Done. Now run: の下に記載されているコマンドを実行します。

  • cd コマンドでプロジェクトのディレクトリ(この例の場合は vue-blog-project)に移動
  • npm install コマンドで必要な依存関係にあるパッケージをインストール
  • npm run dev コマンドで開発サーバを起動
% cd vue-blog-project return  //プロジェクトのディレクトリに移動

% npm install return  //依存関係にあるパッケージをインストール
npm WARN deprecated sourcemap-codec@1.4.8: Please use @jridgewell/sourcemap-codec instead

added 35 packages, and audited 36 packages in 7s

5 packages are looking for funding
  run `npm fund` for details

found 0 vulnerabilities

% npm run dev return //開発サーバを起動

> vue-blog-project@0.0.0 dev
> vite

  VITE v3.2.5  ready in 308 ms

  ➜  Local:   http://127.0.0.1:5173/
  ➜  Network: use --host to expose

コマンドのレスポンスに出力された http://127.0.0.1:5173/ にアクセスして以下のような初期画面が表示されればインストールは完了です。

一度 control + c で開発サーバを終了して、不要なファイルやフォルダを削除します。

以下が不要なファイルやフォルダを削除後の構成の概要です。

vue-blog-project //プロジェクトのディレクトリ(一部のファイルは以下では省略)
├── index.html  //表示用フィル
├── node_modules
├── package-lock.json
├── package.json
├── public
├── src
│   ├── App.vue  // メインコンポーネント(Root Component) レンダリングの起点
│   ├── components  // ページで利用するコンポーネントのディレクトリ
│   ├── main.js  //ルーターの有効化が記述されているエントリポイント
│   ├── router
│   │   └── index.js  //ルーティングの定義とルーターの生成
│   └── views  //ページを構成するコンポーネント(Route Components)のディレクトリ
│       ├── AboutView.vue  // About ページのコンポーネント
│       └── HomeView.vue  // Home ページのコンポーネント
└── vite.config.js  //Vite の設定ファイル

ファイルやフォルダを削除したので、一部のファイルの内容を構成に合わせるように変更します。

index.html

表示用フィルの index.html には Vue アプリのマウント先の id="app"div 要素と type="module" を指定した main.js を読み込む script タグが記述されています。

そのままでも構いませんが、この例では <title> を変更しています。

index.html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8">
    <link rel="icon" href="/favicon.ico">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Vue Router Sample App</title>
  </head>
  <body>
    <div id="app"></div>
    <script type="module" src="/src/main.js"></script>
  </body>
</html>
main.js

main.js には、Vue アプリの生成とルーターのインストール、アプリのマウントが記述されています。

createApp() にメインコンポーネント App を渡して Vue のインスタンス(アプリ)を生成し、router(Router インスタンス)をアプリの use() メソッドに渡してプラグインとしてインストール(有効化)しています。そして最後にアプリをマウントしています。

main.css は削除したので、スタイルシートの読み込みを削除します(5行目)。

src/main.js
import { createApp } from 'vue'  //アプリを生成する createApp をインポート
import App from './App.vue'  //メインコンポーネントをインポート
import router from './router'  //router を router/index.js からインポート

//import './assets/main.css'  //不要なので削除

const app = createApp(App)  //Vue のインスタンスを生成

app.use(router)  //Vue のインスタンス(アプリ)に router をインストールして有効化

app.mount('#app')  //アプリをマウント
HomeView.vue

HomeView.vue は以下のように template ブロックのみに書き換えます。

src/views/HomeView.vue
<template>
  <div class="home">
    <h1>This is Home</h1>
  </div>
</template>
AboutView.vue

AboutView.vue も同様に以下のように template ブロックのみに書き換えます。

src/views/AboutView.vue
<template>
  <div class="about">
    <h1>This is an about page</h1>
  </div>
</template>
App.vue

App.vue は以下のように RouterLink を使った nav 要素と RouterView のみに書き換えます。

RouterView には現在のパスにマッチした(ルーティングに応じた)コンポーネントが描画されます。

スタイルは、現在のページを表すアクティブなリンクを表すクラス .router-link-exact-active が適用されるリンク要素は、クリックできないようにスタイルを設定しています。

src/App.vue
<script setup>
import { RouterLink, RouterView } from 'vue-router'
</script>

<template>
  <nav>
    <ul>
      <li>
        <RouterLink to="/">Home</RouterLink>
      </li>
      <li>
        <RouterLink to="/about">About</RouterLink>
      </li>
    </ul>
  </nav>
  <RouterView /><!-- この部分に現在のパスにマッチしたコンポーネントが描画される -->
</template>

<style scoped>
/* 現在のページに完全に一致するアクティブなリンクを表すクラス */
.router-link-exact-active {
  opacity: 0.5;
  pointer-events: none;
}
/* リンクのスタイル(適当) */
ul {
  display: flex;
}
li {
  margin-right:2rem;
  list-style-type: none;
}
</style>
index.js

src/route にある index.js はルーターの生成とルーティングの定義などを記述するファイルで、初期状態では以下のように HomeView と AboutView へのルート(route)が定義されています。

この時点では変更する必要はありません(後からルートを追加します)。

src/router/index.js
import { createRouter, createWebHistory } from 'vue-router'
import HomeView from '../views/HomeView.vue'

// createRouter メソッドでルーターを生成
const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  // routes オプションに描画するコンポーネント(ビュー)のルート(route)を定義
  routes: [
    {
      path: '/',  //リクエスパス
      name: 'home', //このルートの名前
      component: HomeView  //呼び出されるコンポーネント
    },
    {
      path: '/about',
      name: 'about',
      // route level code-splitting
      // this generates a separate chunk (About.[hash].js) for this route
      // which is lazy-loaded when the route is visited.
      component: () => import('../views/AboutView.vue')
    }
  ]
})

export default router

npm run dev コマンドで開発サーバを起動して http://127.0.0.1:5173/ にアクセスすると、リクエスパスが '/' の場合は HomeView コンポーネントが呼び出され、以下のように表示されます。

About のリンクをクリックすると、URL は http://127.0.0.1:5173/about になり、リクエスパスが '/about' の場合は AboutView コンポーネントが呼び出され、以下のように表示されます。

この例では、以下のようなコンポーネントを作成します。

vue-blog-project
├── index.html
├── src
│   ├── App.vue  //メインコンポーネント
│   ├── components
│   │   ├── PostComments.vue  //投稿のコメントを取得して出力するコンポーネント
│   │   ├── PostUser.vue //ユーザー名を出力するコンポーネント
│   │   └── UserPosts.vue //ユーザーが作成した投稿のリストを出力するコンポーネント
│   ├── main.js
│   ├── router
│   │   └── index.js  //ルーティング
│   └── views
│       ├── AboutView.vue
│       ├── HomeView.vue
│       ├── PostListView.vue  //投稿リストのビュー
│       ├── PostSingleView.vue  //個別投稿のビュー
│       ├── UserListView.vue  //ユーザーリストのビュー
│       └── UserSingleView.vue  //個別ユーザーのビュー
└── vite.config.js

JSON Placeholder API

以下で作成するアプリは {JSON} Placeholder という JSON 形式のデータを返してくれるサービスを利用します。

{JSON} Placeholder は登録も不要で無料で使用することができます。以降では fetch() メソッドを使って投稿(posts)やユーザー(users)などのデータをこの API から取得して利用します。

例えば、以下を記述するとユーザー(users)のデータ(JSON)を取得してコンソールに出力します。

fetch('https://jsonplaceholder.typicode.com/users')
  .then(response => response.json())
  .then(json => console.log(json))

以下のリソース(Resources)を利用できます。

Resources URL 説明
posts https://jsonplaceholder.typicode.com/posts 全ての投稿のデータ(100件)
users https://jsonplaceholder.typicode.com/users 全てのユーザーのデータ(10件)
comments https://jsonplaceholder.typicode.com/comments 500件のコメントのデータ

また、以下のような Routes を利用できます(http または https)。

Routes(例) 説明
/posts 全ての投稿のデータ
/posts/1 投稿の id が 1 のデータ
/posts/1/comments 投稿 id 1のコメント
/comments?postId=1 投稿 id 1のコメント

以下はそれぞれのリソースから取得できるデータの抜粋です。

投稿のデータから投稿の作成者(user)を取得するには userId を使用できます。

/posts(投稿)
[
  { //post(個々の投稿のデータ)
    "userId": 1,  //ユーザー ID(この投稿の作成者の ID)
    "id": 1,  //投稿 ID
    "title": "sunt aut facere repellat provident occaecati excepturi optio reprehenderit",
    "body": "quia et suscipit\nsuscipit recusandae consequuntur expedita et cum\nreprehenderit molestiae ut ut quas totam\nnostrum rerum est autem sunt rem eveniet architecto"
  },
  {
    "userId": 1,
    "id": 2,
    "title": "qui est esse",
    "body": "est rerum tempore vitae\nsequi sint nihil reprehenderit dolor beatae ea dolores neque\nfugiat blanditiis voluptate porro vel nihil molestiae ut reiciendis\nqui aperiam non debitis possimus qui neque nisi nulla"
  },
  ・・・以下省略・・・
]

以下の user の id は、上記の post の userId に対応しています。

/users(ユーザー)
[
  { //user(個々のユーザーのデータ)
    "id": 1,  //ユーザー ID(作成者 ID)
    "name": "Leanne Graham",
    "username": "Bret",
    "email": "Sincere@april.biz",
    "address": {
      "street": "Kulas Light",
      "suite": "Apt. 556",
      "city": "Gwenborough",
      "zipcode": "92998-3874",
      "geo": {
        "lat": "-37.3159",
        "lng": "81.1496"
      }
    },
    "phone": "1-770-736-8031 x56442",
    "website": "hildegard.org",
    "company": {
      "name": "Romaguera-Crona",
      "catchPhrase": "Multi-layered client-server neural-net",
      "bs": "harness real-time e-markets"
    }
  },
  ・・・以下省略・・・
]

以下の comment の postId は、投稿(post)の id に対応しています。コメントのデータから各投稿のコメントを取得するには postId を使用できます。

/comments(コメント)
[
  {
    "postId": 1,  //投稿 ID(このコメントの対象の投稿)
    "id": 1,  //コメント ID
    "name": "id labore ex et quam laborum",
    "email": "Eliseo@gardner.biz",
    "body": "laudantium enim quasi est quidem magnam voluptate ipsam eos\ntempora quo necessitatibus\ndolor quam autem quasi\nreiciendis et nam sapiente accusantium"
  },
  {
    "postId": 1,
    "id": 2,
    "name": "quo vero reiciendis velit similique earum",
    "email": "Jayne_Kuhic@sydney.com",
    "body": "est natus enim nihil est dolore omnis voluptatem numquam\net omnis occaecati quod ullam at\nvoluptatem error expedita pariatur\nnihil sint nostrum voluptatem reiciendis et"
  },
  ・・・以下省略・・・
]

投稿リストの画面

{JSON} Placeholder から fetch API を使って取得した投稿のリストを表示するコンポーネント(View ビュー)を作成し、ルーティングの定義にそのコンポーネントを表示するルート(route)を追加します。

PostListView を作成

投稿のリストを表示するコンポーネント PostListView.vue を src/views フォルダに作成します。

Options API の data に相当するデータ(リアクティブな変数)は setup 内で refreactive メソッドを使用して定義します。

全ての投稿を保持するデータの posts は初期状態では何もないので空の配列を設定しています。fetchPosts 関数で投稿のリソースが取得できれば、投稿のオブジェクトの配列が入ります。

ローディング状態を表す loading は初期値を false に設定します。エラーが発生した場合にエラーを格納する error は初期値を null にします。

fetchPosts 関数は fetch() を使って JSONPlaceholder から全ての投稿のリソースを取得する非同期関数です。https://jsonplaceholder.typicode.com/postsfetch() の引数に指定すると、全ての投稿のデータ(100件)を取得できます。

この関数では、リソースを取得する前に loadingtrue に変更することで、テンプレートの v-if="loading" でロード中であることを表すメッセージ(Loading posts...)を表示します。

エラーが発生した場合は、error プロパティにエラーを代入し、v-if="error" によりエラーメッセージを表示しますます。

リソースの取得が完了するかエラーになった時点(finally)で loadingfalse に戻すことで、ロード中を表すメッセージを非表示にします。

投稿が取得できた場合は、v-if="posts"により、取得した投稿を v-for でループして全ての投稿のタイトルと本文を出力します。その際に、RouterLink タグを使って、個々の投稿へのリンクを設定します。

RouterLinkto 属性には v-bind: を使って、後でルーティングに追加する個別の投稿のページのパス `/post/${post.id}` を指定します。post.id はそれぞれの投稿の id になります。

最後に fetchPosts 関数を呼び出して投稿を取得します。

Composition API の setup 内で関数を呼び出すのは、Options API の created() フックを使用して呼び出すのと同じことです。

src/views/PostListView.vue
<script setup>
// リンクのコンポーネント RouterLink をインポート
import { RouterLink } from 'vue-router'
// ref メソッドをインポート
import { ref } from 'vue'

// 全ての投稿を保持する配列
const posts = ref([])
// ローディングの状態を表す真偽値
const loading = ref(false)
// エラーが有った場合にエラーを保持
const error = ref(null)

// 全ての投稿を取得する非同期関数
const fetchPosts = async () => {
  // posts を初期化
  posts.value = []
  // loading を true に変更
  loading.value = true
  try {
    // JSONPlaceholder から fetch() を使って全ての投稿のリソースを取得
    posts.value = await fetch('https://jsonplaceholder.typicode.com/posts')
    .then((response) => response.json())
  } catch (error) {
    // エラーがあれば、error プロパティに代入
    error.value = error
  } finally {
    // loading を false に戻す(ロード完了)
    loading.value = false
  }
}

// 全ての投稿のデータを取得
fetchPosts()
</script>

<template>
  <main>
    <!-- loading が true であればロード中であることを表すメッセージを表示-->
    <p v-if="loading">Loading posts...</p>
    <!--  error が true であればエラーメッセージを表示 -->
    <p v-if="error">{{ error.message }}</p>
    <!-- 投稿を取得できたら、それぞれの投稿をループしてタイトルと本文を表示 -->
    <div v-if="posts" v-for="post in posts" :key="post.id">
      <!-- RouterLink で個々の投稿へのリンクを設定 -->
      <RouterLink :to="`/post/${post.id}`">{{ post.title }}</RouterLink>
      <p>{{ post.body }}</p>
    </div>
  </main>
</template>
ルート(route)を追加

作成した PostListView.vue を表示するため、src/router/index.js のルーティングを編集します。

routes オプションに以下のように PostListView.vue へのルート(route)を追加します。

path プロパティに PostListView.vue コンポーネントを表示するリクエスパス '/posts' を指定します。これにより、http://127.0.0.1:5173/posts にアクセスすると、PostListView.vue が描画されます。

また、name プロパティにルートの名前を指定します(オプション)。

この例では、about へのルート同様、component プロパティにコンポーネントを取得するための関数を渡して動的にコンポーネントをインポートするようにしています。

src/router/index.js
import { createRouter, createWebHistory } from 'vue-router'
import HomeView from '../views/HomeView.vue'

const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes: [
    //home のルート(route)
    {
      path: '/',
      name: 'home',
      component: HomeView
    },
    //about のルート(route)
    {
      path: '/about',
      name: 'about',
      // route level code-splitting (サンプルに記載されているコメント→削除)
      // this generates a separate chunk (About.[hash].js) for this route
      // which is lazy-loaded when the route is visited.
      component: () => import('../views/AboutView.vue')
    },
    //投稿のリストへのルート(route)を追加
    {
      path: '/posts',
      name: 'posts',
      // 動的にコンポーネントをインポート(route level code-splitting)
      component: () => import('../views/PostListView.vue')
    },
  ]
})

export default router
App.vue にリンクを追加

App.vue に PostListView.vue へのリンクを RouterLink で追加します。

RouterLinkto 属性に、ルーティングの path プロパティに指定した /posts を指定します。

src/App.vue
<script setup>
import { RouterLink, RouterView } from 'vue-router'
</script>

<template>
  <nav>
    <ul>
      <li>
        <RouterLink to="/">Home</RouterLink>
      </li>
      <li>
        <RouterLink to="/about">About</RouterLink>
      </li>
      <!-- 以下のリンクを追加 -->
      <li>
        <RouterLink to="/posts">Posts</RouterLink>
      </li>
    </ul>
  </nav>
  <RouterView />
</template>

<style scoped>
/* 省略 */
</style>

ルーティングで name プロパティを指定しているので、以下のように名前付きルートで指定することもできます。この場合、to 属性には、v-bind::) を使います。

<RouterLink :to="{ name: 'posts' }">Posts</RouterLink>

http://127.0.0.1:5173/posts にアクセスするか、追加したリンク(Posts)をクリックすると、以下のように投稿の一覧が表示されます。

デべロッパーツールのコンソールタブを確認すると、以下のような警告が出力されています。これは PostListView.vue のテンプレートで設定した個々の投稿へのリンクのリンク先のルートとコンポーネントががまだ定義されていないためです。

ユーザーリストの画面

投稿リストの画面と同様に、ユーザーリストの画面を表示するコンポーネント(View ビュー)を作成し、そのコンポーネントを表示するルート(route)を追加します。

UserListView を作成

ユーザーのリストを表示するコンポーネント UserListView.vue を src/views フォルダに作成します。

内容的には前述の PostListView.vue とほぼ同じです。

ユーザーのリソースを取得する URL は末尾が users になります。

リストの出力では、ユーザー名 {{ user.name }} に個々のユーザーページへのリンクを設定します。

リンクは RouterLink:to="`/user/${user.id}`" を指定して、個々のユーザーのページ /user/ユーザーID へ遷移するようにします。

src/views/UserListView.vue
<script setup>
import { RouterLink } from 'vue-router'
import { ref } from 'vue'

// 全てのユーザーを保持する配列
const users = ref([])
const loading = ref(false)
const error = ref(null)

// 全てのユーザーを取得する非同期関数
const fetchUsers = async () => {
  users.value = []
  loading.value = true
  try {
    // 全てのユーザーを取得
    users.value = await fetch('https://jsonplaceholder.typicode.com/users')
    .then((response) => response.json())
  } catch (error) {
    error.value = error
  } finally {
    loading.value = false
  }
}

// 全てのユーザーのデータを取得
fetchUsers()
</script>

<template>
  <main>
    <p v-if="loading">Loading posts...</p>
    <p v-if="error">{{ error.message }}</p>
    <!-- users が取得できれば(fetch が成功したら) -->
    <div v-if="users" v-for="user in users" :key="user.id">
      <!-- RouterLink で個々のユーザーページへのリンクを出力 -->
      <RouterLink :to="`/user/${user.id}`">{{ user.name }}</RouterLink>
    </div>
  </main>
</template>
ルート(route)を追加

作成した UserListView.vue を表示するため、src/router/index.js のルーティングを編集し、routes オプションに UserListView.vue へのルート(route)を追加します。

src/router/index.js
import { createRouter, createWebHistory } from 'vue-router'
import HomeView from '../views/HomeView.vue'

const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes: [
    {
      path: '/',
      name: 'home',
      component: HomeView
    },
    {
      path: '/about',
      name: 'about',
      component: () => import('../views/AboutView.vue')
    },
    {
      path: '/posts',
      name: 'posts',
      component: () => import('../views/PostListView.vue')
    },
    //ユーザーのリスト画面へのルート(route)を追加
    {
      path: '/users',
      name: 'users',
      component: () => import('../views/UserListView.vue')
    },
  ]
})

export default router

個別の投稿の画面

個別の投稿を表示するコンポーネント(View ビュー)を作成し、ルーティングの定義にそのコンポーネントを表示するルート(route)を追加します。

このビューでは、個々の投稿のタイトルと本文と共に、投稿のユーザー名(作者名)とその投稿に対するコメントも合わせて表示するので、以下のようなコンポーネントを作成します。

  • PostComments.vue:現在表示されている投稿のコメントを取得して出力するコンポーネント
  • PostUser.vue:投稿のユーザー名を取得して出力するコンポーネント
  • PostSingleView.vue:上記2つのコンポーネントを使って出力するコンポーネント(View ビュー)

PostComments.vue と PostUser.vue は src/components に、PostSingleView.vue は src/views に配置します。

PostComments の作成

内容的には、投稿のリストやユーザーのリストのコンポーネントとほぼ同じです。

コメントの数を取得するために、computed メソッドを使って算出プロパティ commentsLength を定義しています(取得したコメントの comments.length でもコメント数を取得できます)。

コメントは、投稿の id を使って、/posts/投稿id/comments のような URL でその投稿に対してのコメントを取得します。例えば、/posts/1/comments では id1 の投稿のコメントを全て取得できます。

この例では props を使って親コンポーネントの PostSingleView.vue から投稿の id を postId として受け取り、fetch() の引数で ${props.postId} として使用しています。

script setup 構文では props を宣言するには、defineProps を使用します。

src/components/PostComments.vue
<script setup>
// ref と computed メソッドをインポート
import { ref, computed } from 'vue'

// コメント
const comments = ref([])
const loading = ref(false)
const error = ref(null)

//コメントの数(算出プロパティ)
const commentsLength = computed(() => {
  return comments.value.length
});

//PostSingleView.vue(親コンポーネント)から投稿の id を受け取る props
const props = defineProps({
  postId : {
    type: String,
  }
});
//または const props = defineProps(['postId'])

//現在表示されている投稿のコメントを取得する関数
const fetchPostComments = async () => {
  comments.value = []
  loading.value = true
  try {
    // 投稿の id を使って現在の投稿のコメントを取得(引数の URL はバッククォートで囲む)
    comments.value = await fetch(`https://jsonplaceholder.typicode.com/posts/${props.postId}/comments`)
    .then((response) => response.json())
  } catch (error) {
    error.value = error
  } finally {
    loading.value = false
  }
}

//コメントを取得
fetchPostComments()
</script>

<template>
  <div>
    <p v-if="loading">Loading comments...</p>
    <p v-if="error">{{ error.message }}</p>
    <!-- comments が取得できれば(fetch が成功したら)-->
    <h3 v-if="comments">{{ commentsLength }} Comments: </h3>
    <div v-if="comments" v-for="comment in comments" :key="comment.id">
      <h3>{{ comment.name }}</h3>
      <p>{{ comment.body }}</p>
    </div>
  </div>
</template>
PostUser の作成

以下はユーザーを取得してユーザー名を出力するコンポーネントです。

JSON Placeholder API の投稿のデータ(例 /posts/1)にはその投稿の作成者(ユーザー)の情報が含まれていませんが、作成者(ユーザー)の id は userID として取得できます。

このコンポーネントでは、userIDprops で親コンポーネントの PostSingleView.vue から受け取り、fetch() の引数の URL の末尾に ${props.userId} と指定してユーザーを取得しています。

テンプレートでは、取得した username プロパティでユーザー名を取得し、RouterLink でユーザーの個別ページにリンクしています 。リンク先のパスのユーザーの id は取得したユーザー(user)の id プロパティの ${user.id} としていますが、props${userId} でも同じです(テンプレートでは props. は不要です) 。

src/components/PostUser.vue
<script setup>
import { ref } from 'vue'
import { RouterLink } from 'vue-router'

const user = ref(null)
const loading = ref(false)
const error = ref(null)
//userID を props で PostSingleView.vue から受け取る
const props = defineProps({
  userId : {
    type: Number,
  }
});

//ユーザーを取得する関数
const fetchPostUser = async () => {
  user.value = null
  loading.value = true
  try {
    // userId で指定したユーザーを取得(引数の URL はバッククォートで囲む)
    user.value = await fetch(`https://jsonplaceholder.typicode.com/users/${props.userId}`)
    .then((response) => response.json())
  } catch (error) {
    error.value = error
  } finally {
    loading.value = false
  }
}

//ユーザーを取得
fetchPostUser()
</script>

<template>
  <div>
    <p v-if="loading">Loading user...</p>
    <p v-if="error">{{ error.message }}</p>
    <!-- user が取得できれば(fetch が成功したら) -->
    <p v-if="user">Written by :
      <RouterLink :to="`/user/${user.id}`">{{ user.name }}</RouterLink>
    </p>
  </div>
</template>
PostSingleView を作成

以下は個別の投稿を表示するコンポーネント(View ビュー)です。

投稿のデータを取得する関数 fetchPost は id を引数に取ります。この関数に渡す id はこの後で定義するルーティングのパラメータ(:id)から route.params.id として取得します(34行目)。

コンポーネント側で、ルーティングのパラメータを取得するには、まず、現在のルート(route オブジェクト)を取得します。

Options API の場合は this.$route として現在のルート(route)にアクセスできますが、Comosition API の setup 内では this にアクセスできないので、ルーターから useRoute メソッドをインポートし、useRoute() を実行して route オブジェクトを生成(取得)します。

パラメータは routeparams プロパティ route.params.xxxxとしてアクセスできます。

また、先に作成した PostComments.vue と PostUser.vue をインポートします。

テンプレートではプロパティ(props)を子コンポーネントへ渡すため、PostUser のカスタム属性 user-id に投稿のユーザーID(post.userId)を指定します。同様に PostComments のカスタム属性 post-id に投稿のID(route.params.id)を指定します。

カスタム属性に文字列以外を渡す場合は v-bind: または省略形の : を使用する必要があります。

src/views/PostSingleView.vue
<script setup>
import { ref } from 'vue'
//コメントのコンポーネントをインポート
import PostComments from '../components/PostComments.vue'
//ユーザー名のコンポーネントをインポート
import PostUser from '../components/PostUser.vue'
//ルーターから useRoute をインポート
import { useRoute } from 'vue-router'
//現在のルート(route オブジェクト)を取得
const route = useRoute()

//投稿のデータ
const post = ref(null)
const loading = ref(false)
const error = ref(null)

//投稿のデータを取得する非同期関数
const fetchPost = async (id) => {
  / post を初期化
  post.value = null
  loading.value = true
  try {
    // 指定した id の投稿を取得(引数の URL はバッククォートで囲む)
    post.value = await fetch(`https://jsonplaceholder.typicode.com/posts/${id}`)
    .then((response) => response.json())
  } catch (error) {
    error.value = error
  } finally {
    loading.value = false
  }
}

// ルートのパラメータ(投稿の id)を引数に指定して投稿を取得
fetchPost(route.params.id)
</script>

<template>
  <div>
    <p v-if="loading">Loading post...</p>
    <p v-if="error">{{ error.message }}</p>
    <!-- post が取得できれば(fetch が成功したら) -->
    <div v-if="post">
      <h2>{{ post.title }}</h2>
      <p>{{ post.body }}</p>
      <!-- props(カスタム属性)で投稿のユーザーID(post.userId)を渡す -->
      <PostUser :user-id="post.userId" />
    </div>
    <hr>
    <!-- props(カスタム属性)で投稿のID(route.params.id)を渡す -->
    <PostComments :post-id="route.params.id" />
  </div>
</template>
ルート(route)を追加

作成した PostSingleView.vue を表示するため、src/router/index.js のルーティングを編集し、routes オプションに PostSingleView.vue へのルート(route)を追加します。

path プロパティの末尾にパラメータ(params)の文字列を : を付けて指定して、その値をルーター経由でコンポーネントに引き渡すことができます(パラメータを使った動的ルーティング)。

以下の場合、path プロパティに指定した :id の部分がパラメータとなります。この部分の値をコンポーネント側では route.params.id として参照できます。

src/router/index.js
import { createRouter, createWebHistory } from 'vue-router'
import HomeView from '../views/HomeView.vue'

const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes: [
    {
      path: '/',
      name: 'home',
      component: HomeView
    },
    {
      path: '/about',
      name: 'about',
      component: () => import('../views/AboutView.vue')
    },
    {
      path: '/posts',
      name: 'posts',
      component: () => import('../views/PostListView.vue')
    },
    {
      path: '/users',
      name: 'users',
      component: () => import('../views/UserListView.vue')
    },
    //個別投稿画面へのルート(route)を追加
    {
      path: '/post/:id',
      name: 'post',
      component: () => import('../views/PostSingleView.vue')
    },
  ]
})

export default router

例えば、ブラウザで投稿のリストのページから最初の投稿へのリンクをクリックすると、以下のような投稿(post)の id1http://127.0.0.1:5173/post/1 に遷移し、以下のように表示されます。

この時点では、まだ、ユーザー名に設定した /user/1 へのリンクのルーティングが定義されていないので警告が出力されています。

個別のユーザーの画面

個別のユーザーを表示するコンポーネント(View ビュー)を作成し、ルーティングの定義にそのコンポーネントを表示するルート(route)を追加します。

このビューには、ユーザー名とそのユーザーが作成した投稿のリストを表示します。ユーザーが作成した投稿のリストは、別途 UserPosts.vue というコンポーネントを作成します。

UserPosts を作成

ユーザーが作成した投稿のリストを出力するコンポーネント UserPosts.vue を作成します。

JSON Placeholder API から特定のユーザーが作成した投稿を取得するには、一度全ての投稿を取得して、投稿の userId プロパティがそのユーザーの id に一致するものを配列の filter() メソッドを使って抽出(フィルタ)します。

ユーザーの id は親コンポーネントの UserSingleView.vue から props として受け取ります。

ユーザーの投稿の数は computed() を使って算出プロパティとして定義していますが、テンプレートの中で userPosts.length としても取得できます。

投稿のリストは投稿のタイトルにそれぞれの投稿のページへのリンクを RouterLink で設定しています。

src/components/UserPosts.vue
<script setup>
// ref メソッドをインポート
import { ref, computed } from 'vue'
import { RouterLink } from 'vue-router'

const userPosts = ref([])
const loading = ref(false)
const error = ref(null)

//userID を props で PostSingleView.vue から受け取る
const props = defineProps(['userId'])

//ユーザーの投稿の数(算出プロパティ)
const userPostsLength = computed(() => {
  return userPosts.value.length
});

//ユーザーの投稿を取得する関数
const fetchUserPosts = async () => {
  userPosts.value = []
  loading.value = true
  try {
    // 一度全ての投稿を取得
    userPosts.value = await fetch(`https://jsonplaceholder.typicode.com/posts/`)
    .then((response) => response.json())
    //取得した投稿の中で現在のユーザーの投稿をフィルタ
    userPosts.value = userPosts.value.filter((post) => post.userId === parseInt(props.userId))
  } catch (error) {
    error.value = error
  } finally {
    loading.value = false
  }
}

//ユーザーの投稿を取得
fetchUserPosts()
</script>

<template>
  <div>
    <p v-if="loading">Loading posts...</p>
    <p v-if="error">{{ error.message }}</p>
    <!-- userPosts が取得できれば(fetch が成功したら) -->
    <p v-if="userPosts">{{ userPostsLength }} posts written.</p>
    <div v-if="userPosts" v-for="post in userPosts" :key="post.id">
      <RouterLink :to="`/post/${post.id}`">{{ post.title }}</RouterLink>
    </div>
  </div>
</template>
UserSingleView を作成

個別のユーザーを表示する画面のコンポーネント UserSingleView.vue を src/views に作成します。

表示するユーザーを取得するためのユーザーの id は、後で追加するルート(route)のパラメータ(route.params.uid)から取得します。

また、UserPosts コンポーネントには props を渡すため、カスタム属性 user-id にユーザーの id(route.params.uid)を指定します。

カスタム属性に文字列以外を渡す場合は v-bind: または省略形の : を使用する必要があります。

src/views/UserSingleView.vue
<script setup>
// ref メソッドをインポート
import { ref } from 'vue'
// UserPosts をインポート
import UserPosts from '../components/UserPosts.vue'
// ルーターから useRoute をインポート
import { useRoute } from 'vue-router'
// 現在のルート(route オブジェクト)を取得
const route = useRoute()

const user = ref(null)
const loading = ref(false)
const error = ref(null)

// ユーザーを取得する非同期関数
const fetchUser = async () => {
  user.value = null
  loading.value = true
  try {
    // ルートから取得した id を指定してユーザーを取得(引数の URL はバッククォートで囲む)
    user.value = await fetch(`https://jsonplaceholder.typicode.com/users/${route.params.uid}`)
    .then((response) => response.json())
  } catch (error) {
    error.value = error
  } finally {
    loading.value = false
  }
}

// ユーザーを取得
fetchUser()
</script>

<template>
  <div>
    <p v-if="loading">Loading user...</p>
    <p v-if="error">{{ error.message }}</p>
    <!-- user が取得できれば(fetch が成功したら)ユーザー名を出力 -->
    <h1 v-if="user">{{user.name}}</h1>
    <!-- カスタム属性(props)にユーザーIDを渡して UserPosts を呼び出す -->
    <UserPosts :user-id="route.params.uid"/>
  </div>
</template>
ルート(route)を追加

作成した UserSingleView.vue を表示するため、src/router/index.js のルーティングを編集し、routes オプションに UserSingleView.vue へのルート(route)を追加します。

個別の投稿のルーティング同様、パラメータを使った動的ルーティングを設定します。

以下の場合、path プロパティに指定した :uid の部分がパラメータとなります。コンポーネント側ではこの部分の値を route.params.uid として参照できます。

src/router/index.js
import { createRouter, createWebHistory } from 'vue-router'
import HomeView from '../views/HomeView.vue'

const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes: [
    {
      path: '/',
      name: 'home',
      component: HomeView
    },
    {
      path: '/about',
      name: 'about',
      component: () => import('../views/AboutView.vue')
    },
    {
      path: '/posts',
      name: 'posts',
      component: () => import('../views/PostListView.vue')
    },
    {
      path: '/users',
      name: 'users',
      component: () => import('../views/UserListView.vue')
    },
    {
      path: '/post/:id',
      name: 'post',
      component: () => import('../views/PostSingleView.vue')
    },
    //個別ユーザー画面へのルート(route)を追加
    {
      path: '/user/:uid',
      name: 'user',
      component: () => import('../views/UserSingleView.vue')
    },
  ]
})

export default router

ユーザーリストの最初のユーザーのリンクをクリックすると、以下のように表示されます。

これで今まで作成途中だったため、出力されていた警告は全てクリアされます。

ルーティングの概要

ユーザーのリスト画面の場合、UserListView コンポーネントのテンプレートでは以下のように RouterLinkto 属性に動的に /user/${user.id} へのリンクを出力しています。

<div v-if="users" v-for="user in users" :key="user.id">
  <!-- RouterLink で個々のユーザーページへのリンクを出力 -->
  <RouterLink :to="`/user/${user.id}`">{{ user.name }}</RouterLink>
</div>

v-for により以下のようなリンクのリストが出力されます。

<div><a href="/user/1">Leanne Graham</a></div>
<div><a href="/user/2">Ervin Howell</a></div>
<div><a href="/user/3">Clementine Bauch</a></div>
<div><a href="/user/4">Patricia Lebsack</a></div>
<div><a href="/user/5">Chelsey Dietrich</a></div>
<div><a href="/user/6">Mrs. Dennis Schulist</a></div>
<div><a href="/user/7">Kurtis Weissnat</a></div>
<div><a href="/user/8">Nicholas Runolfsdottir V</a></div>
<div><a href="/user/9">Glenna Reichert</a></div>
<div><a href="/user/10">Clementina DuBuque</a></div>

例えば、/user/7 のリンクをクリックすると、ルーターにより /user/7 に遷移します。

UserSingleView コンポーネントへのルート(route)の定義には '/user/:uid' のようにパラメータ :uid を使った path プロパティが設定されています。

// UserSingleView コンポーネントへのルート(route)の定義
{
  path: '/user/:uid',
  name: 'user',
  component: () => import('../views/UserSingleView.vue')
},

コンポーネント側では route.params.uid でパラメータ部分(:uid)の値 7 にアクセスできます。

これにより、UserSingleView コンポーネントでは fetch() の引数に ${route.params.uid} を指定した URL を渡してユーザーを取得することができます。

fetch(`https://jsonplaceholder.typicode.com/users/${route.params.uid}`)
//fetch(`https://jsonplaceholder.typicode.com/users/7`)
パラメータを props として渡す

ルーティングの定義で props オプションを追加して、パラメータを props として渡すこともできます。

propstrue に設定すると、route.params がコンポーネントのプロパティとして設定されます。

src/router/index.js
import { createRouter, createWebHistory } from 'vue-router'
import HomeView from '../views/HomeView.vue'

const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes: [
    {
      path: '/',
      name: 'home',
      component: HomeView
    },
    {
      path: '/about',
      name: 'about',
      component: () => import('../views/AboutView.vue')
    },
    {
      path: '/posts',
      name: 'posts',
      component: () => import('../views/PostListView.vue')
    },
    {
      path: '/users',
      name: 'users',
      component: () => import('../views/UserListView.vue')
    },
    {
      path: '/post/:id',
      name: 'post',
      component: () => import('../views/PostSingleView.vue'),
      props: true  //props オプションを追加
    },
    {
      path: '/user/:uid',
      name: 'user',
      component: () => import('../views/UserSingleView.vue'),
      props: true  //props オプションを追加
    },
  ]
})

export default router

コンポーネント側では対応する props を定義します。

以下の PostSingleView.vue の場合、route.params.id としていた箇所を、script 部分では props.id に、template 部分では id に置き換えます。

src/views/PostSingleView.vue
<script setup>
import { ref } from 'vue'
import PostComments from '../components/PostComments.vue'
import PostUser from '../components/PostUser.vue'

//props を定義
const props = defineProps(['id']);

const post = ref(null)
const loading = ref(false)
const error = ref(null)

const fetchPost = async (id) => {
  post.value = null
  loading.value = true
  try {
    post.value = await fetch(`https://jsonplaceholder.typicode.com/posts/${id}`)
    .then((response) => response.json())
  } catch (error) {
    error.value = error
  } finally {
    loading.value = false
  }
}

// props.id に変更
fetchPost(props.id)
</script>

<template>
  <div>
    <p v-if="loading">Loading post...</p>
    <p v-if="error">{{ error.message }}</p>
    <div v-if="post">
      <h2>{{ post.title }}</h2>
      <p>{{ post.body }}</p>
      <PostUser :user-id="post.userId" />
    </div>
    <hr>
    <!-- id に変更 -->
    <PostComments :post-id="id" />
  </div>
</template>

以下の UserSingleView.vue の場合、route.params.uid としていた箇所を、script 部分では props.uid に、template 部分では uid に置き換えます。

src/views/UserSingleView.vue
<script setup>
import { ref } from 'vue'
import UserPosts from '../components/UserPosts.vue'

//props を定義
const props = defineProps(['uid']);

const user = ref(null)
const loading = ref(false)
const error = ref(null)

const fetchUser = async () => {
  user.value = null
  loading.value = true
  try {
    // props.uid  に変更
    user.value = await fetch(`https://jsonplaceholder.typicode.com/users/${props.uid}`)
    .then((response) => response.json())
  } catch (error) {
    error.value = error
  } finally {
    loading.value = false
  }
}
fetchUser()
</script>

<template>
  <div>
    <p v-if="loading">Loading user...</p>
    <p v-if="error">{{ error.message }}</p>
    <h1 v-if="user">{{user.name}}</h1>
    <!-- uid に変更-->
    <UserPosts :user-id="uid"/>
  </div>
</template>

以下が最終的なファイルの構成です。

vue-blog-project
├── index.html
├── src
│   ├── App.vue  //メインコンポーネント
│   ├── components
│   │   ├── PostComments.vue  //投稿のコメントを取得して出力するコンポーネント
│   │   ├── PostUser.vue //ユーザー名を出力するコンポーネント
│   │   └── UserPosts.vue //ユーザーが作成した投稿のリストを出力するコンポーネント
│   ├── main.js
│   ├── router
│   │   └── index.js  //ルーティング
│   └── views
│       ├── AboutView.vue
│       ├── HomeView.vue
│       ├── PostListView.vue  //投稿リストのビュー
│       ├── PostSingleView.vue  //個別投稿のビュー
│       ├── UserListView.vue  //ユーザーリストのビュー
│       └── UserSingleView.vue  //個別ユーザーのビュー
└── vite.config.js

Pinia と Vue Router を使って

以下は前述のアプリとほぼ同じものを Pinia を使って作成する例です。

機能的にはほぼ同じですが、一部コンポーネントの名前や構成などが異なるため、書き換えるのではなく、Vite を使って Pinia と Vue Router をインストールしたプロジェクトを新たに作成します。

vue-blog-project2
├── index.html
├── src
│   ├── App.vue  //メインコンポーネント
│   ├── components
│   │   ├── CommentList.vue  //投稿に対するコメントを取得して出力するコンポーネント
│   │   ├── PostItem.vue  //個別の投稿のコンポーネント
│   │   └── UserItem.vue  //個別のユーザーのコンポーネント
│   ├── main.js
│   ├── router
│   │   └── index.js //ルーティング
│   ├── stores // Pinia ストア
│   │   ├── comment.js  //コメントのストア
│   │   ├── post.js  //投稿のストア
│   │   └── user.js  //ユーザーのストア
│   └── views
│       ├── AboutView.vue
│       ├── HomeView.vue
│       ├── PostListView.vue  //投稿リストのビュー
│       ├── PostSingleView.vue  //個別投稿のビュー
│       ├── UserListView.vue  //ユーザーリストのビュー
│       └── UserSingleView.vue  //個別ユーザーのビュー
└── vite.config.js

関連ページ:Pinia を使って状態管理

以下のサイトを参考にさせていただきました。

Complex Vue 3 state management made easy with Pinia

Pinia と Vue Router をインストール

npm init vue@latest を実行して、表示されるプロジェクト生成ウィザードで Add Vue Router for Single Page Application development? と聞かれた際に、Yes を選択して Vue Router を追加し、Add Pinia for state management? と聞かれた際に、Yes を選択して Pinia を追加します。

% npm init vue@latest  return

Vue.js - The Progressive JavaScript Framework

✔ Project name: … vue-blog-project2  //プロジェクト名
✔ Add TypeScript? … No / Yes
✔ Add JSX Support? … No / Yes
✔ Add Vue Router for Single Page Application development? … No / Yes //Yes を選択
✔ Add Pinia for state management? … No / Yes //Yess を選択
✔ Add Vitest for Unit Testing? … No / Yes
✔ Add an End-to-End Testing Solution? › No
✔ Add ESLint for code quality? … No / Yes

Scaffolding project in /Applications/MAMP/htdocs/vue/vue-blog-project2...

Done. Now run:

  cd vue-project
  npm install
  npm run dev

続いて上記のコマンドのレスポンスの Done. Now run: の下に記載されているコマンドを実行します。

  • cd コマンドで作成されたプロジェクトのディレクトリに移動
  • npm install コマンドで必要な依存関係にあるパッケージをインストール
  • npm run dev コマンドで開発サーバを起動
% cd vue-blog-project2  return

% npm install  return
added 37 packages, and audited 38 packages in 9s

7 packages are looking for funding
  run `npm fund` for details

found 0 vulnerabilities

% npm run dev  return

> vue-blog-project2@0.0.0 dev
> vite

  VITE v4.0.0  ready in 269 ms

  ➜  Local:   http://127.0.0.1:5173/
  ➜  Network: use --host to expose

コマンドのレスポンスに出力された http://127.0.0.1:5173/ にアクセスして以下のような初期画面が表示されればインストールは完了です。

一度 control + c で開発サーバを終了して、不要なファイルを削除します。

src フォルダ内の assets フォルダ全体と、components フォルダ内の icons フォルダ全体と3つのサンプルコンポーネントのファイルは不要なので削除します。

また、ストアのファイル(counter.js)の名前を post.js に変更します。

以下が変更後の構成の概要です。

Pinia をインストールしたのでストアのファイルを保存する stores ディレクトリとその中にストアのサンプルファイルが作成されています

vue-blog-project2 //プロジェクトのディレクトリ
├── index.html  //表示用フィル
├── node_modules
├── package-lock.json
├── package.json
├── public
├── src
│   ├── App.vue  // メインコンポーネント
│   ├── components  // ページで利用するコンポーネントのディレクトリ
│   ├── main.js  //ルーターの有効化が記述されているエントリポイント
│   ├── router
│   │   └── index.js  //ルーティングの定義とルーターの生成
│   ├── stores  //Pinia のストアのディレクトリ
│   │   └── post.js  //投稿用のストアのファイル(名前を変更)
│   └── views  //ページを構成するコンポーネントのディレクトリ
│       ├── AboutView.vue  // About ページのコンポーネント
│       └── HomeView.vue  // Home ページのコンポーネント
└── vite.config.js  //Vite の設定ファイル

ファイルやフォルダを削除したので、一部のファイルの内容を構成に合わせるように変更します。

index.html

表示用フィルの index.html には Vue アプリのマウント先の id="app"div 要素と type="module" を指定した main.js を読み込む script タグが記述されています。

そのままでも構いませんが、この例では <title> を変更しています。

index.html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8">
    <link rel="icon" href="/favicon.ico">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Pinia / Vue Router Sample App</title>
  </head>
  <body>
    <div id="app"></div>
    <script type="module" src="/src/main.js"></script>
  </body>
</html>
main.js

main.js には、Vue アプリの生成と Pinia のインスタンスの生成とインストール、ルーターのインストール、及びアプリのマウントが記述されています。

createApp() にメインコンポーネント App を渡して Vue のインスタンス(アプリ)を生成し、createPinia() で生成した Pinia のインスタンスをアプリの use() メソッドに渡してインストールしています。

そしてアプリにルーターをインストールして、最後にアプリをマウントしています。

main.css は削除したので、スタイルシートの読み込みを削除します(7行目)。

src/main.js
import { createApp } from 'vue'  //アプリを生成する createApp をインポート
import { createPinia } from 'pinia' //pinia から createPinia をインポート

import App from './App.vue' //メインコンポーネントをインポート
import router from './router' //ルーターをインポート

//import './assets/main.css'  // 不要なので削除

const app = createApp(App) //Vue アプリのインスタンスを生成

app.use(createPinia()) //Pinia を生成してアプリで読み込む(インストール)
app.use(router) //アプリにルーターをインストール

app.mount('#app')
HomeView.vue

HomeView.vue は以下のように template ブロックのみに書き換えます。

src/views/HomeView.vue
<template>
  <div class="home">
    <h1>This is Home</h1>
  </div>
</template>
AboutView.vue

AboutView.vue も同様に以下のように template ブロックのみに書き換えます。

src/views/AboutView.vue
<template>
  <div class="about">
    <h1>This is an about page</h1>
  </div>
</template>
App.vue

App.vue は以下のように RouterLink を使った nav 要素と RouterView のみに書き換えます。

RouterView には現在のパスにマッチした(ルーティングに応じた)コンポーネントが描画されます。

スタイルは、現在のページを表すアクティブなリンクを表すクラス .router-link-exact-active が適用されるリンク要素は、クリックできないようにスタイルを設定し、その他最低限のスタイルを指定しています。

src/App.vue
<script setup>
import { RouterLink, RouterView } from 'vue-router'
</script>

<template>
  <nav>
    <ul>
      <li>
        <RouterLink to="/">Home</RouterLink>
      </li>
      <li>
        <RouterLink to="/about">About</RouterLink>
      </li>
    </ul>
  </nav>
  <RouterView /><!-- この部分に現在のパスにマッチしたコンポーネントが描画される -->
</template>

<style scoped>
/* 現在のページに完全に一致するアクティブなリンクを表すクラス */
.router-link-exact-active {
  opacity: 0.5;
  pointer-events: none;
}
ul {
  display: flex;
}
li {
  margin-right:2rem;
  list-style-type: none;
}
</style>
index.js

src/route にある index.js はルーターの生成とルーティングの定義などを記述するファイルで、初期状態では以下のように HomeView と AboutView へのルート(route)が定義されています。

この時点では変更する必要はありません(後からルートを追加します)。

src/router/index.js
import { createRouter, createWebHistory } from 'vue-router'
import HomeView from '../views/HomeView.vue'

// createRouter メソッドでルーターを生成
const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  // routes オプションに指定した path で描画するコンポーネントのルート(route)を定義
  routes: [
    {
      path: '/',  //リクエスパス
      name: 'home', //このルートの名前
      component: HomeView  //呼び出されるコンポーネント
    },
    {
      path: '/about',
      name: 'about',
      // route level code-splitting
      // this generates a separate chunk (About.[hash].js) for this route
      // which is lazy-loaded when the route is visited.
      component: () => import('../views/AboutView.vue')
    }
  ]
})

export default router

npm run dev コマンドで開発サーバを起動して http://127.0.0.1:5173/ にアクセスすると、リクエスパスが '/' の場合は HomeView コンポーネントが呼び出され、以下のように表示されます。

About のリンクをクリックすると、URL は http://127.0.0.1:5173/about になり、リクエスパスが '/about' の場合は AboutView コンポーネントが呼び出され、以下のように表示されます。

投稿リストの画面

投稿用のストアを定義して、投稿のリストの画面を表示するコンポーネント(View ビュー)を作成します。

Pinia ではコンポーネント間で共有するデータを必要に応じてストア(store)と呼ばれる場所で管理することができます。

最初のアプリの例では、コンポーネントのファイルごとにデータを定義していましたが、Pinia を使うと、コンポーネント間で共有するデータをまとめてストアのファイルで管理することができます。

ストアは必要なだけ作成することができますが、各ストアをそれぞれ別のファイルに定義する必要があります。ストアを定義するファイルは、src/stores/ に保存します。

投稿のストアを定義

store ディレクトリの post.js(名前を変更したファイル)を書き換えて投稿のストアを定義します。

ストアは defineStore() を使って定義し、名前を付けてエクスポートします。

ストアの名前は慣例的に use で始まり、最後に Store を付けます。

以下では usePostStore という名前を付けてエクスポートしています。

defineStore() は以下の2つの引数を取ります。

  • 第1引数: ID (ストアを識別するための文字列)。以下では 'post'
  • 第2引数:以下のプロパティを持つ Options オブジェクト。または Setup 関数(Setup Syntax
    • state : データ本体
    • getters : state のデータに対する算出プロパティ
    • actions : state の値を更新する関数(ストアのインスタンスに this でアクセス)。this を利用するのでアロー関数は使えません。

defineStore() は定義されたストアのインスタンスを生成する関数(ゲッター)を返します。

利用するコンポーネント側ではストアをインポートしてこの関数(ゲッター)を実行することでストアのインスタンスを取得することができます。

このストアでは投稿に関するプロパティをまとめて管理します。

src/stores/post.js
import { defineStore } from 'pinia'  //defineStore をインポート

//ストアを定義して名前(usePostStore)を付けてエクスポート
export const usePostStore = defineStore('post', {
  //state はデータの初期状態を返す関数として定義
  state: () => ({
    // 全ての投稿を保持する配列の初期値
    posts: [],
    //ローディングの状態の初期値
    loading: false,
    //エラーの初期値
    error: null
  }),

  //actions には state の値を更新する関数を定義
  actions: {
    //全ての投稿を取得する非同期関数(this を使うのでアロー関数は使えない)
    async fetchPosts() {
      //posts を初期化(ストアのインスタンスには this でアクセス)
      this.posts = []
      //loading を true に
      this.loading = true
      //JSONPlaceholder から fetch() を使って全ての投稿のリソースを取得
      try {
        this.posts = await fetch('https://jsonplaceholder.typicode.com/posts')
        .then((response) => response.json())
      } catch (error) {
        //エラーがあれば、error プロパティに代入
        this.error = error
      } finally {
        //loading を false に戻す(ロード完了)
        this.loading = false
      }
    },
  }
})
PostListView を作成

投稿のリストを表示するコンポーネント PostListView.vue を src/views フォルダに作成します。

このコンポーネントでは、定義した投稿のストアをインポートして必要なデータを抽出します。

ストアにアクセス

コンポーネント側でストアにアクセスするには、定義したストアをインポートし、setup 内でインポートしたストアの関数を実行します。

この例の場合では、先に定義した usePostStore をインポートして実行し、戻り値のストアのインスタンスを変数に代入して、この変数を介してストアで定義したプロパティにアクセスします。

<script setup>
//定義したストア(の関数)をインポート
import { usePostStore } from '../stores/post'

//ストアの関数を実行して、戻り値のストアのインスタンスを変数に代入
const postStore = usePostStore()

//上記変数を介してストアのプロパティにアクセス
console.log(postStore.loading)
</script>

分割代入を使って必要なプロパティのみを取得することもできますが、その場合はリアクティビティが失われないように storeToRefs() メソッドを使う必要があります。

但し、actions のプロパティは直接分割代入することができます。

以下は PostListView コンポーネントの定義です。

投稿のストア post.js から定義した usePostStore をインポートしています。

setup 内で usePostStore() を実行して pinia からインポートした storeToRefs() メソッドを使って分割代入で必要な state のプロパティを取得しています。

actionsfetchPosts 関数は直接分割代入して使用します。

src/views/PostListView.vue
<script setup>
  import { RouterLink } from 'vue-router'
  import { storeToRefs } from 'pinia'   // storeToRefs メソッドをインポート
  import { usePostStore } from '../stores/post'  //ストアをインポート

  //storeToRefs メソッドを使って state プロパティを分割代入
  const { posts, loading, error } = storeToRefs(usePostStore())
  //actions は直接分割代入することが可能
  const { fetchPosts } = usePostStore()
  // 全ての投稿を取得
  fetchPosts()
</script>

<template>
  <main>
    <!-- loading が true であればロード中であることを表すメッセージを表示-->
    <p v-if="loading">Loading posts...</p>
    <!--  error が true であればエラーメッセージを表示 -->
    <p v-if="error">{{ error.message }}</p>
    <!-- 投稿を取得できたら、それぞれの投稿をループしてタイトルと本文を表示 -->
    <p v-if="posts" v-for="post in posts" :key="post.id">
      <!-- RouterLink で個々の投稿へのリンクを設定 -->
      <RouterLink :to="`/post/${post.id}`">{{ post.title }}</RouterLink>
      <p>{{ post.body }}</p>
    </p>
  </main>
</template>
ルート(route)を追加

作成した PostListView.vue を表示するため、src/router/index.js のルーティングを編集して route を追加します。内容は先のサンプルの ルート(route)を追加と同じです。

src/router/index.js
import { createRouter, createWebHistory } from 'vue-router'
import HomeView from '../views/HomeView.vue'

const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes: [
    {
      path: '/',
      name: 'home',
      component: HomeView
    },
    {
      path: '/about',
      name: 'about',
      component: () => import('../views/AboutView.vue')
    },
    //投稿のリストへのルートを追加
    {
      path: '/posts',
      name: 'posts',
      // 動的にコンポーネントをインポート(route level code-splitting)
      component: () => import('../views/PostListView.vue')
    },
  ]
})

export default router
App.vue にリンクを追加

App.vue に PostListView.vue へのリンクを RouterLink で追加します。

RouterLinkto 属性に、ルーティングの path プロパティに指定した /posts を指定します。内容は先のサンプルの App.vue にリンクを追加と同じです。

src/App.vue
<script setup>
  import { RouterLink, RouterView } from 'vue-router'
  </script>

  <template>
    <nav>
      <ul>
        <li>
          <RouterLink to="/">Home</RouterLink>
        </li>
        <li>
          <RouterLink to="/about">About</RouterLink>
        </li>
        <!-- 以下のリンクを追加 -->
        <li>
          <RouterLink to="/posts">Posts</RouterLink>
        </li>
      </ul>
    </nav>
    <RouterView />
  </template>

  <style scoped>
  /* 省略 */
  </style>
  

ユーザーリストの画面

ユーザーリストの画面を表示するコンポーネント(View ビュー)を作成します。

ユーザーのストアを定義

store ディレクトリに user.js というファイルを作成してユーザーのストアを定義します。

内容的には投稿のストアとほぼ同じです。ユーザーの取得先の URL の末尾が users になります。

src/stores/user.js
import { defineStore } from 'pinia'  //defineStore をインポート

//ストアを定義して名前(useUserStore)を付けてエクスポート
export const useUserStore = defineStore('user', {
  state: () => ({
    // 全てのユーザーを保持する配列の初期値
    users: [],
    // ローディングの状態の初期値
    loading: false,
    // エラーの初期値
    error: null
  }),

  actions: {
    //全ての投稿を取得する非同期関数
    async fetchUsers() {
      this.users = []
      this.loading = true
      //JSONPlaceholder から fetch() を使って全てのユーザーのリソースを取得
      try {
        this.users = await fetch('https://jsonplaceholder.typicode.com/users')
        .then((response) => response.json())
      } catch (error) {
        this.error = error
      } finally {
        this.loading = false
      }
    },
  }
})

UserListView を作成

ユーザーのリストを表示するコンポーネント UserListView.vue を src/views フォルダに作成します。

リンクは RouterLink:to="`/user/${user.username}`" を指定して、個々のユーザーのページ /user/ユーザー名 へ遷移するようにします。ルート(route)を追加

先の例の場合は、リンク先に `/user/${user.id}` を指定してページのパスを /user/ユーザーID としましたが、この例では個々のユーザーのページのパスを /user/ユーザー名 にしています(特にこのようにする理由はありませんが、バリエーションとして)。

src/views/UserListView.vue
<script setup>
  import { RouterLink } from 'vue-router'
  import { storeToRefs } from 'pinia'
  import { useUserStore } from '../stores/user'

  // useUserStore から users, loading, error を取得
  const { users, loading, error } = storeToRefs(useUserStore())
  // useUserStore から fetchUsers を取得
  const { fetchUsers } = useUserStore()

  // 全てのユーザーのデータを取得
  fetchUsers()
</script>

<template>
  <main>
    <p v-if="loading">Loading users...</p>
    <p v-if="error">{{ error.message }}</p>
    <!-- ユーザーを取得できたら、それぞれのユーザーをループしてタイトルと本文を表示 -->
    <p v-if="users" v-for="user in users" :key="user.id">
      <!-- RouterLink で個々のユーザーへのリンク(/user/ユーザー名)を設定 -->
      <RouterLink :to="`/user/${user.username}`">{{ user.name }}</RouterLink>
    </p>
  </main>
</template>
ルート(route)を追加

作成した UserListView.vue を表示するため、src/router/index.js のルーティングを編集し、routes オプションに UserListView.vue へのルートを追加します。内容は先のサンプルの ルート(route)を追加 と同じです。

src/router/index.js
import { createRouter, createWebHistory } from 'vue-router'
import HomeView from '../views/HomeView.vue'

const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes: [
    {
      path: '/',
      name: 'home',
      component: HomeView
    },
    {
      path: '/about',
      name: 'about',
      component: () => import('../views/AboutView.vue')
    },
    {
      path: '/posts',
      name: 'posts',
      component: () => import('../views/PostListView.vue')
    },
    //ユーザーのリスト画面へのルート(route)を追加
    {
      path: '/users',
      name: 'users',
      component: () => import('../views/UserListView.vue')
    },
  ]
})

export default router

個別の投稿の画面

個別の投稿を表示するコンポーネント(View ビュー)を作成します。

このビューでは、個々の投稿のタイトルと本文と共に、投稿のユーザー名(作者名)とその投稿に対するコメントも合わせて表示します。

投稿に対するコメントを取得して出力するには、別途コメントのストア(comment.js)とコメントのコンポーネント(CommentList.vue)を定義します。

投稿のユーザー名(作者名)の取得には、ユーザーのストア(user.js)にゲッターを追加で定義します。

また、個別の投稿のコンポーネント(PostItem.vue)を作成して、投稿のユーザー名(作者名)やコメントと共にビュー(PostSingleView.vue)で出力します。

投稿のストアを編集

投稿のストア(post.js)を編集して、state に現在の投稿のデータを保持する post を追加し、その現在の投稿を取得する非同期関数 fetchPostactions に追加します。

fetchPost() では、最初に post を初期化します。これを行わないと、一瞬、現在の投稿が前の投稿のデータとともに表示されてしまいます。

fetchPost() の引数に指定する投稿の ID はルート(route)からパラメータとして取得して渡します。

src/stores/post.js
import { defineStore } from 'pinia'

export const usePostStore = defineStore('post', {
  state: () => ({
    posts: [],
    post: null, // *** 追加 *** 現在の投稿のデータの初期値
    loading: false,
    error: null
  }),

  actions: {
    async fetchPosts() {
      this.posts = []
      this.loading = true
      try {
        this.posts = await fetch('https://jsonplaceholder.typicode.com/posts')
        .then((response) => response.json())
      } catch (error) {
        this.error = error
      } finally {
        this.loading = false
      }
    },

    // *** 追加 ***
    async fetchPost(id) {
      //post を初期化。
      this.post = null
      this.loading = true
      try {
        //引数で指定した id の投稿を取得(引数の URL はバッククォートで囲む)
        this.post = await fetch(`https://jsonplaceholder.typicode.com/posts/${id}`)
        .then((response) => response.json())
      } catch (error) {
        this.error = error
      } finally {
        this.loading = false
      }
    },
  }
})
コメントのストアを定義

src/stores に comment.js を作成して、コメントのストアを定義します。

statecommentsactionsfetchComments() で取得した全てのコメントを保持する配列です。

gettersgetPostComments は全てのコメント(comments)から現在の投稿のコメントを取得するゲッター(算出プロパティ)です。

現在の投稿のコメントを取得するには、現在の投稿の ID が必要ですが、投稿の ID は投稿のストア usePostStore で定義されています。

Pinia では他のストアのデータにアクセスすることができます。投稿のストアのデータにアクセスするには、投稿のストア(usePostStore)をインポートして、usePostStore() で投稿のストアのインスタンスを生成して変数(postSore)に代入します。

そして、そのインスタンス(postSore)を介して投稿に postSore.post でアクセスしてその ID(postSore.post.id)を取得します。

src/stores/comment.js
import { defineStore } from 'pinia'
//現在の投稿の ID を取得するために、投稿のストアをインポート(getPostComments で利用)
import { usePostStore } from './post'

export const useCommentStore = defineStore('comment',{
  state: () => ({
    //コメントの配列
    comments: [],
    loading: false,
    error: null
  }),
  getters: {
    //取得した全てのコメントで現在の投稿のコメントを取得
    getPostComments: (state) => {
      //投稿のストアのインスタンスを生成して、投稿の ID(postSore.post.id)を取得
      const postSore = usePostStore()
      //comments の各コメントの postId と投稿の ID が一致するものをフィルタして抽出
      return state.comments.filter((comment) => comment.postId === postSore.post.id)
    }
  },
  actions: {
    //全てのコメントを取得する非同期関数
    async fetchComments() {
      this.comments = []
      this.loading = true
      //JSONPlaceholder から fetch() を使って全てのコメントのリソースを取得
      try {
        this.comments = await fetch('https://jsonplaceholder.typicode.com/comments')
        .then((response) => response.json())
      } catch (error) {
        this.error = error
      } finally {
        this.loading = false
      }
    },
  }
})
コメントのコンポーネントを作成

コメントのコンポーネント CommentList.vue を src/components に作成します。

テンプレートでは props 経由で受け取った comments(コメントの配列)を v-for を使って、コメントのタイトル(comment.name)と本文(comment.body)を出力します。

src/components/CommentList.vue
<script setup>
import { useCommentStore } from '../stores/comment'
import { storeToRefs } from 'pinia'
const { loading, error } = storeToRefs(useCommentStore())
// props を宣言して親コンポーネントから comments を受け取る
defineProps(['comments'])
</script>

<template>
  <div>
    <p v-if="loading">Loading comments...</p>
    <p v-if="error">{{ error.message }}</p>
    <div v-if="comments" v-for="comment in comments" :key="comment.id">
      <h3>{{ comment.name }}</h3>
      <p>{{ comment.body }}</p>
    </div>
  </div>
</template>
ユーザーのストアを編集

個別の投稿の画面には、その投稿の作者(ユーザー)を出力するので、ユーザーのストアに投稿の作者(ユーザー)を取得するゲッターを追加します。

現在の投稿のユーザー ID(userId)を取得するために、投稿のデータが必要なので投稿のストアをインポートします。

gettersgetPostUser では投稿のストアのインスタンスを usePostStore() で生成して、インスタンス経由で投稿のユーザーID(postStore.post.userId)を取得し、全てのユーザー(state.users)をフィルタして、各ユーザーの ID(user.id) と一致するものを取得します。

src/stores/user.js
import { defineStore } from 'pinia'
 //*** 追加 *** 現在の投稿の ID を取得するために、投稿のストアをインポート
import { usePostStore } from './post'

export const useUserStore = defineStore('user', {
  state: () => ({
    users: [],
    loading: false,
    error: null
  }),
  // *** 追加 ***
  getters: {
    //投稿の作者(ユーザー)を取得するゲッター
    getPostUser: (state) => {
      const postStore = usePostStore()
      return state.users.find((user) => user.id === postStore.post.userId)
    }
  },
  actions: {
    async fetchUsers() {
      this.users = []
      this.loading = true
      try {
        this.users = await fetch('https://jsonplaceholder.typicode.com/users')
        .then((response) => response.json())
      } catch (error) {
        this.error = error
      } finally {
        this.loading = false
      }
    },
  }
})
投稿のコンポーネントを作成

個別の投稿のコンポーネント PostItem.vue を src/components に作成します。

props を宣言して親コンポーネントから postuser を受け取り、テンプレートでそれらのプロパティを使って投稿のタイトルやユーザー名を出力します。

RouterLink を使って、ユーザー名にそのユーザーの個別ページのリンクを設定していますが、この例では、リンク先のパスを /user/${user.username} のようにユーザー名を使ったパスを生成しています(先の例の場合はユーザーの ID を使ってパスを生成しています)。

コメントは CommentList コンポーネントにカスタム属性 commentsgetPostComments で取得したコメントを渡して出力します。

src/components/PostItem.vue
<script setup>
  import { RouterLink } from 'vue-router'
  import { storeToRefs } from 'pinia'
  import { useCommentStore } from '../stores/comment'
  import CommentList from '../components/CommentList.vue'

  // props を宣言して親コンポーネントから post と user を受け取る
  defineProps(['post', 'user'])
  // コメントのストアの現在の投稿のコメントを取得するゲッター
  const { getPostComments } = storeToRefs(useCommentStore())
  // コメントのストアのコメントを取得するアクション
  const { fetchComments } = useCommentStore()

  //getPostComments で取得するコメントが適切に更新されるようにコメントを取得
  fetchComments()
</script>

<template>
  <div>
    <div>
      <!-- 投稿のタイトルを出力 -->
      <h2>{{ post.title }}</h2>
      <!-- props の user(ユーザー)が取得できていれば -->
      <p v-if="user">Written by:
        <!-- ユーザー名にそのユーザーの個別ページのリンクを設定して出力 -->
        <RouterLink :to="`/user/${user.username}`">
          {{ user.name }}
        </RouterLink>
          <!-- getPostComments で取得したコメントを出力 -->
        | <span>Comments: {{ getPostComments.length }}</span>
      </p>
      <p>{{ post.body }}</p>
    </div>
    <hr>
    <h3>Comments:</h3>
    <!-- CommentList コンポーネントでコメントを出力 -->
    <!-- カスタム属性 comments で getPostComments で取得したコメントを props に渡す -->
    <CommentList :comments="getPostComments"></CommentList>
  </div>
</template>
PostSingleView を作成

個別の投稿の画面のビュー PostSingleView.vue を src/views に作成します。

ユーザーのストア useUserStore と投稿のストア usePostStore から必要なデータを取得します。

ストアから state や getters のプロパティを分割代入して取得する場合は、リアクティビティを失わないように storeToRefs() を使用します。

そして fetchUsers() を実行して全ユーザーを取得します。

また、ルートのパラメータ(route.params.id)を fetchPost() に渡して、現在表示する投稿を取得し、post を更新します。これにより、getPostUser が適切に更新されます。

投稿のタイトルや本文、その投稿のコメントは PostItem コンポーネントにカスタム属性で postgetPostUser を渡して表示します。

src/views/PostSingleView.vue
<script setup>
  import { useRoute } from 'vue-router'
  import { storeToRefs } from 'pinia'
  import { useUserStore } from '../stores/user'
  import { usePostStore } from '../stores/post'
  import PostItem from '../components/PostItem.vue'
  //現在のルートを取得
  const route = useRoute()
  //ユーザーストアの投稿の作者(ユーザー)を取得するゲッター
  const { getPostUser } = storeToRefs(useUserStore())
  //ユーザーストアの全ユーザーを取得するアクション
  const { fetchUsers} = useUserStore()
  //投稿ストアの state
  const { post, loading, error } = storeToRefs(usePostStore())
  //投稿ストアの単一の投稿を取得するアクション
  const { fetchPost } = usePostStore()

  //全ユーザーを取得
  fetchUsers()
  //ルートのパラメータを指定してこのルートで表示する投稿を取得
  fetchPost(route.params.id)
</script>

<template>
  <div>
    <p v-if="loading">Loading post...</p>
    <p v-if="error">{{ error.message }}</p>
    <p v-if="post">
      <PostItem :post="post" :user="getPostUser"></PostItem>
    </p>
  </div>
</template>
ルート(route)を追加

作成した PostSingleView.vue を表示するため、src/router/index.js のルーティングを編集し、routes オプションに PostSingleView.vue へのルート(route)を追加します。内容は先のサンプルのルート(route)を追加と同じです。

src/router/index.js
import { createRouter, createWebHistory } from 'vue-router'
import HomeView from '../views/HomeView.vue'

const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes: [
    {
      path: '/',
      name: 'home',
      component: HomeView
    },
    {
      path: '/about',
      name: 'about',
      component: () => import('../views/AboutView.vue')
    },
    {
      path: '/posts',
      name: 'posts',
      component: () => import('../views/PostListView.vue')
    },
    {
      path: '/users',
      name: 'users',
      component: () => import('../views/UserListView.vue')
    },
    //個別投稿画面へのルート(route)を追加
    {
      path: '/post/:id',
      name: 'post',
      component: () => import('../views/PostSingleView.vue')
    },
  ]
})

export default router
props を使わない場合

この例の場合、props を使って PostSingleView.vue から PostItem.vue へ、PostItem.vue から CommentList.vue へデータを受け渡していますが、受け渡しているデータはストアにあるので、ストアを使ってデータを受け渡すこともできます。

以下は、props を使わない場合の例です。

src/components/CommentList.vue
<script setup>
import { useCommentStore } from '../stores/comment'
import { storeToRefs } from 'pinia'
// getPostComments を追加で分割代入
const { loading, error, getPostComments } = storeToRefs(useCommentStore())
//defineProps(['comments'])  //削除
</script>

<template>
  <div>
    <p v-if="loading">Loading comments...</p>
    <p v-if="error">{{ error.message }}</p>
    <!-- props の comments の代わりに getPostComments を使用 -->
    <div v-if="getPostComments" v-for="comment in getPostComments" :key="comment.id">
      <h3>{{ comment.name }}</h3>
      <p>{{ comment.body }}</p>
    </div>
  </div>
</template>
src/components/PostItem.vue
<script setup>
  import { RouterLink } from 'vue-router'
  import { storeToRefs } from 'pinia'
  import { useCommentStore } from '../stores/comment'
  import CommentList from '../components/CommentList.vue'

  //ユーザーと投稿のストアをインポート
  import { useUserStore } from '../stores/user'
  import { usePostStore } from '../stores/post'
  //ユーザーのストアから getPostUser を抽出
  const { getPostUser } = storeToRefs(useUserStore())
  //投稿のストアから post を抽出
  const { post } = storeToRefs(usePostStore())

  //defineProps(['post', 'user']) //削除

  const { getPostComments } = storeToRefs(useCommentStore())
  const { fetchComments } = useCommentStore()

  fetchComments()
</script>

<template>
  <div>
    <div>
      <h2>{{ post.title }}</h2>
      <!-- user の代わりに getPostUser を使用 -->
      <p v-if="getPostUser">Written by:
        <RouterLink :to="`/user/${getPostUser.username}`">
          {{ getPostUser.name }}
        </RouterLink>
        | <span>Comments: {{ getPostComments.length }}</span>
      </p>
      <p>{{ post.body }}</p>
    </div>
    <hr>
    <h3>Comments:</h3>
    <!-- 以下に変更 <CommentList :comments="getPostComments"></CommentList> -->
    <CommentList />
  </div>
</template>
src/views/PostSingleView.vue
<script setup>
  import { useRoute } from 'vue-router'
  import { storeToRefs } from 'pinia'
  import { useUserStore } from '../stores/user'
  import { usePostStore } from '../stores/post'
  import PostItem from '../components/PostItem.vue'
  const route = useRoute()
  //const { getPostUser } = storeToRefs(useUserStore())  //削除
  const { fetchUsers} = useUserStore()
  const { post, loading, error } = storeToRefs(usePostStore())
  const { fetchPost } = usePostStore()

  fetchUsers()
  fetchPost(route.params.id)
</script>

<template>
  <div>
    <p v-if="loading">Loading post...</p>
    <p v-if="error">{{ error.message }}</p>
    <p v-if="post">
      <!-- 以下に変更 <PostItem :post="post" :user="getPostUser"></PostItem> -->
      <PostItem />
    </p>
  </div>
</template>

個別のユーザーの画面

個別のユーザーを表示するコンポーネント(View ビュー)を作成します。

このビューには、ユーザー名とそのユーザーが作成した投稿のリストを表示します。

前述の例ではユーザーが作成した投稿のリストを出力するコンポーネントを別途作成しましたが、この例では投稿のストアにユーザーが作成した投稿を取得するゲッターを定義します。

投稿のストアを編集

投稿のストア(post.js)を編集して、ユーザーが作成した投稿を取得するゲッター getPostsPerUser を追加で定義します。

ユーザーが作成した投稿を取得するには、全ての投稿をフィルタしてユーザーの ID にマッチする投稿を抽出します(fetchPosts() で全ての投稿を取得してある必要があります)。

getters は引数を受け取ることができませんが、引数を受け取る関数を返すことができます。

以下では「ユーザーの ID(userId)を受け取り、その ID にマッチする投稿を抽出する関数」を返すゲッターを定義しています。

src/stores/post.js
import { defineStore } from 'pinia'

export const usePostStore = defineStore('post', {
  state: () => ({
    posts: [],
    post: null,
    loading: false,
    error: null
  }),

  //*** 追加 ***
  getters: {
    //ユーザーが作成した投稿を取得するゲッター
    getPostsPerUser: (state) => {
      //ゲッターは引数を受け取れないので、userId を引数に受け取る関数を返す
      return (userId) => state.posts.filter((post) => post.userId === userId)
    }
  },

  actions: {
    async fetchPosts() {
      this.posts = []
      this.loading = true
      try {
        this.posts = await fetch('https://jsonplaceholder.typicode.com/posts')
        .then((response) => response.json())
      } catch (error) {
        this.error = error
      } finally {
        this.loading = false
      }
    },

    async fetchPost(id) {
      this.post = null
      this.loading = true
      try {
        //引数の URL はバッククォートで囲む
        this.post = await fetch(`https://jsonplaceholder.typicode.com/posts/${id}`)
        .then((response) => response.json())
      } catch (error) {
        this.error = error
      } finally {
        this.loading = false
      }
    },
  }
})
ユーザーのコンポーネントの作成

個別のユーザーのコンポーネント UserItem.vue を src/components に作成します。

props を宣言して、この後作成する親コンポーネント(UserSingleView)からユーザー(user)とユーザーが作成した投稿(posts)を受け取ります。

テンプレートでは user.name でユーザーの名前を出力し、そのユーザーが作成した投稿の数を posts.length で出力しています。

そして v-for で、ユーザーが作成した投稿のタイトルに RouterLink でその投稿の個別ページへのリンクを設定した一覧を出力しています。

src/components/UserItem.vue
<script setup>
  import { RouterLink } from 'vue-router'
  // props を宣言(親コンポーネントから user と posts を受け取る)
  defineProps(['user', 'posts'])
</script>

<template>
  <div>
    <h1>{{user.name}}</h1>
    <p>{{posts.length}} posts written.</p>
    <p v-for="post in posts" :key="post.id">
      <RouterLink :to="`/post/${post.id}`">{{ post.title }}</RouterLink>
    </p>
  </div>
</template>
UserSingleView を作成

個別のユーザーを表示する画面のコンポーネント UserSingleView.vue を src/views に作成します。

算出プロパティ getUserByUserName で現在のルートからユーザー名(route.params.username)を取得して、ユーザー名に一致するユーザーを抽出して、現在のページのユーザーとしています。

getUserByUserName の定義では、ユーザーのストアの users が空かどうかを users.value.length で判定して処理を分岐しています。

ユーザーの一覧ページを経由してユーザーの個別ページに来る場合は、ユーザーの一覧ページを表示する際に fetchUsers() により全てのユーザーが取得され、users にはユーザーが取得されていますが、このページで再読み込みをすると、users は空になってしまうので、fetchUsers() で全てのユーザーを取得する必要があります。

テンプレートでは、v-ifgetUserByUserName でユーザーが取得できていれば UserItem を出力するようにしています。v-if を使わなくても表示はされますが、fetchUsers() は非同期処理なので、ページを再読み込みした場合にコンソールにエラーが出力されてしまいます。

UserItem タグのカスタム属性の user には現在のユーザー(getUserByUserName)を指定し、カスタム属性の posts には getPostsPerUser() で取得したユーザーの投稿を指定して、ユーザーのコンポーネント UserItem へ渡します。

src/views/UserSingleView.vue
<script setup>
  import { computed } from 'vue'
  import { useRoute } from 'vue-router'
  import { storeToRefs } from 'pinia'
  import { useUserStore } from '../stores/user'
  import { usePostStore } from '../stores/post'
  //ユーザーのコンポーネントをインポート
  import UserItem from '../components/UserItem.vue'

  //現在のルート(route オブジェクト)を取得
  const route = useRoute()
  //ユーザーのストアから users を抽出
  const { users } = storeToRefs(useUserStore())
  //投稿のストアから getPostsPerUser を抽出
  const { getPostsPerUser } = storeToRefs(usePostStore())
  //投稿のストアから fetchPosts を抽出
  const { fetchPosts } = usePostStore()
  //ユーザーのストアから fetchUsers を抽出
  const { fetchUsers } = useUserStore()

  //ルートのユーザー名からユーザーを取得する算出プロパティ
  const getUserByUserName = computed(() => {
    //全てのユーザーが取得できていれば
    if(users.value.length) {
      //全てのユーザーをフィルタして、ルートのユーザー名に一致するユーザーを抽出
      return users.value.find((user) => user.username === route.params.username)
    }else {
      //全てのユーザーが取得できていなければ、全てのユーザーを取得
      fetchUsers()
      return users.value.find((user) => user.username === route.params.username)
    }
  })

  //全ての投稿を取得(ユーザーが作成した投稿を取得するため)
  fetchPosts()
</script>

<template>
  <div>
    <!-- カスタム属性でユーザーのコンポーネントに user と posts を渡す -->
    <UserItem
      v-if="getUserByUserName"
      :user="getUserByUserName"
      :posts="getPostsPerUser(getUserByUserName.id)">
    </UserItem>
  </div>
</template>
ルート(route)を追加

作成した UserSingleView.vue を表示するため、src/router/index.js のルーティングを編集し、routes オプションに UserSingleView.vue へのルート(route)を追加します。

個別の投稿のルーティング同様、パラメータを使った動的ルーティングを設定します。

以下の場合、path プロパティに指定した :username の部分がパラメータとなります。コンポーネント側ではこの部分の値を route.params.username として参照できます。

src/router/index.js
import { createRouter, createWebHistory } from 'vue-router'
import HomeView from '../views/HomeView.vue'

const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes: [
    {
      path: '/',
      name: 'home',
      component: HomeView
    },
    {
      path: '/about',
      name: 'about',
      component: () => import('../views/AboutView.vue')
    },
    {
      path: '/posts',
      name: 'posts',
      component: () => import('../views/PostListView.vue')
    },
    {
      path: '/users',
      name: 'users',
      component: () => import('../views/UserListView.vue')
    },
    {
      path: '/post/:id',
      name: 'post',
      component: () => import('../views/PostSingleView.vue')
    },
    //個別ユーザー画面へのルート(route)を追加
    {
      path: '/user/:username',  // :username を指定
      name: 'user',
      component: () => import('../views/UserSingleView.vue')
    },
  ]
})

export default router
props を使わない場合

UserSingleView から UserItem へ props を使ってデータを渡していますが、props を使わずにストアからデータを受け取ることもできます(但し、この場合は面倒です)。 以下は props を使わない例です。

UserSingleView では getUserByUserName という算出プロパティを定義して、これを使ってカスタム属性にデータを指定していますが、この算出プロパティをユーザーのストアに移します。

getUserByUserName の定義では、ユーザー名(username)を引数に受け取る関数を返すようにします。

この部分は、props を使う場合でも、このように書き換えた方が良いかもしれません(その他の部分も変更が必要になります)。

src/stores/user.js
import { defineStore } from 'pinia'
import { usePostStore } from './post'

export const useUserStore = defineStore('user', {
  state: () => ({
    users: [],
    user: null,
    loading: false,
    error: null
  }),
  getters: {
    getPostUser: (state) => {
      const postStore = usePostStore()
      return state.users.find((user) => user.id === postStore.post.userId)
    },
    //*** 追加 ***
    getUserByUserName: (state) => {
      //引数 username を受け取る関数を返す
      return (username) => {
        return state.users.find((user) => user.username === username)
      }
    }
  },

  actions: {
    async fetchUsers() {
      this.users = []
      this.loading = true
      try {
        this.users = await fetch('https://jsonplaceholder.typicode.com/users')
        .then((response) => response.json())
      } catch (error) {
        this.error = error
      } finally {
        this.loading = false
      }
    },
  }
})

UserSingleView.vue では、定義していた getUserByUserName を削除し、ストアで定義した getUserByUserName を抽出します。テンプレートでは、v-ifgetUserByUserName に引数としてルートから取得したユーザー名(route.params.username)を指定し、ユーザーが取得できていれば UserItem を出力するようにしています。

src/views/UserSingleView.vue
<script setup>
  //import { computed } from 'vue' //使わない
  import { useRoute } from 'vue-router'
  import { storeToRefs } from 'pinia'
  import { useUserStore } from '../stores/user'
  import { usePostStore } from '../stores/post'
  import UserItem from '../components/UserItem.vue'
  const route = useRoute()
  // getUserByUserName を抽出
  const { getUserByUserName, users } = storeToRefs(useUserStore())
  const { fetchPosts } = usePostStore()
  const { fetchUsers } = useUserStore()

  //ページを再読込した場合は、ユーザーを取得
  if(!users.value.length) {
    fetchUsers()
  }

  fetchPosts()
</script>

<template>
  <div>
    <UserItem v-if="getUserByUserName(route.params.username)"></UserItem>
  </div>
</template>

UserItem では props を使う代わりに、ストアから getPostsPerUsergetUserByUserName を抽出し、それらを使って props で受け取っていた userposts を取得します。

src/components/UserItem.vue
<script setup>
    import { RouterLink } from 'vue-router'
    // 以下を追加でインポート
    import { useRoute } from 'vue-router'
    import { storeToRefs } from 'pinia'
    import { usePostStore } from '../stores/post'
    import { useUserStore } from '../stores/user'
    import { ref } from 'vue'

    // ルートを取得
    const route = useRoute()
    // 投稿のストアから getPostsPerUser を抽出
    const { getPostsPerUser } = storeToRefs(usePostStore())
    // ユーザーのストアから getUserByUserName を抽出
    const { getUserByUserName } = storeToRefs(useUserStore())

    //defineProps(['user', 'posts']) //削除
  </script>

  <template>
    <div>
      <h1>{{ getUserByUserName(route.params.username).name }}</h1>
      <p>{{ getPostsPerUser(getUserByUserName(route.params.username).id).length }} posts written.</p>
      <p v-for="post in getPostsPerUser(getUserByUserName(route.params.username).id)" :key="post.id">
        <RouterLink :to="`/post/${post.id}`">{{ post.title }}</RouterLink>
      </p>
    </div>
  </template>

上記は以下のように user を変数に入れてテンプレートで使用することもできます。

この場合、ストアから抽出した算出プロパティは引数を受け取る関数ですが、script 内で引数を指定して実行する際は .value を付ける必要があります。

また、戻り値はリアクティブではないので、ref メソッドを使用していますが、この場合は使わなくても変わらないです。

但し、posts に関しては変数に代入すると期待通りになりません。

src/components/UserItem.vue
<script setup>
  import { RouterLink } from 'vue-router'
  import { useRoute } from 'vue-router'
  import { storeToRefs } from 'pinia'
  import { usePostStore } from '../stores/post'
  import { useUserStore } from '../stores/user'
  import { ref } from 'vue'

  const route = useRoute()
  const { getPostsPerUser } = storeToRefs(usePostStore())
  const { getUserByUserName } = storeToRefs(useUserStore())

  // user を変数に代入
  const user = ref(getUserByUserName.value(route.params.username))
  //以下は期待通りに動作しない
  //const posts = ref(getPostsPerUser.value(user.id))

</script>

<template>
  <div>
    <h1>{{ user.name }}</h1>
    <!-- posts は getPostsPerUser(user.id) で取得 -->
    <p>{{ getPostsPerUser(user.id).length }} posts written.</p>
    <p v-for="post in getPostsPerUser(user.id)" :key="post.id">
      <RouterLink :to="`/post/${post.id}`">{{ post.title }}</RouterLink>
    </p>
  </div>
</template>

Pinia の Setup Syntax

今までの例では Pinia の defineStore() を使ってストアを定義する際に、第2引数に Options オブジェクトを渡していましたが、Setup 関数(リアクティブなオブジェクトを返す関数)を渡すこともできます。

第2引数に Setup 関数を渡す構文を Setup Syntax と呼び、その構文で作成したストアを Setup Stores と呼びます。

Setup Syntax では第2引数にリアクティブなプロパティ(stategetters に相当)とメソッド(actions に相当)を定義し、公開するプロパティとメソッドを含むオブジェクトを返す関数を渡します。

この場合、ref() で定義したリアクティブなデータの値にアクセスするには .value を使用します。

import { ref, computed } from 'vue'
import { defineStore } from 'pinia'

//Setup 関数を渡す構文(Setup Syntax)の例
export const useCounterStore = defineStore('counter', () => {
  // ref() を使ってリアクティブなデータの初期値を定義(state に相当)
  const count = ref(0)
  // computed メソッドを使用して算出プロパティを定義(getters に相当)
  const doubleCount = computed(() => count.value * 2)
  // メソッドを定義(actions に相当)
  const increment = () => {
    // 値は .value でアクセス
    count.value++
  }
  // 公開したいプロパティとメソッドを含むオブジェクトを返す
  return { count, doubleCount, increment }
})

以下は前述の例の投稿用、ユーザー用、コメント用に分けていたストアを1つのストアにまとめて、Setup Syntax で書き換えたものです。

Setup Syntax の場合、stategettersactions に分ける必要がないので、必要に応じて関連する項目(プロパティ)をまとまりとして記述できます(Composition API に似ています)。

前述の UserSingleView.vue の算出プロパティ getUserByUserName は、コンポーネントの算出プロパティからストアの算出プロパティに変更していますが、それ以外はほぼ同じです。

src/stores/blog.js
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'

//ストアの名前は useBlogStore にしています
export const useBlogStore = defineStore('blog', () => {
  /* post */
  const posts = ref([])
  const loadingPosts = ref(false)
  const errorPosts = ref(null)

  //ユーザーが作成した投稿を取得するゲッター(算出プロパティ)
  const getPostsPerUser = computed(() =>{
    //userId を引数に受け取る関数を返す
    return (userId) => posts.value.filter((post) => post.userId === userId)
  })

  const fetchPosts = async () => {
    posts.value = []
    loadingPosts.value = true
    try {
      posts.value = await fetch('https://jsonplaceholder.typicode.com/posts')
      .then((response) => response.json())
    } catch (error) {
      errorPosts.value = error
    } finally {
      loadingPosts.value = false
    }
  }

  const post = ref(null)
  const loadingPost = ref(false)
  const errorPost = ref(null)

  const fetchPost = async (id) => {
    post.value = null
    loadingPost.value = true
    try {
      post.value = await fetch(`https://jsonplaceholder.typicode.com/posts/${id}`)
      .then((response) => response.json())
      console.log(post)
    } catch (error) {
      errorPost.value = error
    } finally {
      loadingPost.value = false
    }
  }

  /* user */
  const users = ref([])
  const loadingUsers = ref(false)
  const errorUsers = ref(null)

  const fetchUsers = async () => {
    users.value = []
    loadingUsers.value = true
    try {
      users.value = await fetch('https://jsonplaceholder.typicode.com/users')
      .then((response) => response.json())
    } catch (error) {
      errorUsers.value = error
    } finally {
      loadingUsers.value = false
    }
  }
  //投稿の作者(ユーザー)を取得するゲッター(算出プロパティ)
  const getPostUser = computed(() =>{
    return users.value.find((user) => user.id === post.value.userId)
  })

  //route のパラメータのユーザー名からユーザーを取得するゲッター算出プロパティ(追加)
  const getUserByUserName = computed(() =>{
    //引数 username を受け取る関数を返す
    return (username) => {
      return users.value.find((user) => user.username === username)
    }
  })

  /* comment */
  const comments = ref([])
  const loadingComments = ref(false)
  const errorComments = ref(null)

  const getPostComments = computed(() =>{
    return comments.value.filter((comment) => comment.postId === post.value.id)
  })

  const fetchComments = async () => {
    comments.value = []
    loadingComments.value = true
    try {
      comments.value = await fetch('https://jsonplaceholder.typicode.com/comments')
      .then((response) => response.json())
    } catch (error) {
      errorComments.value = error
    } finally {
      loadingComments.value = false
    }
  }

  // 公開したいプロパティとメソッドを含むオブジェクトを返す
  return {
    posts, loadingPosts, errorPosts, fetchPosts, getPostsPerUser,
    post, loadingPost, errorPost, fetchPost,
    users, loadingUsers, errorUsers, fetchUsers, getPostUser, getUserByUserName,
    comments, loadingComments, errorComments, getPostComments, fetchComments,
  }
})

上記の fetch() を使った処理はほとんど同じなので、以下のように別途 fetch() を使った処理の関数を定義すれば、もう少し短く記述できます。

src/stores/blog.js
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'

export const useBlogStore = defineStore('blog', () => {
  // fetch 用の関数を定義
  const fetchItems = async (url, items, loading, error) => {
    Array.isArray(items.value) ? items.value = [] : items.value = null
    loading.value = true
    try {
      items.value = await fetch(url)
      .then((response) => response.json())
    } catch (e) {
      error.value = e
    } finally {
      loading.value = false
    }
  }

  /* post */
  const posts = ref([])
  const loadingPosts = ref(false)
  const errorPosts = ref(null)
  const getPostsPerUser = computed(() =>{
    return (userId) => posts.value.filter((post) => post.userId === userId)
  })
  const fetchPosts = () => {
    // 上で定義した fetch 用の関数を利用
    fetchItems('https://jsonplaceholder.typicode.com/posts', posts, loadingPosts, errorPosts)
  }
  const post = ref(null)
  const loadingPost = ref(false)
  const errorPost = ref(null)
  const fetchPost = (id) => {
    fetchItems(`https://jsonplaceholder.typicode.com/posts/${id}`, post, loadingPost, errorPost)
  }

  /* user */
  const users = ref([])
  const loadingUsers = ref(false)
  const errorUsers = ref(null)
  const fetchUsers = () => {
    fetchItems('https://jsonplaceholder.typicode.com/users', users, loadingUsers, errorUsers)
  }
  const getPostUser = computed(() =>{
    return users.value.find((user) => user.id === post.value.userId)
  })
  const getUserByUserName = computed(() =>{
    return (username) => {
      return users.value.find((user) => user.username === username)
    }
  })

  /* comment */
  const comments = ref([])
  const loadingComments = ref(false)
  const errorComments = ref(null)
  const getPostComments = computed(() =>{
    return comments.value.filter((comment) => comment.postId === post.value.id)
  })
  const fetchComments = () => {
    fetchItems('https://jsonplaceholder.typicode.com/comments', comments, loadingComments, errorComments )
  }

  return {
    posts, loadingPosts, errorPosts, fetchPosts, getPostsPerUser,
    post, loadingPost, errorPost, fetchPost,
    users, loadingUsers, errorUsers, fetchUsers, getPostUser, getUserByUserName,
    comments, loadingComments, errorComments, getPostComments, fetchComments,
  }
})

以下は上記ストアを使うように先のコンポーネントを書き換えたものです。

PostListView

投稿のリストのビュー

src/views/PostListView.vue
<script setup>
  import { RouterLink } from 'vue-router'
  import { storeToRefs } from 'pinia'
  import { useBlogStore } from '../stores/blog'
  const { posts, loadingPosts, errorPosts } = storeToRefs(useBlogStore())
  const { fetchPosts } = useBlogStore()
  fetchPosts()
</script>

<template>
  <main>
    <p v-if="loadingPosts">Loading posts...</p>
    <p v-if="errorPosts">{{ errorPosts.message }}</p>
    <p v-if="posts" v-for="post in posts" :key="post.id">
      <RouterLink :to="`/post/${post.id}`">{{ post.title }}</RouterLink>
      <p>{{ post.body }}</p>
    </p>
  </main>
</template>

UserListView

ユーザーのリストのビュー

src/views/UserListView.vue
<script setup>
  import { RouterLink } from 'vue-router'
  import { storeToRefs } from 'pinia'
  import { useBlogStore } from '../stores/blog'
  const { users, loadingUsers, errorUsers } = storeToRefs(useBlogStore())
  const { fetchUsers } = useBlogStore()
  fetchUsers()
</script>

<template>
  <main>
    <p v-if="loadingUsers">Loading users...</p>
    <p v-if="errorUsers">{{ errorUsers.message }}</p>
    <p v-if="users" v-for="user in users" :key="user.id">
      <RouterLink :to="`/user/${user.username}`">{{ user.name }}</RouterLink>
    </p>
  </main>
</template>

CommentList

コメントのコンポーネント

src/components/CommentList.vue
<script setup>
import { useBlogStore } from '../stores/blog'
import { storeToRefs } from 'pinia'
const { loadingComments, errorComments } = storeToRefs(useBlogStore())
defineProps(['comments'])
</script>

<template>
  <div>
    <p v-if="loadingComments">Loading comments...</p>
    <p v-if="errorComments">{{ errorComments.message }}</p>
    <div v-if="comments" v-for="comment in comments" :key="comment.id">
      <h3>{{ comment.name }}</h3>
      <p>{{ comment.body }}</p>
    </div>
  </div>
</template>

PostItem

投稿のコンポーネント

src/components/PostItem.vue
<script setup>
  import { RouterLink } from 'vue-router'
  import { storeToRefs } from 'pinia'
  import { useBlogStore } from '../stores/blog'
  import CommentList from '../components/CommentList.vue'
  defineProps(['post', 'user'])
  const { getPostComments } = storeToRefs(useBlogStore())
  const { fetchComments } = useBlogStore()
  fetchComments()
</script>

<template>
  <div>
    <div>
      <h2>{{ post.title }}</h2>
      <p v-if="user">Written by:
        <RouterLink :to="`/user/${user.username}`">
          {{ user.name }}
        </RouterLink>
        | <span>Comments: {{ getPostComments.length }}</span>
      </p>
      <p>{{ post.body }}</p>
    </div>
    <hr>
    <h3>Comments:</h3>
    <CommentList :comments="getPostComments"></CommentList>
  </div>
</template>

PostSingleView

個別の投稿のビュー

src/views/PostSingleView.vue
<script setup>
  import { useRoute } from 'vue-router'
  import { storeToRefs } from 'pinia'
  import { useBlogStore } from '../stores/blog'
  import PostItem from '../components/PostItem.vue'
  const route = useRoute()
  const { getPostUser, post, loadingPost, errorPost  } = storeToRefs(useBlogStore())
  const { fetchUsers, fetchPost } = useBlogStore()
  fetchUsers()
  fetchPost(route.params.id)
</script>

<template>
  <div>
    <p v-if="loadingPost">Loading post...</p>
    <p v-if="errorPost">{{ errorPost.message }}</p>
    <p v-if="post">
      <PostItem :post="post" :user="getPostUser"></PostItem>
    </p>
  </div>
</template>

UserItem

ユーザーのコンポーネント

src/components/UserItem.vue
<script setup>
  import { RouterLink } from 'vue-router'
  defineProps(['user', 'posts'])
</script>

<template>
  <div>
    <h1>{{user.name}}</h1>
    <p>{{posts.length}} posts written.</p>
    <p v-for="post in posts" :key="post.id">
      <RouterLink :to="`/post/${post.id}`">{{ post.title }}</RouterLink>
    </p>
  </div>
</template>

UserSingleView

個別のユーザーのビュー

src/views/UserSingleView.vue
<script setup>
  import { useRoute } from 'vue-router'
  import { storeToRefs } from 'pinia'
  import { useBlogStore } from '../stores/blog'
  import UserItem from '../components/UserItem.vue'
  const route = useRoute()
  const username = route.params.username
  const { getUserByUserName, getPostsPerUser, users } = storeToRefs(useBlogStore())
  const { fetchPosts, fetchUsers } = useBlogStore()
  if(!users.value.length) {
    fetchUsers()
  }
  fetchPosts()
</script>

<template>
  <div>
    <UserItem
      v-if="getUserByUserName(username)"
      :user="getUserByUserName(username)"
      :posts="getPostsPerUser(getUserByUserName(username).id)">
    </UserItem>
  </div>
</template>

ルーティング

src/router/index.js
import { createRouter, createWebHistory } from 'vue-router'
import HomeView from '../views/HomeView.vue'

const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes: [
    {
      path: '/',
      name: 'home',
      component: HomeView
    },
    {
      path: '/about',
      name: 'about',
      component: () => import('../views/AboutView.vue')
    },
    {
      path: '/posts',
      name: 'posts',
      component: () => import('../views/PostListView.vue')
    },
    {
      path: '/users',
      name: 'users',
      component: () => import('../views/UserListView.vue')
    },
    {
      path: '/post/:id',
      name: 'post',
      component: () => import('../views/PostSingleView.vue')
    },
    {
      path: '/user/:username',
      name: 'user',
      component: () => import('../views/UserSingleView.vue')
    },
  ]
})

export default router

App

メインコンポーネント

<script setup>
import { RouterLink, RouterView } from "vue-router";
</script>

<template>
  <nav>
    <ul>
      <li>
        <RouterLink to="/">Home</RouterLink>
      </li>
      <li>
        <RouterLink to="/about">About</RouterLink>
      </li>
      <li>
        <RouterLink to="/posts">Posts</RouterLink>
      </li>
      <li>
        <RouterLink to="/users">Users</RouterLink>
      </li>
    </ul>
  </nav>
  <RouterView />
</template>

<style scoped>
.router-link-exact-active {
  opacity: 0.5;
  pointer-events: none;
}
ul {
  display: flex;
}
li {
  margin-right: 2rem;
  list-style-type: none;
}
</style>
import { createApp } from 'vue'
import { createPinia } from 'pinia'

import App from './App.vue'
import router from './router'

const app = createApp(App)

app.use(createPinia())
app.use(router)

app.mount('#app')
<template>
  <div class="home">
    <h1>This is Home</h1>
  </div>
</template>
src/views/AboutView.vue
<template>
  <div class="about">
    <h1>This is an about page</h1>
  </div>
</template>
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8">
    <link rel="icon" href="/favicon.ico">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Pinia / Vue Router App Sample</title>
  </head>
  <body>
    <div id="app"></div>
    <script type="module" src="/src/main.js"></script>
  </body>
</html>

以下はこのアプリのファイルの構成です。

vue-blog-project3
├── index.html
├── src
│   ├── App.vue
│   ├── components
│   │   ├── CommentList.vue
│   │   ├── PostItem.vue
│   │   └── UserItem.vue
│   ├── main.js
│   ├── router
│   │   └── index.js
│   ├── stores
│   │   └── blog.js
│   └── views
│       ├── AboutView.vue
│       ├── HomeView.vue
│       ├── PostListView.vue
│       ├── PostSingleView.vue
│       ├── UserListView.vue
│       └── UserSingleView.vue
└── vite.config.js