vue-component-anatomy について
Vue 3.5+ の単一ファイルコンポーネントを整える、ライブラリ非依存のスキルです。`.vue` ファイル内のブロック順、マクロ、スクリプト、テンプレート、スタイルの規約を示します。
主な機能
- 01SFC のブロックとマクロの順序
- 02スクリプト宣言と関数の配置
- 03props、emits、model の型付け
- 04テンプレートとスタイルの規約
- 05空行と書式のルール
活用シーン
- 01Vue 単一ファイルコンポーネントの新規作成
- 02コンポーネントのスクリプトとマクロの整理
- 03props、イベント、双方向バインディングの書き方選定
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ basmilius/ skills --skill vue-component-anatomyインストール後、Claude Code で/vue-component-anatomyを入力して読み込まれたか確認します。
詳細
Vue コンポーネントの構造
Vue 3 の単一ファイルコンポーネントの内部構造について説明します。対象は、1 つの .vue ファイルの構成、つまりブロックの順序、マクロ、スクリプト、スタイルです。特定のライブラリには依存しません。機能を複数のコンポーネントに分割する方法は vue-build-feature を、UI コンポーネントの選び方(プロジェクトで Flux を使用している場合)は flux-ui を参照してください。プロジェクト固有のルールは、リポジトリの CLAUDE.md / AGENTS.md に記載されています。
リポジトリに別途指定がない限り、<script setup lang="ts"> と Vue 3.5 以降を前提とします。
1. SFC の骨組みとテンプレートのルート
- ブロックの順序: まず
<template>、次に<script setup lang="ts">、続いて<style>、最後に任意の<i18n>ブロックを置きます。コードベース全体で一貫させてください。 <i18n>ブロック(vue-i18n の SFC メッセージを使うプロジェクトの場合)は、必ず<style>の後に置きます。既存のコンポーネントと形式を合わせてください。記述する前に、コードベースが<i18n lang="yaml">と JSON のどちらを使っているかを確認し、どこでも同じlang属性を使ってください。- テンプレートでは、ルート要素を 1 つにするのが望ましいです。属性のフォールスルー(
class、@click、v-bind="$attrs"が 1 つのノードに適用される動作)が予測しやすくなり、多くの<Transition>の構成でも必要です。Vue 3 ではフラグメント(複数のルート要素)が使えますが、意図的に選択してください。複数のルート要素があると$attrsは自動継承されず、明示的にバインドする必要があります。 - ダイアログやツールチップなど、テレポートされる子要素やオーバーレイは、ルート要素の兄弟としてではなく、その内側に描画してください。そうすれば、トランジションとフォールスルーを維持できます。
2. マクロの順序(<script setup> の先頭)
コンポーネントの契約をひと目で把握できるように、コンパイラーマクロを次の順序で最初に宣言します。
defineOptions(まれ) -> defineEmits -> defineModel -> defineProps -> defineSlots
defineExpose は例外です。スクリプトの一番下(関数宣言の後、第 3 節を参照)に置き、親コンポーネントが ref ハンドルを必要とする場合にのみ使用します。外部に公開するインターフェースを記述するものなので、公開する内容をすべて定義した後に置きます。
- それぞれに型を指定してください。型引数でより適切に指定できる場合は、実行時オプションオブジェクトを渡さないでください。
3. <script setup> の順序
状態を使用する前に宣言できるよう、本文を上から下へ次の順序にします。
- インポート
- マクロ(emits / models / props / slots)— 第 2 節を参照
- モジュール定数(
ALL_CAPS:設定値、オプションリスト、ルックアップマップ) - ストア(
use*Store()— 共有状態) - リアクティブな状態(
ref、reactive、shallowRef、テンプレート ref) - コンポーザブル(
useRouter、useI18n、その他のuse*動作ヘルパー) - computed
- watch / watchEffect
- ライフサイクルフック(
onMounted、onUnmounted、...) - 関数宣言(イベントハンドラーとヘルパー)
defineExposeは最後に置き、親コンポーネントが ref ハンドルを必要とする場合にのみ使用
- モジュール定数はマクロの下に置き、上には置かないでください。 マクロはコンポーネントの公開契約なので先に読み、定数は実装の詳細としてその後に置きます。
t()やその他のリアクティブな入力が必要な値は定数ではありません。手順 7 でcomputedにしてください。 - ストアはリアクティブな状態の上、コンポーザブルはその下に置きます。 ストアは共有状態なのでコンポーネント独自の状態とまとめて確認できます。コンポーザブルは動作や派生データを提供し、その状態を基に構成される場合があります。目安として、ストアレイヤーの
use*Store()はストアで、それ以外のuse*呼び出しはコンポーザブルです。 - テンプレート ref には
useTemplateRef('name')(Vue 3.5 以降)を使い、リアクティブな状態と同じ位置に置きます。文字列引数はテンプレート内のref="name"と一致させます。 - **インポート形式(エイリアスか相対パスか)**は
vue-build-featureに従います。レイヤー間ではパスエイリアス(@/...)を使い、相対パスは同じ機能内の兄弟ファイルに限ります。 - 副作用(フェッチの開始、タイトルの設定など)はライフサイクルの手順に置き、宣言の合間に散在させないでください。
4. function とアロー関数
- 名前付きのハンドラーとヘルパーには、
function宣言を使います (function onSubmit(): void { ... })。巻き上げられるため、テンプレート内で読みやすく、スタックトレースにも名前が表示されます。 - アロー関数は、インラインコールバックとクロージャ(ウォッチャー、
array.map、.filter、イベントオプション)にのみ使います。 - ライフサイクルの本体はインラインのままにします。
onMounted(async () => { ... })のように書くことで、 副作用が実行される場所に記述できます。2つ目の呼び出し元(ウォッチャー、 再試行ボタン、テンプレートなど)でも必要になる場合、または本体が 数個の文を超えるほど長くなる場合に限り、名前付き関数に切り出します。 名前を付けるだけの目的では切り出しません。
5. Props とその型
- ジェネリック形式で props に型を付けます:
defineProps<{ readonly id: number }>()。 props は不変なので、フィールドにはreadonlyを指定します。 - デフォルト値: デフォルト値にはリアクティブな props の分割代入を使います:
const { size = 'md' } = defineProps<{ size?: Size }>()。分割代入した バインディングのリアクティブ性は維持されます。デフォルト値が必要な props のみ分割代入します。 - props やデータの形状には、
interfaceではなくtypeを使います。interfaceは 宣言のマージが必要な場合(モジュール拡張、declare global)にのみ使います。 - props の型を
defineProps<{ ... }>()以外では使わない場合は、型をその場に記述します。 別の場所でも再利用する場合(別のジェネリックに渡す場合など)に限り、 名前付きのエクスポートされたtype Propsにします。 <script>内で、テンプレートから読み取るだけの props を分割代入しないでください。 スクリプト内での分割代入は、デフォルト値またはローカルバインディングが必要な場合にのみ行います。
6. Emits とモデル
- emits に型を付けます:
const emit = defineEmits<{ submit: [id: number]; cancel: [] }>()。 - 双方向バインディングには
defineModelを使います。modelValueprop とupdate:modelValueemit を手作業で組み合わせるのではなく、const value = defineModel<string>()のようにします。名前付きモデルはdefineModel<number>('page')とし、親でv-model:pageと組み合わせます。 - モデルのデフォルト値には、実行時オプションオブジェクトを使います(セクション 2 の例外):
defineModel<string>({ default: '' })。配列またはオブジェクトのモデルには ファクトリーデフォルトを指定し、値がundefinedにならないようにします:defineModel<string[]>({ default: () => [] })。 - 子コンポーネントは単純なままにします。イベントを発行するかモデルを書き換え、 判断は親コンポーネントに任せます。
7. テンプレートの規約
v-forには必ず安定した:keyを指定します(ID を使い、リストの順序が変わる場合は 配列のインデックスを使わないでください)。- テンプレートに複雑なロジックを記述しません。 単純な式を超える処理は、
computedまたは名前付き関数に移します。 - 名前付きモデルは
v-model:xでバインドします。親もイベントを処理する場合は、 ネストされたインタラクティブ要素でイベントの伝播を止めます(@click.stop)。
8. スタイリング
- 基本的には
<style module>($style.someClass)を使い、クラス名にはキャメルケースを使用します。コンポーネントにちなんだクラス名を付けます。ベースクラスはコンポーネント名と一致させ($style.tagInput)、子要素はbase + suffix($style.tagInputChip、$style.tagInputField)、状態は同じ要素に独立したis*修飾子(.tagInput.isDisabled)として付けます。このプレフィックスですでにクラスが分離されるため、スコープ指定はほぼ不要です。プロジェクトで使用している場合に限り、<style scoped>を使います。 - 修飾子は配列/オブジェクトのバインディングで切り替えます。
:class="[$style.tagInput, { [$style.isDisabled]: disabled }]"。 - **カスタム CSS/グローバル CSS/深いセレクターは最後の手段です。**最小限にとどめ、モジュール方式が使えなかった理由を説明するコメントを1行残します。
- コンポーネントライブラリの内部スタイルを上書きする場合(props、slots、デザイントークンでは本当に対応できない場合)は、独自のベースクラスの下に
:local(.pane)をネストします。機能的にはローカルクラス参照にすぎませんが、:local()を明示することで外部のクラスに意図的にアクセスしていることを示します。これは、アプリとライブラリで同じ名前ベースの CSS Modules の難読化方式を共有している場合(たとえば、両方が@basmilius/vite-presetでビルドされている場合)にのみ、ライブラリのクラスに解決されます。上記の1行コメントのルールも適用されます。 - 他のコンポーネントの内部セレクターを上書きするのではなく、CSS カスタムプロパティ(デザイントークン)でテーマを設定します。プロジェクトにトークンセットがない場合、スタンドアロンのコンポーネントは独自の
var(--x, fallback)プロパティを公開し、利用側が上書きできるようにします。
9. 空白
<script setup>内のトップレベルグループ(インポート、各マクロブロック、定数、ストア、状態、コンポーザブル、算出プロパティ、ウォッチ、ライフサイクル)の間には、空行を1行入れます。また、すべてのfunction宣言の間にも空行を1行入れます。- 同じグループ内で隣接する単一行の宣言の間には、空行を入れません(ref やコンポーザブルが続く場合はまとめます)。
- 関数内では、論理的な処理の区切りに空行を1行入れます。具体的には、ガード節/早期リターンの後と、セットアップとそれに続く処理の間です。
- テンプレートでは、論理的に分かれた要素グループの間に空行を1行入れます。
<template>、<script>、<style>、<i18n>の各ブロックの間には、空行を1行入れます。
関連スキル
vue-build-feature- ビューと複数のコンポーネント、コンポーザブル、データアクセス、ルートにまたがる機能の分割。flux-ui- プロジェクトで Flux を使用している場合の、コンポーネントの選択と組み合わせ。- リポジトリの
CLAUDE.md/AGENTS.md- ここで示す中立的なデフォルトより優先される、プロジェクト固有のスタイル、i18n、ツールのルール。