Skip to content

Vue 最佳實踐

本文件定義 ITO 前端團隊的 Vue 開發規範。假設你已熟悉 Vue Composition API,這裡說明的是團隊在多種合理做法中的選擇,而非基礎語法教學。

基礎規範

永遠使用 script setup

在組件中一律使用 <script setup> 語法,不使用傳統 Composition API 或 Options API。

原因

  • 語法更簡潔,無需 export default 和手動 return
  • 編譯時優化,執行效能更好
  • 更好的 TypeScript 支援與型別推斷

✅ Good

vue
<script setup lang="ts">
import { ref } from 'vue';

const msg = ref('Hello world!');
</script>

❌ Bad

vue
<!-- 傳統 Composition API,需手動 return -->
<script>
import { ref } from 'vue';

export default {
  setup() {
    const msg = ref('Hello world!');
    return { msg };
  },
};
</script>
vue
<!-- Options API -->
<script>
export default {
  data() {
    return { msg: 'Hello world!' };
  },
  methods: {},
};
</script>

永遠使用 ref,而非 reactive

統一使用 ref 處理所有響應式狀態,包括原始型別和物件型別,避免使用 reactive

原因

  • ref 同時適用於原始型別與物件型別,統一使用能減少心智負擔
  • .value 的存取方式能明確辨識該變數為響應式狀態
  • 避免 reactive 解構後失去響應性的陷阱

✅ Good

vue
<script setup lang="ts">
import { ref } from 'vue';

const email = ref('');
const user = ref({ name: '小明', age: 16 });

// .value 明確標示這是響應式狀態
function updateUser() {
  user.value = { name: '小華', age: 18 };
}
</script>

❌ Bad

vue
<script setup lang="ts">
import { reactive } from 'vue';

// reactive 解構後會失去響應性
const user = reactive({ name: '小明', age: 16 });
const { name } = user; // name 不再是響應式
</script>

偏好 Immutable 更新,State 保持原子化

雖然 Vue 的響應式系統是 mutable,但團隊偏好 immutable 的更新方式,State 應保持扁平結構。

原因

  • Immutable 更新讓資料流更清晰,易於追蹤變化
  • 扁平的 State 結構降低複雜度,避免深層巢狀更新的麻煩
  • 若有效能考量,可使用 shallowRef 僅追蹤 .value 的變化

✅ Good

vue
<script setup lang="ts">
import { ref, shallowRef } from 'vue';

// 原子化 state(單層物件 OK)
const user = ref({
  name: '小明',
  age: 16,
  address: '台北市',
});

// immutable 更新陣列
const orderList = ref<Order[]>([]);

function addOrder(order: Order) {
  orderList.value = [...orderList.value, order];
}

function updateOrder(id: number, data: Partial<Order>) {
  orderList.value = orderList.value.map(order =>
    order.id === id ? { ...order, ...data } : order,
  );
}

function removeOrder(id: number) {
  orderList.value = orderList.value.filter(order => order.id !== id);
}

// 效能考量時使用 shallowRef
const largeList = shallowRef<Order[]>([]);

function setLargeList(newList: Order[]) {
  largeList.value = newList; // 整個替換才會觸發更新
}
</script>

❌ Bad

vue
<script setup lang="ts">
import { ref } from 'vue';

// 過深的巢狀物件
const user = ref({
  profile: {
    name: '小明',
    address: {
      city: '台北',
      district: '信義區',
    },
  },
});

// mutable 更新,難以追蹤變化
const orderList = ref<Order[]>([]);
orderList.value.push(newOrder);
orderList.value[0].status = 'completed';
</script>

使用 getter-only computed

computed 應保持純粹,僅用於衍生狀態的計算,不應包含 setter。

原因

  • 保持 computed 的純函式特性,易於理解和測試
  • 避免隱式副作用,資料流更清晰
  • 需要可寫入的響應式資料時,使用 ref 搭配函式處理更明確

✅ Good

