## 背景
本業で1からWebプロダクトをつくることになった。重要視されるのは開発スピードとデザインの2つ。品質はまだそこまで優先されていない。また、初期フェーズのあとは担当チームに引き継ぐ必要があるため、あまりリスクの大きい技術は利用しない。
## はじめに
作業開始前にいくつか。
### 技術選定の理由
まだ確定ではないが、候補として挙げた理由は以下。
| 技術 | 理由 |
| ---------------- | ------------------------------ |
| [[Nuxt3]] | 社内のプロダクトの大半が利用しており、自身の知見も深い |
| [[TypeScript]] | [[JavaScript]]にする理由は全くない |
| [[shadcn-vue]] | 詳細なデザインカスタマイズが可能 かつ 初期スピードも出せる |
| [[Tailwind CSS]] | [[shadcn-vue]]使えばついてくる |
| [[Pinia]] | 公式推奨のストア |
| [[Zod]] | API部分は堅牢にやりたいので |
| [[Bun]] | [[Node.js]]よりも速いから |
別の観点で分析もしてみた。分析というより気分なので何の根拠もないけど...。Bが普通。
| 技術 | 社内普及率 | 自身の経験 | 自身のやる気 | 世間の評判 |
| ---------------- | ------ | ----- | ------ | ----- |
| [[Nuxt3]] | **S** | **S** | A | A |
| [[TypeScript]] | **A+** | **S** | A | S |
| [[shadcn-vue]] | E | E | **S** | **S** |
| [[Tailwind CSS]] | C | D | **S** | **S** |
| [[Pinia]] | A | B | B | A |
| [[Zod]] | D | A | B | A |
| [[Bun]] | D | **S** | **S** | A |
当たり前だけど、世間で評判よくて経験ないものの方がやる気が出る。今まで、デザイナーと一緒にWeb開発をしたことが実質無いと言ってもいいので、今回はそこが割と楽しみである。普段はデザインやQAは自分1人でやるので[[Vuetify]]や[[Naive UI]]を使っているが、今回はデザイナーと軽く相談して[[Tailwind CSS]]にチャレンジしてみることになった。[[shadcn-vue]]は試しに提案してみただけ。今回使ってみて温度感を決めたい。
それ以外の技術は一度使っており、それなりに手堅いと思っている。[[Bun]]はv1が出たばかりだが、[[パッケージマネージャー]]としての利用がメインのため恐らく問題ないと思っている。テストコードでハマるリスクはあるが、最悪[[Node.js]]に戻せばよい。構成は似ているので。
API周りの実装をどうするかは後で決める。[[Mockoon]]でモック化するか、[[Go]]と[[Echo]]で作るか、もしくは[[Bun]]で作ってしまってもよい。Web側が主目的なのであとで考える。それまでは[[Pinia]]をストレージとして扱う。
### お題
お題がないとやる気が出ない... ので **『エンジニアのスキル可視化システム』** みたいなものを作ってみることにする。細かい仕様などはノリで決めるが、少なくとも以下のような機能は必要だろう。
- メンバー一覧表示
- メンバー登録
- メンバー編集
- メンバー削除
- 実績マスター一覧表示
- 実績マスター登録
- 実績マスター編集
- 実績マスター削除
見ての通り、[[CRUD]]はどれも似たような形になる。拘りのUIにすることもできるが、まずはテーブルコンポーネントで事務的なUIの作成を目指す。
### デザイン
以下のサイトの内容が素晴らしいので、こちらをベースに検討していく。[[Tailwind CSS]]初心者なのでありがたい。
<div class="link-card">
<div class="link-card-header">
<img src="https://techracho.bpsinc.jp/wp-content/uploads/2017/09/cropped-techracho_official_icon-1-32x32.png" class="link-card-site-icon"/>
<span class="link-card-site-name">TechRacho</span>
</div>
<div class="link-card-body">
<div class="link-card-content">
<p class="link-card-title">Tailwind CSSをカオスにしないための5つのベストプラクティス(翻訳)|TechRacho by BPS株式会社</p>
<p class="link-card-description">概要 元サイトの許諾を得て翻訳・公開いたします。 英語記事: 5 best practices for preventing chaos ... </p>
</div>
<img src="https://techracho.bpsinc.jp/wp-content/uploads/2023/10/tailwindcss_5_best_practices_eyecatc-min.png" class="link-card-image" />
</div>
<a href="https://techracho.bpsinc.jp/hachi8833/2023_11_08/136255"></a>
</div>
> [[📚Tailwind CSSをカオスにしないための5つのベストプラクティス(翻訳)]]
## プロジェクト作成
### Nuxt
[[Nuxt]]のページから。
<div class="link-card">
<div class="link-card-header">
<img src="https://nuxt.com/icon.png" class="link-card-site-icon"/>
<span class="link-card-site-name">Nuxt</span>
</div>
<div class="link-card-body">
<div class="link-card-content">
<p class="link-card-title">Installation · Get Started with Nuxt</p>
<p class="link-card-description">Get started with Nuxt quickly with our online starters or start locally with your terminal.</p>
</div>
<img src="https://nuxt.com/__og-image__/image/docs/getting-started/installation/og.png" class="link-card-image" />
</div>
<a href="https://nuxt.com/docs/getting-started/installation#new-project"></a>
</div>
```console
$ bun x nuxi@latest init isekai
✔ Which package manager would you like to use?
bun
◐ Installing dependencies...
bun install v1.1.5 (b257a309)
$ nuxt prepare
✔ Types generated in .nuxt
+
[email protected]
+
[email protected]
+
[email protected]
757 packages installed [1315.00ms]
✔ Installation completed.
✔ Initialize git repository?
Yes
ℹ Initializing git repository...
✨ Nuxt project has been created with the v3 template. Next steps:
› cd isekai
› Start development server with bun run dev
```
起動してみる。
```console
cd isekai
bun dev
```
URLにアクセスして画面が出ればOK。
### shadcn-vue
[[Tailwind CSS]]や[[shadcn-vue]]のインストール。
<div class="link-card">
<div class="link-card-header">
<img src="https://www.shadcn-vue.com/favicon-16x16.png" class="link-card-site-icon"/>
<span class="link-card-site-name">www.shadcn-vue.com</span>
</div>
<div class="link-card-body">
<div class="link-card-content">
<p class="link-card-title">Nuxt - shadcn/vue</p>
<p class="link-card-description">Install and configure Nuxt.</p>
</div>
</div>
<a href="https://www.shadcn-vue.com/docs/installation/nuxt.html"></a>
</div>
[[Bun]]の場合は実行するだけなら[[TypeScript]]のインストールは不要なので3からで良いが、[[Neovim]]を使う都合上、[[TypeScript]]をインストールする。
```console
$ bun add --dev typescript
bun add v1.1.5 (b257a309)
$ nuxt prepare
ℹ Using default Tailwind CSS file
WARN ENOENT: no such file or directory, scandir '/home/tadashi-aikawa/git/github.com/tadashi-aikawa/isekai/components/ui
WARN Components directory not found: /home/tadashi-aikawa/git/github.com/tadashi-aikawa/isekai/components/ui
✔ Types generated in .nuxt
installed
[email protected] with binaries:
- tsc
- tsserver
1 package installed [1.95s]
```
まずは[[Tailwind CSS]]。
```console
$ bun x nuxi@latest module add @nuxtjs/tailwindcss
ℹ Installing @nuxtjs/tailwindcss@latest dependency
bun add v1.1.5 (b257a309)
$ nuxt prepare
✔ Types generated in .nuxt
installed @nuxtjs/
[email protected]
97 packages installed [4.05s]
ℹ Updating nuxt.config.ts
ℹ Adding @nuxtjs/tailwindcss to the modules
✔ nuxt.config.ts updated
```
続いて[[shadcn-vue]]。
```console
$ bun x nuxi@latest module add shadcn-nuxt
ℹ Installing shadcn-nuxt@latest dependency
bun add v1.1.5 (b257a309)
$ nuxt prepare
ℹ Using default Tailwind CSS file
✔ Types generated in .nuxt
installed
[email protected]
2 packages installed [2.65s]
ℹ Updating nuxt.config.ts
ℹ Adding shadcn-nuxt to the modules
✔ nuxt.config.ts updated
```
`nuxt.config.ts`を編集。
```ts
// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
devtools: { enabled: true },
modules: ["@nuxtjs/tailwindcss", "shadcn-nuxt"],
// ここから下を追加!
shadcn: {
/**
* Prefix for all the imported component
*/
prefix: "",
/**
* Directory that the component lives in.
* @default "./components/ui"
*/
componentDir: "./components/ui",
},
});
```
[[shadcn-vue]]プロジェクトの初期化。
```console
$ bun x shadcn-vue@latest init
✔ Would you like to use TypeScript (recommended)? … yes
✔ Which framework are you using? › Nuxt
✔ Which style would you like to use? › Default
✔ Which color would you like to use as base color? › Slate
✔ Where is your global CSS file? … assets/css/tailwind.css
✔ Would you like to use CSS variables for colors? … yes
✔ Where is your tailwind.config located? … tailwind.config.js
✔ Configure the import alias for components: … @/components
✔ Configure the import alias for utils: … @/lib/utils
✔ Write configuration to components.json. Proceed? … yes
✔ Writing components.json...
✔ Initializing project...
✔ Installing dependencies...
ℹ Success! Project initialization completed.
```
試しにボタンのコンポーネントを追加してみる。
```console
bun x shadcn-vue@latest add button
```
`Button.vue`が追加された。
```console
$ tree components
components
└── ui
└── button
├── Button.vue
└── index.ts
```
### Pinia
[[Pinia]]を導入する。
<div class="link-card">
<div class="link-card-header">
<img src="https://pinia.vuejs.org/logo.svg" class="link-card-site-icon"/>
<span class="link-card-site-name">pinia.vuejs.org</span>
</div>
<div class="link-card-body">
<div class="link-card-content">
<p class="link-card-title">Nuxt.js | Pinia</p>
<p class="link-card-description">Intuitive, type safe, light and flexible Store for Vue</p>
</div>
</div>
<a href="https://pinia.vuejs.org/ssr/nuxt.html"></a>
</div>
```console
$ bun add pinia @pinia/nuxt
bun add v1.1.6 (e58d67b4)
$ nuxt prepare
ℹ Using Tailwind CSS from ~/assets/css/tailwind.css
✔ Types generated in .nuxt
installed
[email protected]
installed @pinia/
[email protected]
2 packages installed [2.17s]
```
`nuxt.config.ts`の`mocules`に`@pinia/nuxt`を追加する。
```ts
export default defineNuxtConfig({
modules: ["@nuxtjs/tailwindcss", "shadcn-nuxt", "@pinia/nuxt"],
```
テスト用にStoreのコードを書いてみる。
<div class="link-card">
<div class="link-card-header">
<img src="https://pinia.vuejs.org/logo.svg" class="link-card-site-icon"/>
<span class="link-card-site-name">pinia.vuejs.org</span>
</div>
<div class="link-card-body">
<div class="link-card-content">
<p class="link-card-title">Defining a Store | Pinia</p>
<p class="link-card-description">Intuitive, type safe, light and flexible Store for Vue</p>
</div>
</div>
<a href="https://pinia.vuejs.org/core-concepts/"></a>
</div>
`stores/index.ts`
```ts
export const useWelcomeMessageStore = defineStore("welcomeMessage", () => {
const _message = ref("Hello World");
const message = computed(() => _message.value);
const updateMessage = (message: string) => {
_message.value = message;
};
return { message, updateMessage };
});
```
> [!hint] [[Auto-imports (Nuxt)|Auto-imports]]が効かないときは...
> `bun dev`でビルドしなおしたり、エディタの言語機能を再起動したりすると[[Auto-imports (Nuxt)|Auto-imports]]が解消する。
`Textare`コンポーネントを使いたいので追加しておく。
```console
bun x shadcn-vue@latest add textarea
```
`app.vue`
```html
<script setup lang="ts">
const welcomeMessageStore = useWelcomeMessageStore();
const text = ref("");
const message = computed(() => welcomeMessageStore.message);
const handleClickUpdate = () => {
welcomeMessageStore.updateMessage(text.value);
};
</script>
<template>
<h1 class="text-4xl font-bold text-center p-5">{{ message }}</h1>
<div class="flex justify-center">
<div class="flex flex-col w-96 gap-4">
<Textarea
v-model="text"
placeholder="Write your favorite welcome message!"
/>
<Button variant="destructive" @click="handleClickUpdate">Update</Button>
</div>
</div>
</template>
```
`Update`ボタンを押したときに、`Textare`に入力した内容でタイトルが更新される。
### Zod
利用技術の最後は[[Zod]]。[[OpenAPI Specification]]との連携もあるので、[[openapi-zod-client]]を使ってクライアントコードを自動生成しつつ、UIの実装に集中できるベースを整えてみた。方針と概要は以下のとおり。
<div class="link-card">
<div class="link-card-header">
<img src="https://publish-01.obsidian.md/access/35d05cd1bf5cc500e11cc8ba57daaf88/favicon-64.png" class="link-card-site-icon"/>
<span class="link-card-site-name">minerva.mamansoft.net</span>
</div>
<div class="link-card-body">
<div class="link-card-content">
<p class="link-card-title">📜2024-04-29 openapi-zod-clientを使ってOpenAPI SpecificationのyamlファイルからZodのTypeScriptファイルを自動生成してみた</p>
</div>
<img src="https://publish-01.obsidian.md/access/35d05cd1bf5cc500e11cc8ba57daaf88/Notes/attachments/minerva-image.webp" class="link-card-image" />
</div>
<a class="internal-link" data-href="Notes/📜2024-04-29 openapi-zod-clientを使ってOpenAPI SpecificationのyamlファイルからZodのTypeScriptファイルを自動生成してみた.md"></a>
</div>
%%[[📜2024-04-29 openapi-zod-clientを使ってOpenAPI SpecificationのyamlファイルからZodのTypeScriptファイルを自動生成してみた]]%%
まずインストール。
```console
bun add --dev openapi-zod-client
```
今回は[[Reqres]]を使ってみる。その[[OpenAPI Specification]]を`GET /users`のみ用意する。
`openapi.yaml`
```yaml
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
paths:
/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"
```
[[openapi-zod-client]]でクライアントコードを自動生成する。
```console
$ bun x openapi-zod-client openapi.yaml -o client.ts
Retrieving OpenAPI document from openapi.yaml
Done generating <client.ts> !
```
`composables/reqres.ts`を作成。
```ts
import { createApiClient } from "~/client";
import { isErrorFromPath } from "@zodios/core";
export const useReqres = (baseUrl: string = "https://reqres.in/api") => {
const client = createApiClient(baseUrl);
const getUsers = async () => {
const path = "/users";
try {
return { res: await client.get(path) };
} catch (error) {
// このif文には入らないが一応今後のお手本として
if (isErrorFromPath(client.api, "get", path, error)) {
return { error };
}
throw Error("Unexpected error");
}
};
return { getUsers };
};
```
`app.vue`を変更。
```ts
<script setup lang="ts">
import { useReqres } from "./composables/reqres";
const welcomeMessageStore = useWelcomeMessageStore();
const text = ref("");
const message = computed(() => welcomeMessageStore.message);
const handleClickUpdate = () => {
welcomeMessageStore.updateMessage(text.value);
};
const reqres = useReqres();
onMounted(async () => {
const { res, error } = await reqres.getUsers();
if (error) {
console.error(error);
return;
}
text.value = res?.data?.map((x) => x.first_name).join(" / ") ?? "undefined";
});
</script>
<template>
<h1 class="text-4xl font-bold text-center p-5">{{ message }}</h1>
<div class="flex justify-center">
<div class="flex flex-col w-96 gap-4">
<Textarea
v-model="text"
placeholder="Write your favorite welcome message!"
/>
<Button variant="destructive" @click="handleClickUpdate">Update</Button>
</div>
</div>
</template>
```
これでAPIによる通信処理ベースが実装できた。