dramaling-app/docs/04_technical/03_frontend/native-frontend-architectur...

31 KiB
Raw Blame History

Drama Ling 原生前端技術架構規劃

📋 架構概述

專案名稱: Drama Ling 語言學習應用 (Web端)
建立日期: 2025-09-10
技術主軸: 原生Web技術 (HTML5 + CSS3 + Modern JavaScript)
對應後端: .NET Core API
部署目標: 響應式Web應用程式

核心設計目標

  • 🎯 像素級精確: 完全按照HTML原型設計實現無框架抽象干擾
  • 📱 響應式設計: 桌面優先,兼容平板和手機
  • 🚀 輕量高效: 無框架負擔,快速載入和流暢互動
  • 🔒 企業級安全: 資料保護、安全認證
  • 💎 Claude Code友好: 適合AI輔助開發易於理解和修改

🛠️ 技術堆疊

核心技術

{
  "markup": "HTML5",           // 語義化標記,無障礙支援
  "styling": "CSS3 + SCSS",    // 現代CSS特性 + 預處理器
  "scripting": "ES2022+",      // 現代JavaScript特性
  "bundler": "Vite 5.x",       // 快速開發伺服器和打包工具
  "typescript": "5.x"          // 可選的強型別支援
}

開發工具鏈

{
  "dev_tools": {
    "bundler": "Vite 5.x",           // 快速HMR和打包
    "css_preprocessor": "Sass/SCSS", // CSS預處理器
    "linter": "ESLint 9.x",          // JavaScript代碼檢查
    "formatter": "Prettier 3.x",     // 代碼格式化
    "css_linter": "Stylelint 16.x",  // CSS代碼檢查
    "testing": "Vitest 1.6.x",       // 快速單元測試
    "e2e_testing": "Playwright"      // 端到端測試
  },
  "build_optimization": {
    "minification": "Terser",         // JS壓縮
    "css_optimization": "cssnano",    // CSS優化
    "image_optimization": "vite-plugin-imagemin",
    "pwa_support": "@vite/plugin-pwa"
  }
}

🏗️ 專案結構設計

整體目錄結構

web-frontend/
├── public/
│   ├── icons/                 # PWA圖標和favicon
│   ├── assets/                # 靜態資源
│   │   ├── audio/             # 音頻檔案
│   │   ├── images/            # 圖片資源
│   │   └── fonts/             # 字體檔案
│   └── manifest.json          # PWA配置
├── src/
│   ├── pages/                 # 頁面HTML檔案
│   │   ├── home/              # 首頁相關
│   │   ├── auth/              # 認證相關頁面
│   │   ├── vocabulary/        # 詞彙學習頁面
│   │   ├── dialogue/          # 情境對話頁面
│   │   └── profile/           # 用戶相關頁面
│   ├── components/            # 可重用組件
│   │   ├── ui/                # 基礎UI組件
│   │   ├── layout/            # 佈局組件
│   │   └── business/          # 業務邏輯組件
│   ├── styles/                # 樣式檔案
│   │   ├── base/              # 基礎樣式
│   │   │   ├── reset.scss     # CSS重置
│   │   │   ├── typography.scss # 字體樣式
│   │   │   └── variables.scss  # SCSS變數
│   │   ├── components/        # 組件樣式
│   │   ├── pages/             # 頁面樣式
│   │   └── utilities/         # 工具類樣式
│   ├── scripts/               # JavaScript模組
│   │   ├── core/              # 核心功能
│   │   │   ├── router.js      # 路由管理
│   │   │   ├── state.js       # 狀態管理
│   │   │   ├── api.js         # API客戶端
│   │   │   └── events.js      # 事件系統
│   │   ├── modules/           # 功能模組
│   │   │   ├── auth/          # 認證模組
│   │   │   ├── vocabulary/    # 詞彙學習
│   │   │   ├── audio/         # 音頻處理
│   │   │   └── analytics/     # 數據分析
│   │   ├── utils/             # 工具函數
│   │   └── services/          # 業務服務
│   └── main.js                # 應用入口
├── tests/                     # 測試檔案
├── docs/                      # 專案文檔
└── vite.config.js             # Vite配置

模組化設計

// modules/vocabulary/index.js
export class VocabularyModule {
  constructor() {
    this.state = new VocabularyState()
    this.api = new VocabularyAPI()
    this.ui = new VocabularyUI()
  }
  