vue
<script setup lang="ts">
import { ref, computed } from 'vue';

const firstName = ref('小明');
const lastName = ref('王');

// getter-only computed
const fullName = computed(() => `${firstName.value}${lastName.value}`);

function updateName(first: string, last: string) {
  firstName.value = first;
  lastName.value = last;
}
</script>

❌ Bad

vue
<script setup lang="ts">
import { ref, computed } from 'vue';

const firstName = ref('小');
const lastName = ref('明');

// 帶有 setter 的 computed,產生隱式副作用
const fullName = computed({
  get: () => `${firstName.value}${lastName.value}`,
  set: (val: string) => {
    const [first, ...rest] = val;
    firstName.value = first;
    lastName.value = rest.join('');
  },
});
</script>

Template 避免複雜邏輯

Template 應保持簡潔,複雜的邏輯應抽到 computed 或函式中。

原因

  • 提升可讀性,讓 template 專注於呈現結構
  • 提升可測試性,邏輯可以獨立測試
  • 邏輯可複用,避免在多處重複相同的計算

✅ Good

vue
<script setup lang="ts">
import { ref, computed } from 'vue';

const orderList = ref<Order[]>([]);

const activeOrders = computed(() =>
  orderList.value.filter(order => order.status === 'active'),
);

const sortedActiveOrders = computed(() =>
  [...activeOrders.value].sort((a, b) => a.createdAt - b.createdAt),
);

const totalAmount = computed(() =>
  activeOrders.value.reduce((sum, order) => sum + order.amount, 0),
);
</script>

<template>
  <ul>
    <li v-for="order in sortedActiveOrders" :key="order.id">
      {{ order.productName }}
    </li>
  </ul>
  <p>總金額:{{ totalAmount }}</p>
</template>

❌ Bad

vue
<template>
  <ul>
    <li
      v-for="order in orderList
        .filter(o => o.status === 'active')
        .sort((a, b) => a.createdAt - b.createdAt)"
      :key="order.id"
    >
      {{ order.productName }}
    </li>
  </ul>
  <p>
    總金額:{{
      orderList
        .filter(o => o.status === 'active')
        .reduce((sum, o) => sum + o.amount, 0)
    }}
  </p>
</template>

優先使用事件處理,而非 watch

優先使用明確的事件處理函式,保留 watch 給真正需要監聽資料變化的場景。

原因

  • 事件處理的執行流程更明確,易於追蹤和 debug
  • watch 在跨組件時難以追蹤觸發來源
  • 保留 watch 給必要場景(如 route 參數變化、外部資料源同步)

✅ Good

vue
<script setup lang="ts">
import { ref } from 'vue';

const selectedProductId = ref(0);

// 明確的事件驅動流程
async function onSelectProduct(productId: number) {
  selectedProductId.value = productId;
  await fetchProductDetails(productId);
}
</script>

<template>
  <button @click="onSelectProduct(123)">查看商品</button>
</template>

❌ Bad

vue
<script setup lang="ts">
import { ref, watch } from 'vue';

const selectedProductId = ref(0);

// 難以追蹤誰修改了 selectedProductId
watch(selectedProductId, async productId => {
  await fetchProductDetails(productId);
});
</script>

<template>
  <button @click="selectedProductId = 123">查看商品</button>
</template>

組件設計

組件命名統一使用 PascalCase

無論是檔案名稱或在 template 中使用,皆採用 PascalCase 命名組件。

原因

  • 與 HTML 原生標籤明確區分,一眼就能辨識是自訂組件
  • 與 JavaScript 類別/建構函式的命名慣例一致
  • 統一的命名風格提升程式碼可讀性

✅ Good

vue
<script setup lang="ts">
import UserProfile from '@/components/UserProfile.vue';
import ProductCard from '@/components/ProductCard.vue';
</script>

<template>
  <UserProfile :user="currentUser" />
  <ProductCard :product="selectedProduct" @add-to-cart="handleAddToCart" />
