## 背景 本業で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による通信処理ベースが実装できた。