TECH PLAY

Nuxt.js

イベント

該当するコンテンツが見つかりませんでした

マガジン

該当するコンテンツが見つかりませんでした

技術ブログ

RevCommの熊谷です。 AIエージェントを使った開発が当たり前になり、コーディングの速度は確かに上がりました。一方で、フロントエンドは自由度が高いぶん、油断するとすぐに 設計がブレる という次の問題も見えてきます。今回は、それをどうやって抑えているかを書いてみます。私たちのチームではルールを文章で書き下すよりも、「参考実装を指す」ことを軸にしています。 困っていたこと 紹介するのは、MiiTel Admin という管理画面での話です。自社サービス「MiiTel(ミーテル)」を支える、Vue 3 + Nuxt 4 + TypeScript で作られた Web アプリケーション。ミーテルの成長とともに機能が増え続け、現在は 92 ページの大規模な管理画面になっています(ここ半年で 8 ページ増えてる...!)。 このプロダクトはフロントエンド専任ではない開発者(バックエンド・モバイル・音声 AI エンジニア)も気軽に機能追加に入ってくれます。それ自体はありがたいのですが、そのぶん設計のブレが起きやすい環境でもあります。 エージェント導入前は、既存のページをコピペして少し直すような書き方が多かったんですよね。コピペにはもちろん問題もあるのですが、結果として構成が大きく外れにくく、書く速度もそこまで速くなかったので、設計のずれはレビューで拾えていました。 ところが、エージェントが入ってから一気に状況が変わりました。 みんな書く速度が一気に上がった 設計のブレがレビューで追いつかない量で発生するようになった 開発者ごとに使うエージェント・モデルが違う(Copilot / Claude / Cursor / Devin、モデルもいろいろ) このまま放っておくとカオスになる、というのが課題感でした。 基本方針 打ち手はいくつかあるのですが、根っこの方針はシンプルです。 エージェント・モデルは開発者の好きなものを使ってOK(強制しない) ただし「何を見て書くか」だけは全エージェント共通にする 人間がレビューで都度直すのではなく、書かれる前に方向を揃える エージェントを「設計のブレ防止装置」として使うイメージです。本業で使い慣れたエージェントをそのまま使える方が、開発者にとっても嬉しいですしね。 やっていること エージェント向けのドキュメント整備は ルールを文章で書き下す アプローチが多いと思いますが、私たちはむしろ 参考実装そのものを指す アプローチを軸にしています。文章のルールはどうしても実装とズレていきますが、コードは常に最新だからです。以下の 3 つは、その方針から派生した工夫です。 1. リファレンス実装と「参考の優先順位」を明示する 1ページを徹底的にリファクタしてお手本にする、というやり方です。 前回のブログ でも書いたパターンですね。 現在は SMS テンプレート機能 src/pages/sms-template/ を参照実装にしていて、エージェントが見る .github/copilot-instructions.md にも明記しています。 ## 作業の進め方(最優先) - 変更行数が合計 20 行を超える場合は必ず守ること。 1. コードを書く前に対応するスキルを呼び出す 2. 使用するスキルが 3 つを超える場合は、作業分割をユーザーに提案する — ユーザーが「そのまま進めて」と回答すれば継続してよい 3. 既存のコンポーネント構造・設計に従う 4. 明示的な指示がない場合は新しい設計パターンの導入はしない 5. 判断に迷った場合は、一般的なベストプラクティスに従うよりも、既存コードの設計・パターンに合わせることを優先する ## 参考実装の優先順位 1. `src/pages/sms-template/`(composable 分離・Pinia Setup Store・vee-validate・i18n が揃ったパターン) 2. `src/pages/softphone-setting/`(Pinia 移行済みの最新実装) 3. 同カテゴリの既存画面(同じユースケース・UI 構成) 4. `src/composables/`、`src/components/organisms/` の既存実装 参考実装の場所さえ明示しておけば、エージェントは常に最新のコードを見にいってくれます。ドキュメントの更新が遅れても、自然と最新パターンに揃っていくというのが、このやり方のいちばん効いている点です。 「既存パターンを優先する」と明文化しておくと、エージェントがプロジェクト側に寄せてくれやすくなります。バックエンドエンジニアもフロントを触る体制なので明示的に書いていますが、フロント専任メンバーで固まっているチームでは、ここまで強く書く必要はないかもしれません。 2. コンテキストに乗せる情報を絞る ドキュメントは育てると肥大化しがちです。とくに先ほどの参考実装アプローチでは、エージェントが skill / instructions に加えて実コードまで読みにいくので、コンテキストはなおさら膨らみやすい。コンテキストが膨らむと、 エージェントの注意がそれて、長いルールほど読み飛ばされやすくなる 。動作そのものも遅くなります。なので、「何を読ませないか」もセットで設計しています。 トピックごとに skill に分割: state-management / i18n-rules / vee-validate-form など。関連する作業のときだけ読み込ませる 小規模変更ではスキップ可能に: 「変更行数が 20 行を超える場合は必ず skill を呼ぶ」のように、軽微な編集では読み込みを省略できる逃げ道を書いておく 人間向けドキュメントと分離: 設計ドキュメント ( docs/architecture.md など) は人間向けに保ち、エージェント向けは簡潔な技術用語の羅列に近い形に 最後の点は試行錯誤がありました。最初は copilot-instructions.md から docs/architecture.md を参照させていたのですが、結局コンテキストが膨らむだけで効果が薄かった。エージェント向けには「読みやすさ」より「短さ」を優先して、技術用語の羅列に近い形に分離したら、必要な情報だけ拾ってもらえるようになりました。 3. ライブラリの標準パターンに素直に乗る 2 はドキュメントを絞る工夫でしたが、もう一歩進めて、 そもそもドキュメントを書かなくて済むようにする こともできます。エージェントがすでに知っている公式パターンに乗せれば、プロジェクト固有のルールを書く必要がありません。 これは、考え方が大きく変わったところです。 以前は「Nuxt/Vue 固有機能をバックエンドエンジニアにまで覚えてもらうのは忍びない」と思っていて、Vanilla 寄りの実装で吸収していました。たとえばページの表示条件は、各ページで router.push を使って書いていました。 // src/pages/sms-templates.vue (抜粋) const router = useRouter(); const { isSmsAvailable } = usePermission(); onMounted(() => { if (!isSmsAvailable.value) router.push('/'); }); 今は Nuxt の definePageMeta + middleware に置き換えました。各ページは宣言を 1 行書くだけです。 // src/pages/sms-templates.vue (抜粋) definePageMeta({ permission: (ctx: TenantContext) => ctx.isSmsAvailable, }); 判定の本体は middleware に集約。全ページぶんが、ざっくりこの数行で完結します。 // middleware/permission.global.ts (抜粋) export default defineNuxtRouteMiddleware(to => { const ctx = usePermission(); if (!to.meta.permission?.(ctx)) return navigateTo('/'); }); この設計は Vue Router 公式の route.meta + navigation guard パターン をほぼそのまま踏襲したもので、プロジェクト固有の語彙も permission / TenantContext の 2 つに絞れているので、エージェントは公式パターンの知識をほぼそのまま使って書けます。 振り返ると、MiiTel Admin は SSR を採用していないこともあり、これまで Nuxt らしい機能を十分に活かせていませんでした。それがエージェントの登場で逆転しました。 エージェントが補完してくれるなら、標準パターンに乗る方がブレない 。学習コストの高い固有機能も気軽に取り入れられるようになり、Nuxt を選んだメリットをようやく実感できているところです。 ルールを長く運用する工夫 一次ソースを 1 箇所に集約する ドキュメントは長く運用すると、コピーが増えてあちこちでずれていく、というのが起きがちです。一次ソースを 1 ヶ所に集約して、各エージェントからは同じファイルを参照させるようにしています。 .github/copilot-instructions.md と .github/instructions/*.instructions.md を起点に、Claude 用 .claude/skills/ や Cursor からも同じファイルを参照。コピーを作らないので更新がずれません。一番制約がきつい Copilot(2026 年 5 月時点では外部ファイルを参照できず、 .instructions.md 自体に内容を直接書く必要があります)に合わせて書いておけば、他のエージェントでも破綻しにくい、というのも分かってきました。 たとえば Claude 用の .claude/skills/i18n-rules/SKILL.md は、 .github/instructions/ 配下の対応するファイルを cat するだけのシンプルな中継ファイルです。 --- name: i18n-rules description: Use this skill when creating or editing i18n files (...). --- !`cat ${CLAUDE_SKILL_DIR}/../../../.github/instructions/i18n-rules.instructions.md` これで Claude も Copilot も結局は同じ .github/instructions/i18n-rules.instructions.md を見ているので、更新は 1 箇所で済みます。 PR レビューでルールを育てる それでも、思ったような PR が出てこないことはあります。レビューで違和感を覚えるときって、実装した本人も「なんか変だな」と感じていることが結構あるんですよね。そんな時は、本人が使っているエージェント環境で、そのまま 「今回どの skill / instructions 読んだ?参考実装は何を見た?」と聞いてもらいます。自分の環境で再現するより速いし、本人もその場で原因を切り分けやすい。ルール自体が足りないのか、ルールへの導線が悪いのか、参考実装が古いのか。「あ、これ参照されてなかったね、書き足しておくね」みたいに、雑談ベースでドキュメント改善を回しています。 原因の切り分けが終わったら、その流れでエージェントに .github/instructions/ の修正 PR まで作ってもらうこともあります。違和感を覚えた人が、その場でルールを直す側に回れるのは、エージェント時代ならではの良さだなと感じています。 おわりに エージェント時代のフロントエンド開発で効いたのは、 「何を見て書くか」を揃えること だなと感じています。エージェントを厳しく縛るのではなく、既存設計に自然に合流できる道を整える。それができれば、速く作りながら長く保守できるプロダクトに近づけるはずです。 「スピードは出るようになったけど、設計がブレ始めた」というフェーズに入ったチームの参考になれば嬉しいです。
フロントエンドもアーキテクチャに向き合う! こんにちは!フロントエンドエンジニアの浅川です! この記事では、社内向け管理画面(以降「社内システム」と呼びます)を段階的に整えながら、今の形へたどり着くまでの考え方を、時系列でまとめてみたいと思います。 この記事でまとめること 各レイヤーの責務と、ディレクトリ構造 「どこに何を置くか」の分割の目安 前提:社内システムの構成 まず、社内システムの技術スタックを簡単に紹介します。 フレームワーク: Vue 3 + Nuxt 4(SPA、SSR なし) バックエンド: Firebase(Firestore / Cloud St
SCSKの畑です。 今年度の Web アプリケーション開発関連のテーマは大体書きたいもの書けたからもう良いかなと思ってたんですが、本件がそれなりに大変だったことを今更思い出したので備忘として残しておこうと思います。   背景 本 Web アプリケーションの開発を始めたのが 2024 年の 5 月頃だったと思うのですが、その時点での最新版は Nuxt.js が 3.x 系、Nuxt UI が 2.x 系でした。事前調査で Nuxt.js は 2.x 系と 3.x 系で仕様がかなり異なることが分かっていたので最初から 3.x 系を入れたのですが、Nuxt UI の 3.x 系のリリースは今調べたら2025 年の 3 月ということでそもそも選択肢に上がらず。 今年度も当初は(他に優先すべきタスクがあったこともあり)特に移行することは考えていなかったのですが、お客さんから要望頂いた機能を実装するのに以下 URL のコンポーネントをどうしても使いたくなってしまい。更にその頃には既に Nuxt.js / Nuxt UI 共に 4.x 系がリリースされ始めており、Nuxt.js はまだしも Nuxt UI は 2.x からそろそろ上げておかないと EOL になってしまうかも?と思ったこともあって、少し手が空いたタイミングでやってしまうことにしました。 Vue Table Component - Nuxt UI A responsive table element to display data in rows and columns. ui3.nuxt.com ちなみに Nuxt UI 4.x では 2.x や 3.x では有料だった Pro コンポーネントが使用できるようになったため一気に 4.x に移行してしまうことも考えたのですが、その場合 Nuxt.js も 4.x 系への移行が必要になりそうだったので今回のタイミングでは断念しました。   移行ガイド 公式から移行ガイドが出ているので、まずはそれを見ながら進めていくことになります。特に 2.x / 3.x の非互換については「Changes from v2」セクション以降にまとまっているため、このセクションの内容については必ず確認しておきましょう。 Migration - Nuxt UI A comprehensive guide to migrate your application from Nuxt UI v2 to Nuxt UI v3. ui3.nuxt.com ただし、残念ながら非互換となる項目が網羅されている訳ではないようで、他にも動かないコンポーネントが大量に出てくる有様だったため、最終的にはほぼ全てのコンポーネントについて Nuxt UI のドキュメントとにらめっこしながら修正していくことになりました。 ということで、あくまで今回のケースに関する内容にはなりますが、上記 URL 以外の観点で修正が必要だったコンポーネントとその内容をざっくりまとめてみました。   個別に修正したコンポーネント 以下、順番に記載していきます。   FileUpload (Input から変更) 一部画面でブラウザからファイルをアップロードするために UInput コンポーネントを使用していたのですが、3.x に移行後は正常に動作しなくなってしまいました。3.x のマニュアルを見る限り使用方法は変わらないように見受けられたので原因が良く分からなかったのと、複数ファイルを同時にアップロードする要件も出てきたことから、コンポーネント自体を 3.x で追加された UFileUpload に変更することで解決しました。今思うと、UForm の validate のロジックに原因があった可能性が高そうですが・・ Vue FileUpload Component - Nuxt UI An input element to upload files. ui3.nuxt.com   Modal 2.x のサンプル実装だと以下 URL のように UCard と合わせて使用されているのですが、これをそのまま 3.x で動かしたところ UCard 部分が悪さをしているのか画面レイアウトがおかしなことになってしまいました。画面レイアウト上 UCard の使用がマストではなかったため、使用しない実装に変更しました。 Modal - Nuxt UI Display a modal within your application. ui2.nuxt.com   Progress インジケータの進捗状況を示す value プロパティが v-model ディレクティブに変更されています。使い方自体はこれまでと大きく変わりません。   SelectMenu プルダウンメニューのコンテンツを指すプロパティが options から items に変更されている他、プルダウンメニューにおけるラベルと値をコンテンツのプロパティにバインドする方法も変わっています。 2.x だと option-attribute でラベル、value-attribute で値のプロパティを指定していましたが、3.x ではラベルのプロパティは label 固定で、値のプロパティを指定する場合は value-key を使用します。なお、2.x/3.x どちらも値のプロパティを指定しない場合は選択したメニュー項目に対応する全ての値がバインドされるようです。   Table テーブルのコンテンツ(行データ)を指すプロパティが rows から data に変更されている他、列情報の定義方法(指定すべきプロパティ)も変更されています。また、テーブルデータを変換・加工してテーブル内に表示する場合や、何らかのアクションボタンなどをテーブルデータとは別の列として表示したい場合の実現方法が変わっています。 2.x の場合は列定義に対象列の情報のみを含めた上で、template 構文の中で列定義(key プロパティ)に対応した名前付きスロットを定義して行う形式でした。以下、該当部分を抜粋した実装例です。 <UTable :columns="TableCols" :rows="TableRows" :loading="!TableLoadStatus">     <template #status-data="{ row }">         <div class="flex items-center place-content-center">             <MTStatusBudge :table_name=row.name :display_normal=true class="ml-2"/>         </div>     </template>     <template #update-data="{ row }">         <UButton icon="i-material-symbols-edit" size="2xs" variant="outline" @click="getSettingModal('update_table', row.name)"/>     </template>     <template #delete-data="{ row }">         <UButton icon="i-material-symbols-delete" color="pink" size="2xs" variant="outline" @click="deleteTable(row.name, row.logi_name"/>     </template> </UTable> <script setup lang="ts"> TableCols.value = [   {label: "論理名", key: "logi_name", sortable: true }, {label: "物理名", key: "name", sortable: true }, {label: "編集可能組織", key: "groups" }, {label: "ステータス", key: "status", sortable: true }, {label: "ロック元テーブル", key: "locked_by", sortable: true }, {label: "編集者", key: "editor", sortable: true }, {label: "承認者", key: "author", sortable: true }, {label: "更新内容", key: "temp_changes" }, {key: "update" }, {key: "delete" }, ] </script>   3.x の場合は、以下のように template 構文で定義していた情報も列定義に含めるような形式になっているようです。編集・削除機能をボタンからプルダウンメニューに変更しているため、2.x の実装例とは等価になっていない部分がありますが。 Vue Table Component - Nuxt UI A responsive table element to display data in rows and columns. ui3.nuxt.com <UTable :columns="TableCols" :data="TableRows" :loading="!TableLoadStatus"> <script setup lang="ts"> TableCols.value = [ {header: "論理名", accessorKey: "logi_name" }, {header: "物理名", accessorKey: "name" }, {header: "編集可能組織", accessorKey: "groups" }, { header: "ステータス", accessorKey: "status", meta: { class: { th: 'text-center', td: 'text-center' } }, cell: ({ row }) => { return h( MTStatusBudge, { table_name: row.getValue('name'), display_normal: true } ) } }, {header: "ロック元テーブル", accessorKey: "locked_by" }, {header: "編集者", accessorKey: "editor" }, {header: "承認者", accessorKey: "author" }, {header: "更新内容", accessorKey: "temp_changes" }, { id: 'actions', cell: ({ row }) => { return h( 'div', { class: 'text-right' }, h( UDropdownMenu, { content: { align: 'end' }, items: getActions(row), 'aria-label': 'Actions dropdown' }, () => h(UButton, { icon: 'i-lucide-ellipsis-vertical', color: 'neutral', variant: 'ghost', class: 'ml-auto', 'aria-label': 'Actions dropdown' }) ) ) } }] </script>   Tabs 2.x ではタブの切替イベントを以下のように @change イベントで検知できたのですが、3.x ではこの仕組みが使えなくなっているようでした。実際の画面では選択されているタブに応じて表示するデータを変更する実装としていたため、影響が大きかったです。また、初期選択されているタブを指定する方法も変更されており、以下のように Tab_Items 内のインデックス値を指定する方法は使えず、合わせて実装の変更が必要となりました。 <UTabs :items="Tab_Items" :default-index="1" @change="onChangeTabs"> <script setup lang="ts"> const Tab_Items = ref([{ label: 'オリジナルデータ表示', icon: 'material-symbols:table-chart-outline', }, { label: '更新差分表示', icon: DiffTabIcon.value, }, { label: 'リレーションシップ(ERD図)表示', icon: 'material-symbols:dashboard-2-outline', }]) const onChangeTabs = (index: number) => { const tab_item = Tab_Items.value[index] // 以下、具体的なタブ切替時の処理内容を記述 // } </script>   一方 3.x における代替手段はというと、移行ガイドには @change の代わりに @update:modelValue を使用する旨記載があったものの 、Tabs の場合はタブの選択状態も合わせて変更する必要があるためその処理と合わせてどう実装するのかが良く分からず。v-model ディレクティブを使用する必要がありそうなことは分かったものの、2.x のように Tab_Items 内のインデックス値を指定しても正常に動作せず、どのような値を指定すべきか分からなかったのであれこれ試行錯誤する羽目になりました。 Vue Tabs Component - Nuxt UI A set of tab panels that are displayed one at a time. ui3.nuxt.com 結論としては、Tab_Item に value プロパティを追加した上でそのプロパティの値を指定することで対応するタブを選択することができました。タブ切替時の処理を含めて考えると上記 URL のサンプル通り v-model に computed() を指定するのが筋が良さそうだったのでそれも踏まえて以下のような実装としています。最も、このサンプルが正直分かり難かったのが実装に手間取った理由というか、@change からの移行パスとして分かるような形で書いておいて欲しかったところではありますが・・ <UTabs :items="Tab_Items" v-model="Tab_Activate"/> <script setup lang="ts"> const Tab_Items = ref([{ label: 'オリジナルデータ表示', icon: 'material-symbols:table-chart-outline', value: 'original_data', }, { label: '更新差分表示', icon: DiffTabIcon.value, value: 'diff_data', }, { label: 'リレーションシップ(ERD図)表示', icon: 'material-symbols:dashboard-2-outline', value: 'erd_view', }]) // 初期選択されるタブを変更 const currentTabValue = ref<string>('diff_data') const Tab_Activate = computed({ get() { return currentTabValue.value }, set(value: string) { currentTabValue.value = value // valueに基づいて対応するタブ項目を検索 const tabItem = Tab_Items.value.find(item => item.value === value) // 以下、具体的なタブ切替時の処理内容を記述 // } }) </script>   Toast(旧 Notification) 移行ガイドの内容以外で 1 点使い勝手が大きく変わっているところがありました。画面上に表示した特定のポップアップを削除する場合の方法が変更されています。 2.x の場合は以下のように、ポップアップ表示(toast.add)時に任意の id を定義した上で、その id をポップアップ削除(toast.remove)の引数に指定することで対象のポップアップを削除します。 toast.add({ id: 'toast_sample', title: 'toastのサンプル表示です。'}) toast.remove('toast_sample') 3.x の場合はこの方法が使用できなくなっており、 その代わりに toast.add の返り値として返却された id を toast.remove 時に指定する方法に変更されているようです。ただ、これが 3.x のマニュアルのどこにも書いておらず、調べるのに結構苦労しました。。 const toast_info = toast.add({title: 'toastのサンプル表示です。'}) toast.remove(toast_info.id)   NavigationMenu(旧 HorizontalNavigation, VerticalNavigation) 移行ガイドだとサラッと名前が変わっている程度に受け取れなくもないのですが、実態としては上記 2 つのコンポーネントが統合されているので、両方のコンポーネントを使用している状態で単純に名前を置換しただけだと画面がえらいことになります。メニューの並べ方 (horizontal or vertical) は orientation オプションで指定します。 他、2.x ではメニュー構造のカスタマイズをするためには #default スロットを使用する必要がありましたが、3.x の場合は children オプションでメニューをネストできるため、その目的で #default スロットを使用していた箇所を変更しました。VerticalNavigation の場合は 以下 URL の通り Accordion と組み合わせることでメニューのネスト構造を実現していた箇所もあったのでそちらも合わせて変更しています。相対的にシンプルな実装にはなったのでこの変更自体は良かったですが、変更箇所は多岐に渡りました。 Accordion - Nuxt UI Display togglable accordion panels. ui2.nuxt.com   まとめ 来年は Nuxt.js / Nuxt UI 共に 4.x 系に上げないといけないかなーと思っています。どちらも 2.x 系から 3.x 系に上げるのよりは大変じゃないよ!みたいなことが書いてあったので、いまのところは楽観視していますが。それより先にバックエンド処理に使用している Lambda の Python バージョンアップをまずやらないといけなさそうなのがちょっと厄介そうです。 ざっと書いたこともあり全量を網羅できているかちょっと怪しいので、もし他に思い出したら追記しようと思います。 本記事がどなたかの役に立てば幸いです。

動画

書籍