</template>

❌ Bad

vue
<script setup lang="ts">
import UserProfile from '@/components/user-profile.vue';
import ProductCard from '@/components/product-card.vue';
</script>

<template>
  <user-profile :user="currentUser" />
  <product-card :product="selectedProduct" @add-to-cart="handleAddToCart" />
</template>

Script 內程式碼順序

依照以下原則組織 <script setup> 內的程式碼:

  1. Props / Emits / Model — 組件介面定義(最上方)
  2. 依功能分組 — 相關的狀態、computed、函式放在一起
  3. 生命週期 HooksonMountedonUnmounted 等(最下方)

每個功能區塊內部建議順序:狀態 → computed → 函式

原因

  • 組件介面定義在最上方,快速了解組件的輸入輸出
  • 依功能分組讓相關邏輯集中,提升可讀性和可維護性
  • 生命週期 hooks 放最下方,避免干擾主要邏輯閱讀

✅ Good

vue
<script setup lang="ts">
// 1. 組件介面定義
const props = defineProps<{
  userId: number;
  readonly?: boolean;
}>();

const emits = defineEmits<{
  save: [data: UserData];
  cancel: [];
}>();

// 2. 功能區塊:使用者資料管理
const { user, loading, fetchUser } = useUser();
const formData = ref({ name: '', email: '' });
const isDirty = computed(() => formData.value.name !== user.value?.name);

function resetForm() {
  formData.value = {
    name: user.value?.name ?? '',
    email: user.value?.email ?? '',
  };
}

function handleSave() {
  emit('save', formData.value);
}

// 2. 功能區塊:權限檢查
const authStore = useAuthStore();
const canEdit = computed(
  () => !props.readonly && authStore.hasPermission('edit'),
);

// 3. 生命週期 hooks
onMounted(() => {
  fetchUser(props.userId);
});
</script>

Props 與 Emits 定義

使用 TypeScript 型別語法定義 Props 和 Emits,不使用 runtime 宣告。

原因

  • TypeScript 型別語法提供更好的型別推斷和 IDE 支援
  • 程式碼更簡潔,型別定義更直觀
  • 與 TypeScript 生態系統整合更緊密

✅ Good

vue
<script setup lang="ts">
// Props 定義
const props = withDefaults(
  defineProps<{
    title: string;
    status?: 'active' | 'inactive';
    items?: string[];
  }>(),
  {
    status: 'active',
    items: () => [],
  },
);

// Emits 定義
const emits = defineEmits<{
  updateValue: [value: string];
  submit: [data: FormData];
}>();
</script>

❌ Bad

vue
<script setup lang="ts">
// Runtime 宣告,型別推斷較弱
const props = defineProps({
  title: String,
  status: {
    type: String,
    default: 'active',
  },
  items: {
    type: Array,
    default: () => [],
  },
});

const emits = defineEmits(['updateValue', 'submit']);
</script>

不要解構 Props

一律使用 props.xxx 存取 props,不解構 props。

原因

  • 明確來源,props.xxx 讓我們清楚知道該值來自父組件
  • 避免誤用,在 Vue 3.4 及更早版本中解構會失去響應性
  • 跨版本相容,避免依賴編譯器的隱式轉換

✅ Good

vue
<script setup lang="ts">
const props = defineProps<{
  userId: number;
  userName: string;
}>();

// 使用 props.xxx
function greet() {
  console.log(`Hello, ${props.userName}`);
}

watchEffect(() => {
  console.log(props.userId);
});
</script>

❌ Bad

vue
<script setup lang="ts">
const props = defineProps<{
  userId: number;
  userName: string;
}>();

// 解構 props
const { userId, userName } = props;

function greet() {
  console.log(`Hello, ${userName}`); // 不清楚來源
}

watchEffect(() => {
  console.log(userId); // Vue 3.4 及以前版本會失去響應性
});
</script>

事件命名規範