  init() {
    this.setupEventListeners()
    this.loadInitialData()
  }
  
  setupEventListeners() {
    // 事件監聽設置
  }
}

// 每個模組包含完整的功能實現
// modules/vocabulary/
├── components/           # 詞彙相關組件
├── services/             # API服務
├── state/                # 狀態管理
├── utils/                # 工具函數
└── index.js              # 模組入口

🔄 狀態管理架構

簡單狀態管理模式

// core/state.js
class ApplicationState {
  constructor() {
    this.stores = {
      auth: new AuthState(),
      vocabulary: new VocabularyState(),
      ui: new UIState(),
      audio: new AudioState()
    }
    this.subscribers = new Map()
  }
  
  // 訂閱狀態變化
  subscribe(storeName, callback) {
    if (!this.subscribers.has(storeName)) {
      this.subscribers.set(storeName, [])
    }
    this.subscribers.get(storeName).push(callback)
  }
  
  // 通知訂閱者
  notify(storeName, data) {
    const callbacks = this.subscribers.get(storeName) || []
    callbacks.forEach(callback => callback(data))
  }
  
  // 更新狀態
  updateStore(storeName, updater) {
    const store = this.stores[storeName]
    if (store) {
      store.update(updater)
      this.notify(storeName, store.getState())
    }
  }
}

// 認證狀態管理
class AuthState {
  constructor() {
    this.state = {
      user: null,
      token: localStorage.getItem('auth_token'),
      isAuthenticated: false
    }
    this.checkAuthStatus()
  }
  
  update(updater) {
    this.state = { ...this.state, ...updater(this.state) }
    this.persistState()
  }
  
  persistState() {
    if (this.state.token) {
      localStorage.setItem('auth_token', this.state.token)
    } else {
      localStorage.removeItem('auth_token')
    }
  }
  
  getState() {
    return { ...this.state }
  }
}

// 全域狀態實例
export const appState = new ApplicationState()

本地存儲策略

// utils/storage.js
class StorageManager {
  constructor() {
    this.prefix = 'dramaling_'
  }
  
  // localStorage封裝
  setLocal(key, value) {
    try {
      localStorage.setItem(
        `${this.prefix}${key}`, 
        JSON.stringify(value)
      )
    } catch (error) {
      console.error('LocalStorage write failed:', error)
    }
  }
  
  getLocal(key, defaultValue = null) {
    try {
      const item = localStorage.getItem(`${this.prefix}${key}`)
      return item ? JSON.parse(item) : defaultValue
    } catch (error) {
      console.error('LocalStorage read failed:', error)
      return defaultValue
    }
  }
  
  // sessionStorage封裝
  setSession(key, value) {
    try {
      sessionStorage.setItem(
        `${this.prefix}${key}`, 
        JSON.stringify(value)
      )
    } catch (error) {
      console.error('SessionStorage write failed:', error)
    }
  }
  
  getSession(key, defaultValue = null) {
    try {
      const item = sessionStorage.getItem(`${this.prefix}${key}`)
      return item ? JSON.parse(item) : defaultValue
    } catch (error) {
      console.error('SessionStorage read failed:', error)
      return defaultValue
    }
  }
}

export const storage = new StorageManager()

🎨 CSS架構設計

SCSS組織結構

// styles/main.scss
// 1. 工具和變數
@import 'base/variables';
@import 'base/functions';
@import 'base/mixins';

// 2. 基礎樣式
@import 'base/reset';
@import 'base/typography';
@import 'base/layout';

// 3. 組件樣式
@import 'components/buttons';
@import 'components/forms';
@import 'components/cards';
@import 'components/modals';

// 4. 頁面樣式
@import 'pages/home';
@import 'pages/vocabulary';
@import 'pages/auth';

// 5. 工具類
@import 'utilities/spacing';
@import 'utilities/colors';
@import 'utilities/responsive';

變數系統

// styles/base/_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;

// 字體系統
$font-family-primary: 'Inter', 'Noto Sans TC', sans-serif;
$font-family-secondary: 'Roboto', 'Microsoft JhengHei', 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;

響應式設計混合器

// styles/base/_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; }
  }
}

