## 背景
1年前に以下のようなSPAベースを作成した。
<div class="link-card-v2">
<div class="link-card-v2-site">
<img class="link-card-v2-site-icon" src="https://publish-01.obsidian.md/access/35d05cd1bf5cc500e11cc8ba57daaf88/favicon-64.png" />
<span class="link-card-v2-site-name">Minerva</span>
</div>
<div class="link-card-v2-title">
📜2024-04-28 Nuxt3 x TypeScript x shadcn-vue x TailwindCSS x Pinia x Zod x Bunで最強のSPAベースをつくってみる
</div>
<div class="link-card-v2-content">本業で1からWebプロダクトをつくることになった。重要視されるのは開発スピードとデザインの2つ。品質はまだそこまで優先されていない。また、初期フェーズのあとは担当チームに引き継ぐ必要があるため、あまりリスクの大きい技術は利用しない。</div>
<img class="link-card-v2-image" src="https://publish-01.obsidian.md/access/35d05cd1bf5cc500e11cc8ba57daaf88/Notes/attachments/activity.webp" />
<a data-href="📜2024-04-28 Nuxt3 x TypeScript x shadcn-vue x TailwindCSS x Pinia x Zod x Bunで最強のSPAベースをつくってみる" class="internal-link"></a>
</div>
%%[[📜2024-04-28 Nuxt3 x TypeScript x shadcn-vue x TailwindCSS x Pinia x Zod x Bunで最強のSPAベースをつくってみる]]%%
今回もほぼ同じような動機でSPAベースを作成してみることになった。
## 利用する技術
1年前と似ているが若干変化がある。
| 技術 | 理由 |
| ----------------- | -------------------------------------- |
| [[Nuxt3]] | 社内のプロダクトの大半が利用しており、自身の知見も深い |
| [[TypeScript]] | [[JavaScript]]にする理由は全くない |
| [[shadcn-vue]] | 詳細なデザインカスタマイズが可能 かつ 初期スピードも出せる |
| [[VueUse]] | [[Vue]]とセットの鉄板 |
| [[VeeValidate]] | UIと分離できて使い勝手もよい. [[Zod]]との連携も強力 |
| [[Zod]] | フォームなどのバリデート用 (APIは使わない) |
| [[Pinia]] | 公式推奨のストア |
| [[Tailwind CSS]] | [[shadcn-vue]]使えばついてくる |
| [[OpenAPI fetch]] | [[HTTPクライアント]] |
| [[pnpm]] | フロントエンドで[[Deno]]や[[Bun]]はエッジケースでリスクがある |
| [[🦉Owlelia]] | 日付系のユーテリティーとして |
| 対象 | バージョン |
| ------------------------------------ | ------- |
| [[Node.js]] | v22.8.0 |
| [[pnpm]] | 9.15.4 |
| [[Nuxt]] | 3.16.2 |
| [[Vue]] | 3.5.13 |
| [[Vue Router]] | 4.5.0 |
| [[Prettier]] | 3.5.3 |
| [[Prettier Plugin Organize Imports]] | 4.1.0 |
| [[vue-tsc]] | 2.2.8 |
| [[shadcn-nuxt]] | 2.1.0 |
| [[Reka UI]] | 2.2.0 |
| [[Tailwind CSS]] | 4.1.4 |
| [[openapi-typescript]] | 7.6.1 |
| [[OpenAPI fetch]] | 0.13.5 |
| [[🦉Owlelia]] | 0.49.0 |
## 個人的準備
作業をしていて[[Neovim]]の調子が悪かったため、事前にやったこと。
- [[📝NeovimでVueのtemplate内で補完候補を選択すると挿入場所がおかしい]]
- [[📜2025-04-20 nvim-lspconfigでVolarのHybrid Modeを有効にする]]
## プロジェクトベース作成
### [[Nuxt]]のインストール
```console
pnpm create nuxt harmonious
```
以下をインストールした。
```console
dependencies:
+ nuxt 3.16.2
+ vue 3.5.13
+ vue-router 4.5.0
dependencies:
+ @nuxt/eslint 1.3.0
+ @nuxt/fonts 0.11.1
+ @nuxt/icon 1.12.0
+ @nuxt/image 1.10.0
```
### [[TypeScript]]のインストール
```console
pnpm add -D typescript
```
### [[Prettier]]のインストール
import文を最適化するため [[Prettier Plugin Organize Imports]] も入れる。
```console
pnpm add -D prettier prettier-plugin-organize-imports vue-tsc
```
`.prettierrc.json`
```json
{
"plugins": ["prettier-plugin-organize-imports"]
}
```
### 型チェックやimportを堅牢にする
以下の記事で紹介した手法を用いる。
<div class="link-card-v2">
<div class="link-card-v2-site">
<img class="link-card-v2-site-icon" src="https://publish-01.obsidian.md/access/35d05cd1bf5cc500e11cc8ba57daaf88/favicon-64.png" />
<span class="link-card-v2-site-name">Minerva</span>
</div>
<div class="link-card-v2-title">
📘完全武装をしてNeovimでも安全で快適なNuxt3の開発をするのだ
</div>
<div class="link-card-v2-content">NeovimでNuxt3とTypeScriptを型安全かつ快適に開発するための設定方法を解説します。Auto-importsの無効化や未使用importの自動削除、未定義タグの検知、フォールスルー属性対応など、VSCodeやWebStormでは得られないNeovim特有の課題とその解決策を詳しく紹介しています。詳しい手順や設定例は記事でご確認ください。</div>
<img class="link-card-v2-image" src="https://publish-01.obsidian.md/access/35d05cd1bf5cc500e11cc8ba57daaf88/%F0%9F%93%98Articles/attachments/2024-07-26.webp" />
<a data-href="📘完全武装をしてNeovimでも安全で快適なNuxt3の開発をするのだ" class="internal-link"></a>
</div>
%%[[📘完全武装をしてNeovimでも安全で快適なNuxt3の開発をするのだ]]%%
`nuxt.config.ts`
```ts
export default defineNuxtConfig({
// インポート用(Auto-imports含む)の型定義に追加するためスキャンしない
imports: { scan: false },
// コンポーネントとして扱うディレクトリを指定しない
components: { dirs: [] }
})
```
`tsconfig.json`
```ts
{
// https://nuxt.com/docs/guide/concepts/typescript
"extends": "./.nuxt/tsconfig.json",
// これを追加
"vueCompilerOptions": {
"strictTemplates": true
}
}
```
`allow-fallthrough-props.d.ts`
```ts
import "vue";
declare module "vue" {
// for vue components
interface AllowedComponentProps {
// inputにフォールスルー属性として利用
type?: unknown;
dataSlot?: unknown; // kebab-caseはcamelCaseで書く(data-slot)
// LargeInput以外の箇所でフォールスルー属性として利用と仮定
placeholder?: unknown;
// @changeのフォールスルー属性
onChange?: unknown;
// TODO: フォールスルー属性が増えたら追加していく
}
// for native html elements
interface HTMLAttributes {
// allow any data-* attr
[key: `data-${string}`]: string;
}
}
export {};
```
キャッシュ周りを一度綺麗にする。
```console
rm -rf .nuxt
pnpm dev
```
## ESLintとPrettierの共存
[[ESLint]]と[[Prettier]]が相互干渉するのを防ぐため、[[eslint-config-prettier]]を入れる。
```console
pnpm add -D eslint-config-prettier
```
`eslint.config.mjs` に 以下を追加。
```js
// @ts-check
// ★追加
import eslintConfigPrettier from "eslint-config-prettier/flat";
import withNuxt from "./.nuxt/eslint.config.mjs";
// ★変更: eslintConfigPrettierを指定
export default withNuxt(eslintConfigPrettier);
```
以下のような `<img />` に対して[[ESLint]]が何も警告を出さなければOK。
```html
<script setup lang="ts"></script>
<template>
<img />
</template>
```
## [[shadcn-vue]]の導入
[[Nuxt]]のインストールガイドを見て進める。
<div class="link-card-v2">
<div class="link-card-v2-site">
<img class="link-card-v2-site-icon" src="https://www.shadcn-vue.com/favicon-16x16.png" />
<span class="link-card-v2-site-name">www.shadcn-vue.com</span>
</div>
<div class="link-card-v2-title">
Nuxt - shadcn/vue
</div>
<div class="link-card-v2-content">
Install and configure Nuxt.
</div>
<img class="link-card-v2-image" src="https://shadcn-vue.com/og.png" />
<a href="https://www.shadcn-vue.com/docs/installation/nuxt.html"></a>
</div>
1は完了しているので [[Tailwind CSS]] の追加から。
### [[Tailwind CSS]]の導入
```console
pnpm add tailwindcss @tailwindcss/vite
```
```console
dependencies:
+ @tailwindcss/vite 4.1.4
+ tailwindcss 4.1.4
```
`assets/css/tailwind.css`
```css
@import "tailwindcss";
```
`nuxt.config.ts`
```ts
// ★追加
import tailwindcss from "@tailwindcss/vite";
// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
compatibilityDate: "2024-11-01",
ssr: false,
// ★追加
css: ["~/assets/css/tailwind.css"],
vite: {
plugins: [tailwindcss()],
},
// modules: ["@nuxt/eslint", "@nuxt/fonts", "@nuxt/image", "@nuxt/icon"],
// インポート用(Auto-imports含む)の型定義に追加するためスキャンしない
imports: { scan: false },
// コンポーネントとして扱うディレクトリを指定しない
components: { dirs: [] },
});
```
### Nuxt moduleの追加
```console
pnpm dlx nuxi@latest module add shadcn-nuxt
```
`nuxt.config.ts`
```ts
import tailwindcss from "@tailwindcss/vite";
// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
compatibilityDate: "2024-11-01",
ssr: false,
css: ["~/assets/css/tailwind.css"],
vite: {
plugins: [tailwindcss()],
},
modules: [
"@nuxt/eslint",
"@nuxt/fonts",
"@nuxt/image",
"@nuxt/icon",
// ★追加
"shadcn-nuxt",
],
// modules: ["@nuxt/eslint", "@nuxt/fonts", "@nuxt/image", "@nuxt/icon"],
// インポート用(Auto-imports含む)の型定義に追加するためスキャンしない
imports: { scan: false },
// コンポーネントとして扱うディレクトリを指定しない
components: { dirs: [] },
// ★追加
shadcn: {
prefix: '',
componentDir: './components/ui'
}
});
```
### Nuxt Prepareの実行
`.nuxt` を作るために必要。
```console
pnpm dlx nuxi prepare
```
### CLIの実行
```console
pnpm dlx shadcn-vue@latest init
```
```console
✔ Preflight checks.
✔ Verifying framework. Found Nuxt.
✔ Validating Tailwind CSS config. Found v4.
✔ Validating import alias.
✔ Which color would you like to use as the base color? › Neutral
✔ Writing components.json.
✔ Checking registry.
✔ Updating CSS variables in assets/css/tailwind.css
Packages: +5
+++++
Progress: resolved 1113, reused 992, downloaded 5, added 5, doneownloaded 5, added 4
⠴ Installing dependencies.
dependencies:
+ class-variance-authority 0.7.1
+ clsx 2.1.1
+ lucide-vue-next 0.501.0
+ tailwind-merge 3.2.0
+ tw-animate-css 1.2.5
```
### ボタンコンポーネント追加
```console
pnpm dlx shadcn-vue@latest add button
```
```console
dependencies:
+ reka-ui 2.2.0
Done in 3.9s
✔ Installing dependencies.
✔ Created 2 files:
- components/ui/button/Button.vue 3:25:46 PM
- components/ui/button/index.ts
```
> [!info]
> [[Radix Vue]]がv2で[[Reka UI]]という名前に変わったらしい。[[shadcn-vue]]と同じ[[unovue]]によって引き続き開発されているので安心感はある。
`Button` を使ってみる。
`app.vue`
```html
<script setup lang="ts">
import Button from "./components/ui/button/Button.vue";
</script>
<template>
<Button variant="destructive" size="lg" class="m-12">Hello</Button>
</template>
```
こんな感じに表示されればOK。
![[Pasted image 20250420154050.png]]
## [[OpenAPI fetch]]の導入
<div class="link-card-v2">
<div class="link-card-v2-site">
<img class="link-card-v2-site-icon" src="https://static-production.npmjs.com/b0f1a8318363185cc2ea6a40ac23eeb2.png" />
<span class="link-card-v2-site-name">npm</span>
</div>
<div class="link-card-v2-title">
openapi-fetch
</div>
<div class="link-card-v2-content">
Fast, type-safe fetch client for your OpenAPI schema. Only 6 kb (min). Works with React, Vue, Svelte, or vanilla ...
</div>
<img class="link-card-v2-image" src="https://static-production.npmjs.com/338e4905a2684ca96e08c7780fc68412.png" />
<a href="https://www.npmjs.com/package/openapi-fetch"></a>
</div>
### インストール
```console
pnpm add openapi-fetch
pnpm add -D openapi-typescript
```
```
dependencies:
+ openapi-fetch 0.13.5
```
```
devDependencies:
+ openapi-typescript 7.6.1
```
### OpenAPIファイルを用意
[[Reqres]]の定義を[[JSON]]ファイルとして利用してみる。エラーになっているところは対処済。
> [!code]- `openapi.json`
>
> ```json
> {
> "openapi": "3.1.0",
> "info": {
> "title": "ReqRes API",
> "description": "Fake data CRUD API",
> "version": 1
> },
> "servers": [
> {
> "url": "https://reqres.in/api"
> }
> ],
> "components": {
> "schemas": {
> "User": {
> "type": "object",
> "properties": {
> "id": {
> "type": "integer"
> },
> "email": {
> "type": "string"
> },
> "first_name": {
> "type": "string"
> },
> "last_name": {
> "type": "string"
> },
> "avatar": {
> "type": "string"
> }
> }
> },
> "UnknownResource": {
> "type": "object",
> "properties": {
> "id": {
> "type": "integer"
> },
> "name": {
> "type": "string"
> },
> "year": {
> "type": "integer"
> },
> "color": {
> "type": "string"
> },
> "pantone_value": {
> "type": "string"
> }
> }
> }
> }
> },
> "paths": {
> "/{resource}": {
> "get": {
> "summary": "Fetches a resource list",
> "parameters": [
> {
> "in": "query",
> "name": "page",
> "schema": {
> "type": "integer"
> }
> },
> {
> "in": "query",
> "name": "per_page",
> "schema": {
> "type": "integer"
> }
> }
> ],
> "responses": {
> "200": {
> "description": "Success",
> "content": {
> "application/json": {
> "schema": {
> "type": "object",
> "properties": {
> "page": {
> "type": "integer"
> },
> "per_page": {
> "type": "integer"
> },
> "total": {
> "type": "integer"
> },
> "total_pages": {
> "type": "integer"
> },
> "data": {
> "type": "array",
> "items": {
> "$ref": "#/components/schemas/UnknownResource"
> }
> }
> }
> }
> }
> }
> }
> }
> }
> },
> "/users": {
> "get": {
> "summary": "Fetches a user list",
> "parameters": [
> {
> "in": "query",
> "name": "page",
> "schema": {
> "type": "integer"
> }
> },
> {
> "in": "query",
> "name": "per_page",
> "schema": {
> "type": "integer"
> }
> }
> ],
> "responses": {
> "200": {
> "description": "Success",
> "content": {
> "application/json": {
> "schema": {
> "type": "object",
> "properties": {
> "page": {
> "type": "integer"
> },
> "per_page": {
> "type": "integer"
> },
> "total": {
> "type": "integer"
> },
> "total_pages": {
> "type": "integer"
> },
> "data": {
> "type": "array",
> "items": {
> "$ref": "#/components/schemas/User"
> }
> }
> }
> }
> }
> }
> }
> }
> }
> },
> "/users/{id}": {
> "get": {
> "summary": "Fetches a user",
> "parameters": [
> {
> "in": "path",
> "name": "id",
> "schema": {
> "type": "integer"
> }
> }
> ],
> "responses": {
> "200": {
> "description": "Success",
> "content": {
> "application/json": {
> "schema": {
> "type": "object",
> "properties": {
> "data": {
> "$ref": "#/components/schemas/User"
> }
> }
> }
> }
> }
> }
> }
> },
> "put": {
> "summary": "Updates a user",
> "responses": {
> "200": {
> "description": "Success",
> "content": {
> "application/json": {
> "schema": {
> "type": "object",
> "properties": {
> "updatedAt": {
> "type": "string"
> }
> }
> }
> }
> }
> }
> }
> },
> "patch": {
> "summary": "Updates a user",
> "responses": {
> "200": {
> "description": "Success",
> "content": {
> "application/json": {
> "schema": {
> "type": "object",
> "properties": {
> "updatedAt": {
> "type": "string"
> }
> }
> }
> }
> }
> }
> }
> },
> "delete": {
> "summary": "Deletes a user",
> "responses": {
> "204": {
> "description": "Success"
> }
> }
> }
> },
> "/{resource}/{id}": {
> "get": {
> "summary": "Fetches an unknown resource",
> "parameters": [
> {
> "in": "path",
> "name": "id",
> "schema": {
> "type": "integer"
> }
> }
> ],
> "responses": {
> "200": {
> "description": "Success",
> "content": {
> "application/json": {
> "schema": {
> "type": "object",
> "properties": {
> "data": {
> "$ref": "#/components/schemas/UnknownResource"
> }
> }
> }
> }
> }
> }
> }
> },
> "put": {
> "summary": "Updates an unknown resource",
> "responses": {
> "200": {
> "description": "Success",
> "content": {
> "application/json": {
> "schema": {
> "type": "object",
> "properties": {
> "updatedAt": {
> "type": "string"
> }
> }
> }
> }
> }
> }
> }
> },
> "patch": {
> "summary": "Updates an unknown resource",
> "responses": {
> "200": {
> "description": "Success",
> "content": {
> "application/json": {
> "schema": {
> "type": "object",
> "properties": {
> "updatedAt": {
> "type": "string"
> }
> }
> }
> }
> }
> }
> }
> },
> "delete": {
> "summary": "Deletes an unknown resource",
> "responses": {
> "204": {
> "description": "Success"
> }
> }
> }
> },
> "/login": {
> "post": {
> "summary": "Creates a session",
> "requestBody": {
> "required": true,
> "content": {
> "application/json": {
> "schema": {
> "type": "object",
> "properties": {
> "username": {
> "type": "string"
> },
> "email": {
> "type": "string"
> },
> "password": {
> "type": "string"
> }
> }
> }
> }
> }
> },
> "responses": {
> "200": {
> "description": "Success",
> "content": {
> "application/json": {
> "schema": {
> "type": "object",
> "properties": {
> "token": {
> "type": "string"
> }
> }
> }
> }
> }
> },
> "400": {
> "description": "Login error",
> "content": {
> "application/json": {
> "schema": {
> "type": "object",
> "properties": {
> "error": {
> "type": "string"
> }
> }
> }
> }
> }
> }
> }
> }
> },
> "/register": {
> "post": {
> "summary": "Creates a user",
> "requestBody": {
> "required": true,
> "content": {
> "application/json": {
> "schema": {
> "type": "object",
> "properties": {
> "username": {
> "type": "string"
> },
> "email": {
> "type": "string"
> },
> "password": {
> "type": "string"
> }
> }
> }
> }
> }
> },
> "responses": {
> "200": {
> "description": "Success",
> "content": {
> "application/json": {
> "schema": {
> "type": "object",
> "properties": {
> "id": {
> "type": "string"
> },
> "token": {
> "type": "string"
> }
> }
> }
> }
> }
> },
> "400": {
> "description": "Register error",
> "content": {
> "application/json": {
> "schema": {
> "type": "object",
> "properties": {
> "error": {
> "type": "string"
> }
> }
> }
> }
> }
> }
> }
> }
> },
> "/logout": {
> "post": {
> "summary": "Ends a session",
> "responses": {
> "200": {
> "description": "Success"
> }
> }
> }
> }
> }
> }
> ```
### 定義ファイルを作成
```console
pnpm exec openapi-typescript ./openapi.json -o reqres.d.ts
```
### 呼び出してみる
`app.vue` で動作確認。
```html
<script setup lang="ts">
import createClient from "openapi-fetch";
import Button from "./components/ui/button/Button.vue";
import type { paths } from "./reqres";
const client = createClient<paths>({ baseUrl: "https://reqres.in/api" });
const userNames = ref<string[]>([]);
const handleClick = async () => {
const { data, error } = await client.GET("/users");
if (error) {
console.error(error);
return;
}
userNames.value =
data.data?.map((user) => `${user.first_name} ${user.last_name}`) ?? [];
};
</script>
<template>
<Button variant="destructive" size="lg" class="m-12" @click="handleClick"
>Search</Button
>
<div class="flex flex-col items-center">
<h1 class="text-2xl font-bold">User List</h1>
<ul class="list-disc">
<li v-for="(user, index) in userNames" :key="index">{{ user }}</li>
</ul>
</div>
</template>
```
これで `Search` ボタンを押したら結果が表示されるようになる。もちろん型安全。
![[Pasted image 20250420182547.png]]
## [[Composable]]とドメインモデルの分離
設計で責務を分離する。
| パス | 役目 | 備考 |
| -------------------------- | -------------- | ----------------------------------------------------------------------------- |
| `composables/useOOO.ts` | [[Composable]] | ドメインモデルのクラスみたいなもの |
| `composables/useReqres.ts` | [[Composable]] | [[Reqres]]とのクライアントラッパ |
| `domain/entities/OOO.ts` | エンティティ | - [[DDD]]の[[エンティティ (DDD)\|エンティティ]]相当<br>- 基本的に[[BFF]]のモデルに従順<br>- 必要に応じて拡張は可能 |
ドメインモデルは基本的に[[型エイリアス (TypeScript)|型エイリアス]]。
`domain/entities/User.ts`
```ts
import type { paths } from "~/reqres";
export type User = NonNullable<
paths["/users"]["get"]["responses"]["200"]["content"]["application/json"]["data"]
>[number];
```
[[OpenAPI fetch]]のクライアントを生成する薄いラッパ。ミドルウェアを実装する場合はこのレイヤーに。
`composables/useReqres.ts`
```ts
import createClient from "openapi-fetch";
import type { paths } from "~/reqres";
// TODO: 共通処理やラップ処理はここで吸収したい
export async function useReqres() {
const client = ref(createClient<paths>({ baseUrl: "https://reqres.in/api" }));
return { client };
}
```
[[OpenAPI fetch]]のクライアントを利用し、必要なやりとりを行うコアな[[Composable]]。ドメインモデルをインターフェースとしているのが最大の特徴。
`composables/useUsers.ts`
```ts
import { useReqres } from "~/composables/useReqres";
import type { User } from "~/domain/entities/User";
export function useUsers() {
const { client } = useReqres();
const users = ref<User[] | null>(null);
const error = ref<Error | null>(null);
const loading = ref(false);
const fetchUsers = async () => {
loading.value = true;
// WARN: 本来ならOASでdelayを定義しておく(定義がないので型エラーになる)
const { data: res, error: _error } = await client.GET("/users", {
params: { query: { delay: 1 } },
});
loading.value = false;
// WARN: 本来ならOASの方でエラー定義をしておく
if (_error) {
error.value = _error;
return;
}
// WARN: 本来ならOASの方でrequiredにしておく
users.value = res.data!;
};
return { users, error, loading, fetchUsers };
}
```
ドメインモデルベースの[[Composable]]を使ってシンプルにUIを表現する。
`app.vue`
```html
<script setup lang="ts">
import Button from "~/components/ui/button/Button.vue";
import { useUsers } from "~/composables/useUsers";
const { users, loading, fetchUsers } = useUsers();
</script>
<template>
<Button variant="destructive" size="lg" class="m-12" @click="fetchUsers">
{{ loading ? "Loading..." : "Fetch Users" }}
</Button>
<div class="flex flex-col items-center">
<h1 class="text-2xl font-bold">User List</h1>
<ul class="list-disc">
<li v-for="user in users" :key="user.id">
{{ user.first_name }} {{ user.last_name }}
</li>
</ul>
</div>
</template>
```
こんな感じになればOK。
![[2025-04-21_00h10_59.mp4]]
## レイアウトとルーティング
今は `app.vue` のみのルートページなので、`/users` ページをつくる。中身は `app.vue` をそのまま移植。(ルート要素だけ追加)
`pages/users.vue`
```html
<script setup lang="ts">
import Button from "~/components/ui/button/Button.vue";
import { useUsers } from "~/composables/useUsers";
const { users, loading, fetchUsers } = useUsers();
</script>
<template>
<div>
<Button variant="destructive" size="lg" class="m-12" @click="fetchUsers">
{{ loading ? "Loading..." : "Fetch Users" }}
</Button>
<div class="flex flex-col items-center">
<h1 class="text-2xl font-bold">User List</h1>
<ul class="list-disc">
<li v-for="user in users" :key="user.id">
{{ user.first_name }} {{ user.last_name }}
</li>
</ul>
</div>
</div>
</template>
```
`layouts/default.vue`
```html
<template>
<div>
<p class="bg-green-300 p-2 text-lg font-bold">
Some default layout content shared across all pages
</p>
<slot />
</div>
</template>
```
`app.vue`
```html
<template>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</template>
```
`localhost:3000/users` で以下にアクセスできればOK。
![[Pasted image 20250421002531.png]]
## Tailwind CSSのクラス名並び順最適化
いつも並び順が気になるので、[[PrettierでTailwind CSSのクラス名ソートを最適化]]する。[[prettier-plugin-tailwindcss]]を使う。
```console
pnpm add -D prettier-plugin-tailwindcss
```
`.prettierrc.json`
```json
{
"plugins": [
"prettier-plugin-organize-imports",
// ★追加 (追加場所はpluginsの最後でなければいけない)
"prettier-plugin-tailwindcss"
]
}
```
これで保存したらクラス名順序が自動で変わるようになった。
## Piniaの導入
[[#Composable とドメインモデルの分離]]で作成したクライアントを[[Pinia]]でstore化してみる。
<div class="link-card-v2">
<div class="link-card-v2-site">
<img class="link-card-v2-site-icon" src="https://pinia.vuejs.org/logo.svg" />
<span class="link-card-v2-site-name">pinia.vuejs.org</span>
</div>
<div class="link-card-v2-title">
Nuxt | Pinia
</div>
<div class="link-card-v2-content">
Intuitive, type safe, light and flexible Store for Vue
</div>
<a href="https://pinia.vuejs.org/ssr/nuxt.html"></a>
</div>
```console
pnpm add pinia @pinia/nuxt
```
`nuxt.config.ts` の `modules` に追加。
```ts
modules: [
"@nuxt/eslint",
"@nuxt/fonts",
"@nuxt/image",
"@nuxt/icon",
"shadcn-nuxt",
// ★追加
"@pinia/nuxt",
],
```
`store/useReqres.ts`
```ts
import createClient from "openapi-fetch";
import { defineStore } from "pinia";
import type { paths } from "~/reqres";
export const useReqresStore = defineStore("reqres", () => {
const client = createClient<paths>({
baseUrl: "https://reqres.in/api",
});
return { client };
});
```
`composables/useReqres` は削除。
`composables/useUsers.ts`
```ts
import type { User } from "~/domain/entities/User";
// ★storeに変更
import { useReqresStore } from "~/store/useReqres";
export function useUsers() {
// ★変更
const rqStore = useReqresStore();
const users = ref<User[] | null>(null);
const error = ref<Error | null>(null);
const loading = ref(false);
const fetchUsers = async () => {
loading.value = true;
// WARN: 本来ならOASでdelayを定義しておく(定義がないので型エラーになる)
// ★変更
const { data: res, error: _error } = await rqStore.client.GET("/users", {
params: { query: { delay: 1 } },
});
loading.value = false;
// WARN: 本来ならOASの方でエラー定義をしておく
if (_error) {
error.value = _error;
return;
}
// WARN: 本来ならOASの方でrequiredにしておく
users.value = res.data!;
};
return { users, error, loading, fetchUsers };
}
```
## ページベースの作成
[[shadcn-vue]]でブロック実装の例が提供されているのでこれを参考にしていく。
<div class="link-card-v2">
<div class="link-card-v2-site">
<img class="link-card-v2-site-icon" src="https://www.shadcn-vue.com/favicon-16x16.png" />
<span class="link-card-v2-site-name">www.shadcn-vue.com</span>
</div>
<div class="link-card-v2-title">
Building Blocks - shadcn/vue
</div>
<div class="link-card-v2-content">
Beautifully designed components built with Radix Vue and Tailwind CSS.
</div>
<img class="link-card-v2-image" src="https://shadcn-vue.com/og.png" />
<a href="https://www.shadcn-vue.com/blocks.html"></a>
</div>
`Featured` がカッコイイので拝借する。
### 必要なコンポーネントのインストール
- [ ] Avater
- [ ] Breadcrumb
- [ ] Separator
- [ ] Sidebar
- [ ] Dropdown Menu
- [ ] Collapsible
```console
pnpm dlx shadcn-vue@latest add breadcrumb separator sidebar dropdown-menu collapsible avatar
```
## GitHub Actionsでデプロイ
[[GitHub Actions]]で[[GitHub Pages]]にデプロイする。
```yaml
name: Deploy Nuxt site to Pages
on:
push:
branches: ["master"]
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: "pages"
cancel-in-progress: false
jobs:
# Build job
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
- run: corepack enable
- run: pnpm install
- run: pnpm run generate
- uses: actions/upload-pages-artifact@v3
with:
path: ./dist
deploy:
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
needs: build
steps:
- id: deployment
uses: actions/deploy-pages@v4
```
https://tadashi-aikawa.github.io/harmonious/ にアクセスしてみるがリソースが根こそぎ404になる。
## ベースURLの設定
`/harmonious` がベースにくっついてくるので、`nuxt.config.ts` に `app.baseURL` を指定する必要がある。
`nuxt.config.ts`
```ts
export default defineNuxtConfig({
app: {
baseURL: "/harmonious",
},
```
## Nuxt 3.17にアップデート
数日前に新バージョンが出ていたのでアップデートする。
<div class="link-card-v2">
<div class="link-card-v2-site">
<img class="link-card-v2-site-icon" src="https://nuxt.com/icon.png" />
<span class="link-card-v2-site-name">Nuxt</span>
</div>
<div class="link-card-v2-title">
Nuxt 3.17 · Nuxt Blog
</div>
<div class="link-card-v2-content">
Nuxt 3.17 is out - bringing a major reworking of the async data layer, a new built-in component, better warnings ...
</div>
<img class="link-card-v2-image" src="https://nuxt.com/assets/blog/v3.17.png" />
<a href="https://nuxt.com/blog/v3-17"></a>
</div>
```console
pnpm dlx nuxi@latest upgrade --dedupe
```
```
dependencies:
- nuxt 3.16.2
+ nuxt 3.17.1
```
## [[TS-Pattern]]の導入
パフォーマンスにクリティカルでなければ便利そうなので。
```console
pnpm add ts-pattern
```