Vue Router と Pinia を使った簡単なアプリの作成
JSON Placeholder という JSON 形式のデータを返してくれる無料のサービスを利用して、Vue Router と Pinia を使って投稿やユーザーの情報などを表示する簡単なアプリを作成する方法の覚書です。
関連ページ
- Vue の基本的な使い方 (1) Options API
- Vue の基本的な使い方 (2) Composition API
- Vue の基本的な使い方 (3) Vite と SFC 単一ファイルコンポーネント
- Vue の基本的な使い方 (4) Vue Router ルーティング
- Vue の基本的な使い方 (5) Pinia を使って状態管理
- Vue の基本的な使い方 (6) Vue3 で簡単な To-Do アプリを色々な方法で作成
作成日: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 には Vue アプリのマウント先の id="app"
の div
要素と type="module"
を指定した main.js を読み込む script
タグが記述されています。
そのままでも構いませんが、この例では <title>
を変更しています。
<!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 には、Vue アプリの生成とルーターのインストール、アプリのマウントが記述されています。
createApp()
にメインコンポーネント App を渡して Vue のインスタンス(アプリ)を生成し、router
(Router インスタンス)をアプリの use()
メソッドに渡してプラグインとしてインストール(有効化)しています。そして最後にアプリをマウントしています。
main.css は削除したので、スタイルシートの読み込みを削除します(5行目)。
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 は以下のように template ブロックのみに書き換えます。
<template> <div class="home"> <h1>This is Home</h1> </div> </template>
AboutView.vue も同様に以下のように template ブロックのみに書き換えます。
<template> <div class="about"> <h1>This is an about page</h1> </div> </template>
App.vue は以下のように RouterLink を使った nav
要素と RouterView のみに書き換えます。
RouterView
には現在のパスにマッチした(ルーティングに応じた)コンポーネントが描画されます。
スタイルは、現在のページを表すアクティブなリンクを表すクラス .router-link-exact-active が適用されるリンク要素は、クリックできないようにスタイルを設定しています。
<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>
src/route にある index.js はルーターの生成とルーティングの定義などを記述するファイルで、初期状態では以下のように HomeView と AboutView へのルート(route
)が定義されています。
この時点では変更する必要はありません(後からルートを追加します)。
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 を使用できます。
[ { //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 に対応しています。
[ { //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 を使用できます。
[ { "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 内で ref や reactive メソッドを使用して定義します。
全ての投稿を保持するデータの posts
は初期状態では何もないので空の配列を設定しています。fetchPosts
関数で投稿のリソースが取得できれば、投稿のオブジェクトの配列が入ります。
ローディング状態を表す loading
は初期値を false
に設定します。エラーが発生した場合にエラーを格納する error
は初期値を null
にします。
fetchPosts
関数は fetch() を使って JSONPlaceholder から全ての投稿のリソースを取得する非同期関数です。https://jsonplaceholder.typicode.com/posts
を fetch()
の引数に指定すると、全ての投稿のデータ(100件)を取得できます。
この関数では、リソースを取得する前に loading
を true
に変更することで、テンプレートの v-if="loading"
でロード中であることを表すメッセージ(Loading posts...)を表示します。
エラーが発生した場合は、error
プロパティにエラーを代入し、v-if="error"
によりエラーメッセージを表示しますます。
リソースの取得が完了するかエラーになった時点(finally)で loading
を false
に戻すことで、ロード中を表すメッセージを非表示にします。
投稿が取得できた場合は、v-if="posts"
により、取得した投稿を v-for
でループして全ての投稿のタイトルと本文を出力します。その際に、RouterLink タグを使って、個々の投稿へのリンクを設定します。
RouterLink
の to
属性には v-bind:
を使って、後でルーティングに追加する個別の投稿のページのパス `/post/${post.id}`
を指定します。post.id
はそれぞれの投稿の id になります。
最後に fetchPosts
関数を呼び出して投稿を取得します。
Composition API の setup
内で関数を呼び出すのは、Options API の created()
フックを使用して呼び出すのと同じことです。
<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
プロパティにコンポーネントを取得するための関数を渡して動的にコンポーネントをインポートするようにしています。
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 で追加します。
RouterLink
の to
属性に、ルーティングの path
プロパティに指定した /posts
を指定します。
<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
へ遷移するようにします。
<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
)を追加します。
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
App.vue にリンクを追加
App.vue に UserListView.vue へのリンクを RouterLink で追加します。
RouterLink
の to
属性に、ルーティングの path
プロパティに指定した /users
を指定します。
<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> /* 省略 */ </style>
http://127.0.0.1:5173/users
にアクセスするか、追加したリンク(Users)をクリックすると、以下のように投稿の一覧が表示されます。この時点では投稿のリスト同様、コンソールのタブを確認すると、個々のユーザーページのへのリンクのリンク先のルートとコンポーネントががまだ定義されていないため以下のような警告が出力されています。
個別の投稿の画面
個別の投稿を表示するコンポーネント(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 では id
が 1
の投稿のコメントを全て取得できます。
この例では props
を使って親コンポーネントの PostSingleView.vue から投稿の id を postId
として受け取り、fetch()
の引数で ${props.postId}
として使用しています。
script setup
構文では props
を宣言するには、defineProps を使用します。
<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
として取得できます。
このコンポーネントでは、userID
を props
で親コンポーネントの PostSingleView.vue から受け取り、fetch()
の引数の URL の末尾に ${props.userId}
と指定してユーザーを取得しています。
テンプレートでは、取得した user
の name
プロパティでユーザー名を取得し、RouterLink
でユーザーの個別ページにリンクしています 。リンク先のパスのユーザーの id は取得したユーザー(user
)の id
プロパティの ${user.id}
としていますが、props
の ${userId}
でも同じです(テンプレートでは props.
は不要です) 。
<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
オブジェクトを生成(取得)します。
パラメータは route
の params
プロパティ route.params.xxxx
としてアクセスできます。
また、先に作成した PostComments.vue と PostUser.vue をインポートします。
テンプレートではプロパティ(props
)を子コンポーネントへ渡すため、PostUser のカスタム属性 user-id
に投稿のユーザーID(post.userId
)を指定します。同様に PostComments のカスタム属性 post-id
に投稿のID(route.params.id
)を指定します。
カスタム属性に文字列以外を渡す場合は v-bind:
または省略形の :
を使用する必要があります。
<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
として参照できます。
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
)の id
が 1
の http://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
で設定しています。
<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:
または省略形の :
を使用する必要があります。
<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
として参照できます。
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 コンポーネントのテンプレートでは以下のように RouterLink
の to
属性に動的に /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
として渡すこともできます。
props
を true
に設定すると、route.params
がコンポーネントのプロパティとして設定されます。
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
に置き換えます。
<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
に置き換えます。
<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 には Vue アプリのマウント先の id="app"
の div
要素と type="module"
を指定した main.js を読み込む script
タグが記述されています。
そのままでも構いませんが、この例では <title>
を変更しています。
<!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 には、Vue アプリの生成と Pinia のインスタンスの生成とインストール、ルーターのインストール、及びアプリのマウントが記述されています。
createApp()
にメインコンポーネント App を渡して Vue のインスタンス(アプリ)を生成し、createPinia()
で生成した Pinia のインスタンスをアプリの use()
メソッドに渡してインストールしています。
そしてアプリにルーターをインストールして、最後にアプリをマウントしています。
main.css は削除したので、スタイルシートの読み込みを削除します(7行目)。
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 は以下のように template ブロックのみに書き換えます。
<template> <div class="home"> <h1>This is Home</h1> </div> </template>
AboutView.vue も同様に以下のように template ブロックのみに書き換えます。
<template> <div class="about"> <h1>This is an about page</h1> </div> </template>
App.vue は以下のように RouterLink を使った nav
要素と RouterView のみに書き換えます。
RouterView
には現在のパスにマッチした(ルーティングに応じた)コンポーネントが描画されます。
スタイルは、現在のページを表すアクティブなリンクを表すクラス .router-link-exact-active が適用されるリンク要素は、クリックできないようにスタイルを設定し、その他最低限のスタイルを指定しています。
<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>
src/route にある index.js はルーターの生成とルーティングの定義などを記述するファイルで、初期状態では以下のように HomeView と AboutView へのルート(route
)が定義されています。
この時点では変更する必要はありません(後からルートを追加します)。
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()
は定義されたストアのインスタンスを生成する関数(ゲッター)を返します。
利用するコンポーネント側ではストアをインポートしてこの関数(ゲッター)を実行することでストアのインスタンスを取得することができます。
このストアでは投稿に関するプロパティをまとめて管理します。
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
のプロパティを取得しています。
actions
の fetchPosts
関数は直接分割代入して使用します。
<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)を追加と同じです。
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 で追加します。
RouterLink
の to
属性に、ルーティングの path
プロパティに指定した /posts
を指定します。内容は先のサンプルの 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
になります。
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/ユーザー名
にしています(特にこのようにする理由はありませんが、バリエーションとして)。
<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)を追加 と同じです。
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
App.vue にリンクを追加
App.vue に UserListView.vue へのリンクを RouterLink で追加します。
RouterLink
の to
属性に、ルーティングの path
プロパティに指定した /users
を指定します。内容は先のサンプルの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> <!-- 以下のリンクを追加 --> <li> <RouterLink to="/users">Users</RouterLink> </li> </ul> </nav> <RouterView /> </template> <style scoped> /* 省略 */ </style>
個別の投稿の画面
個別の投稿を表示するコンポーネント(View ビュー)を作成します。
このビューでは、個々の投稿のタイトルと本文と共に、投稿のユーザー名(作者名)とその投稿に対するコメントも合わせて表示します。
投稿に対するコメントを取得して出力するには、別途コメントのストア(comment.js
)とコメントのコンポーネント(CommentList.vue
)を定義します。
投稿のユーザー名(作者名)の取得には、ユーザーのストア(user.js
)にゲッターを追加で定義します。
また、個別の投稿のコンポーネント(PostItem.vue
)を作成して、投稿のユーザー名(作者名)やコメントと共にビュー(PostSingleView.vue
)で出力します。
投稿のストアを編集
投稿のストア(post.js
)を編集して、state
に現在の投稿のデータを保持する post
を追加し、その現在の投稿を取得する非同期関数 fetchPost
を actions
に追加します。
fetchPost()
では、最初に post を初期化します。これを行わないと、一瞬、現在の投稿が前の投稿のデータとともに表示されてしまいます。
fetchPost()
の引数に指定する投稿の ID はルート(route
)からパラメータとして取得して渡します。
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 を作成して、コメントのストアを定義します。
state
の comments
は actions
の fetchComments()
で取得した全てのコメントを保持する配列です。
getters
の getPostComments
は全てのコメント(comments
)から現在の投稿のコメントを取得するゲッター(算出プロパティ)です。
現在の投稿のコメントを取得するには、現在の投稿の ID が必要ですが、投稿の ID は投稿のストア usePostStore
で定義されています。
Pinia では他のストアのデータにアクセスすることができます。投稿のストアのデータにアクセスするには、投稿のストア(usePostStore
)をインポートして、usePostStore()
で投稿のストアのインスタンスを生成して変数(postSore
)に代入します。
そして、そのインスタンス(postSore
)を介して投稿に postSore.post
でアクセスしてその ID(postSore.post.id
)を取得します。
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 } }, } })
ユーザーのストアを編集
個別の投稿の画面には、その投稿の作者(ユーザー)を出力するので、ユーザーのストアに投稿の作者(ユーザー)を取得するゲッターを追加します。
現在の投稿のユーザー ID(userId
)を取得するために、投稿のデータが必要なので投稿のストアをインポートします。
getters
の getPostUser
では投稿のストアのインスタンスを usePostStore()
で生成して、インスタンス経由で投稿のユーザーID(postStore.post.userId
)を取得し、全てのユーザー(state.users
)をフィルタして、各ユーザーの ID(user.id
) と一致するものを取得します。
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
を宣言して親コンポーネントから post
と user
を受け取り、テンプレートでそれらのプロパティを使って投稿のタイトルやユーザー名を出力します。
RouterLink
を使って、ユーザー名にそのユーザーの個別ページのリンクを設定していますが、この例では、リンク先のパスを /user/${user.username}
のようにユーザー名を使ったパスを生成しています(先の例の場合はユーザーの ID を使ってパスを生成しています)。
コメントは CommentList
コンポーネントにカスタム属性 comments
で getPostComments
で取得したコメントを渡して出力します。
<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
コンポーネントにカスタム属性で post
と getPostUser
を渡して表示します。
<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)を追加と同じです。
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
を使わない場合の例です。
<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>
<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>
<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 にマッチする投稿を抽出する関数」を返すゲッターを定義しています。
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
でその投稿の個別ページへのリンクを設定した一覧を出力しています。
<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-if
で getUserByUserName
でユーザーが取得できていれば UserItem
を出力するようにしています。v-if
を使わなくても表示はされますが、fetchUsers()
は非同期処理なので、ページを再読み込みした場合にコンソールにエラーが出力されてしまいます。
UserItem
タグのカスタム属性の user
には現在のユーザー(getUserByUserName
)を指定し、カスタム属性の posts
には getPostsPerUser()
で取得したユーザーの投稿を指定して、ユーザーのコンポーネント UserItem
へ渡します。
<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
として参照できます。
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
を使う場合でも、このように書き換えた方が良いかもしれません(その他の部分も変更が必要になります)。
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-if
で getUserByUserName
に引数としてルートから取得したユーザー名(route.params.username
)を指定し、ユーザーが取得できていれば UserItem
を出力するようにしています。
<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
を使う代わりに、ストアから getPostsPerUser
と getUserByUserName
を抽出し、それらを使って props
で受け取っていた user
と posts
を取得します。
<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
に関しては変数に代入すると期待通りになりません。
<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引数にリアクティブなプロパティ(state
と getters
に相当)とメソッド(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 の場合、state
や getters
、actions
に分ける必要がないので、必要に応じて関連する項目(プロパティ)をまとまりとして記述できます(Composition API に似ています)。
前述の UserSingleView.vue の算出プロパティ getUserByUserName
は、コンポーネントの算出プロパティからストアの算出プロパティに変更していますが、それ以外はほぼ同じです。
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()
を使った処理の関数を定義すれば、もう少し短く記述できます。
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
投稿のリストのビュー
<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
ユーザーのリストのビュー
<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
コメントのコンポーネント
<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
投稿のコンポーネント
<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
個別の投稿のビュー
<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
ユーザーのコンポーネント
<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
個別のユーザーのビュー
<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>
ルーティング
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>
<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
コメントのコンポーネントを作成
コメントのコンポーネント CommentList.vue を
src/components
に作成します。テンプレートでは
props
経由で受け取ったcomments
(コメントの配列)をv-for
を使って、コメントのタイトル(comment.name
)と本文(comment.body
)を出力します。