// CSS Grid佈局工具
@mixin grid-container($columns: 1, $gap: $spacing-md) {
  display: grid;
  grid-template-columns: repeat($columns, 1fr);
  gap: $gap;
}

// 按鈕樣式基礎
@mixin button-base {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  padding: $spacing-sm $spacing-md;
  border: none;
  border-radius: 4px;
  font-family: $font-family-primary;
  font-size: $font-size-base;
  cursor: pointer;
  transition: all 0.2s ease;
  text-decoration: none;
  
  &:disabled {
    opacity: 0.5;
    cursor: not-allowed;
  }
}

// 卡片組件樣式
@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);
  }
}

🚦 路由和導航

簡單路由器實現

// core/router.js
class SimpleRouter {
  constructor() {
    this.routes = new Map()
    this.currentRoute = null
    this.init()
  }
  
  init() {
    window.addEventListener('popstate', () => {
      this.handleRoute()
    })
    
    // 處理初始路由
    this.handleRoute()
  }
  
  // 註冊路由
  addRoute(path, handler, options = {}) {
    this.routes.set(path, {
      handler,
      requiresAuth: options.requiresAuth || false,
      title: options.title || 'Drama Ling'
    })
  }
  
  // 導航到指定路由
  navigate(path, state = {}) {
    history.pushState(state, '', path)
    this.handleRoute()
  }
  
  // 處理路由變化
  handleRoute() {
    const path = window.location.pathname
    const route = this.findRoute(path)
    
    if (route) {
      // 檢查認證要求
      if (route.requiresAuth && !this.checkAuth()) {
        this.navigate('/login')
        return
      }
      
      // 設置頁面標題
      document.title = route.title
      
      // 執行路由處理器
      route.handler(path)
      this.currentRoute = path
    } else {
      this.handle404()
    }
  }
  
  // 查找匹配的路由
  findRoute(path) {
    // 精確匹配
    if (this.routes.has(path)) {
      return this.routes.get(path)
    }
    
    // 動態路由匹配 (例如: /vocabulary/:id)
    for (const [pattern, route] of this.routes) {
      const regex = this.pathToRegex(pattern)
      if (regex.test(path)) {
        return route
      }
    }
    
    return null
  }
  
  // 路徑轉正則表達式
  pathToRegex(path) {
    const regexPath = path
      .replace(/:\w+/g, '([^/]+)')  // :id -> ([^/]+)
      .replace(/\*/g, '.*')          // * -> .*
    return new RegExp(`^${regexPath}$`)
  }
  
  // 檢查用戶認證狀態
  checkAuth() {
    return appState.stores.auth.getState().isAuthenticated
  }
  
  // 處理404錯誤
  handle404() {
    document.title = 'Page Not Found - Drama Ling'
    // 顯示404頁面
  }
}

// 路由配置
export const router = new SimpleRouter()

// 註冊路由
router.addRoute('/', () => import('../pages/home/index.js'), { 
  title: 'Drama Ling - AI語言學習' 
})

router.addRoute('/login', () => import('../pages/auth/login.js'), { 
  title: '登入 - Drama Ling' 
})

router.addRoute('/vocabulary', () => import('../pages/vocabulary/index.js'), { 
  requiresAuth: true,
  title: '詞彙學習 - Drama Ling' 
})

router.addRoute('/vocabulary/:id', (path) => {
  const id = path.split('/').pop()
  import('../pages/vocabulary/detail.js').then(module => {
    module.default.init(id)
  })
}, { 
  requiresAuth: true,
  title: '詞彙詳情 - Drama Ling' 
})

導航組件

// components/layout/Navigation.js
export class Navigation {
  constructor() {
    this.element = null
    this.init()
  }
  
  init() {
    this.render()
    this.setupEventListeners()
  }
  
  render() {
    this.element = document.createElement('nav')
    this.element.className = 'main-navigation'
    this.element.innerHTML = `
      <div class="nav-container">
        <div class="nav-brand">
          <a href="/" data-navigate>Drama Ling</a>
        </div>
        <ul class="nav-menu">
          <li><a href="/vocabulary" data-navigate>詞彙學習</a></li>
          <li><a href="/dialogue" data-navigate>情境對話</a></li>
          <li><a href="/profile" data-navigate>個人中心</a></li>
        </ul>
        <div class="nav-actions">
          <button id="logout-btn" class="btn btn-outline">登出</button>
        </div>
      </div>
    `
  }
  