事件名稱統一使用 kebab-case,無論是在 emit 或 template 中。

原因

  • HTML 屬性不區分大小寫,kebab-case 避免混淆
  • 與 HTML 原生事件命名慣例一致(如 @click@input
  • 統一風格提升程式碼可讀性

✅ Good

vue
<script setup lang="ts">
const emits = defineEmits<{
  updateValue: [value: string];
  addToCart: [productId: number];
}>();

// 使用 kebab-case
emit('update-value', value);
emit('add-to-cart', productId);
</script>

<template>
  <ProductCard @add-to-cart="handleAddToCart" />
</template>

❌ Bad

vue
<script setup lang="ts">
const emits = defineEmits<{
  updateValue: [value: string];
  addToCart: [productId: number];
}>();

// 使用 camelCase
emit('updateValue', value);
emit('addToCart', productId);
</script>

<template>
  <ProductCard @addToCart="handleAddToCart" />
</template>

狀態管理

使用 Pinia 管理共享狀態

使用 Pinia 集中管理跨組件的共享狀態,避免使用 provide / inject

原因

  • 集中於 store 統一管理,比起分散在各組件更易於維護與追蹤
  • Pinia 是 Vue 官方推薦的新一代狀態管理方案
  • 若專案無法引入 Pinia,可考慮 VueUse 的 createGlobalState

✅ Good

vue
<script setup lang="ts">
import { useAuthStore } from '@/stores/auth';
import { storeToRefs } from 'pinia';

// 使用 Pinia 管理共享狀態
const authStore = useAuthStore();
const { user, isAuthenticated } = storeToRefs(authStore);

function handleLogout() {
  authStore.logout();
}
</script>

❌ Bad

vue
<!-- Parent Component -->
<script setup lang="ts">
import { ref, provide } from 'vue';

// 使用 provide / inject 難以追蹤狀態來源
const user = ref(null);
provide('user', user);
</script>

<!-- Child Component -->
<script setup lang="ts">
import { inject } from 'vue';

const user = inject('user'); // 不清楚 user 從哪裡來
</script>

避免使用 provide / inject

provide / inject 難以追蹤狀態來源,應改用 Pinia 或 composables。

原因

  • 難以追蹤資料流向,不清楚狀態從哪個組件提供
  • 缺乏型別安全,inject 的值可能為 undefined
  • Pinia 或 composables 提供更好的可維護性和開發體驗

✅ Good

vue
<script setup lang="ts">
import { useAuthStore } from '@/stores/auth';
import { storeToRefs } from 'pinia';

// 使用 Pinia,狀態來源明確
const authStore = useAuthStore();
const { token } = storeToRefs(authStore);
</script>

❌ Bad

vue
<!-- Parent Component -->
<script setup lang="ts">
import { ref, provide } from 'vue';

const token = ref(null);
provide('token', token); // 難以追蹤誰使用了這個 token
</script>

<!-- Child Component -->
<script setup lang="ts">
import { inject } from 'vue';

const token = inject('token'); // 不清楚 token 從哪裡來
</script>

Pinia 只使用 Setup Store

Pinia Store 統一使用 Setup Store 語法,不使用 Option Store。

原因

  • 語法與 Composition API 一致,能無縫整合 composables
  • 提供更好的 TypeScript 型別推斷
  • 邏輯組織更靈活,可依功能分組而非強制分類為 state / getters / actions

✅ Good

ts
// Setup Store
import { ref, computed } from 'vue';
import { defineStore } from 'pinia';

export const useCartStore = defineStore('cart', () => {
  const items = ref<CartItem[]>([]);
  const totalAmount = computed(() =>
    items.value.reduce((sum, item) => sum + item.price * item.quantity, 0),
  );

  function addItem(item: CartItem) {
    items.value = [...items.value, item];
  }

  return { items, totalAmount, addItem };
});

❌ Bad

ts
// Option Store
import { defineStore } from 'pinia';

export const useCartStore = defineStore('cart', {
  state: () => ({
    items: [],
  }),
  getters: {
    totalAmount: state =>
      state.items.reduce((sum, item) => sum + item.price * item.quantity, 0),
  },
  actions: {
    addItem(item) {
      this.items.push(item);
    },
  },
});

Composables

定義與分類

Composable 是使用 Vue Composition API 封裝的邏輯函式,我們將其分為通用型和組件專屬型兩類。

原因

  • 明確區分可複用的通用邏輯與專案特定邏輯
  • 通用型 composable 可跨專案複用,提升開發效率
  • 組件專屬型 composable 幫助簡化複雜組件邏輯
類型用途命名範例
通用型單一功能、可跨專案複用useDebounceuseLocalStorage
組件專屬型組件邏輯過於複雜,抽離以提升可讀性,不考慮複用useOrderFormLogicuseUserProfileState

兩種類型皆統一放置於 /composables 目錄下。

Composable vs Pure Function

依據是否使用 Vue API 區分 Composable 和 Pure Function,並放置於不同目錄。

原因

  • 清楚區分依賴 Vue 的邏輯和純函式邏輯
  • Composable 可使用 Vue 的響應式系統和生命週期
  • Pure Function 更容易測試和跨框架複用

✅ Good

ts
// /utils/format.ts - Pure function,不依賴 Vue
export function formatCurrency(value: number): string {
  return new Intl.NumberFormat('zh-TW', {
    style: 'currency',
    currency: 'TWD',
  }).format(value);
}

// /composables/useCounter.ts - Composable,使用 Vue API
import { ref, computed } from 'vue';

export function useCounter(initial = 0) {
  const count = ref(initial);
  const doubled = computed(() => count.value * 2);

  function increment() {
    count.value++;
  }

  return { count, doubled, increment };
}

❌ Bad

ts
// /composables/format.ts - Pure function 不應放在 composables
export function formatCurrency(value: number): string {
  return new Intl.NumberFormat('zh-TW', {
    style: 'currency',
    currency: 'TWD',
  }).format(value);
}

// /utils/useCounter.ts - Composable 不應放在 utils
import { ref } from 'vue';

export function useCounter() {
  const count = ref(0);
  return { count };
}

命名規範

Composable 函式名稱統一使用 use 前綴。

原因

  • use 前綴是 Vue 社群的慣例,清楚表達這是一個 composable
  • 與一般函式明確區分,一眼就能辨識
  • 與 React Hooks 命名慣例一致,降低學習成本

✅ Good

ts
export function useUserList() {
  const users = ref<User[]>([]);

  async function fetchUsers() {
    users.value = await api.getUsers();
  }

  return { users, fetchUsers };
}

export function useFormValidation() {
  const errors = ref<Record<string, string>>({});

  function validate(data: FormData) {
    // 驗證邏輯
  }

  return { errors, validate };
}

❌ Bad

ts
// 缺少 use 前綴
export function getUserList() {
  /* ... */
}

export function formValidation() {
  /* ... */
}

參數偏好傳入 Raw Value

Composable 的函式參數偏好接收原始值,而非直接傳入 refgetter

原因

  • 呼叫端更明確,清楚知道何時觸發操作
  • Composable 邏輯更單純,不需處理 ref 或 getter 的轉換
  • 避免隱式的 watch 行為,執行時機更可控

✅ Good

ts
export function useSearch() {
  const results = ref<Product[]>([]);
  const loading = ref(false);

  async function search(keyword: string) {
    loading.value = true;
    results.value = await api.searchProducts(keyword);
    loading.value = false;
  }

  return { results, loading, search };
}

// 使用時
const keyword = ref('');
const { results, loading, search } = useSearch();

async function onSearch() {
  await search(keyword.value); // 明確傳入當下的值
}

❌ Bad

ts
// 直接傳入 ref,呼叫端不知何時觸發
import type { MaybeRefOrGetter } from 'vue';
import { toValue, watch } from 'vue';

export function useSearch(keyword: MaybeRefOrGetter<string>) {
  const results = ref<Product[]>([]);

  watch(
    () => toValue(keyword),
    async val => {
      results.value = await api.searchProducts(val);
    },
    { immediate: true },
  );

  return { results };
}

優先使用物件的形式回傳

Composable 回傳值統一使用物件形式,方便呼叫端按需解構。

原因

  • 呼叫端可按需解構,不需要的值可以不取
  • 語意明確,一眼就能看出每個值的用途
  • 方便擴充,新增回傳值不會影響現有程式碼

✅ Good

ts
export function useOnline() {
  const isOnline = ref(navigator.onLine);

  window.addEventListener('online', () => (isOnline.value = true));
  window.addEventListener('offline', () => (isOnline.value = false));

  return { isOnline };
}

export function useFetch<T>(url: string) {
  const data = ref<T | null>(null);
  const error = ref<Error | null>(null);
  const loading = ref(false);

  async function execute() {
    loading.value = true;
    error.value = null;
    try {
      data.value = await fetch(url).then(r => r.json());
    } catch (e) {
      error.value = e as Error;
    } finally {
      loading.value = false;
    }
  }

  return { data, error, loading, execute };
}

❌ Bad

ts
// 回傳陣列,呼叫端需要記住順序
export function useFetch<T>(url: string) {
  const data = ref<T | null>(null);
  const error = ref<Error | null>(null);
  const loading = ref(false);

  async function execute() {
    // ...
  }

  return [data, error, loading, execute]; // 順序難記憶
}

// 使用時
const [data, error, loading, execute] = useFetch('/api/users');

Composable 專注邏輯,UI 留在組件

Composable 負責狀態與業務邏輯,不處理 UI 相關的樣式、DOM 操作或 template 渲染。

原因

  • 職責分離,Composable 專注於邏輯,組件專注於呈現
  • 提升可測試性,邏輯可以獨立測試而不需要渲染組件
  • 提升可複用性,相同邏輯可以在不同 UI 呈現中複用

✅ Good

vue
<!-- UI 邏輯留在組件 -->
<script setup lang="ts">
import { useOrderForm } from '@/composables/useOrderForm';

const { formData, errors, submit, isSubmitting } = useOrderForm();
</script>

<template>
  <form @submit.prevent="submit">
    <input
      v-model="formData.productName"
      :class="{ error: errors.productName }"
    />
    <span v-if="errors.productName" class="error-text">
      {{ errors.productName }}
    </span>
    <button :disabled="isSubmitting">送出訂單</button>
  </form>
</template>
ts
// /composables/useOrderForm.ts
// 專注於邏輯,不涉及 UI
export function useOrderForm() {
  const formData = ref({ productName: '', quantity: 1 });
  const errors = ref<Record<string, string>>({});
  const isSubmitting = ref(false);

  async function validate() {
    errors.value = {};
    if (!formData.value.productName) {
      errors.value.productName = '請輸入商品名稱';
    }
    return Object.keys(errors.value).length === 0;
  }

  async function submit() {
    const isValid = await validate();
    if (!isValid) return;

    isSubmitting.value = true;
    await api.createOrder(formData.value);
    isSubmitting.value = false;
  }

  return { formData, errors, isSubmitting, submit };
}

❌ Bad

ts
// /composables/useOrderForm.ts
// 在 composable 中處理 UI 邏輯
export function useOrderForm() {
  const formData = ref({ productName: '', quantity: 1 });

  async function submit() {
    // ❌ 在 composable 中操作 DOM
    document.querySelector('.submit-btn')?.classList.add('loading');

    await api.createOrder(formData.value);

    // ❌ 在 composable 中顯示 UI 提示
    alert('訂單已送出');
  }

  return { formData, submit };
}

使用 Named Export

避免使用 export default,具名匯出有利於 tree-shaking 與 IDE 自動補全

ts
// Good ✅ 具名匯出
export function useCounter() {
  /* ... */
}

// Bad ❌ 預設匯出
export default function useCounter() {
  /* ... */
}

Spec 區塊

什麼是 Spec 區塊?

<spec> 是 Vue SFC 的自訂區塊,使用 Markdown 格式將元件規格與實作程式碼共存於同一檔案中。

  • 描述 WHAT(元件應該是什麼),而非 HOW(如何實作)
  • 「如果換一種實作方式,這個描述需要更新嗎?」
    • 如果「需要」→ 過於詳細,移除實作細節
  • 「AI 讀完這個 Spec 後,還需要額外說明嗎?」
    • 如果「需要」→ 過於模糊,補充條件邏輯

Spec 區塊結構

永遠使用這四個段落,不多不少

markdown
<spec lang="md">
# {ComponentName}

## Props

[資料結構與型別定義]

## Behavior

[條件邏輯 → 視覺結果]

## Interaction

[使用者操作 → 事件定義]
</spec>

{ComponentName} 段落

顯示這個 component 的名稱

Props 段落

使用 TypeScript 型別語法定義資料結構:

markdown
## Props

- user: { name: string, role: 'admin' | 'user', avatar: string, id: number }

多個 props 範例:

markdown
## Props

- status: 'active' | 'inactive' | 'pending'
- label: string
- count: number
- onClick: () => void

Behavior 段落

使用 條件 → 結果 格式,搭配 Design Token 描述視覺規格:

markdown
## Behavior

role='admin' → 顯示編輯按鈕、金色邊框 (amber-400)
role='user' → 隱藏編輯按鈕、灰色邊框 (gray-200)

關鍵規則:

  • 使用 Design Token(amber-400green-500),不使用 hex 色碼或顏色名稱
  • 使用箭頭格式(),不使用冗長的「當...時」句式
  • 只描述視覺結果,不包含 pixel 值或實作細節
  • 禁止框架 API 術語(如 primarydefaultlarge
  • 禁止顏色名稱(如「綠色」、「紅色」),必須使用 Design Token

錯誤範例(冗長、使用顏色名稱)

markdown
## Behavior

-`role === 'admin'` 時:
  - 顯示編輯按鈕 (primary 樣式)
  - 卡片邊框為綠色,寬度 2px

正確範例(簡潔、使用 Design Token)

markdown
## Behavior

role='admin' → 顯示編輯按鈕、金色邊框 (amber-400)
role='user' → 隱藏編輯按鈕、灰色邊框 (gray-200)
status='active' → 背景 (green-500)、文字 (white)、圖示 ✓
status='inactive' → 背景 (red-500)、文字 (white)、圖示 ✗

Interaction 段落

定義使用者操作與事件,包含 payload 結構:

markdown
## Interaction

點擊編輯按鈕 → emit('edit', user.id)
點擊刪除按鈕 → emit('delete', { id: user.id, name: user.name })

何時撰寫或更新 Spec

在以下情況應撰寫或更新 <spec> 區塊:

  • ✅ 建立新的 Vue 元件
  • ✅ 修改現有元件的行為
  • ✅ 變更 Props、Events 或視覺外觀
  • ✅ 元件經常被修改、高風險或大量重複使用

常見錯誤

錯誤寫法問題正確做法
章節名稱使用「事件」、「樣式」、「功能」章節名稱必須精確使用 Props、Behavior、Interaction
Props 使用條列式加註解格式錯誤單行 TypeScript 型別語法
使用顏色名稱(「綠色」、「紅色」)缺乏語意使用 Design Token(green-500red-500
使用 hex 色碼(#fbbf24實作細節使用 Design Token(amber-400
包含 CSS class 名稱(btn-primary實作細節只描述視覺結果
包含 pixel 值(2px寬度 1px實作細節只描述語意層級
使用框架 API 術語(primarylarge實作細節描述視覺或行為結果
冗長的「當...時」句式可讀性差使用箭頭格式(