# Vue.js 開發規範和最佳實踐 ## 📋 總體指導原則 ### 核心原則 1. **可讀性優先**: 代碼應該易於理解和維護 2. **一致性**: 整個專案使用統一的編碼風格 3. **可測試性**: 編寫易於測試的代碼 4. **效能考量**: 避免不必要的重複渲染和記憶體洩漏 5. **類型安全**: 充分利用 TypeScript 的類型檢查 ### 技術選型標準 - **Vue 3 + Composition API**: 優先使用 Composition API - **TypeScript**: 所有 .vue 檔案使用 ` ``` #### 複雜組件結構範例 ```vue ``` ### 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 **維護團隊**: 前端開發團隊