  setupEventListeners() {
    // 導航點擊處理
    this.element.addEventListener('click', (e) => {
      const link = e.target.closest('[data-navigate]')
      if (link) {
        e.preventDefault()
        const path = link.getAttribute('href')
        router.navigate(path)
      }
    })
    
    // 登出處理
    this.element.querySelector('#logout-btn').addEventListener('click', () => {
      appState.updateStore('auth', (state) => ({
        ...state,
        user: null,
        token: null,
        isAuthenticated: false
      }))
      router.navigate('/login')
    })
  }
  
  mount(container) {
    container.appendChild(this.element)
  }
}

🔌 API服務層架構

HTTP客戶端

// services/httpClient.js
class HttpClient {
  constructor() {
    this.baseURL = import.meta.env.VITE_API_BASE_URL || '/api'
    this.timeout = 10000
  }
  
  // 通用請求方法
  async request(url, options = {}) {
    const config = {
      method: 'GET',
      headers: {
        'Content-Type': 'application/json',
        ...this.getAuthHeaders(),
        ...options.headers
      },
      timeout: this.timeout,
      ...options
    }
    
    try {
      const response = await fetch(`${this.baseURL}${url}`, config)
      return await this.handleResponse(response)
    } catch (error) {
      throw this.handleError(error)
    }
  }
  
  // 獲取認證標頭
  getAuthHeaders() {
    const token = appState.stores.auth.getState().token
    return token ? { Authorization: `Bearer ${token}` } : {}
  }
  
  // 處理回應
  async handleResponse(response) {
    if (!response.ok) {
      if (response.status === 401) {
        // 處理認證過期
        appState.updateStore('auth', (state) => ({
          ...state,
          user: null,
          token: null,
          isAuthenticated: false
        }))
        router.navigate('/login')
      }
      throw new Error(`HTTP ${response.status}: ${response.statusText}`)
    }
    
    const contentType = response.headers.get('content-type')
    if (contentType && contentType.includes('application/json')) {
      return await response.json()
    }
    return await response.text()
  }
  
  // 錯誤處理
  handleError(error) {
    console.error('API Error:', error)
    return error
  }
  
  // 便捷方法
  get(url, params = {}) {
    const queryString = new URLSearchParams(params).toString()
    const fullUrl = queryString ? `${url}?${queryString}` : url
    return this.request(fullUrl)
  }
  
  post(url, data = {}) {
    return this.request(url, {
      method: 'POST',
      body: JSON.stringify(data)
    })
  }
  
  put(url, data = {}) {
    return this.request(url, {
      method: 'PUT',
      body: JSON.stringify(data)
    })
  }
  
  delete(url) {
    return this.request(url, {
      method: 'DELETE'
    })
  }
}

export const httpClient = new HttpClient()

API服務類別

// services/vocabularyApi.js
class VocabularyAPI {
  constructor() {
    this.basePath = '/vocabulary'
  }
  
  // 獲取詞彙介紹
  async getWordIntroduction(wordId) {
    return await httpClient.get(`${this.basePath}/words/${wordId}`)
  }
  
  // 提交練習結果
  async submitPracticeResult(result) {
    return await httpClient.post(`${this.basePath}/practice`, result)
  }
  
  // 獲取複習計劃
  async getReviewSchedule() {
    return await httpClient.get(`${this.basePath}/review/schedule`)
  }
  
  // 獲取分析數據
  async getAnalytics(timeRange) {
    return await httpClient.get(`${this.basePath}/analytics`, { timeRange })
  }
}

export const vocabularyAPI = new VocabularyAPI()

🎵 音頻處理整合

Web Audio API封裝

// modules/audio/audioManager.js
class AudioManager {
  constructor() {
    this.audioContext = null
    this.currentSource = null
    this.isPlaying = false
  }
  
  // 初始化音頻上下文
  async initializeAudioContext() {
    if (!this.audioContext) {
      this.audioContext = new (window.AudioContext || window.webkitAudioContext)()
      
      // 處理瀏覽器自動播放政策
      if (this.audioContext.state === 'suspended') {
        await this.audioContext.resume()
      }
    }
  }
  
