# Vue.js 開發規範和最佳實踐
## 📋 總體指導原則
### 核心原則
1. **可讀性優先**: 代碼應該易於理解和維護
2. **一致性**: 整個專案使用統一的編碼風格
3. **可測試性**: 編寫易於測試的代碼
4. **效能考量**: 避免不必要的重複渲染和記憶體洩漏
5. **類型安全**: 充分利用 TypeScript 的類型檢查
### 技術選型標準
- **Vue 3 + Composition API**: 優先使用 Composition API
- **TypeScript**: 所有 .vue 檔案使用 `
```
#### 複雜組件結構範例
```vue
{{ word.definition }}
```
### Props 設計規範
#### Props 類型定義
```typescript
// ✅ 正確:明確的類型定義
interface Props {
// 必需 props
userId: string
words: VocabularyWord[]
// 可選 props (使用 ?)
title?: string
maxItems?: number
isLoading?: boolean
// 具有預設值的 props
pageSize?: number
sortOrder?: 'asc' | 'desc'
// 複雜物件類型
filters?: {
difficulty: number
category: string[]
}
// 函數類型 (建議使用 emit)
onUpdate?: (data: UpdateData) => void
}
// 使用 withDefaults 設定預設值
const props = withDefaults(defineProps(), {
title: '詞彙學習',
maxItems: 10,
isLoading: false,
pageSize: 20,
sortOrder: 'asc',
filters: () => ({ difficulty: 1, category: [] })
})
```
#### Props 驗證
```typescript
// 複雜驗證邏輯可以使用計算屬性
const isValidProps = computed(() => {
return props.words.length > 0 && props.userId.length > 0
})
// 在組件內進行運行時檢查
watch([() => props.words, () => props.userId], () => {
if (!isValidProps.value) {
console.warn('Invalid props provided to VocabularyCard')
}
}, { immediate: true })
```
### Emits 設計規範
#### Emit 事件命名
```typescript
// ✅ 正確:使用動詞命名,描述發生的動作
interface Emits {
// 數據更新事件
'update:modelValue': [value: string]
'update:filters': [filters: FilterOptions]
// 用戶互動事件
submit: [formData: FormData]
cancel: []
select: [itemId: string]
// 狀態變化事件
load: []
error: [error: Error]
complete: [result: any]
}
// ❌ 錯誤:使用名詞或不清楚的命名
interface BadEmits {
data: [any] // 不清楚
click: [] // 太通用
thing: [something: any] // 無意義
}
```
#### 事件觸發範例
```typescript
const emit = defineEmits()
// 正確的事件觸發方式
const handleFormSubmit = async (formData: FormData) => {
try {
emit('load')
const result = await submitForm(formData)
emit('submit', formData)
emit('complete', result)
} catch (error) {
emit('error', error as Error)
}
}
```
## 🔧 Composables 開發規範
### 組合式函數命名和結構
#### 基本 Composable 結構
```typescript
// composables/useVocabulary.ts
import { ref, computed, type Ref } from 'vue'
import type { VocabularyWord, PracticeResult } from '@/types'
// 選項介面 (如果需要)
interface UseVocabularyOptions {
autoLoad?: boolean
cacheEnabled?: boolean
}
// 返回類型定義
interface UseVocabularyReturn {
// 狀態
words: Readonly[>
currentWord: Readonly][>
isLoading: Readonly][>
error: Readonly][>
// 計算屬性
wordsCount: ComputedRef
hasWords: ComputedRef
// 方法
loadWords: (level: number) => Promise
selectWord: (wordId: string) => void
submitPractice: (result: PracticeResult) => Promise
reset: () => void
}
// 主要函數
export function useVocabulary(
initialLevel: number = 1,
options: UseVocabularyOptions = {}
): UseVocabularyReturn {
// 預設選項
const { autoLoad = true, cacheEnabled = true } = options
// 響應式狀態
const words = ref([])
const currentWord = ref(null)
const isLoading = ref(false)
const error = ref(null)
// 計算屬性
const wordsCount = computed(() => words.value.length)
const hasWords = computed(() => words.value.length > 0)
// 私有方法
const clearError = () => {
error.value = null
}
// 公開方法
const loadWords = async (level: number) => {
try {
isLoading.value = true
clearError()
const response = await vocabularyApi.getWordsByLevel(level)
words.value = response.data
if (words.value.length > 0) {
currentWord.value = words.value[0]
}
} catch (err) {
error.value = '載入詞彙失敗'
console.error('Failed to load vocabulary:', err)
} finally {
isLoading.value = false
}
}
const selectWord = (wordId: string) => {
const word = words.value.find(w => w.id === wordId)
if (word) {
currentWord.value = word
}
}
const submitPractice = async (result: PracticeResult) => {
try {
isLoading.value = true
await vocabularyApi.submitPracticeResult(result)
// 更新本地狀態
const wordIndex = words.value.findIndex(w => w.id === result.wordId)
if (wordIndex !== -1) {
words.value[wordIndex].practiceCount = (words.value[wordIndex].practiceCount || 0) + 1
}
} catch (err) {
error.value = '提交練習結果失敗'
throw err
} finally {
isLoading.value = false
}
}
const reset = () => {
words.value = []
currentWord.value = null
clearError()
}
// 自動載入 (如果啟用)
if (autoLoad) {
loadWords(initialLevel)
}
return {
// 只讀狀態
words: readonly(words),
currentWord: readonly(currentWord),
isLoading: readonly(isLoading),
error: readonly(error),
// 計算屬性
wordsCount,
hasWords,
// 方法
loadWords,
selectWord,
submitPractice,
reset
}
}
```
#### 複雜 Composable 範例
```typescript
// composables/useDialogue.ts
export function useDialogue(scenarioId: string) {
// 多個狀態管理
const messages = ref([])
const currentScene = ref(null)
const userInput = ref('')
const isRecording = ref(false)
const analysisResult = ref(null)
// 計算屬性
const canSubmit = computed(() => userInput.value.trim().length > 0)
const messageCount = computed(() => messages.value.length)
const lastMessage = computed(() => {
return messages.value[messages.value.length - 1] || null
})
// 組合其他 composables
const { record, stopRecording } = useSpeechRecognition()
const { playAudio } = useAudio()
const { analyzeResponse } = useAI()
// 複雜業務邏輯
const startDialogue = async () => {
try {
const scene = await dialogueApi.getScene(scenarioId)
currentScene.value = scene
// 添加系統開場白
messages.value.push({
id: generateId(),
type: 'system',
content: scene.opening,
timestamp: Date.now()
})
// 播放開場音頻
if (scene.opening_audio) {
await playAudio(scene.opening_audio)
}
} catch (error) {
console.error('Failed to start dialogue:', error)
throw error
}
}
const sendMessage = async (content: string) => {
// 添加用戶消息
const userMessage: DialogueMessage = {
id: generateId(),
type: 'user',
content: content.trim(),
timestamp: Date.now()
}
messages.value.push(userMessage)
// 清空輸入
userInput.value = ''
try {
// AI 分析和回應
const analysis = await analyzeResponse(content, currentScene.value)
analysisResult.value = analysis
// 添加系統回應
const systemMessage: DialogueMessage = {
id: generateId(),
type: 'system',
content: analysis.response,
timestamp: Date.now(),
analysis: analysis
}
messages.value.push(systemMessage)
// 播放回應音頻
if (analysis.audio_url) {
await playAudio(analysis.audio_url)
}
} catch (error) {
// 錯誤處理
const errorMessage: DialogueMessage = {
id: generateId(),
type: 'error',
content: '抱歉,系統發生錯誤,請稍後再試。',
timestamp: Date.now()
}
messages.value.push(errorMessage)
}
}
// 語音輸入處理
const startVoiceInput = async () => {
try {
isRecording.value = true
const transcript = await record()
userInput.value = transcript
} finally {
isRecording.value = false
}
}
const stopVoiceInput = () => {
stopRecording()
isRecording.value = false
}
return {
// 狀態
messages: readonly(messages),
currentScene: readonly(currentScene),
userInput,
isRecording: readonly(isRecording),
analysisResult: readonly(analysisResult),
// 計算屬性
canSubmit,
messageCount,
lastMessage,
// 方法
startDialogue,
sendMessage,
startVoiceInput,
stopVoiceInput
}
}
```
### Composable 使用規範
#### 在組件中正確使用
```vue
```
## 🎨 樣式開發規範
### SCSS 組織結構
#### 樣式檔案組織
```scss
// assets/styles/main.scss
// 1. 變數和混合器
@import 'variables';
@import 'mixins';
// 2. 基礎樣式
@import 'base/reset';
@import 'base/typography';
@import 'base/layout';
// 3. 組件樣式
@import 'components/buttons';
@import 'components/forms';
@import 'components/cards';
// 4. 工具類
@import 'utilities/spacing';
@import 'utilities/colors';
// 5. 響應式斷點
@import 'responsive';
```
#### 變數命名規範
```scss
// assets/styles/_variables.scss
// 色彩系統
$color-primary: #1976d2;
$color-primary-light: lighten($color-primary, 20%);
$color-primary-dark: darken($color-primary, 20%);
$color-secondary: #26a69a;
$color-accent: #9c27b0;
$color-success: #21ba45;
$color-warning: #f2c037;
$color-error: #c10015;
$color-info: #31ccec;
// 灰階
$color-grey-1: #fafafa;
$color-grey-2: #f5f5f5;
$color-grey-3: #eeeeee;
$color-grey-4: #e0e0e0;
$color-grey-5: #bdbdbd;
// 字體
$font-family-primary: 'Inter', sans-serif;
$font-family-secondary: 'Roboto', sans-serif;
$font-size-xs: 0.75rem; // 12px
$font-size-sm: 0.875rem; // 14px
$font-size-base: 1rem; // 16px
$font-size-lg: 1.125rem; // 18px
$font-size-xl: 1.25rem; // 20px
// 間距
$spacing-xs: 0.25rem; // 4px
$spacing-sm: 0.5rem; // 8px
$spacing-md: 1rem; // 16px
$spacing-lg: 1.5rem; // 24px
$spacing-xl: 2rem; // 32px
// 響應式斷點
$breakpoint-xs: 0;
$breakpoint-sm: 600px;
$breakpoint-md: 1024px;
$breakpoint-lg: 1440px;
$breakpoint-xl: 1920px;
// Z-index 層級
$z-index-dropdown: 1000;
$z-index-modal: 1050;
$z-index-popover: 1060;
$z-index-tooltip: 1070;
$z-index-toast: 1080;
```
#### 混合器和工具
```scss
// assets/styles/_mixins.scss
// 響應式混合器
@mixin respond-to($breakpoint) {
@if $breakpoint == xs {
@media (max-width: #{$breakpoint-sm - 1px}) { @content; }
}
@if $breakpoint == sm {
@media (min-width: #{$breakpoint-sm}) and (max-width: #{$breakpoint-md - 1px}) { @content; }
}
@if $breakpoint == md {
@media (min-width: #{$breakpoint-md}) and (max-width: #{$breakpoint-lg - 1px}) { @content; }
}
@if $breakpoint == lg {
@media (min-width: #{$breakpoint-lg}) { @content; }
}
}
// 文字省略
@mixin text-ellipsis($lines: 1) {
@if $lines == 1 {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
} @else {
display: -webkit-box;
-webkit-line-clamp: $lines;
-webkit-box-orient: vertical;
overflow: hidden;
}
}
// 載入動畫
@mixin loading-skeleton {
background: linear-gradient(90deg, $color-grey-2 25%, $color-grey-1 50%, $color-grey-2 75%);
background-size: 200% 100%;
animation: loading 1.5s infinite;
}
@keyframes loading {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
// 卡片陰影
@mixin card-shadow($level: 1) {
@if $level == 1 {
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
@if $level == 2 {
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.12), 0 2px 4px rgba(0, 0, 0, 0.08);
}
@if $level == 3 {
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.15), 0 4px 8px rgba(0, 0, 0, 0.1);
}
}
```
### 組件樣式規範
#### BEM 命名方法
```scss
// ✅ 正確:使用 BEM 命名
.vocabulary-card {
@include card-shadow(1);
&__header {
padding: $spacing-md;
border-bottom: 1px solid $color-grey-3;
&--highlighted {
background-color: $color-primary-light;
}
}
&__content {
padding: $spacing-md;
}
&__word {
font-size: $font-size-lg;
font-weight: 600;
color: $color-primary;
&--difficult {
color: $color-warning;
}
}
&--disabled {
opacity: 0.5;
pointer-events: none;
}
}
// ❌ 錯誤:嵌套過深,命名不清楚
.vocabulary-card {
.header {
.title {
.word {
.text {
color: blue; // 嵌套過深且使用硬編碼顏色
}
}
}
}
}
```
#### 響應式設計實作
```scss
.vocabulary-practice {
display: grid;
grid-template-columns: 1fr;
gap: $spacing-md;
padding: $spacing-md;
// 平板以上:雙欄佈局
@include respond-to(sm) {
grid-template-columns: 1fr 1fr;
padding: $spacing-lg;
}
// 桌面:三欄佈局
@include respond-to(md) {
grid-template-columns: repeat(3, 1fr);
gap: $spacing-lg;
padding: $spacing-xl;
}
&__card {
@include card-shadow(1);
transition: transform 0.2s ease;
&:hover {
transform: translateY(-2px);
@include card-shadow(2);
}
// 手機上禁用 hover 效果
@include respond-to(xs) {
&:hover {
transform: none;
box-shadow: none;
}
}
}
}
```
## 🧪 測試規範
### 單元測試規範
#### 組件測試範例
```typescript
// tests/unit/components/VocabularyCard.test.ts
import { mount } from '@vue/test-utils'
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import VocabularyCard from '@/components/business/VocabularyCard.vue'
import type { VocabularyWord } from '@/types'
// 測試數據
const mockWord: VocabularyWord = {
id: '1',
text: 'hello',
pronunciation: '/həˈloʊ/',
definition: '你好,哈囉',
audio_url: '/audio/hello.mp3',
difficulty: 1
}
// Mock 外部依賴
const mockPlayAudio = vi.fn()
vi.mock('@/composables/useAudio', () => ({
useAudio: () => ({
playAudio: mockPlayAudio,
isLoading: false
})
}))
describe('VocabularyCard', () => {
beforeEach(() => {
// 重置 Pinia store
setActivePinia(createPinia())
// 重置 mocks
vi.clearAllMocks()
})
it('renders word information correctly', () => {
const wrapper = mount(VocabularyCard, {
props: { word: mockWord }
})
// 測試渲染內容
expect(wrapper.find('[data-test="word-text"]').text()).toBe('hello')
expect(wrapper.find('[data-test="pronunciation"]').text()).toBe('/həˈloʊ/')
expect(wrapper.find('[data-test="definition"]').text()).toBe('你好,哈囉')
})
it('plays audio when pronunciation button is clicked', async () => {
const wrapper = mount(VocabularyCard, {
props: { word: mockWord }
})
const playButton = wrapper.find('[data-test="play-audio"]')
await playButton.trigger('click')
expect(mockPlayAudio).toHaveBeenCalledWith('/audio/hello.mp3')
})
it('emits select event when card is clicked', async () => {
const wrapper = mount(VocabularyCard, {
props: { word: mockWord }
})
await wrapper.trigger('click')
expect(wrapper.emitted('select')).toBeTruthy()
expect(wrapper.emitted('select')?.[0]).toEqual([mockWord.id])
})
it('applies correct CSS classes based on difficulty', () => {
const easyWord = { ...mockWord, difficulty: 1 }
const hardWord = { ...mockWord, difficulty: 3 }
const easyWrapper = mount(VocabularyCard, { props: { word: easyWord } })
const hardWrapper = mount(VocabularyCard, { props: { word: hardWord } })
expect(easyWrapper.classes()).toContain('vocabulary-card--easy')
expect(hardWrapper.classes()).toContain('vocabulary-card--hard')
})
it('handles disabled state correctly', () => {
const wrapper = mount(VocabularyCard, {
props: {
word: mockWord,
disabled: true
}
})
expect(wrapper.classes()).toContain('vocabulary-card--disabled')
expect(wrapper.find('[data-test="play-audio"]').attributes('disabled')).toBeDefined()
})
})
```
#### Composable 測試範例
```typescript
// tests/unit/composables/useVocabulary.test.ts
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { useVocabulary } from '@/composables/useVocabulary'
import { flushPromises } from '@vue/test-utils'
// Mock API
const mockVocabularyApi = {
getWordsByLevel: vi.fn(),
submitPracticeResult: vi.fn()
}
vi.mock('@/services/vocabularyApi', () => ({
vocabularyApi: mockVocabularyApi
}))
describe('useVocabulary', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('loads words on initialization when autoLoad is true', async () => {
mockVocabularyApi.getWordsByLevel.mockResolvedValue({
data: [mockWord]
})
const { words, isLoading, loadWords } = useVocabulary(1, { autoLoad: true })
// 初始狀態檢查
expect(isLoading.value).toBe(true)
await flushPromises()
// 載入完成後狀態檢查
expect(isLoading.value).toBe(false)
expect(words.value).toEqual([mockWord])
expect(mockVocabularyApi.getWordsByLevel).toHaveBeenCalledWith(1)
})
it('handles API errors gracefully', async () => {
const errorMessage = 'API Error'
mockVocabularyApi.getWordsByLevel.mockRejectedValue(new Error(errorMessage))
const { error, loadWords } = useVocabulary(1, { autoLoad: false })
await loadWords(1)
await flushPromises()
expect(error.value).toBe('載入詞彙失敗')
})
it('calculates computed properties correctly', async () => {
mockVocabularyApi.getWordsByLevel.mockResolvedValue({
data: [mockWord, { ...mockWord, id: '2' }]
})
const { words, wordsCount, hasWords } = useVocabulary(1)
await flushPromises()
expect(wordsCount.value).toBe(2)
expect(hasWords.value).toBe(true)
})
})
```
### E2E 測試規範
#### 用戶流程測試
```typescript
// tests/e2e/vocabulary-learning.cy.ts
describe('Vocabulary Learning Flow', () => {
beforeEach(() => {
// 登入用戶
cy.login('test@example.com', 'password')
// 模擬 API 回應
cy.intercept('GET', '/api/vocabulary/level/1', {
fixture: 'vocabulary-words.json'
}).as('getWords')
})
it('completes vocabulary learning session', () => {
// 導航到詞彙學習頁面
cy.visit('/vocabulary/introduction/word-1')
// 等待數據載入
cy.wait('@getWords')
// 檢查頁面內容
cy.get('[data-test="word-text"]').should('be.visible')
cy.get('[data-test="definition"]').should('contain', '你好')
// 播放音頻
cy.get('[data-test="play-audio"]').click()
// 進入練習模式
cy.get('[data-test="start-practice"]').click()
// 完成選擇題練習
cy.get('[data-test="practice-option-1"]').click()
cy.get('[data-test="submit-answer"]').click()
// 檢查結果
cy.get('[data-test="result-correct"]').should('be.visible')
// 繼續下一個詞彙
cy.get('[data-test="next-word"]').click()
// 檢查進度更新
cy.get('[data-test="progress-bar"]').should('have.attr', 'aria-valuenow', '50')
})
it('handles practice errors gracefully', () => {
// 模擬 API 錯誤
cy.intercept('POST', '/api/vocabulary/practice', {
statusCode: 500,
body: { error: 'Server error' }
}).as('practiceError')
cy.visit('/vocabulary/practice')
// 提交答案
cy.get('[data-test="practice-option-1"]').click()
cy.get('[data-test="submit-answer"]').click()
cy.wait('@practiceError')
// 檢查錯誤處理
cy.get('[data-test="error-message"]')
.should('be.visible')
.and('contain', '提交失敗,請稍後再試')
// 檢查重試按鈕
cy.get('[data-test="retry-button"]').should('be.visible')
})
})
```
## 📋 代碼審查檢查清單
### 組件審查要點
- [ ] 組件命名使用 PascalCase
- [ ] Props 有明確的 TypeScript 類型定義
- [ ] 使用 `defineEmits` 定義所有事件
- [ ] 計算屬性使用 `computed()` 而非方法
- [ ] 使用 `readonly()` 暴露響應式狀態
- [ ] 組件有適當的測試覆蓋
- [ ] 樣式使用 scoped 或 CSS Modules
- [ ] 遵循無障礙性最佳實踐
### 性能審查要點
- [ ] 避免在模板中直接調用方法
- [ ] 使用 `v-memo` 優化重複渲染
- [ ] 大列表使用虛擬滾動
- [ ] 圖片使用 lazy loading
- [ ] 組件使用 `defineAsyncComponent` 懶載入
- [ ] 避免不必要的響應式包裝
### 安全性審查要點
- [ ] 用戶輸入已進行適當清理
- [ ] 使用 `v-html` 時已進行 XSS 防護
- [ ] API 調用包含錯誤處理
- [ ] 敏感資料不在客戶端暴露
- [ ] 路由包含適當的權限檢查
---
**文檔狀態**: 🟢 完整開發規範
**最後更新**: 2025-09-09
**適用版本**: Vue 3 + Composition API
**維護團隊**: 前端開發團隊]