32 KiB
32 KiB
Vue.js 開發規範和最佳實踐
📋 總體指導原則
核心原則
- 可讀性優先: 代碼應該易於理解和維護
- 一致性: 整個專案使用統一的編碼風格
- 可測試性: 編寫易於測試的代碼
- 效能考量: 避免不必要的重複渲染和記憶體洩漏
- 類型安全: 充分利用 TypeScript 的類型檢查
技術選型標準
- Vue 3 + Composition API: 優先使用 Composition API
- TypeScript: 所有 .vue 檔案使用
<script setup lang="ts"> - Single File Components: 採用 SFC 結構
- 組件化設計: 遵循原子設計理念
🏗️ 組件開發規範
組件命名規範
組件檔案命名
# ✅ 正確:使用 PascalCase
UserProfile.vue
VocabularyCard.vue
DialogueInterface.vue
PaymentModal.vue
# ❌ 錯誤
userProfile.vue
vocabulary-card.vue
dialogueInterface.vue
組件註冊和使用
<!-- ✅ 正確:模板中使用 PascalCase -->
<template>
<UserProfile :user="currentUser" />
<VocabularyCard v-for="word in words" :key="word.id" :word="word" />
</template>
<!-- ❌ 錯誤:使用 kebab-case -->
<template>
<user-profile :user="currentUser" />
</template>
組件結構標準
基本 SFC 結構
<template>
<!-- 模板內容 -->
</template>
<script setup lang="ts">
// 1. 導入 (按順序:Vue APIs → 第三方庫 → 內部模組)
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { VocabularyCard } from '@/components'
// 2. 類型定義
interface Props {
title: string
items?: string[]
}
interface Emits {
update: [value: string]
submit: [data: FormData]
}
// 3. Props 和 Emits 定義
const props = withDefaults(defineProps<Props>(), {
items: () => []
})
const emit = defineEmits<Emits>()
// 4. 響應式數據
const isLoading = ref(false)
const selectedItem = ref<string | null>(null)
// 5. 計算屬性
const filteredItems = computed(() => {
return props.items.filter(item => item.length > 0)
})
// 6. 方法
const handleSubmit = () => {
emit('submit', new FormData())
}
// 7. 生命週期
onMounted(() => {
console.log('Component mounted')
})
// 8. 暴露給父組件的方法 (如需要)
defineExpose({
handleSubmit
})
</script>
<style scoped lang="scss">
// 樣式內容
</style>
複雜組件結構範例
<!-- VocabularyPracticeCard.vue -->
<template>
<q-card class="vocabulary-practice-card" :class="cardClasses">
<q-card-section class="card-header">
<div class="word-display">
<h3 class="word-text">{{ word.text }}</h3>
<span class="pronunciation">{{ word.pronunciation }}</span>
</div>
<q-btn
icon="volume_up"
flat
round
@click="playAudio"
:loading="audioLoading"
/>
</q-card-section>
<q-card-section class="card-content">
<p class="definition">{{ word.definition }}</p>
<div class="practice-options">
<q-btn-group>
<q-btn
v-for="option in practiceOptions"
:key="option.id"
:label="option.text"
:color="getOptionColor(option.id)"
@click="selectOption(option.id)"
/>
</q-btn-group>
</div>
</q-card-section>
<q-card-actions align="right">
<q-btn
label="提交答案"
color="primary"
:disabled="!selectedOption"
@click="submitAnswer"
/>
</q-card-actions>
</q-card>
</template>
<script setup lang="ts">
// 類型定義
interface VocabularyWord {
id: string
text: string
pronunciation: string
definition: string
audio_url: string
}
interface PracticeOption {
id: string
text: string
is_correct: boolean
}
interface Props {
word: VocabularyWord
options: PracticeOption[]
disabled?: boolean
}
interface Emits {
answer: [optionId: string, isCorrect: boolean]
audio: [wordId: string]
}
// Props 和 Emits
const props = withDefaults(defineProps<Props>(), {
disabled: false
})
const emit = defineEmits<Emits>()
// 組合式函數
const { playAudio: playWordAudio, isLoading: audioLoading } = useAudio()
// 響應式狀態
const selectedOption = ref<string | null>(null)
// 計算屬性
const cardClasses = computed(() => ({
'card--disabled': props.disabled,
'card--answered': selectedOption.value !== null
}))
const practiceOptions = computed(() => props.options)
// 方法
const playAudio = async () => {
await playWordAudio(props.word.audio_url)
emit('audio', props.word.id)
}
const selectOption = (optionId: string) => {
if (props.disabled) return
selectedOption.value = optionId
}
const getOptionColor = (optionId: string): string => {
if (selectedOption.value === optionId) {
return 'primary'
}
return 'grey'
}
const submitAnswer = () => {
if (!selectedOption.value) return
const selectedOpt = props.options.find(opt => opt.id === selectedOption.value)
if (selectedOpt) {
emit('answer', selectedOption.value, selectedOpt.is_correct)
}
}
// 重置方法
const reset = () => {
selectedOption.value = null
}
// 暴露方法給父組件
defineExpose({
reset
})
</script>
<style scoped lang="scss">
.vocabulary-practice-card {
max-width: 400px;
margin: 0 auto;
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
.word-display {
flex: 1;
.word-text {
margin: 0 0 4px 0;
font-size: 24px;
font-weight: 600;
color: var(--q-primary);
}
.pronunciation {
color: var(--q-secondary);
font-style: italic;
}
}
}
.card-content {
.definition {
margin-bottom: 16px;
line-height: 1.5;
}
.practice-options {
.q-btn-group {
width: 100%;
.q-btn {
flex: 1;
}
}
}
}
// 狀態樣式
&.card--disabled {
opacity: 0.6;
pointer-events: none;
}
&.card--answered {
border-left: 4px solid var(--q-positive);
}
}
// 響應式設計
@media (max-width: 600px) {
.vocabulary-practice-card {
.card-header {
.word-text {
font-size: 20px;
}
}
.practice-options {
.q-btn-group {
flex-direction: column;
.q-btn {
margin-bottom: 8px;
}
}
}
}
}
</style>
Props 設計規範
Props 類型定義
// ✅ 正確:明確的類型定義
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<Props>(), {
title: '詞彙學習',
maxItems: 10,
isLoading: false,
pageSize: 20,
sortOrder: 'asc',
filters: () => ({ difficulty: 1, category: [] })
})
Props 驗證
// 複雜驗證邏輯可以使用計算屬性
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 事件命名
// ✅ 正確:使用動詞命名,描述發生的動作
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] // 無意義
}
事件觸發範例
const emit = defineEmits<Emits>()
// 正確的事件觸發方式
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 結構
// 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<Ref<VocabularyWord[]>>
currentWord: Readonly<Ref<VocabularyWord | null>>
isLoading: Readonly<Ref<boolean>>
error: Readonly<Ref<string | null>>
// 計算屬性
wordsCount: ComputedRef<number>
hasWords: ComputedRef<boolean>
// 方法
loadWords: (level: number) => Promise<void>
selectWord: (wordId: string) => void
submitPractice: (result: PracticeResult) => Promise<void>
reset: () => void
}
// 主要函數
export function useVocabulary(
initialLevel: number = 1,
options: UseVocabularyOptions = {}
): UseVocabularyReturn {
// 預設選項
const { autoLoad = true, cacheEnabled = true } = options
// 響應式狀態
const words = ref<VocabularyWord[]>([])
const currentWord = ref<VocabularyWord | null>(null)
const isLoading = ref(false)
const error = ref<string | null>(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 範例
// composables/useDialogue.ts
export function useDialogue(scenarioId: string) {
// 多個狀態管理
const messages = ref<DialogueMessage[]>([])
const currentScene = ref<DialogueScene | null>(null)
const userInput = ref('')
const isRecording = ref(false)
const analysisResult = ref<AnalysisResult | null>(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 使用規範
在組件中正確使用
<script setup lang="ts">
// ✅ 正確:在 setup 函數頂層調用
const { words, isLoading, loadWords } = useVocabulary(1, {
autoLoad: false,
cacheEnabled: true
})
// ✅ 正確:條件性使用 (但要確保順序一致)
const enableAdvanced = ref(false)
const advancedFeatures = enableAdvanced.value
? useAdvancedVocabulary()
: null
// ❌ 錯誤:在條件語句或循環中調用
if (someCondition) {
const { data } = useVocabulary() // 這會導致錯誤
}
// ❌ 錯誤:在異步函數中調用
onMounted(async () => {
const { data } = useVocabulary() // 這會導致錯誤
})
🗃️ 狀態管理規範 (Pinia)
Store 設計原則
基本 Store 結構
// stores/vocabularyStore.ts
import { defineStore } from 'pinia'
import type { VocabularyWord, PracticeSession } from '@/types'
// State 介面
interface VocabularyState {
words: VocabularyWord[]
currentSession: PracticeSession | null
userProgress: Map<string, number>
settings: {
dailyGoal: number
difficulty: number
enableAudio: boolean
}
}
export const useVocabularyStore = defineStore('vocabulary', () => {
// State
const words = ref<VocabularyWord[]>([])
const currentSession = ref<PracticeSession | null>(null)
const userProgress = ref(new Map<string, number>())
const settings = ref({
dailyGoal: 20,
difficulty: 1,
enableAudio: true
})
// Getters (計算屬性)
const learnedWordsCount = computed(() => {
return Array.from(userProgress.value.values())
.filter(progress => progress >= 80).length
})
const todaysProgress = computed(() => {
const today = new Date().toDateString()
return words.value.filter(word =>
word.lastPracticed?.toDateString() === today
).length
})
const progressPercentage = computed(() => {
return Math.round((todaysProgress.value / settings.value.dailyGoal) * 100)
})
// Actions
const loadWords = async (level: number): Promise<void> => {
try {
const response = await vocabularyApi.getWordsByLevel(level)
words.value = response.data
} catch (error) {
console.error('Failed to load words:', error)
throw error
}
}
const startPracticeSession = (wordIds: string[]): void => {
currentSession.value = {
id: generateId(),
wordIds,
startTime: Date.now(),
answers: [],
isCompleted: false
}
}
const updateProgress = (wordId: string, score: number): void => {
userProgress.value.set(wordId, score)
// 更新單詞的練習記錄
const wordIndex = words.value.findIndex(w => w.id === wordId)
if (wordIndex !== -1) {
words.value[wordIndex].lastPracticed = new Date()
}
}
const updateSettings = (newSettings: Partial<typeof settings.value>): void => {
settings.value = { ...settings.value, ...newSettings }
}
// 重置功能
const resetProgress = (): void => {
userProgress.value.clear()
currentSession.value = null
}
return {
// State (只讀)
words: readonly(words),
currentSession: readonly(currentSession),
userProgress: readonly(userProgress),
settings: readonly(settings),
// Getters
learnedWordsCount,
todaysProgress,
progressPercentage,
// Actions
loadWords,
startPracticeSession,
updateProgress,
updateSettings,
resetProgress
}
})
Store 組合和模組化
// stores/learningStore.ts - 複合 Store
export const useLearningStore = defineStore('learning', () => {
// 組合其他 stores
const vocabularyStore = useVocabularyStore()
const dialogueStore = useDialogueStore()
const userStore = useUserStore()
// 跨模組的計算屬性
const overallProgress = computed(() => {
const vocabProgress = vocabularyStore.progressPercentage
const dialogueProgress = dialogueStore.progressPercentage
return Math.round((vocabProgress + dialogueProgress) / 2)
})
// 跨模組的 actions
const initializeUser = async (userId: string) => {
await Promise.all([
userStore.loadUser(userId),
vocabularyStore.loadWords(userStore.currentLevel),
dialogueStore.loadScenarios(userStore.currentLevel)
])
}
return {
overallProgress,
initializeUser
}
})
Store 使用最佳實踐
組件中正確使用 Store
<script setup lang="ts">
// ✅ 正確:解構響應式狀態
const vocabularyStore = useVocabularyStore()
const { words, isLoading } = storeToRefs(vocabularyStore)
const { loadWords, updateProgress } = vocabularyStore
// ✅ 正確:直接使用 store 的計算屬性
const progressPercentage = computed(() => vocabularyStore.progressPercentage)
// ❌ 錯誤:直接解構會失去響應性
const { words, isLoading } = useVocabularyStore() // 失去響應性
// ❌ 錯誤:不需要用 storeToRefs 包裝方法
const { loadWords } = storeToRefs(useVocabularyStore()) // 方法不需要響應性
</script>
🎨 樣式開發規範
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';
變數命名規範
// 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;
混合器和工具
// 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 命名方法
// ✅ 正確:使用 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; // 嵌套過深且使用硬編碼顏色
}
}
}
}
}
響應式設計實作
.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;
}
}
}
}
🧪 測試規範
單元測試規範
組件測試範例
// 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 測試範例
// 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 測試規範
用戶流程測試
// 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
維護團隊: 前端開發團隊