  // 播放音頻
  async playAudio(url, playbackRate = 1.0) {
    try {
      await this.initializeAudioContext()
      
      // 停止當前播放
      this.stopCurrentAudio()
      
      // 載入音頻
      const response = await fetch(url)
      const arrayBuffer = await response.arrayBuffer()
      const audioBuffer = await this.audioContext.decodeAudioData(arrayBuffer)
      
      // 創建音頻源
      this.currentSource = this.audioContext.createBufferSource()
      this.currentSource.buffer = audioBuffer
      this.currentSource.playbackRate.value = playbackRate
      this.currentSource.connect(this.audioContext.destination)
      
      // 播放結束處理
      this.currentSource.onended = () => {
        this.isPlaying = false
        this.currentSource = null
      }
      
      // 開始播放
      this.currentSource.start()
      this.isPlaying = true
      
      return this.currentSource
    } catch (error) {
      console.error('Audio playback failed:', error)
      this.isPlaying = false
      throw error
    }
  }
  
  // 停止當前播放
  stopCurrentAudio() {
    if (this.currentSource && this.isPlaying) {
      this.currentSource.stop()
      this.currentSource = null
      this.isPlaying = false
    }
  }
  
  // 錄製音頻
  async startRecording() {
    try {
      const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
      const mediaRecorder = new MediaRecorder(stream)
      
      const audioChunks = []
      mediaRecorder.ondataavailable = (event) => {
        audioChunks.push(event.data)
      }
      
      return new Promise((resolve, reject) => {
        mediaRecorder.onstop = () => {
          const audioBlob = new Blob(audioChunks, { type: 'audio/wav' })
          resolve(audioBlob)
        }
        
        mediaRecorder.onerror = (event) => {
          reject(event.error)
        }
        
        mediaRecorder.start()
        
        // 返回停止函數
        setTimeout(() => {
          mediaRecorder.stop()
          stream.getTracks().forEach(track => track.stop())
        }, 5000) // 5秒自動停止
      })
    } catch (error) {
      console.error('Recording failed:', error)
      throw error
    }
  }
}

export const audioManager = new AudioManager()

🧩 組件系統

Web Components基礎

// components/ui/BaseComponent.js
class BaseComponent extends HTMLElement {
  constructor() {
    super()
    this.state = {}
    this.listeners = new Map()
  }
  
  // 組件生命週期
  connectedCallback() {
    this.render()
    this.setupEventListeners()
    this.onMounted()
  }
  
  disconnectedCallback() {
    this.cleanup()
    this.onUnmounted()
  }
  
  // 狀態管理
  setState(newState) {
    this.state = { ...this.state, ...newState }
    this.render()
  }
  
  getState() {
    return { ...this.state }
  }
  
  // 事件處理
  addEventListener(event, handler) {
    if (!this.listeners.has(event)) {
      this.listeners.set(event, [])
    }
    this.listeners.get(event).push(handler)
    super.addEventListener(event, handler)
  }
  
  // 清理資源
  cleanup() {
    for (const [event, handlers] of this.listeners) {
      handlers.forEach(handler => {
        super.removeEventListener(event, handler)
      })
    }
    this.listeners.clear()
  }
  
  // 子類需要實現的方法
  render() {
    throw new Error('render method must be implemented')
  }
  
  onMounted() {
    // 組件掛載後的邏輯
  }
  
  onUnmounted() {
    // 組件卸載前的清理
  }
  
  setupEventListeners() {
    // 事件監聽設置
  }
}

export { BaseComponent }

詞彙卡片組件範例

// components/business/VocabularyCard.js
import { BaseComponent } from '../ui/BaseComponent.js'

class VocabularyCard extends BaseComponent {
  constructor() {
    super()
    this.state = {
      word: null,
      isFlipped: false,
      isLoading: false
    }
  }
  
  static get observedAttributes() {
    return ['word-id', 'show-pronunciation', 'interactive']
  }
  
  attributeChangedCallback(name, oldValue, newValue) {
    if (name === 'word-id' && newValue !== oldValue) {
      this.loadWord(newValue)
    }
  }
  
  async loadWord(wordId) {
    this.setState({ isLoading: true })
    try {
      const word = await vocabularyAPI.getWordIntroduction(wordId)
      this.setState({ word, isLoading: false })
    } catch (error) {
      console.error('Failed to load word:', error)
      this.setState({ isLoading: false })
    }
  }
  
  render() {
    if (this.state.isLoading) {
      this.innerHTML = `
        <div class="vocabulary-card loading">
          <div class="loading-spinner"></div>
        </div>
      `
      return
    }
    
    if (!this.state.word) {
      this.innerHTML = `
        <div class="vocabulary-card error">
          <p>無法載入詞彙資料</p>
        </div>
      `
      return
    }
    
    const { word } = this.state
    const showPronunciation = this.getAttribute('show-pronunciation') !== 'false'
    const interactive = this.getAttribute('interactive') !== 'false'
    
    this.innerHTML = `
      <div class="vocabulary-card ${this.state.isFlipped ? 'flipped' : ''}">
        <div class="card-front">
          <div class="word-header">
            <h3 class="word-text">${word.word}</h3>
            ${showPronunciation ? `<span class="pronunciation">${word.pronunciation}</span>` : ''}
          </div>
          ${interactive ? `
            <button class="audio-btn" data-audio-url="${word.audio_url}">
              🔊 播放發音
            </button>
            <button class="flip-btn">
              顯示定義
            </button>
          ` : ''}
        </div>
        
        <div class="card-back">
          <div class="definition">
            <h4>定義</h4>
            <p>${word.definition_zh}</p>
          </div>
          <div class="examples">
            <h4>例句</h4>
            ${word.examples.map(example => `
              <p class="example">${example.sentence}</p>
            `).join('')}
          </div>
          ${interactive ? `
            <button class="flip-btn">
              返回
            </button>
          ` : ''}
        </div>
      </div>
    `
  }
  
  setupEventListeners() {
    this.addEventListener('click', (e) => {
      if (e.target.matches('.flip-btn')) {
        this.setState({ isFlipped: !this.state.isFlipped })
      }
      
      if (e.target.matches('.audio-btn')) {
        const audioUrl = e.target.getAttribute('data-audio-url')
        audioManager.playAudio(audioUrl)
      }
    })
  }
}

// 註冊自定義元素
customElements.define('vocabulary-card', VocabularyCard)

export { VocabularyCard }

🚀 效能優化策略

載入優化

// utils/lazyLoader.js
class LazyLoader {
  constructor() {
    this.observer = new IntersectionObserver(
      this.handleIntersection.bind(this),
      { threshold: 0.1 }
    )
  }
  
  // 延遲載入圖片
  lazyLoadImages() {
    const images = document.querySelectorAll('img[data-src]')
    images.forEach(img => this.observer.observe(img))
  }
  
  // 延遲載入組件
  lazyLoadComponent(element, importFunction) {
    const observer = new IntersectionObserver(
      (entries) => {
        entries.forEach(entry => {
          if (entry.isIntersecting) {
            importFunction().then(module => {
              module.default.init(element)
            })
            observer.disconnect()
          }
        })
      },
      { threshold: 0.1 }
    )
    observer.observe(element)
  }
  
  handleIntersection(entries) {
    entries.forEach(entry => {
      if (entry.isIntersecting) {
        const img = entry.target
        img.src = img.getAttribute('data-src')
        img.removeAttribute('data-src')
        this.observer.unobserve(img)
      }
    })
  }
}

export const lazyLoader = new LazyLoader()

快取策略

// utils/cache.js
class CacheManager {
  constructor() {
    this.memoryCache = new Map()
    this.maxAge = 5 * 60 * 1000 // 5分鐘
  }
  
  // 記憶體快取
  setMemoryCache(key, data, maxAge = this.maxAge) {
    this.memoryCache.set(key, {
      data,
      timestamp: Date.now(),
      maxAge
    })
  }
  
  getMemoryCache(key) {
    const cached = this.memoryCache.get(key)
    if (!cached) return null
    
    if (Date.now() - cached.timestamp > cached.maxAge) {
      this.memoryCache.delete(key)
      return null
    }
    
    return cached.data
  }
  
  // localStorage快取
  setLocalCache(key, data, maxAge = 24 * 60 * 60 * 1000) {
    const cacheData = {
      data,
      timestamp: Date.now(),
      maxAge
    }
    storage.setLocal(`cache_${key}`, cacheData)
  }
  
  getLocalCache(key) {
    const cached = storage.getLocal(`cache_${key}`)
    if (!cached) return null
    
    if (Date.now() - cached.timestamp > cached.maxAge) {
      storage.removeLocal(`cache_${key}`)
      return null
    }
    
    return cached.data
  }
}

export const cacheManager = new CacheManager()

🔒 安全性實作

XSS防護

// utils/security.js
class SecurityUtils {
  // HTML清理
  sanitizeHTML(html) {
    const temp = document.createElement('div')
    temp.textContent = html
    return temp.innerHTML
  }
  
  // 輸入驗證
  validateInput(input, type = 'text') {
    const patterns = {
      email: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
      phone: /^\+?[\d\s\-\(\)]+$/,
      username: /^[a-zA-Z0-9_]{3,20}$/,
      password: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,}$/
    }
    
    if (patterns[type]) {
      return patterns[type].test(input)
    }
    
    // 基本長度檢查
    return input.length > 0 && input.length < 1000
  }
  
  // CSRF Token處理
  async getCSRFToken() {
    try {
      const response = await httpClient.get('/csrf-token')
      return response.token
    } catch (error) {
      console.error('Failed to get CSRF token:', error)
      return null
    }
  }
}

export const security = new SecurityUtils()

🧪 測試策略

Vitest配置

// vite.config.js
import { defineConfig } from 'vite'

export default defineConfig({
  test: {
    environment: 'happy-dom',
    globals: true,
    coverage: {
      provider: 'v8',
      reporter: ['text', 'json-summary', 'html'],
      threshold: {
        global: {
          branches: 80,
          functions: 80,
          lines: 80,
          statements: 80
        }
      }
    }
  }
})

組件測試範例

// tests/components/VocabularyCard.test.js
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { VocabularyCard } from '../../src/components/business/VocabularyCard.js'

describe('VocabularyCard', () => {
  let container
  let vocabularyCard
  
  beforeEach(() => {
    // 設置DOM容器
    container = document.createElement('div')
    document.body.appendChild(container)
    
    // 創建組件實例
    vocabularyCard = document.createElement('vocabulary-card')
    container.appendChild(vocabularyCard)
  })
  
  afterEach(() => {
    // 清理DOM
    document.body.removeChild(container)
  })
  
  it('should render word information correctly', () => {
    vocabularyCard.setAttribute('word-id', 'test-word')
    
    // 模擬API回應
    vi.mock('../../src/services/vocabularyApi.js', () => ({
      vocabularyAPI: {
        getWordIntroduction: vi.fn().mockResolvedValue({
          word: 'hello',
          pronunciation: '/həˈloʊ/',
          definition_zh: '你好',
          examples: [
            { sentence: 'Hello, world!' }
          ]
        })
      }
    }))
    
    // 等待組件渲染
    return new Promise(resolve => {
      setTimeout(() => {
        expect(vocabularyCard.querySelector('.word-text')).toHaveTextContent('hello')
        expect(vocabularyCard.querySelector('.pronunciation')).toHaveTextContent('/həˈloʊ/')
        resolve()
      }, 100)
    })
  })
})

📦 建構和部署

Vite建構配置

// vite.config.js
import { defineConfig } from 'vite'
import { VitePWA } from 'vite-plugin-pwa'

export default defineConfig({
  plugins: [
    VitePWA({
      registerType: 'autoUpdate',
      manifest: {
        name: 'Drama Ling',
        short_name: 'DramaLing',
        description: 'AI-powered language learning app',
        theme_color: '#1976d2',
        background_color: '#ffffff',
        display: 'standalone',
        icons: [
          {
            src: '/icons/icon-192x192.png',
            sizes: '192x192',
            type: 'image/png'
          }
        ]
      }
    })
  ],
  
  build: {
    rollupOptions: {
      output: {
        manualChunks: {
          vendor: ['vite'],
          utils: ['./src/utils/index.js']
        }
      }
    },
    minify: 'terser',
    terserOptions: {
      compress: {
        drop_console: true,
        drop_debugger: true
      }
    }
  },
  
  css: {
    preprocessorOptions: {
      scss: {
        additionalData: `@import "./src/styles/base/variables.scss";`
      }
    }
  }
})

文檔狀態: 🟢 已完成原生前端技術架構規劃
最後更新: 2025-09-10
負責團隊: 前端開發團隊
下次檢查: 開發開始前進行技術實施確認